Compare commits

...

65 Commits

Author SHA1 Message Date
58be922fb3 fix(ui): comprehensive mobile optimizations across homepage and blocks
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 53s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🏗️ Build (push) Successful in 2m58s
Build & Deploy / 🚀 Deploy (push) Successful in 38s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m1s
Build & Deploy / 🔔 Notify (push) Successful in 3s
- fix: bottom nav and footer padding collision
- fix: reveal animation flickering (Certificates, SubCompanyTiles)
- fix: tooltip flyout overflow on mobile in Germany map
- fix: flex direction for 'Projekt anfragen' and Service Detail icons
- fix: text alignment and spacing issues in ReferencesSlider
- fix: reduce excessive whitespace above footer
2026-06-19 21:39:55 +02:00
111672a614 feat(referenzen): inject MDX project details into overview cards
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m11s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🏗️ Build (push) Successful in 3m6s
Build & Deploy / 🚀 Deploy (push) Successful in 37s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 21:27:35 +02:00
d1387d2685 fix(referenzen): assign unique, matching images to all reference projects 2026-06-19 21:21:50 +02:00
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
cc22aa6c8c hotfix: resolve double header on referenzen page and fix hash link scroll bug
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m24s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m37s
Build & Deploy / 🚀 Deploy (push) Successful in 36s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 54s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-18 15:53:24 +02:00
3ae1e6257d chore: trigger deployment
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🏗️ Build (push) Successful in 2m34s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 53s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 15:07:28 +02:00
1419bab7cf Revert "fix(ci): explicitly mount docker.sock via container options"
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 1s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Failing after 1s
This reverts commit ac6b121685.
2026-06-18 15:05:10 +02:00
ac6b121685 fix(ci): explicitly mount docker.sock via container options
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 3s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 15:03:45 +02:00
4a9a7adf93 fix(ci): restore deploy.yml to last working version and remove test workflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 3s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 15:02:02 +02:00
3b297aae3f chore: test dind service
Some checks failed
Build & Deploy / 🧪 QA (push) Blocked by required conditions
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Test Docker Host / test (push) Failing after 10s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-06-18 14:59:19 +02:00
c6b81a3d77 chore: test gateway dind reachability
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Test Docker Host / test (push) Successful in 2s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-06-18 14:58:39 +02:00
c8f3420a5c chore: test act-runner-dind reachability
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Test Docker Host / test (push) Successful in 2s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Failing after 27s
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-18 14:55:41 +02:00
a6559eb7bb chore: test docker env
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Test Docker Host / test (push) Successful in 3s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Failing after 27s
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-18 14:52:27 +02:00
8d75c2ec12 fix(ci): restore full deployment pipeline with buildx driver docker
Some checks failed
Build & Deploy / 🏗️ Build (push) Failing after 29s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔍 Prepare (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-06-18 14:46:38 +02:00
6a18eb4ee1 chore: test barebones build with driver: docker
Some checks failed
Build and Deploy / 🏗️ Build (push) Has been cancelled
Build and Deploy / 🔍 Prepare (push) Has been cancelled
2026-06-18 14:45:44 +02:00
d91eb220be fix(ci): restore build-push-action to fix docker socket access in runner
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-06-18 14:41:53 +02:00
5c40d55b21 chore: test docker
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Failing after 25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 14:38:00 +02:00
a512bbe226 fix(ci): restore container blocks and explicitly mount docker.sock volume
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Failing after 24s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 14:19:01 +02:00
8b3851f45c fix: run CI jobs on runner host to fix docker daemon socket access
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Failing after 24s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-18 14:11:38 +02:00
e2667ed750 fix(ui): unify reference project naming scheme in map-data
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Failing after 26s
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-18 14:03:36 +02:00
0253956bd9 fix(ci): remove broken maintenance step and clean up prepare
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-06-18 14:02:04 +02:00
42cd4394c7 fix(ci): checkout repository in prepare job to fix priority-gate execution crash
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 25s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-18 13:59:53 +02:00
276ceed010 debug(ci): bypass priority gate to prevent prepare failure
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 3s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 13:58:02 +02:00
cdf10bd670 debug(ci): hardcode GITHUB_OUTPUT entirely
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 2s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-18 13:55:08 +02:00
a1ea41b42f ci(deploy): map main branch to staging environment automatically
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 3s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 13:14:31 +02:00
561d75d2b8 Merge fix/ui-and-map-formatting
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 2s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 13:08:56 +02:00
685c0ffff6 Merge fix/referenzen-map-glow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 7s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 13:08:46 +02:00
17b001a97d Merge fix/header-dropdown-nav 2026-06-18 13:08:00 +02:00
1dc2fe8a25 fix(header): close navigation dropdown immediately on pathname change
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🏗️ Build (push) Successful in 2m39s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-28 12:15:33 +02:00
ed802d03e7 fix: clean location names in reference badges and detail pages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🧪 QA (push) Failing after 58s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-15 21:52:58 +02:00
68b0ed7ba0 fix: render map as card on references page to enable glowing border 2026-05-15 21:15:57 +02:00
1845 changed files with 311709 additions and 8531 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-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-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-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

@@ -8,8 +8,17 @@ import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
import { SITE_URL } from '@/lib/schema';
import Image from 'next/image';
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
import { HeroSection } from '@/components/blocks/HeroSection';
import { defaultLocations, minorLocations } from '@/lib/map-data';
import { MDXRemote } from 'next-mdx-remote/rsc';
const mdxComponents = {
ul: (props: any) => <ul className="list-disc pl-5 mb-6 text-neutral-600 text-sm space-y-2" {...props} />,
li: (props: any) => <li className="pl-1" {...props} />,
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
};
interface PageProps {
params: Promise<{
locale: string;
@@ -72,7 +81,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
return (
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
{/* Map Hero Section */}
{/* Map Section */}
<InteractiveGermanyMap
isHero={true}
badge={locale === 'en' ? 'Our References' : 'Unsere Referenzen'}
@@ -89,11 +98,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 */}
@@ -110,7 +117,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">
@@ -123,10 +130,17 @@ 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>
{/* Project Details / Content */}
{ref.content && (
<div className="flex-grow mb-6">
<MDXRemote source={ref.content} components={mdxComponents} />
</div>
)}
{/* Technical Meta Data */}
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
<div className="min-w-0">
@@ -146,7 +160,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">
@@ -140,7 +140,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
variants={containerVariants}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-50px" }}
viewport={{ once: true, amount: 0.1 }}
>
{certificates.map((cert, index) => {
const isIso = cert.type === 'iso';

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">
@@ -81,12 +81,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
return (
<section className="py-12 md:py-24 bg-neutral text-neutral-dark relative overflow-hidden">
<Container className="relative z-10 px-0 md:px-12 lg:px-16">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 px-4 md:px-0">
<div className="flex flex-row justify-between items-end mb-12 px-4 md:px-0">
<div className="max-w-2xl text-left">
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
</div>
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
<Button href={ctaHref} variant="ghost" className="bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0 whitespace-nowrap">
{ctaLabel}
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</Button>

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 */}
{!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
@@ -228,17 +239,16 @@ export function InteractiveGermanyMap({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="absolute z-[9999] pointer-events-auto"
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw-2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
style={{
left: `${activeLocation.x}%`,
top: `${activeLocation.y}%`,
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
}}
'--md-left': `${activeLocation.x}%`,
'--md-top': `${activeLocation.y}%`,
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
} as React.CSSProperties}
onMouseEnter={() => handleMouseEnter(activeLocation)}
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">
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
{activeLocation.featuredImage && (
<div className="relative w-full h-32 bg-neutral-900">
<Image

View File

@@ -94,15 +94,15 @@ 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">
<div>
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div className="text-left">
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
</div>
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
<div className="text-left max-w-3xl mx-auto md:mx-0 mb-8 md:mb-16">
{description}
</div>
</div>
@@ -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">
@@ -193,7 +193,7 @@ export function ServiceDetailGrid({
{/* On a white card, a very subtle primary/grey shine works best */}
<HoverShineOverlay shineColor="via-primary/5" />
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start mb-12 gap-6">
<div className="relative z-10 flex flex-row justify-between items-start mb-12 gap-6">
<div className="pt-2 flex-1 min-w-0 pr-4">
<h4 className="text-lg md:text-2xl font-extrabold text-neutral-dark uppercase tracking-wider text-balance break-words" lang="de">{panel.title}</h4>
<div className="h-1 w-12 bg-primary mt-6 transition-all duration-700 group-hover:w-24 rounded-full"></div>

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) && (
@@ -85,7 +85,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
viewport={{ once: true, amount: 0.1 }}
>
{companiesData.map((company: any, index: number) => {
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';

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-24 pb-28 md:pt-32 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

@@ -0,0 +1,86 @@
import * as React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { Header, NavLink } from './Header';
import { usePathname } from 'next/navigation';
// Mock next/navigation
vi.mock('next/navigation', () => ({
usePathname: vi.fn(),
}));
// Mock TransitionLink to render standard anchor tag and forward all props
vi.mock('@/components/ui/TransitionLink', () => ({
TransitionLink: ({ children, href, onClick, ...props }: any) => (
<a href={href} onClick={onClick} {...props}>
{children}
</a>
),
}));
// Mock next/image
vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: any) => <img src={src} alt={alt} {...props} />,
}));
// Mock LanguageSwitcher
vi.mock('./LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
describe('Header Dropdown Navigation TDD', () => {
const mockNavLinks: NavLink[] = [
{
label: 'Home',
url: '/',
},
{
label: 'Leistungen',
url: '/leistungen',
children: [
{ label: 'Kabelbau', url: '/leistungen/kabelbau' },
{ label: 'Bohrtechnik', url: '/leistungen/bohrtechnik' },
],
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it('closes the dropdown menu immediately after a page navigation occurs', async () => {
// 1. Set initial pathname
let currentPath = '/de';
vi.mocked(usePathname).mockImplementation(() => currentPath);
// 2. Render Header
const { rerender } = render(<Header navLinks={mockNavLinks} />);
// 3. Hover over "Leistungen" menu item to open dropdown
const leistungenNavItem = screen.getByText('Leistungen');
const navItemContainer = leistungenNavItem.closest('.group');
expect(navItemContainer).toBeTruthy();
act(() => {
fireEvent.mouseEnter(navItemContainer!);
});
// 4. Assert dropdown is open and children are rendered
expect(screen.getByText('Kabelbau')).toBeTruthy();
expect(screen.getByText('Bohrtechnik')).toBeTruthy();
// 5. Simulate page navigation by changing the path
currentPath = '/de/leistungen/kabelbau';
act(() => {
// Trigger pathname hook change and force rerender
rerender(<Header navLinks={mockNavLinks} />);
});
// 6. Assert dropdown is closed (children are not in the document)
await waitFor(() => {
expect(screen.queryByText('Kabelbau')).toBeNull();
expect(screen.queryByText('Bohrtechnik')).toBeNull();
});
});
});

View File

@@ -56,7 +56,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
}, [currentLocale, navLinks]);
return (
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-6 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-2 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
<nav className="flex justify-around items-center max-w-md mx-auto">
{items.map((item) => {
// Determine if active

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

@@ -24,6 +24,11 @@ export function TransitionLink({ children, href, onClick, transitionMessage, ...
return;
}
// Hash links on the same page should not trigger a transition
if (href.toString().startsWith('#')) {
return;
}
// Default Link Navigation blockieren
e.preventDefault();

BIN
contact_sheet.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

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

@@ -4,7 +4,7 @@ client: "Energiekontor AG"
date: "2026-06-01"
dateString: "01/2025 bis 06/2026"
location: "18334 Dettmannsdorf"
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-256.jpg"
featuredImage: "/assets/photos/DJI_0301.JPG"
---
- 12.300 m Kabelgraben ausheben und verfüllen

View File

@@ -4,7 +4,7 @@ client: "wpd GmbH"
date: "2026-03-01"
dateString: "seit 03/2026"
location: "24808 Jevenstedt"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-15.jpg"
featuredImage: "/assets/photos/DJI_0037.JPG"
---
- 500 m Kabelgraben ausheben und verfüllen

View File

@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
date: "2025-06-01"
dateString: "01/2025 bis 06/2025"
location: "14669 Ketzin"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-14.jpg"
featuredImage: "/assets/photos/DSC02643.JPG"
---
- 1.600 m Kabelgraben ausheben und verfüllen

View File

@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
date: "2026-03-01"
dateString: "02/2025 bis 03/2026"
location: "48346 Ostbevern"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg"
featuredImage: "/assets/photos/DJI_0048.JPG"
---
- 4.100 m Kabelgraben ausheben und verfüllen

View File

@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
date: "2025-02-01"
dateString: "04/2024 bis 02/2025"
location: "15326 Petershagen"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-12.jpg"
featuredImage: "/assets/photos/Screenshot_2025-09-30_085736.png"
---
- 12.000 m Kabelgraben ausheben und verfüllen

View File

@@ -4,7 +4,7 @@ client: "Trianel GmbH"
date: "2025-08-01"
dateString: "02/2025 bis 08/2025"
location: "15345 Prötzel"
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-294.jpg"
featuredImage: "/assets/photos/DJI_0243.JPG"
---
- 2.600 m Kabelgraben ausheben und verfüllen

View File

@@ -61,9 +61,7 @@ layout: "fullBleed"
</div>
<h2 className="font-heading text-4xl md:text-5xl lg:text-6xl font-extrabold mb-6 leading-tight text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70">Infrastruktur für<br />ein ganzes Land</h2>
<p className="text-lg md:text-xl text-white/60 mb-12 max-w-2xl mx-auto leading-relaxed">Seit 2016 haben wir in über 200 Projekten quer durch Deutschland bewiesen, dass wir Infrastrukturprojekte jeder Größenordnung verlässlich umsetzen.</p>
<Button href="/de/referenzen" variant="primary" size="lg" className="shadow-[0_0_20px_rgba(130,237,32,0.3)]">
Zu unseren Referenzen <span className="text-xl leading-none">&rarr;</span>
</Button>
<Button href="/de/referenzen" variant="primary" size="lg" className="shadow-[0_0_20px_rgba(130,237,32,0.3)]">Zu unseren Referenzen <span className="text-xl leading-none">&rarr;</span></Button>
</div>
</div>

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

@@ -4,7 +4,7 @@ client: "Energiekontor AG"
date: "2026-06-01"
dateString: "01/2025 to 06/2026"
location: "18334 Dettmannsdorf"
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-256.jpg"
featuredImage: "/assets/photos/DJI_0301.JPG"
---
- Excavation and backfilling of 12,300 m cable trench

View File

@@ -4,7 +4,7 @@ client: "wpd GmbH"
date: "2026-03-01"
dateString: "since 03/2026"
location: "24808 Jevenstedt"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-15.jpg"
featuredImage: "/assets/photos/DJI_0037.JPG"
---
- Excavation and backfilling of 500 m cable trench

View File

@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
date: "2025-06-01"
dateString: "01/2025 to 06/2025"
location: "14669 Ketzin"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-14.jpg"
featuredImage: "/assets/photos/DSC02643.JPG"
---
- Excavation and backfilling of 1,600 m cable trench

View File

@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
date: "2026-03-01"
dateString: "02/2025 to 03/2026"
location: "48346 Ostbevern"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg"
featuredImage: "/assets/photos/DJI_0048.JPG"
---
- Excavation and backfilling of 4,100 m cable trench

View File

@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
date: "2025-02-01"
dateString: "04/2024 to 02/2025"
location: "15326 Petershagen"
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-12.jpg"
featuredImage: "/assets/photos/Screenshot_2025-09-30_085736.png"
---
- Excavation and backfilling of 12,000 m cable trench

View File

@@ -4,7 +4,7 @@ client: "Trianel GmbH"
date: "2025-08-01"
dateString: "02/2025 to 08/2025"
location: "15345 Prötzel"
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-294.jpg"
featuredImage: "/assets/photos/DJI_0243.JPG"
---
- Excavation and backfilling of 2,600 m cable trench

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'
]

31
make_contact_sheet.py Normal file
View File

@@ -0,0 +1,31 @@
import os
import math
from PIL import Image, ImageDraw
folder = 'public/assets/photos'
files = [f for f in os.listdir(folder) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
files.sort()
thumb_w, thumb_h = 300, 300
cols = 8
rows = math.ceil(len(files) / cols)
contact_sheet = Image.new('RGB', (cols * thumb_w, rows * thumb_h), (255, 255, 255))
draw = ImageDraw.Draw(contact_sheet)
for i, f in enumerate(files):
path = os.path.join(folder, f)
try:
img = Image.open(path)
img.thumbnail((thumb_w, thumb_h - 40))
x = (i % cols) * thumb_w
y = (i // cols) * thumb_h
img_x = x + (thumb_w - img.width) // 2
img_y = y + (thumb_h - 40 - img.height) // 2
contact_sheet.paste(img, (img_x, img_y))
draw.text((x + 10, y + thumb_h - 30), f, fill=(0,0,0))
except Exception as e:
pass
contact_sheet.save('contact_sheet.jpg')
print("Saved contact_sheet.jpg")

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