Compare commits
488 Commits
v1.0.0
...
1db7f3af6c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db7f3af6c | |||
| 909bad573b | |||
| 24a19adf19 | |||
| 3313206734 | |||
| ec989690ce | |||
| 37807079cd | |||
| af4213ad59 | |||
| dc1ba4def3 | |||
| f87c714402 | |||
| f989e0604f | |||
| 4a2d094cbd | |||
| 77181fc983 | |||
| 32b56696a6 | |||
| 9b28dd20d9 | |||
| 1970ae310f | |||
| 7bc8811a60 | |||
| 530503fa09 | |||
| a539e3c498 | |||
| 14574fb312 | |||
| fdeb34ea3c | |||
| ac189f84f5 | |||
| 06dac33ef8 | |||
| 529696ba8e | |||
| 7922210ef0 | |||
| 5021259d22 | |||
| 666bcf0b9d | |||
| c3068f23e7 | |||
| 2c9263999a | |||
| 89e3cc462a | |||
| 03a3073263 | |||
| 1a7c342fbe | |||
| fc03399285 | |||
| 2445e7d968 | |||
| c05a8eef16 | |||
| c32969767d | |||
| 004c922eca | |||
| 52e18b10d6 | |||
| 65efbd147c | |||
| d713ef6900 | |||
| a38bee9af2 | |||
| 7cb3763125 | |||
| 64ec24f8b2 | |||
| 0091b56dd1 | |||
| e9e03c217a | |||
| dcd099f5b1 | |||
| db754a6325 | |||
| 9dc553b76c | |||
| 48101cc421 | |||
| 615f762d5e | |||
| 0c4c6e8dc0 | |||
| 3046a19113 | |||
| a4df12ddb3 | |||
| 73542237d5 | |||
| 52b66da16b | |||
| d75a83ccf2 | |||
| 5991bd8392 | |||
| 6207e04bf5 | |||
| 8ffb5967d3 | |||
| 8ba1c7ea38 | |||
| a546ffe69c | |||
| 15740db51e | |||
| 13ab755857 | |||
| 1a68af0eec | |||
| 275784745d | |||
| 4aef49cf2c | |||
| 8ad3abb6f3 | |||
| 1d75b60236 | |||
| 3dff19eca2 | |||
| 07b01c622a | |||
| 50de18c09c | |||
| dbee0cd8bc | |||
| f30f8ddd8d | |||
| bb9fd65dbb | |||
| 036fba8b53 | |||
| 3e8d5ad8b6 | |||
| 70ad2e3041 | |||
| 5376b939d5 | |||
| 6f80e72c1d | |||
| d9334f558d | |||
| cb436d31d0 | |||
| 4b3ef49522 | |||
| 301e112488 | |||
| 2d4919cc1f | |||
| 6a748a3ac8 | |||
| d69e0eebe6 | |||
| 1577bfd2ec | |||
| 6440d893f0 | |||
| d8e3c7d9a3 | |||
| aa14f39dba | |||
| 1cfc0523f3 | |||
| 3ff20fd2c9 | |||
| 549ee34490 | |||
| 8a8e30400c | |||
| 4faed38f47 | |||
| 1e0886144f | |||
| c933d9b886 | |||
| 5c56d8babf | |||
| c4c6fb3b07 | |||
| ff685b9933 | |||
| 980258af5c | |||
| 57b6963efe | |||
| 1a136540d0 | |||
| 92bc88dfbd | |||
| fb3ec6e10a | |||
| acf642d7e6 | |||
| d5da2a91c8 | |||
| ebe664f984 | |||
| 9c7324ee92 | |||
| 0c8d9ea669 | |||
| 1bb0efc85b | |||
| 4adf547265 | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 | |||
| a2cf9791ae | |||
| aa4e3aab4f | |||
| ce719a1d70 | |||
| bd2f92125b | |||
| eebe7972e0 | |||
| a9c7fa7c5e | |||
| 85e7ff71d5 | |||
| 2acb0c1608 | |||
| 082733c4f4 | |||
| af67ae7994 | |||
| 1fd247e358 | |||
| 44401cf546 | |||
| 7f106b1fa7 | |||
| 08425a3a42 | |||
| 62f1e9a89c | |||
| a5718c5013 | |||
| 82bb7240d5 | |||
| 9e7f6ec76f | |||
| b3057d8be0 | |||
| 3b45a967f7 | |||
| cadb104917 | |||
| 0be885428d | |||
| 009f12a3bf | |||
| 8e2a06d6f2 | |||
| 4f2bf3fa51 | |||
| 064ebf45e3 | |||
| e6dfeaffef | |||
| 7cdfe5d7f8 | |||
| 83f4b8eea8 | |||
| 97e76c7cac | |||
| 6caa850045 | |||
| 04ce0ecedd | |||
| 083859d52d | |||
| a13074902b | |||
| 4280f11772 | |||
| 3049c1b6e7 | |||
| 647f9a5f19 | |||
| a2872be02e | |||
| 9c3c7bd34b | |||
| 45602db7ff | |||
| 89405e6e18 | |||
| 57d54231eb | |||
| 5c4225d0a9 | |||
| e1101f2e60 | |||
| 0be6076512 | |||
| 62400943c2 | |||
| 4c60029e21 | |||
| b3c5b911d9 | |||
| 89f00c79a1 | |||
| 98ac3dbd10 | |||
| 0db4c819ff | |||
| 08a3b0be7b | |||
| a953820241 | |||
| fa02ac597f | |||
| 925765233e | |||
| 0487bd8ebe | |||
| 87b2624ab3 | |||
| 7cad437eb4 | |||
| f8b7d4f59d | |||
| 7fb4d306c3 | |||
| 294907977d | |||
| 3de13b4fb3 | |||
| 7d65237ee9 | |||
| 1963a93123 | |||
| 44d3e8585b | |||
| 5652f27c71 | |||
| c769da5f26 | |||
| ef5e749056 | |||
| 9c2344afd9 | |||
| 0b3de9f98c | |||
| 5813b4bd49 | |||
| 33f0238d58 | |||
| d5da64cb76 | |||
| c3111a04d8 | |||
| 2fabfc4445 | |||
| fb62113a32 | |||
| bdde7c242c | |||
| 90f657ce8d | |||
| a168f96f3c | |||
| 2db2a3aff9 | |||
| 2ba67af68a | |||
| b0f088a1dc | |||
| f358492a99 | |||
| 32576b5391 | |||
| 1e9cf7d9ab | |||
| f0f840ad5a | |||
| ca352fea3a | |||
| 323886443f | |||
| c5851370bf | |||
| 0186dd2dc9 | |||
| 82156d30f7 | |||
| 3dcde28071 | |||
| c4fca24eca | |||
| 2435b968cc | |||
| b6a1ebd236 | |||
| aa0c9cd9f5 | |||
| a3899f6cdd | |||
| a960a7b139 | |||
| 824ee3cb75 | |||
| 28633f187c | |||
| 51e0d86a6c | |||
| 923ff2071b | |||
| 30eb2e6e0e | |||
| dd830f9077 | |||
| ba16f1d7aa | |||
| 0842c136a6 | |||
| 36b8e64d69 | |||
| 4833af81f4 | |||
| 5f766589c4 | |||
| 56a7613e85 | |||
| c7c345eaad | |||
| ec99dc0317 | |||
| a6dd7913a7 | |||
| 388b90ddb0 | |||
| d57700d322 | |||
| f7aa880d9f | |||
| 2bac8d6e8a | |||
| 5bd95bca4f | |||
| 6f8d63200a | |||
| 4742630260 | |||
| a5d77fc69b | |||
| 41cfe19cbf | |||
| e4f68713e7 | |||
| 2fbcce0990 | |||
| c414a7614b | |||
| 6a0269facc | |||
| 477a3bb8ce | |||
| b1859c15ce | |||
| 6085cc05dc | |||
| bcf2d60da6 | |||
| f4fdb89ba4 | |||
| 9de3931e33 | |||
| b10dbcb23f | |||
| 65bb9c620a | |||
| 63853ffa89 | |||
| 9694c77ef7 | |||
| 2c11b5026a | |||
| eaa90c65f1 | |||
| 2a47d22e26 | |||
| 33d2d67774 | |||
| 3de62dba04 | |||
| fb2354d2cc | |||
| 70984b9021 | |||
| e1b441e8e7 | |||
| 470e532d2c | |||
| 1d24a8fb7a | |||
| 73c4988eb2 | |||
| 4a75db5f54 | |||
| d76fadd6e8 | |||
| 4b2638caed | |||
| a6dcc64833 | |||
| a55680ed41 | |||
| 1a39e9c0e4 | |||
| 16723a04b7 | |||
| 639e25276f | |||
| ad2936bf93 | |||
| f0522ff3b7 | |||
| d6c799078c | |||
| d11dae5f85 | |||
| dd7e800ec4 | |||
| 046ad4475e | |||
| b29e08e954 | |||
| 36d193f8ec | |||
| b8f04d3595 | |||
| 5f7dd838ac | |||
| 8c9f51b74a | |||
| cef86717d9 | |||
| a97a00b7fd | |||
| f696e55600 | |||
| 36455ef479 | |||
| a5384134e7 | |||
| 4965e4ae26 | |||
| 1153a79eb6 | |||
| 678c803408 | |||
| 21288a4a45 | |||
| b514125e0d | |||
| 55a084e762 | |||
| 2b09cfc5d9 | |||
| 927ce977f2 | |||
| 85bc03b9d2 | |||
| c4bc10ef76 | |||
| e95f7c6dd2 | |||
| 17a91e48e6 | |||
| 4d0a94d288 | |||
| 3568c13941 | |||
| d538d7b9ec | |||
| 8c08b552cf | |||
| 1dd74a3861 | |||
| 8d77ca45f7 | |||
| c646815a3a | |||
| 23bf327670 | |||
| c77f99ef37 | |||
| bffcc98820 | |||
| 7519e17280 | |||
| 5bd7421764 | |||
| d7aba218d9 | |||
| e20d7f42c0 | |||
| 16d06d3275 | |||
| 7542f42568 | |||
| 474fa4f3df | |||
| f1d49416d1 | |||
| e3e0a7670c | |||
| 8a87318b12 | |||
| 93cb12d7d9 | |||
| 44f0c430a9 | |||
| 1478909a73 | |||
| 837abd4921 | |||
| 75c6d363c0 | |||
| a2b7f28b9f | |||
| 52ecd1b052 | |||
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 | |||
| 02bd1dcd7f | |||
| 4b0433394f | |||
| d9bddae20e | |||
| e7c482dabf | |||
| 8974d89b33 | |||
| f99ca4d35d | |||
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c | |||
| 1d074ba6d2 | |||
| 0e972983bc | |||
| c979582193 | |||
| e47ba31763 | |||
| 28072908f7 | |||
| 7e6b4a3ed7 | |||
| d7e5a57344 | |||
| c859d5e677 | |||
| e036dea089 | |||
| 39088ca868 | |||
| 18f9104623 | |||
| 76f745cc87 | |||
| 848d58010f | |||
| c0f5799667 | |||
| 0e089f9471 | |||
| 52b17423dd | |||
| bfd3c8164b | |||
| b091175b89 | |||
| 1baf03a84e | |||
| 483dfabe10 | |||
| 65f8b2c485 | |||
| 90cdd7e713 | |||
| 40fa2a7721 | |||
| a136e7b4a7 | |||
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 | |||
| 319b2b3e0c | |||
| d7f5504149 | |||
| 0f705b474b | |||
| 67046b9301 | |||
| 0b6211cf5f | |||
| c7f2c3fdfe | |||
| f30c93ffce | |||
| 3e6bbe9a93 | |||
| c6cbb02dfa | |||
| bec1916ccc | |||
| ab17e9e758 | |||
| f257e5428f | |||
| 797411ccc3 | |||
| 94a609e438 | |||
| 409ac3fea7 | |||
| b3876666c8 | |||
| bd1a61e9cd | |||
| f2ce9ec262 | |||
| eddfa3a1f1 | |||
| 1e77914314 | |||
| 52dfbb3870 | |||
| 72e85b99ee | |||
| c7807610f6 | |||
| 81f0dd88a6 | |||
| 458e467a14 | |||
| 060118202f | |||
| 64af78a984 | |||
| f6d7584613 | |||
| 2192f37fee | |||
| 8a9cd7ef3e | |||
| 406cf22050 | |||
| 5e82d6edc9 | |||
| 85375eefb0 | |||
| fe829b0c4c | |||
| 9ed08004af | |||
| fa6f27114b | |||
| a60e8af26b | |||
| c111efae1a | |||
| a12759d507 | |||
| eefabfa3ff | |||
| 86d28796a7 | |||
| bb9424d482 | |||
| b1515155b7 | |||
| 65d54ae789 | |||
| dc21d480ab | |||
| 51043da882 | |||
| 4a31cddf11 | |||
| 1b999510db | |||
| 0d852db651 | |||
| f3ff9cd364 | |||
| f15957847c | |||
| 55fc63fed5 | |||
| dac719efd2 | |||
| ec3f9d5c8e | |||
| 7ad5b5696d | |||
| 9bcf946752 | |||
| 1fefb794c1 | |||
| 1c1aebb804 | |||
| 30d8645f74 | |||
| 365cd50402 | |||
| a9f03b24c8 | |||
| 79a2a5121e | |||
| b2f26208ad | |||
| 6c739e2726 | |||
| 0ec830f5c6 | |||
| 713908ef95 | |||
| c3f41a24d5 | |||
| 013fbc5d66 | |||
| fd65b19f1d | |||
| 340c145863 | |||
| 2da182ec47 | |||
| 33a0877a6d | |||
| fdd1d5afb7 | |||
| bf996934af | |||
| 3e724f74fa | |||
| cfd5cbda55 | |||
| 0032da1562 | |||
| 7965e9c01a | |||
| f5df62c297 | |||
| 87ef5798d2 | |||
| 90e992636c | |||
| 44dbfdb3a8 | |||
| f60288a06c | |||
| 5906fc3375 | |||
| f36c6731e8 | |||
| 65ce8adc5d | |||
| 1d7c52fbca | |||
| 16f0e9b4e5 | |||
| 8dc41d52ed | |||
| 169b25ea12 | |||
| 205880b41a | |||
| 84555d11ed | |||
| 1dce82b74e | |||
| 3be4939ff5 | |||
| e054bb3490 | |||
| 75234095b7 | |||
| 4bdd4efdc3 | |||
| 47ca58a85a | |||
| d5d39a218a | |||
| ae7a45a911 |
@@ -1,5 +1,13 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.turbo
|
||||||
|
reference/
|
||||||
|
.next
|
||||||
!.next/cache
|
!.next/cache
|
||||||
.git
|
.git
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -8,3 +16,5 @@ node_modules
|
|||||||
docs
|
docs
|
||||||
reference
|
reference
|
||||||
public/datasheets/*.pdf
|
public/datasheets/*.pdf
|
||||||
|
.pnpm-store
|
||||||
|
.gitea
|
||||||
|
|||||||
42
.env
42
.env
@@ -1,10 +1,13 @@
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -14,21 +17,22 @@ MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
|||||||
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
# Directus
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
DIRECTUS_URL=https://cms.klz-cables.com
|
# Payload Infrastructure (Dockerized)
|
||||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
# manually write the connection strings here.
|
||||||
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
PAYLOAD_DB_NAME=payload
|
||||||
DIRECTUS_DB_NAME=directus
|
PAYLOAD_DB_USER=payload
|
||||||
DIRECTUS_DB_USER=directus
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
# Local Development
|
|
||||||
PROJECT_NAME=klz-cables
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
GATEKEEPER_BYPASS_ENABLED=true
|
# Hetzner S3 Object Storage
|
||||||
TRAEFIK_HOST=klz.localhost
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
DIRECTUS_HOST=cms.klz.localhost
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
COOKIE_DOMAIN=localhost
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
S3_BUCKET=mintel
|
||||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
S3_REGION=fsn1
|
||||||
|
S3_PREFIX=klz-cables
|
||||||
16
.env.example
16
.env.example
@@ -10,11 +10,11 @@
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
DIRECTUS_PORT=8055
|
|
||||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
TARGET=development
|
TARGET=development
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
@@ -46,9 +46,18 @@ MAIL_RECIPIENTS=info@klz-cables.com
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
# For Directus Error Tracking
|
|
||||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Payload Infrastructure (Dockerized)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
|
# manually write the connection strings here.
|
||||||
|
PAYLOAD_DB_NAME=payload
|
||||||
|
PAYLOAD_DB_USER=payload
|
||||||
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Deployment Configuration (CI/CD only)
|
# Deployment Configuration (CI/CD only)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -56,6 +65,9 @@ SENTRY_DSN=
|
|||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
TRAEFIK_HOST=klz-cables.com
|
TRAEFIK_HOST=klz-cables.com
|
||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
||||||
|
# Next.js will proxy requests from /_img to this URL.
|
||||||
|
IMGPROXY_URL=https://img.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Varnish Configuration
|
# Varnish Configuration
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
name: CI - Lint, Typecheck & Test
|
name: CI - Lint, Typecheck & Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-pipeline
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-assurance:
|
quality-assurance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -13,20 +14,38 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --no-frozen-lockfile
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: 🔍 Lint
|
- name: 🧪 QA Checks
|
||||||
run: npm run lint
|
env:
|
||||||
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
|
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
- name: 🏗️ Typecheck
|
- name: 🏗️ Build
|
||||||
run: npm run typecheck
|
run: pnpm build
|
||||||
|
|
||||||
- name: 🧪 Test
|
- name: ♿ Accessibility Check
|
||||||
run: npm run test
|
run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000"
|
||||||
|
|
||||||
|
- name: ♿ WCAG Sitemap Audit
|
||||||
|
run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000"
|
||||||
|
# monitor trigger
|
||||||
|
|||||||
@@ -1,45 +1,42 @@
|
|||||||
name: Build & Deploy KLZ Cables
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- '**'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
skip_long_checks:
|
skip_checks:
|
||||||
description: 'Skip tests? (true/false)'
|
description: 'Skip tests? (true/false)'
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
|
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
|
group: deploy-pipeline
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 1: Prepare & Determine Environment
|
# JOB 1: Prepare Environment
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
prepare:
|
prepare:
|
||||||
name: 🔍 Prepare Environment
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
target: ${{ steps.determine.outputs.target }}
|
target: ${{ steps.determine.outputs.target }}
|
||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
env_file: ${{ steps.determine.outputs.env_file }}
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
traefik_host_rule: ${{ steps.determine.outputs.traefik_host_rule }}
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
primary_host: ${{ steps.determine.outputs.primary_host }}
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
|
||||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
|
||||||
directus_host: ${{ steps.determine.outputs.directus_host }}
|
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
is_prod: ${{ steps.determine.outputs.is_prod }}
|
|
||||||
gotify_title: ${{ steps.determine.outputs.gotify_title }}
|
|
||||||
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
|
||||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -48,108 +45,114 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Purging old build layers and dangling images..."
|
echo "Purging old build layers and dangling images..."
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
docker builder prune -f --filter "until=6h"
|
docker builder prune -f --filter "until=24h"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: 🔍 Environment ermitteln
|
||||||
- name: 🔍 Environment & Version ermitteln
|
|
||||||
id: determine
|
id: determine
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.ref_name }}"
|
REF="${{ github.ref_name }}"
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
IMAGE_TAG="sha-${SHORT_SHA}"
|
DOMAIN="klz-cables.com"
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
PRJ="klz"
|
||||||
|
|
||||||
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||||
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
|
|
||||||
TARGET="skip"
|
|
||||||
GOTIFY_TITLE="ℹ️ Skip Deploy (Chore)"
|
|
||||||
GOTIFY_PRIORITY=2
|
|
||||||
else
|
|
||||||
TARGET="testing"
|
TARGET="testing"
|
||||||
IMAGE_TAG="main-${SHORT_SHA}"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.testing"
|
ENV_FILE=".env.testing"
|
||||||
TRAEFIK_HOST="testing.klz-cables.com"
|
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||||
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
|
|
||||||
DIRECTUS_URL="https://cms.testing.klz-cables.com"
|
|
||||||
DIRECTUS_HOST="cms.testing.klz-cables.com"
|
|
||||||
PROJECT_NAME="klz-cables-testing"
|
|
||||||
IS_PROD="false"
|
|
||||||
GOTIFY_TITLE="🧪 Testing-Deploy"
|
|
||||||
GOTIFY_PRIORITY=4
|
|
||||||
fi
|
|
||||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$TAG"
|
IMAGE_TAG="$REF"
|
||||||
ENV_FILE=".env.prod"
|
ENV_FILE=".env.prod"
|
||||||
TRAEFIK_HOST="klz-cables.com, www.klz-cables.com"
|
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||||
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
|
|
||||||
DIRECTUS_URL="https://cms.klz-cables.com"
|
|
||||||
DIRECTUS_HOST="cms.klz-cables.com"
|
|
||||||
PROJECT_NAME="klz-cables-prod"
|
|
||||||
IS_PROD="true"
|
|
||||||
GOTIFY_TITLE="🚀 Production-Release"
|
|
||||||
GOTIFY_PRIORITY=6
|
|
||||||
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
|
|
||||||
TARGET="staging"
|
|
||||||
IMAGE_TAG="$TAG"
|
|
||||||
ENV_FILE=".env.staging"
|
|
||||||
TRAEFIK_HOST="staging.klz-cables.com"
|
|
||||||
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
|
|
||||||
DIRECTUS_URL="https://cms.staging.klz-cables.com"
|
|
||||||
DIRECTUS_HOST="cms.staging.klz-cables.com"
|
|
||||||
PROJECT_NAME="klz-cables-staging"
|
|
||||||
IS_PROD="false"
|
|
||||||
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
|
||||||
GOTIFY_PRIORITY=5
|
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="staging"
|
||||||
GOTIFY_TITLE="❓ Unbekannter Tag"
|
IMAGE_TAG="$REF"
|
||||||
GOTIFY_PRIORITY=3
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="branch"
|
||||||
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
# Multi-domain: Host(`a.com`) || Host(`b.com`)
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}')
|
||||||
TRAEFIK_HOST_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
|
||||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
else
|
else
|
||||||
# Single domain: Host(`domain.com`)
|
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
|
||||||
TRAEFIK_HOST_RULE="Host(\`$TRAEFIK_HOST\`)"
|
|
||||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "target=$TARGET"
|
echo "target=$TARGET"
|
||||||
echo "image_tag=$IMAGE_TAG"
|
echo "image_tag=$IMAGE_TAG"
|
||||||
echo "env_file=$ENV_FILE"
|
echo "env_file=$ENV_FILE"
|
||||||
echo "traefik_host=$TRAEFIK_HOST"
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
echo "traefik_host_rule=$TRAEFIK_HOST_RULE"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
echo "primary_host=$PRIMARY_HOST"
|
echo "gatekeeper_host=$GATEKEEPER_HOST"
|
||||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
echo "directus_url=$DIRECTUS_URL"
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
echo "directus_host=$DIRECTUS_HOST"
|
echo "project_name=klz-cablescom"
|
||||||
echo "project_name=$PROJECT_NAME"
|
elif [[ "$TARGET" == "branch" ]]; then
|
||||||
echo "is_prod=$IS_PROD"
|
echo "project_name=$PRJ-branch-$SLUG"
|
||||||
echo "gotify_title=$GOTIFY_TITLE"
|
else
|
||||||
echo "gotify_priority=$GOTIFY_PRIORITY"
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
fi
|
||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
echo "commit_msg=$COMMIT_MSG"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
|
# 1. Discovery (Works without token for public repositories)
|
||||||
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" 2>/dev/null | grep "$TAG_TO_WAIT" | awk '{print $1}' | tail -n1 || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
else
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
if [[ -n "$POLL_TOKEN" ]]; then
|
||||||
|
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||||
|
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||||
|
chmod +x wait-for-upstream.sh
|
||||||
|
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||||
|
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 2: Quality Assurance (Lint & Test)
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
qa:
|
qa:
|
||||||
name: 🧪 Quality Assurance
|
name: 🧪 QA
|
||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -158,369 +161,436 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: 🔐 Registry Auth
|
||||||
run: npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
- name: 🧪 Run Checks in Parallel
|
|
||||||
if: github.event.inputs.skip_long_checks != 'true'
|
|
||||||
run: |
|
run: |
|
||||||
npm run lint &
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
LINT_PID=$!
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
npm run typecheck &
|
- name: Install dependencies
|
||||||
TYPE_PID=$!
|
run: |
|
||||||
npm run test &
|
pnpm store prune
|
||||||
TEST_PID=$!
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
# Wait for all and fail if any fail
|
- name: 🔒 Security Audit
|
||||||
wait $LINT_PID || exit 1
|
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||||
wait $TYPE_PID || exit 1
|
- name: 🧪 QA Checks
|
||||||
wait $TEST_PID || exit 1
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
|
env:
|
||||||
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
|
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push Docker Image
|
# JOB 3: Build & Push
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
build-app:
|
build:
|
||||||
name: 🏗️ Build App
|
name: 🏗️ Build
|
||||||
needs: prepare
|
needs: [prepare, qa]
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
- name: 🏗️ Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
- name: 🏗️ App bauen & pushen
|
with:
|
||||||
env:
|
context: .
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
push: true
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
provenance: false
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
platforms: linux/amd64
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
build-args: |
|
||||||
run: |
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
docker buildx build \
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
--pull \
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
--platform linux/arm64 \
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||||
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
secrets: |
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
||||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
|
|
||||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
|
|
||||||
--push .
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy via SSH
|
# JOB 4: Deploy
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
name: 🚀 Deploy
|
name: 🚀 Deploy
|
||||||
needs: [prepare, build-app, qa]
|
needs: [prepare, build, qa]
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
|
||||||
UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
|
||||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
|
||||||
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
|
||||||
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
|
||||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
|
||||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
|
|
||||||
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
|
|
||||||
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
|
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
|
||||||
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
|
||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
|
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||||
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
|
|
||||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
# Secrets mapping (Payload CMS)
|
||||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
|
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
||||||
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
|
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
||||||
|
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
|
||||||
|
|
||||||
|
# Secrets mapping (Mail)
|
||||||
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
|
||||||
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
|
||||||
|
# Gatekeeper
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
- name: 📝 Generate Environment
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: 🚀 Deploy to ${{ env.TARGET }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
# Middleware Selection Logic
|
||||||
|
# Regular app routes get auth on non-production
|
||||||
|
# Unprotected routes (/stats, /errors) never get auth
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
|
COMPOSE_PROFILES=""
|
||||||
|
else
|
||||||
|
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||||
|
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||||
|
COMPOSE_PROFILES="gatekeeper"
|
||||||
|
fi
|
||||||
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
|
# Gatekeeper Origin
|
||||||
|
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Generated by CI - $TARGET"
|
||||||
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
|
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||||
|
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||||
|
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||||
|
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||||
|
echo "MAIL_HOST=$MAIL_HOST"
|
||||||
|
echo "MAIL_PORT=$MAIL_PORT"
|
||||||
|
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||||
|
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||||
|
echo "MAIL_FROM=$MAIL_FROM"
|
||||||
|
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||||
|
echo ""
|
||||||
|
echo "# Payload CMS"
|
||||||
|
echo "PAYLOAD_SECRET=$PAYLOAD_SECRET"
|
||||||
|
echo "PAYLOAD_DB_NAME=$PAYLOAD_DB_NAME"
|
||||||
|
echo "PAYLOAD_DB_USER=$PAYLOAD_DB_USER"
|
||||||
|
echo "PAYLOAD_DB_PASSWORD=$PAYLOAD_DB_PASSWORD"
|
||||||
|
echo ""
|
||||||
|
echo "# Gatekeeper"
|
||||||
|
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||||
|
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||||
|
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
echo "# Analytics"
|
||||||
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
|
echo ""
|
||||||
|
echo "TARGET=$TARGET"
|
||||||
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
|
||||||
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
|
echo "TRAEFIK_TLS=true"
|
||||||
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
|
echo "ENV_FILE=$ENV_FILE"
|
||||||
|
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||||
|
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||||
|
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||||
|
} > .env.deploy
|
||||||
|
|
||||||
|
echo "--- Generated .env.deploy ---"
|
||||||
|
cat .env.deploy
|
||||||
|
echo "----------------------------"
|
||||||
|
|
||||||
|
- name: 🚀 SSH Deploy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
# Generated by CI - $TARGET - $(date -u)
|
# Transfer and Restart
|
||||||
# Determine dynamic values before writing the file
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
elif [[ "$TARGET" == "testing" ]]; then
|
||||||
|
SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
|
||||||
cat > /tmp/klz-cables.env << EOF
|
elif [[ "$TARGET" == "staging" ]]; then
|
||||||
# Generated by CI - $TARGET - $(date -u)
|
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
|
||||||
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
|
||||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
|
||||||
LOG_LEVEL=$LOG_LEVEL
|
|
||||||
MAIL_HOST=$MAIL_HOST
|
|
||||||
MAIL_PORT=$MAIL_PORT
|
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
|
||||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
|
||||||
MAIL_FROM=$MAIL_FROM
|
|
||||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
|
||||||
|
|
||||||
# Directus
|
|
||||||
DIRECTUS_URL=$DIRECTUS_URL
|
|
||||||
DIRECTUS_HOST=$DIRECTUS_HOST
|
|
||||||
DIRECTUS_KEY=$DIRECTUS_KEY
|
|
||||||
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
|
||||||
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
|
||||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
|
||||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
|
||||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
|
||||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
|
||||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
|
||||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
|
||||||
|
|
||||||
TARGET=$TARGET
|
|
||||||
SENTRY_ENVIRONMENT=$TARGET
|
|
||||||
PROJECT_NAME=$PROJECT_NAME
|
|
||||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
|
||||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Append complex variables that contain backticks using printf to avoid shell expansion hits
|
|
||||||
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> /tmp/klz-cables.env
|
|
||||||
printf "TRAEFIK_HOST_RULE='%s'\n" '${{ needs.prepare.outputs.traefik_host_rule }}' >> /tmp/klz-cables.env
|
|
||||||
|
|
||||||
# 1. Cleanup and Create Directories on server BEFORE SCP
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
|
||||||
set -e
|
|
||||||
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
|
||||||
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads \
|
|
||||||
/home/deploy/sites/klz-cables.com/directus/extensions \
|
|
||||||
/home/deploy/sites/klz-cables.com/directus/schema
|
|
||||||
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
|
||||||
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
|
||||||
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
|
||||||
fi
|
|
||||||
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. Transfer files
|
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
|
||||||
scp -r -o StrictHostKeyChecking=accept-new directus/schema root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/directus/
|
|
||||||
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
|
||||||
set -e
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
chown deploy:deploy "$ENV_FILE"
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
echo "→ Pulling image: $IMAGE_TAG"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
|
||||||
echo "→ Starting containers..."
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
|
||||||
docker system prune -f --filter "until=24h"
|
|
||||||
echo "→ Waiting 15s for warmup..."
|
|
||||||
sleep 15
|
|
||||||
echo "→ Container status:"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps
|
|
||||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps | grep -q "Up"; then
|
|
||||||
echo "❌ Fehler: Container nicht Up!"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "→ Applying Directus Schema Snapshot..."
|
|
||||||
# Note: We check if snapshot exists first to avoid failing if no snapshot is committed yet
|
|
||||||
if docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes
|
|
||||||
else
|
else
|
||||||
echo "ℹ️ No snapshot.yaml found, skipping schema apply."
|
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
|
||||||
|
fi
|
||||||
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||||
|
|
||||||
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
|
|
||||||
|
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
||||||
|
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
||||||
|
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||||
|
echo "⏳ Waiting for database container to be ready..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then
|
||||||
|
echo "✅ Database is ready."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Attempt $i/15..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "🔧 Sanitizing payload_migrations table (if exists)..."
|
||||||
|
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
|
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
|
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||||
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
|
|
||||||
|
# Auto-detect migrations from src/migrations/*.ts
|
||||||
|
BATCH=1
|
||||||
|
VALUES=""
|
||||||
|
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
||||||
|
NAME=$(basename "$f" .ts)
|
||||||
|
[ -n "$VALUES" ] && VALUES="$VALUES,"
|
||||||
|
VALUES="$VALUES ('$NAME', $BATCH)"
|
||||||
|
((BATCH++))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$VALUES" ]; then
|
||||||
|
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||||
|
DO \\\$\\\$ BEGIN
|
||||||
|
DELETE FROM payload_migrations WHERE batch = -1;
|
||||||
|
INSERT INTO payload_migrations (name, batch)
|
||||||
|
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||||
|
END \\\$\\\$;
|
||||||
|
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "→ Verifying Varnish Backend Health..."
|
# Restart app to pick up clean migration state
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
|
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||||
echo "❌ Fehler: Varnish Backend ist SICK!"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
exit 1
|
|
||||||
fi
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
echo "✅ Varnish Backend ist Healthy."
|
if: always()
|
||||||
|
run: docker builder prune -f --filter "until=1h"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 5: PageSpeed Test
|
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
pagespeed:
|
post_deploy_checks:
|
||||||
name: ⚡ PageSpeed
|
name: 🧪 Post-Deploy Verification
|
||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
if: |
|
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||||
always() &&
|
|
||||||
needs.prepare.outputs.target != 'skip' &&
|
|
||||||
needs.deploy.result == 'success' &&
|
|
||||||
github.event.inputs.skip_long_checks != 'true'
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
# outputs:
|
|
||||||
# report_url: ${{ steps.save.outputs.report_url }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
id: deps
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Cache APT Packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||||
|
|
||||||
|
- name: 💾 Cache Chromium
|
||||||
|
id: cache-chromium
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /usr/bin/chromium
|
||||||
|
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y gnupg wget ca-certificates
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
# Detect OS
|
|
||||||
OS_ID=$(. /etc/os-release && echo $ID)
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
if [ "$OS_ID" = "debian" ]; then
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
echo "🎯 Debian detected - installing native chromium"
|
|
||||||
apt-get install -y chromium
|
apt-get install -y chromium
|
||||||
else
|
else
|
||||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
|
||||||
mkdir -p /etc/apt/keyrings
|
mkdir -p /etc/apt/keyrings
|
||||||
KEY_ID="82BB6851C64F6880"
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
# Multi-method Key Fetch
|
|
||||||
SUCCESS=false
|
|
||||||
echo "Fetching key $KEY_ID..."
|
|
||||||
|
|
||||||
# Method 1: gpg --recv-keys (standard)
|
|
||||||
for server in "hkp://keyserver.ubuntu.com:80" "hkp://keyserver.ubuntu.com:11371"; do
|
|
||||||
if gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --keyserver "$server" --recv-keys "$KEY_ID"; then
|
|
||||||
gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --export > /etc/apt/keyrings/xtradeb.gpg
|
|
||||||
SUCCESS=true && break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Method 2: Direct wget (fallback)
|
|
||||||
if [ "$SUCCESS" = false ]; then
|
|
||||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg && SUCCESS=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$SUCCESS" = true ]; then
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
else
|
|
||||||
echo "⚠️ GPG fetch failed, using legacy apt-key as last resort..."
|
|
||||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$KEY_ID" || true
|
|
||||||
echo "deb http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
|
||||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --allow-downgrades chromium || apt-get install -y chromium-browser
|
apt-get install -y --allow-downgrades chromium
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force clean paths (remove existing dead links/files if they are snap wrappers)
|
|
||||||
rm -f /usr/bin/google-chrome /usr/bin/chromium-browser
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
echo "✅ Binary check:"
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
ls -l /usr/bin/chromium* /usr/bin/google-chrome || true
|
- name: 🏥 CMS Deep Health Check
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: 🧪 Run PageSpeed (Lighthouse)
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: |
|
||||||
|
echo "Waiting 60s for app to fully start (cold start / migrations / hydration)..."
|
||||||
|
sleep 60
|
||||||
|
|
||||||
|
echo "Checking basic health..."
|
||||||
|
curl -sfL --retry 5 --retry-delay 10 "$DEPLOY_URL/health" || { echo "❌ Basic health check failed after retries"; exit 1; }
|
||||||
|
echo "✅ Basic health OK"
|
||||||
|
|
||||||
|
echo "Checking CMS DB connectivity..."
|
||||||
|
# We use -v to see if we hit Gatekeeper (307) or 503/504
|
||||||
|
URL="$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS&cb=$(date +%s)"
|
||||||
|
RESPONSE=$(curl -sfL --retry 5 --retry-delay 10 "$URL" 2>&1) || {
|
||||||
|
echo "⚠️ CMS health check failed, but site might still be fine."
|
||||||
|
echo "--- DEBUG INFO ---"
|
||||||
|
curl -svI "$URL"
|
||||||
|
echo "--- RESPONSE BODY ---"
|
||||||
|
curl -sL "$URL" | head -c 1000
|
||||||
|
echo ""
|
||||||
|
echo "Proceeding to smoke tests to confirm actual site functionality."
|
||||||
|
}
|
||||||
|
echo "✅ CMS health output: $RESPONSE"
|
||||||
|
|
||||||
|
|
||||||
|
- name: 🚀 OG Image Check
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
env:
|
||||||
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
run: pnpm run check:og
|
||||||
|
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||||
|
with:
|
||||||
|
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
PAGESPEED_LIMIT: 8
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
|
||||||
|
- name: 📝 E2E Form Submission Test
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET }}
|
||||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
CHROME_PATH: /usr/bin/chromium
|
run: pnpm run check:forms
|
||||||
run: npm run pagespeed:test
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 6: Notifications
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notifications
|
name: 🔔 Notify
|
||||||
needs: [prepare, qa, build-app, deploy, pagespeed]
|
needs: [prepare, deploy, post_deploy_checks]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 📊 Deployment Summary
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "┌──────────────────────────────┐"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
echo "│ Deployment Summary │"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
echo "├──────────────────────────────┤"
|
PERF="${{ needs.post_deploy_checks.result }}"
|
||||||
echo "│ Status: ${{ needs.deploy.result }} │"
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
|
|
||||||
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
|
|
||||||
echo "└──────────────────────────────┘"
|
|
||||||
|
|
||||||
- name: 🔔 Gotify - Success
|
# Gotify priority scale:
|
||||||
if: needs.deploy.result == 'success'
|
# 1-3 = low (silent/info)
|
||||||
run: |
|
# 4-5 = normal
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
# 6-7 = high (warning)
|
||||||
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
# 8-10 = critical (alarm)
|
||||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
if [[ "$DEPLOY" != "success" ]]; then
|
||||||
-F "priority=4" || true
|
PRIORITY=10
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS_LINE="DEPLOY FAILED"
|
||||||
|
elif [[ "$SMOKE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="⚠️"
|
||||||
|
STATUS_LINE="Smoke tests failed"
|
||||||
|
elif [[ "$PERF" != "success" ]]; then
|
||||||
|
PRIORITY=5
|
||||||
|
EMOJI="📉"
|
||||||
|
STATUS_LINE="Performance degraded"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS_LINE="All checks passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
||||||
|
MESSAGE="$STATUS_LINE
|
||||||
|
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||||
|
$URL"
|
||||||
|
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||||
|
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 🔔 Gotify - Failure
|
|
||||||
if: |
|
|
||||||
needs.prepare.result == 'failure' ||
|
|
||||||
needs.qa.result == 'failure' ||
|
|
||||||
needs.build-app.result == 'failure' ||
|
|
||||||
needs.deploy.result == 'failure' ||
|
|
||||||
needs.pagespeed.result == 'failure'
|
|
||||||
run: |
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
-F "title=$TITLE" \
|
||||||
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
|
-F "message=$MESSAGE" \
|
||||||
-F "priority=8" || true
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
236
.gitea/workflows/qa.yml
Normal file
236
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
name: Nightly QA
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_URL: 'https://testing.klz-cables.com'
|
||||||
|
PROJECT_NAME: 'klz-2026'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 1. Static Checks (HTML, Assets, HTTP)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
static:
|
||||||
|
name: 🔍 Static Analysis
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🌐 HTML Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:html
|
||||||
|
- name: 🖼️ Broken Assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
ASSET_CHECK_LIMIT: 10
|
||||||
|
run: pnpm run check:assets
|
||||||
|
- name: 🔒 HTTP Headers
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:http
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 2. Accessibility (WCAG)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
a11y:
|
||||||
|
name: ♿ Accessibility
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: ♿ WCAG Scan
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 3. Performance (Lighthouse)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: 🎭 Lighthouse
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🎭 Desktop
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||||
|
- name: 📱 Mobile
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 4. Link Check & Dependency Audit
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
links:
|
||||||
|
name: 🔗 Links & Deps
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Depcheck
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||||
|
- name: 🔗 Lychee Link Check
|
||||||
|
uses: lycheeverse/lychee-action@v2
|
||||||
|
with:
|
||||||
|
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||||
|
fail: true
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 5. Notification
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
notify:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [static, a11y, lighthouse, links]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
STATIC="${{ needs.static.result }}"
|
||||||
|
A11Y="${{ needs.a11y.result }}"
|
||||||
|
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||||
|
LINKS="${{ needs.links.result }}"
|
||||||
|
|
||||||
|
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS="Failed"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS="Passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||||
|
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||||
|
${{ env.TARGET_URL }}"
|
||||||
|
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||||
|
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=$TITLE" \
|
||||||
|
-F "message=$MESSAGE" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,9 +1,30 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.pnpm-store
|
||||||
|
public/uploads
|
||||||
|
public/media
|
||||||
|
|
||||||
# Directus
|
# Lighthouse CI
|
||||||
|
.lighthouseci/
|
||||||
|
lighthouserc.cjs
|
||||||
|
.lighthouserc.json
|
||||||
|
|
||||||
|
# Legacy (Directus) cleanup
|
||||||
directus/uploads
|
directus/uploads
|
||||||
!directus/extensions/
|
|
||||||
!directus/schema/
|
.next-docker
|
||||||
!directus/migrations/
|
|
||||||
|
# Pa11y CI
|
||||||
|
.pa11yci/
|
||||||
|
|
||||||
|
.htmlvalidate-tmp
|
||||||
|
|
||||||
|
# Turborepo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Test Outputs
|
||||||
|
html-errors*.json
|
||||||
|
reference/
|
||||||
|
# Database backups
|
||||||
|
backups/
|
||||||
|
|||||||
26
.htmlvalidate.json
Normal file
26
.htmlvalidate.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": ["html-validate:recommended", "html-validate:document"],
|
||||||
|
"rules": {
|
||||||
|
"require-sri": "off",
|
||||||
|
"meta-refresh": "off",
|
||||||
|
"heading-level": "warn",
|
||||||
|
"no-trailing-whitespace": "off",
|
||||||
|
"wcag/h37": "warn",
|
||||||
|
"no-inline-style": "off",
|
||||||
|
"svg-focusable": "off",
|
||||||
|
"attribute-boolean-style": "off",
|
||||||
|
"attr-case": "off",
|
||||||
|
"void-style": "off",
|
||||||
|
"no-implicit-button-type": "off",
|
||||||
|
"unique-landmark": "off",
|
||||||
|
"long-title": "off",
|
||||||
|
"valid-id": "off",
|
||||||
|
"element-required-attributes": "off",
|
||||||
|
"attribute-empty-style": "off",
|
||||||
|
"element-permitted-content": "off",
|
||||||
|
"element-required-content": "off",
|
||||||
|
"element-permitted-parent": "off",
|
||||||
|
"no-implicit-close": "off",
|
||||||
|
"close-order": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
.husky/pre-push
Executable file
32
.husky/pre-push
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Husky pre-push hook to validate tags
|
||||||
|
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
|
||||||
|
|
||||||
|
z40=0000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
while read local_ref local_sha remote_ref remote_sha
|
||||||
|
do
|
||||||
|
# Check if we are pushing a tag
|
||||||
|
case "$local_ref" in
|
||||||
|
refs/tags/*)
|
||||||
|
tag_name="${local_ref#refs/tags/}"
|
||||||
|
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: Invalid tag name '$tag_name'"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo "Consistency check failed: All tags MUST start with 'v'."
|
||||||
|
echo "Example: v1.0.10"
|
||||||
|
echo ""
|
||||||
|
echo "Please delete the invalid tag and create a new one:"
|
||||||
|
echo " git tag -d $tag_name"
|
||||||
|
echo " git tag v$tag_name"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const path = require('path');
|
/* eslint-disable no-undef */
|
||||||
|
const path = require('path'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||||
|
|
||||||
const buildEslintCommand = (filenames) =>
|
const buildEslintCommand = (filenames) =>
|
||||||
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
|
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||||
26
.pa11yci.json
Normal file
26
.pa11yci.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"standard": "WCAG2AA",
|
||||||
|
"runners": ["axe", "htmlcs"],
|
||||||
|
"ignore": [],
|
||||||
|
"timeout": 50000,
|
||||||
|
"wait": 1000,
|
||||||
|
"chromeLaunchConfig": {
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
},
|
||||||
|
"threshold": 25
|
||||||
|
},
|
||||||
|
"urls": [
|
||||||
|
"http://localhost:3000/en",
|
||||||
|
"http://localhost:3000/en/blog",
|
||||||
|
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
|
||||||
|
"http://localhost:3000/en/contact",
|
||||||
|
"http://localhost:3000/en/team",
|
||||||
|
"http://localhost:3000/en/products",
|
||||||
|
"http://localhost:3000/en/products/medium-voltage-cables",
|
||||||
|
"http://localhost:3000/en/products/low-voltage-cables",
|
||||||
|
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
|
||||||
|
"http://localhost:3000/en/legal-notice",
|
||||||
|
"http://localhost:3000/en/privacy-policy"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Ignore Next.js auto-generated environment file
|
||||||
|
# It often uses different quote styles than our project config
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
|
||||||
|
# Ignore other potentially generated files
|
||||||
|
pnpm-lock.yaml
|
||||||
648
AVB_2026.html
Normal file
648
AVB_2026.html
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
<h1 id="allgemeine-verkaufsbedingungen">Allgemeine
|
||||||
|
Verkaufsbedingungen</h1>
|
||||||
|
<h3 id="geltungsbereich">1 Geltungsbereich</h3>
|
||||||
|
<p>1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen
|
||||||
|
(nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr
|
||||||
|
(nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH,
|
||||||
|
Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und
|
||||||
|
Unternehmern in Ausübung ihrer gewerblichen oder selbständigen
|
||||||
|
beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts
|
||||||
|
(nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder
|
||||||
|
entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird
|
||||||
|
hiermit widersprochen.</p>
|
||||||
|
<p>1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an
|
||||||
|
den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen
|
||||||
|
AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden
|
||||||
|
ausführt.</p>
|
||||||
|
<p>1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch
|
||||||
|
dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich
|
||||||
|
erneut in den Vertrag einbezogen wurden.</p>
|
||||||
|
<p>1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax
|
||||||
|
oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen
|
||||||
|
nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten
|
||||||
|
die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht
|
||||||
|
und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der
|
||||||
|
Mitteilung über die Änderung der Geschäftsbedingungen gesondert
|
||||||
|
hingewiesen.</p>
|
||||||
|
<p>1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen
|
||||||
|
dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige
|
||||||
|
Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in
|
||||||
|
diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen
|
||||||
|
werden.</p>
|
||||||
|
<h3 id="vertragsschluss">2 Vertragsschluss</h3>
|
||||||
|
<p>2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich
|
||||||
|
durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail
|
||||||
|
oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform
|
||||||
|
z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der
|
||||||
|
Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag
|
||||||
|
oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des
|
||||||
|
Angebots bei KLZ vorbehaltlos ausführt.</p>
|
||||||
|
<p>2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich
|
||||||
|
Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen –
|
||||||
|
freibleibend und unverbindlich, sofern sie nicht ausdrücklich in
|
||||||
|
Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen
|
||||||
|
unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen
|
||||||
|
Selbstbelieferung von KLZ.</p>
|
||||||
|
<p>2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem
|
||||||
|
jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.</p>
|
||||||
|
<p>2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen,
|
||||||
|
Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige
|
||||||
|
Beschreibungen der zu liefernden Ware in dem Angebot oder der
|
||||||
|
Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht
|
||||||
|
ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden.
|
||||||
|
Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer
|
||||||
|
entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware
|
||||||
|
dar, es sei denn, sie wurden ausdrücklich in Textform als solche
|
||||||
|
vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich
|
||||||
|
der zu liefernden Ware oder deren Verwendung.</p>
|
||||||
|
<p>2.5 Handelsübliche und branchenübliche Abweichungen vom
|
||||||
|
Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit
|
||||||
|
sie die Verwendbarkeit der zu liefernden Ware zum vertraglich
|
||||||
|
vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder
|
||||||
|
Unterlieferungen. Abweichungen der zu liefernden Ware, die durch
|
||||||
|
zwingende rechtliche oder technische Normen, die nach
|
||||||
|
Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit
|
||||||
|
sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu
|
||||||
|
einer technischen Verbesserung der zu liefernden Ware führen, sind stets
|
||||||
|
zulässig.</p>
|
||||||
|
<p>2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer
|
||||||
|
Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ.
|
||||||
|
Mehraufwände und sonstige Kosten, die durch die Änderung des
|
||||||
|
Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden
|
||||||
|
gesondert zu vergüten.</p>
|
||||||
|
<p>2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ
|
||||||
|
offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das
|
||||||
|
Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht
|
||||||
|
verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.</p>
|
||||||
|
<p>2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden
|
||||||
|
wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder
|
||||||
|
vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse
|
||||||
|
abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag
|
||||||
|
zurückzutreten.</p>
|
||||||
|
<h3 id="abrufaufträge">3 Abrufaufträge</h3>
|
||||||
|
<p>3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die
|
||||||
|
Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist
|
||||||
|
der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen,
|
||||||
|
dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der
|
||||||
|
Ware mindestens vierzehn (14) Werktage und zwischen Eingang der
|
||||||
|
Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig
|
||||||
|
(90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen
|
||||||
|
es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz
|
||||||
|
von KLZ handelt.</p>
|
||||||
|
<h3 id="preise-und-zahlungsbedingungen">4 Preise und
|
||||||
|
Zahlungsbedingungen</h3>
|
||||||
|
<p>4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ
|
||||||
|
angegebenen Preise gelten für den in dem Angebot oder der
|
||||||
|
Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich
|
||||||
|
grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem
|
||||||
|
jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und
|
||||||
|
zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht
|
||||||
|
ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem
|
||||||
|
Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise
|
||||||
|
verstehen sich darüber hinaus zuzüglich Metallzuschlag und
|
||||||
|
auftragsspezifischer Schnittkosten.</p>
|
||||||
|
<p>4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur
|
||||||
|
Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen
|
||||||
|
erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet
|
||||||
|
und vergütet.</p>
|
||||||
|
<p>4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der
|
||||||
|
vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie,
|
||||||
|
Transport, Arbeitskraft oder Material zwischen Vertragsschluss und
|
||||||
|
Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien
|
||||||
|
partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw.
|
||||||
|
des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen
|
||||||
|
Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab
|
||||||
|
Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung
|
||||||
|
gefunden wird.</p>
|
||||||
|
<p>4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung
|
||||||
|
der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung
|
||||||
|
fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart
|
||||||
|
sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10)
|
||||||
|
Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem
|
||||||
|
welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu
|
||||||
|
bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des
|
||||||
|
ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf
|
||||||
|
der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des
|
||||||
|
ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer
|
||||||
|
gesonderten Mahnung von KLZ bedarf.</p>
|
||||||
|
<p>4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ
|
||||||
|
berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der
|
||||||
|
Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt
|
||||||
|
auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden.
|
||||||
|
Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund
|
||||||
|
dessen Zahlungsverzug uneingeschränkt zu.</p>
|
||||||
|
<p>4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen
|
||||||
|
in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten
|
||||||
|
und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem
|
||||||
|
Kunden hieraus Ansprüche entstehen.</p>
|
||||||
|
<p>4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit
|
||||||
|
Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als
|
||||||
|
seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind,
|
||||||
|
oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis
|
||||||
|
zueinanderstehen.</p>
|
||||||
|
<h3 id="metallnotierung">5 Metallnotierung</h3>
|
||||||
|
<p>5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des
|
||||||
|
Liefervormonats der Notierung „LME Copper official price cash offer“,
|
||||||
|
zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.</p>
|
||||||
|
<p>5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt
|
||||||
|
des Liefervormonats der Notierung „LME Aluminium official price cash
|
||||||
|
offer” zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.</p>
|
||||||
|
<p>5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR
|
||||||
|
umgerechnet. Die entsprechenden Notierungen können der Webseite
|
||||||
|
www.westmetall.com entnommen werden. Die Prämienzuschläge können stark
|
||||||
|
variieren und KLZ behält sich das Recht vor, diese fristgerecht
|
||||||
|
anzupassen, ungeachtet der Angebotslegung.</p>
|
||||||
|
<h3 id="metallzahl">6 Metallzahl</h3>
|
||||||
|
<p>6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung
|
||||||
|
ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für
|
||||||
|
den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels
|
||||||
|
eingeht.</p>
|
||||||
|
<p>6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden
|
||||||
|
eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen.
|
||||||
|
Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel
|
||||||
|
enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer
|
||||||
|
Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel
|
||||||
|
verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden
|
||||||
|
hiermit ausdrücklich darauf hin, final nur den Vollpreis für
|
||||||
|
Vergleichszwecke heranzuziehen.</p>
|
||||||
|
<p>6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu
|
||||||
|
bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher
|
||||||
|
Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des
|
||||||
|
Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.</p>
|
||||||
|
<h3 id="lieferung">7 Lieferung</h3>
|
||||||
|
<p>7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern
|
||||||
|
die Parteien keine anderweitige Vereinbarung in Textform getroffen
|
||||||
|
haben.</p>
|
||||||
|
<p>7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen
|
||||||
|
vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen,
|
||||||
|
wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens
|
||||||
|
von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten
|
||||||
|
würde.</p>
|
||||||
|
<p>7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind
|
||||||
|
grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss
|
||||||
|
ausdrücklich in Textform als solche gekennzeichnet werden.</p>
|
||||||
|
<p>7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung
|
||||||
|
bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige
|
||||||
|
Klärung aller kaufmännischen und technischen Fragen, sowie die
|
||||||
|
Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen
|
||||||
|
Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht
|
||||||
|
rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies
|
||||||
|
gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten
|
||||||
|
hat.</p>
|
||||||
|
<p>7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter
|
||||||
|
dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer
|
||||||
|
und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten.
|
||||||
|
KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum
|
||||||
|
Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden
|
||||||
|
unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom
|
||||||
|
Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des
|
||||||
|
Kunden zurück.</p>
|
||||||
|
<p>7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware
|
||||||
|
dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort
|
||||||
|
innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten
|
||||||
|
hat.</p>
|
||||||
|
<p>7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt
|
||||||
|
KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ
|
||||||
|
nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der
|
||||||
|
Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen
|
||||||
|
ist.</p>
|
||||||
|
<p>7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf
|
||||||
|
Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware,
|
||||||
|
oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu
|
||||||
|
vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der
|
||||||
|
vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2)
|
||||||
|
% des jeweiligen Nettoauftragswertes für jeden angefangenen Monat,
|
||||||
|
maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung
|
||||||
|
zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des
|
||||||
|
Nettoauftragswertes pro Vertrag beschränkt.</p>
|
||||||
|
<p>7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden
|
||||||
|
Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der
|
||||||
|
Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende
|
||||||
|
Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen
|
||||||
|
verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der
|
||||||
|
Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer
|
||||||
|
Schaden entstanden ist.</p>
|
||||||
|
<p>7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert.
|
||||||
|
Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen
|
||||||
|
eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu
|
||||||
|
beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige
|
||||||
|
Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität
|
||||||
|
schließt KLZ aus.</p>
|
||||||
|
<p>7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in
|
||||||
|
Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.</p>
|
||||||
|
<h3 id="gefahrübergang">8 Gefahrübergang</h3>
|
||||||
|
<p>8.1 Die Gefahr des zufälligen Untergangs und der zufälligen
|
||||||
|
Verschlechterung der zu liefernden Ware geht grundsätzlich DAP
|
||||||
|
(INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine
|
||||||
|
anderweitige Vereinbarung in Textform getroffen haben.</p>
|
||||||
|
<p>8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen,
|
||||||
|
die der Kunde zu vertreten hat, geht die Gefahr des zufälligen
|
||||||
|
Untergangs und der zufälligen Verschlechterung der Ware mit der
|
||||||
|
Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen
|
||||||
|
Öffnungszeiten des Kunden durch KLZ auf den Kunden über.</p>
|
||||||
|
<h3 id="eigentumsvorbehalt">9 Eigentumsvorbehalt</h3>
|
||||||
|
<p>9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen
|
||||||
|
Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung
|
||||||
|
zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend
|
||||||
|
„Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die
|
||||||
|
Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere
|
||||||
|
auch Saldoforderungen aus laufender Rechnung.</p>
|
||||||
|
<p>9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des
|
||||||
|
ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder
|
||||||
|
weiterzuverarbeiten.</p>
|
||||||
|
<p>9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den
|
||||||
|
Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der
|
||||||
|
neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer
|
||||||
|
einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt
|
||||||
|
oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum
|
||||||
|
an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den
|
||||||
|
anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung,
|
||||||
|
Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung,
|
||||||
|
Vermengung oder sonstige Verbindung dergestalt, dass die Sache des
|
||||||
|
Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt
|
||||||
|
dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem
|
||||||
|
Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu
|
||||||
|
übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für
|
||||||
|
Vorbehaltsware.</p>
|
||||||
|
<p>9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für
|
||||||
|
KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen
|
||||||
|
Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt
|
||||||
|
seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab;
|
||||||
|
KLZ nimmt die Abtretung an.</p>
|
||||||
|
<p>9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen
|
||||||
|
Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern.
|
||||||
|
Verpfändungen und Sicherungsübereignungen hinsichtlich der
|
||||||
|
Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung
|
||||||
|
oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt
|
||||||
|
werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde
|
||||||
|
hat den Dritten darüber hinaus unverzüglich in Textform darauf
|
||||||
|
hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der
|
||||||
|
Dritte nicht in der Lage ist, KLZ die gerichtlichen und
|
||||||
|
außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten,
|
||||||
|
haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende
|
||||||
|
Ansprüche gegen den Kunden bleiben unberührt.</p>
|
||||||
|
<p>9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der
|
||||||
|
Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen
|
||||||
|
Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches
|
||||||
|
gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware
|
||||||
|
treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B.
|
||||||
|
Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei
|
||||||
|
Verlust oder Zerstörung.</p>
|
||||||
|
<p>9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit
|
||||||
|
anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit
|
||||||
|
einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der
|
||||||
|
Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine
|
||||||
|
Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten
|
||||||
|
Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.</p>
|
||||||
|
<p>9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ
|
||||||
|
einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen
|
||||||
|
Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht
|
||||||
|
selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ
|
||||||
|
zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der
|
||||||
|
Forderung an Dritte, einschließlich Forderungsverkauf an Factoring
|
||||||
|
Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von
|
||||||
|
KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine
|
||||||
|
gleichwertige Sicherheit gestellt wird.</p>
|
||||||
|
<p>9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung
|
||||||
|
widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug
|
||||||
|
befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren
|
||||||
|
über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen
|
||||||
|
wurde.</p>
|
||||||
|
<p>9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10)
|
||||||
|
Werktagen auf ein von KLZ benanntes Konto zu überweisen.</p>
|
||||||
|
<p>9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von
|
||||||
|
dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern
|
||||||
|
anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner
|
||||||
|
Abnehmer bekanntzugeben und alle erforderlichen Unterlagen
|
||||||
|
herauszugeben, die für die Einziehung der Forderung durch KLZ selbst
|
||||||
|
erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet,
|
||||||
|
die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich
|
||||||
|
mitzuteilen.</p>
|
||||||
|
<p>9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden
|
||||||
|
Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der
|
||||||
|
gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl
|
||||||
|
der danach freizugebenden Gegenstände liegt bei KLZ.</p>
|
||||||
|
<p>9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden,
|
||||||
|
insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist
|
||||||
|
KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in
|
||||||
|
diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die
|
||||||
|
erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an
|
||||||
|
KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend
|
||||||
|
zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden
|
||||||
|
oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie
|
||||||
|
bereits verarbeitet ist, und eine Aufstellung aller abgetretenen
|
||||||
|
Forderungen nebst Rechnungsgutschriften.</p>
|
||||||
|
<h3 id="höhere-gewalt">10 Höhere Gewalt</h3>
|
||||||
|
<p>10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder
|
||||||
|
Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer
|
||||||
|
vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem
|
||||||
|
Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis
|
||||||
|
außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt
|
||||||
|
des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c)
|
||||||
|
die Auswirkungen des Hindernisses von der betroffenen Partei nicht in
|
||||||
|
zumutbarer Weise hätte vermieden oder überwunden werden können.</p>
|
||||||
|
<p>10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht
|
||||||
|
abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im
|
||||||
|
Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs-
|
||||||
|
und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen,
|
||||||
|
Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise-
|
||||||
|
und Exportverbote oder staatliche Einreise- und Importverbote,
|
||||||
|
Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis,
|
||||||
|
Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder
|
||||||
|
Versammlungen, Ereignisse, die das Passieren von wichtigen
|
||||||
|
Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere
|
||||||
|
Boykott, Streik und Aussperrungen, Energieknappheit oder
|
||||||
|
Beeinträchtigung von Transportmitteln.</p>
|
||||||
|
<p>10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist
|
||||||
|
ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung
|
||||||
|
unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen
|
||||||
|
Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen
|
||||||
|
vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern
|
||||||
|
der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei
|
||||||
|
unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten
|
||||||
|
Hindernisses oder Ereignisses vorübergehend, so gelten die eben
|
||||||
|
dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die
|
||||||
|
Vertragserfüllung durch die betroffene Partei verhindert sowie einer
|
||||||
|
angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer
|
||||||
|
Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die
|
||||||
|
andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung
|
||||||
|
bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige
|
||||||
|
Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts
|
||||||
|
sind ausgeschlossen.</p>
|
||||||
|
<p>10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb
|
||||||
|
einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ
|
||||||
|
eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von
|
||||||
|
diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der
|
||||||
|
Ware besteht.</p>
|
||||||
|
<p>10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die
|
||||||
|
wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware
|
||||||
|
erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt,
|
||||||
|
werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von
|
||||||
|
Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht
|
||||||
|
vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten.
|
||||||
|
Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach
|
||||||
|
Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem
|
||||||
|
Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden
|
||||||
|
eine Verlängerung der Lieferzeit vereinbart war.</p>
|
||||||
|
<h3 id="gewährleistung-und-mängelrüge">11 Gewährleistung und
|
||||||
|
Mängelrüge</h3>
|
||||||
|
<p>11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung
|
||||||
|
der gelieferten Ware von den vereinbarten Spezifikationen, bei
|
||||||
|
unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit,
|
||||||
|
bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung
|
||||||
|
ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer
|
||||||
|
äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren
|
||||||
|
Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß
|
||||||
|
Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für
|
||||||
|
diese und die daraus entstehenden Folgen ebenfalls keine
|
||||||
|
Mängelansprüche.</p>
|
||||||
|
<p>11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der
|
||||||
|
Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht
|
||||||
|
nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf
|
||||||
|
äußerlich erkennbare Transportschäden und Identität der gelieferten Ware
|
||||||
|
mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind
|
||||||
|
unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab
|
||||||
|
Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer
|
||||||
|
Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in
|
||||||
|
Textform geltend zu machen.</p>
|
||||||
|
<p>11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen
|
||||||
|
Mängel, der betroffenen Lieferungen und wenn möglich, auch eine
|
||||||
|
bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt,
|
||||||
|
mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung
|
||||||
|
anzufordern.</p>
|
||||||
|
<p>11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in
|
||||||
|
Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger
|
||||||
|
vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob
|
||||||
|
Auffälligkeiten zu vermerken sind.</p>
|
||||||
|
<p>11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist,
|
||||||
|
anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung)
|
||||||
|
oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der
|
||||||
|
Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang
|
||||||
|
unterstützen.</p>
|
||||||
|
<p>11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei
|
||||||
|
berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für
|
||||||
|
Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch
|
||||||
|
den Kunden an einen anderen als den Ort der gewerblichen Niederlassung
|
||||||
|
des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht
|
||||||
|
dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz
|
||||||
|
1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass
|
||||||
|
der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.</p>
|
||||||
|
<p>11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder
|
||||||
|
grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu
|
||||||
|
verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.</p>
|
||||||
|
<p>11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der
|
||||||
|
Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten
|
||||||
|
Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts
|
||||||
|
oder der Tauglichkeit der gelieferten Waren ist der Rücktritt
|
||||||
|
ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet
|
||||||
|
oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ
|
||||||
|
gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz
|
||||||
|
wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom
|
||||||
|
Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der
|
||||||
|
Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung
|
||||||
|
angezeigt.</p>
|
||||||
|
<p>11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde
|
||||||
|
unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz
|
||||||
|
verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher
|
||||||
|
Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der
|
||||||
|
Leistung verlangen kann.</p>
|
||||||
|
<p>11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel
|
||||||
|
an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige
|
||||||
|
Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für
|
||||||
|
die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ
|
||||||
|
ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts
|
||||||
|
zu tragen, wenn das Gutachten einen Mangel feststellt.</p>
|
||||||
|
<p>11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden
|
||||||
|
verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438
|
||||||
|
Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon
|
||||||
|
unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz,
|
||||||
|
grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit,
|
||||||
|
oder jedweder wesentlicher Vertragspflichtverletzung, zwingender
|
||||||
|
gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei
|
||||||
|
Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen
|
||||||
|
Verschweigen eines Mangels. Die gesetzlichen Regelungen über
|
||||||
|
Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.</p>
|
||||||
|
<h3 id="haftung">12 Haftung</h3>
|
||||||
|
<p>12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit.
|
||||||
|
KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des
|
||||||
|
Lebens, des Körpers oder der Gesundheit, sowie für arglistiges
|
||||||
|
Verschweigen eines Mangels und Verletzung von garantierten
|
||||||
|
Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher
|
||||||
|
Vorschriften, wie etwa denen des Produkthaftungsgesetzes.</p>
|
||||||
|
<p>12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ
|
||||||
|
im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ
|
||||||
|
typischerweise vorhersehbaren Schaden bzw. die typischerweise
|
||||||
|
vorhersehbaren Aufwendungen. Vertragswesentlich sind die
|
||||||
|
Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung
|
||||||
|
erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch
|
||||||
|
vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht
|
||||||
|
auf Schadensersatz.</p>
|
||||||
|
<p>12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge
|
||||||
|
von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden,
|
||||||
|
die bei bestimmungsgemäßer Verwendung der gelieferten Ware
|
||||||
|
typischerweise zu erwarten sind.</p>
|
||||||
|
<p>12.4 Die Haftung entfällt für Schäden, die auf Reparaturen,
|
||||||
|
Änderungen oder anderen Eingriffen oder auf die Verwendung von
|
||||||
|
Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw.
|
||||||
|
des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ
|
||||||
|
vorgenommen hat.</p>
|
||||||
|
<p>12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist,
|
||||||
|
gilt dies auch für die Haftung der Angestellten, Arbeitnehmer,
|
||||||
|
Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.</p>
|
||||||
|
<h3 id="produkthaftung">13 Produkthaftung</h3>
|
||||||
|
<p>13.1 Der Kunde wird die gelieferten Waren ausschließlich
|
||||||
|
vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ
|
||||||
|
verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren
|
||||||
|
bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei
|
||||||
|
Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von
|
||||||
|
Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für
|
||||||
|
den die Haftung auslösenden Fehler nicht verantwortlich.</p>
|
||||||
|
<p>13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf
|
||||||
|
oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften
|
||||||
|
bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig
|
||||||
|
hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der
|
||||||
|
-warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den
|
||||||
|
eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht
|
||||||
|
verantwortlich.</p>
|
||||||
|
<p>13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken
|
||||||
|
bei der Verwendung der gelieferten Ware und mögliche Produktfehler in
|
||||||
|
Textform informieren.</p>
|
||||||
|
<h3 id="exportkontrolle">14 Exportkontrolle</h3>
|
||||||
|
<p>14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass
|
||||||
|
der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder
|
||||||
|
internationalen exportkontrollrechtlichen Vorschriften entgegenstehen.
|
||||||
|
Sofern sich die Durchführung des Vertrages aufgrund von
|
||||||
|
Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden
|
||||||
|
Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von
|
||||||
|
der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu
|
||||||
|
kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung
|
||||||
|
nationaler oder internationalen Exportkontrollbestimmung erforderlich
|
||||||
|
ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen
|
||||||
|
Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung
|
||||||
|
ist ausgeschlossen.</p>
|
||||||
|
<p>14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und
|
||||||
|
internationalen Exportkontrollrechts, insbesondere bei Weitergabe der
|
||||||
|
gelieferten Ware an Dritte.</p>
|
||||||
|
<h3 id="urheberrechte-und-datenschutz">15 Urheberrechte und
|
||||||
|
Datenschutz</h3>
|
||||||
|
<p>15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen,
|
||||||
|
Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen
|
||||||
|
(im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums-
|
||||||
|
und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die
|
||||||
|
Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ
|
||||||
|
Dritten zugänglich gemacht werden und sind KLZ auf Verlangen
|
||||||
|
unverzüglich zurückzugeben.</p>
|
||||||
|
<p>15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde
|
||||||
|
nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.</p>
|
||||||
|
<p>15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich
|
||||||
|
gemäß den Bestimmungen der Datenschutzgrundverordnung und des
|
||||||
|
Bundesdatenschutzgesetzes.</p>
|
||||||
|
<p>15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche
|
||||||
|
Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses
|
||||||
|
erforderlich sind (Bestandsdaten), werden ausschließlich zur
|
||||||
|
Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages
|
||||||
|
verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten
|
||||||
|
im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure)
|
||||||
|
weitergegeben werden. Der Kunde erteilt für die vorbeschriebene
|
||||||
|
Verwendung seiner personenbezogenen Daten hiermit eine entsprechende
|
||||||
|
Einwilligung.</p>
|
||||||
|
<p>15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von
|
||||||
|
Gründen- widerruflich. Der Widerruf ist zu richten an
|
||||||
|
info@klz-cables.com .</p>
|
||||||
|
<p>15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden
|
||||||
|
für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten
|
||||||
|
Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen
|
||||||
|
Einwilligung des Kunden.</p>
|
||||||
|
<p>15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall
|
||||||
|
Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung
|
||||||
|
an die zuständige Stelle erteilen, soweit dies für Zwecke der
|
||||||
|
Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen
|
||||||
|
Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte
|
||||||
|
am geistigen Eigentum erforderlich ist.</p>
|
||||||
|
<h3 id="geheimhaltung">16 Geheimhaltung</h3>
|
||||||
|
<p>16.1 „Vertrauliche Informationen“ sind (i) alle Informationen, Daten,
|
||||||
|
Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige
|
||||||
|
Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von
|
||||||
|
Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen,
|
||||||
|
Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten,
|
||||||
|
gleich in welcher Verkörperung, (ii) sowie alle nicht offenkundigen
|
||||||
|
kaufmännischen, betrieblichen oder technischen Informationen oder
|
||||||
|
Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen
|
||||||
|
der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt
|
||||||
|
werden, (iii) die entweder direkt als vertraulich gekennzeichnet wurden
|
||||||
|
oder nachträglich schriftlich oder in Textform als vertraulich
|
||||||
|
gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als
|
||||||
|
vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder
|
||||||
|
aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die
|
||||||
|
Parteien nichts anderes in Schriftform vereinbart haben.</p>
|
||||||
|
<p>16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch
|
||||||
|
nicht solche Informationen, (i) die der Kunde von öffentlich
|
||||||
|
zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf
|
||||||
|
legalem Wege zugänglich sind und diese Quellen die Informationen
|
||||||
|
ebenfalls auf legalem Wege erlangt haben, (ii) die der Kunde von einem
|
||||||
|
berechtigten Dritten rechtmäßig erhalten hat, (iii) die der Kunde
|
||||||
|
eigenständig entwickelt hat, (iv) die kraft Gesetzes oder aufgrund
|
||||||
|
gerichtlicher oder behördlicher Anordnung offengelegt wurden oder
|
||||||
|
offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor
|
||||||
|
einer solchen Offenlegung der Informationen unverzüglich zu informieren
|
||||||
|
und zu versuchen, die Offenlegung zu verhindern, soweit dies mit
|
||||||
|
angemessenen und gesetzlich zulässigen Mitteln möglich ist.</p>
|
||||||
|
<p>16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer
|
||||||
|
16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf
|
||||||
|
das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.</p>
|
||||||
|
<p>16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen
|
||||||
|
strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des
|
||||||
|
Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen
|
||||||
|
durch den Kunden nur an solche Mitarbeiter oder andere Personen
|
||||||
|
weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend
|
||||||
|
erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von
|
||||||
|
Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls
|
||||||
|
zur Geheimhaltung hinsichtlich der Vertraulichen Informationen
|
||||||
|
verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für
|
||||||
|
die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem
|
||||||
|
Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies
|
||||||
|
rechtlich möglich ist.</p>
|
||||||
|
<p>16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ
|
||||||
|
und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des
|
||||||
|
zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter
|
||||||
|
diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen
|
||||||
|
und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu
|
||||||
|
zerstören bzw. sicher und dauerhaft zu löschen und die vollständige
|
||||||
|
Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere
|
||||||
|
und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.</p>
|
||||||
|
<p>16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die
|
||||||
|
zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in
|
||||||
|
automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen
|
||||||
|
Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen
|
||||||
|
Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung
|
||||||
|
bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder
|
||||||
|
vollständigen sachgemäßen Zerstörung fort.</p>
|
||||||
|
<p>16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten
|
||||||
|
für einen Zeitraum von fünf (5) Jahren nach Beendigung des
|
||||||
|
zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.</p>
|
||||||
|
<p>16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die
|
||||||
|
Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur
|
||||||
|
Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das
|
||||||
|
billige Ermessen von KLZ gestellt wird und im Streitfall durch das
|
||||||
|
zuständige Gericht überprüft werden kann. Durch die Geltendmachung der
|
||||||
|
Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ
|
||||||
|
nicht ausgeschlossen.</p>
|
||||||
|
<h3 id="schlussbestimmungen">17 Schlussbestimmungen</h3>
|
||||||
|
<p>17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform.
|
||||||
|
Dies gilt auch für die Abänderung des Textformerfordernisses selbst.</p>
|
||||||
|
<p>17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser
|
||||||
|
AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden
|
||||||
|
gilt ausschließlich das Recht der Bundesrepublik Deutschland unter
|
||||||
|
Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort
|
||||||
|
sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im
|
||||||
|
Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist
|
||||||
|
Stuttgart.</p>
|
||||||
|
<p>17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder
|
||||||
|
undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller
|
||||||
|
sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die
|
||||||
|
unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien
|
||||||
|
einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem
|
||||||
|
wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren
|
||||||
|
Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer
|
||||||
|
Regelungslücke.</p>
|
||||||
|
<p>Stand: April 2026</p>
|
||||||
238
AVB_2026.md
Normal file
238
AVB_2026.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Allgemeine Verkaufsbedingungen
|
||||||
|
|
||||||
|
### 1 Geltungsbereich
|
||||||
|
|
||||||
|
1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen (nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr (nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH, Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und Unternehmern in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts (nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird hiermit widersprochen.
|
||||||
|
|
||||||
|
1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden ausführt.
|
||||||
|
|
||||||
|
1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich erneut in den Vertrag einbezogen wurden.
|
||||||
|
|
||||||
|
1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der Mitteilung über die Änderung der Geschäftsbedingungen gesondert hingewiesen.
|
||||||
|
|
||||||
|
1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden.
|
||||||
|
|
||||||
|
### 2 Vertragsschluss
|
||||||
|
|
||||||
|
2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des Angebots bei KLZ vorbehaltlos ausführt.
|
||||||
|
|
||||||
|
2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen – freibleibend und unverbindlich, sofern sie nicht ausdrücklich in Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen Selbstbelieferung von KLZ.
|
||||||
|
|
||||||
|
2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.
|
||||||
|
|
||||||
|
2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen, Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige Beschreibungen der zu liefernden Ware in dem Angebot oder der Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden. Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware dar, es sei denn, sie wurden ausdrücklich in Textform als solche vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich der zu liefernden Ware oder deren Verwendung.
|
||||||
|
|
||||||
|
2.5 Handelsübliche und branchenübliche Abweichungen vom Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit sie die Verwendbarkeit der zu liefernden Ware zum vertraglich vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder Unterlieferungen. Abweichungen der zu liefernden Ware, die durch zwingende rechtliche oder technische Normen, die nach Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu einer technischen Verbesserung der zu liefernden Ware führen, sind stets zulässig.
|
||||||
|
|
||||||
|
2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ. Mehraufwände und sonstige Kosten, die durch die Änderung des Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden gesondert zu vergüten.
|
||||||
|
|
||||||
|
2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.
|
||||||
|
|
||||||
|
2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag zurückzutreten.
|
||||||
|
|
||||||
|
### 3 Abrufaufträge
|
||||||
|
|
||||||
|
3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der Ware mindestens vierzehn (14) Werktage und zwischen Eingang der Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig (90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz von KLZ handelt.
|
||||||
|
|
||||||
|
### 4 Preise und Zahlungsbedingungen
|
||||||
|
|
||||||
|
4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ angegebenen Preise gelten für den in dem Angebot oder der Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise verstehen sich darüber hinaus zuzüglich Metallzuschlag und auftragsspezifischer Schnittkosten.
|
||||||
|
|
||||||
|
4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet und vergütet.
|
||||||
|
|
||||||
|
4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie, Transport, Arbeitskraft oder Material zwischen Vertragsschluss und Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw. des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung gefunden wird.
|
||||||
|
|
||||||
|
4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10) Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer gesonderten Mahnung von KLZ bedarf.
|
||||||
|
|
||||||
|
4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden. Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund dessen Zahlungsverzug uneingeschränkt zu.
|
||||||
|
|
||||||
|
4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem Kunden hieraus Ansprüche entstehen.
|
||||||
|
|
||||||
|
4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind, oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis zueinanderstehen.
|
||||||
|
|
||||||
|
### 5 Metallnotierung
|
||||||
|
|
||||||
|
5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Copper official price cash offer“, zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.
|
||||||
|
|
||||||
|
5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Aluminium official price cash offer" zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.
|
||||||
|
|
||||||
|
5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können der Webseite www.westmetall.com entnommen werden. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||||
|
|
||||||
|
### 6 Metallzahl
|
||||||
|
|
||||||
|
6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels eingeht.
|
||||||
|
|
||||||
|
6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen. Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden hiermit ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen.
|
||||||
|
|
||||||
|
6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.
|
||||||
|
|
||||||
|
### 7 Lieferung
|
||||||
|
|
||||||
|
7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
|
||||||
|
|
||||||
|
7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen, wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten würde.
|
||||||
|
|
||||||
|
7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss ausdrücklich in Textform als solche gekennzeichnet werden.
|
||||||
|
|
||||||
|
7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige Klärung aller kaufmännischen und technischen Fragen, sowie die Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten hat.
|
||||||
|
|
||||||
|
7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten. KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des Kunden zurück.
|
||||||
|
|
||||||
|
7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten hat.
|
||||||
|
|
||||||
|
7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen ist.
|
||||||
|
|
||||||
|
7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware, oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2) % des jeweiligen Nettoauftragswertes für jeden angefangenen Monat, maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des Nettoauftragswertes pro Vertrag beschränkt.
|
||||||
|
|
||||||
|
7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer Schaden entstanden ist.
|
||||||
|
|
||||||
|
7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließt KLZ aus.
|
||||||
|
|
||||||
|
7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.
|
||||||
|
|
||||||
|
### 8 Gefahrübergang
|
||||||
|
|
||||||
|
8.1 Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der zu liefernden Ware geht grundsätzlich DAP (INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
|
||||||
|
|
||||||
|
8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen, die der Kunde zu vertreten hat, geht die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware mit der Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen Öffnungszeiten des Kunden durch KLZ auf den Kunden über.
|
||||||
|
|
||||||
|
### 9 Eigentumsvorbehalt
|
||||||
|
|
||||||
|
9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend „Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere auch Saldoforderungen aus laufender Rechnung.
|
||||||
|
|
||||||
|
9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder weiterzuverarbeiten.
|
||||||
|
|
||||||
|
9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung, Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung, Vermengung oder sonstige Verbindung dergestalt, dass die Sache des Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für Vorbehaltsware.
|
||||||
|
|
||||||
|
9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab; KLZ nimmt die Abtretung an.
|
||||||
|
|
||||||
|
9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern. Verpfändungen und Sicherungsübereignungen hinsichtlich der Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde hat den Dritten darüber hinaus unverzüglich in Textform darauf hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der Dritte nicht in der Lage ist, KLZ die gerichtlichen und außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten, haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende Ansprüche gegen den Kunden bleiben unberührt.
|
||||||
|
|
||||||
|
9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B. Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei Verlust oder Zerstörung.
|
||||||
|
|
||||||
|
9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.
|
||||||
|
|
||||||
|
9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der Forderung an Dritte, einschließlich Forderungsverkauf an Factoring Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine gleichwertige Sicherheit gestellt wird.
|
||||||
|
|
||||||
|
9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen wurde.
|
||||||
|
|
||||||
|
9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10) Werktagen auf ein von KLZ benanntes Konto zu überweisen.
|
||||||
|
|
||||||
|
9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner Abnehmer bekanntzugeben und alle erforderlichen Unterlagen herauszugeben, die für die Einziehung der Forderung durch KLZ selbst erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet, die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich mitzuteilen.
|
||||||
|
|
||||||
|
9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl der danach freizugebenden Gegenstände liegt bei KLZ.
|
||||||
|
|
||||||
|
9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden, insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie bereits verarbeitet ist, und eine Aufstellung aller abgetretenen Forderungen nebst Rechnungsgutschriften.
|
||||||
|
|
||||||
|
### 10 Höhere Gewalt
|
||||||
|
|
||||||
|
10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c) die Auswirkungen des Hindernisses von der betroffenen Partei nicht in zumutbarer Weise hätte vermieden oder überwunden werden können.
|
||||||
|
|
||||||
|
10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs- und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen, Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise- und Exportverbote oder staatliche Einreise- und Importverbote, Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis, Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder Versammlungen, Ereignisse, die das Passieren von wichtigen Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere Boykott, Streik und Aussperrungen, Energieknappheit oder Beeinträchtigung von Transportmitteln.
|
||||||
|
|
||||||
|
10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten Hindernisses oder Ereignisses vorübergehend, so gelten die eben dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die Vertragserfüllung durch die betroffene Partei verhindert sowie einer angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts sind ausgeschlossen.
|
||||||
|
|
||||||
|
10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der Ware besteht.
|
||||||
|
|
||||||
|
10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt, werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten. Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden eine Verlängerung der Lieferzeit vereinbart war.
|
||||||
|
|
||||||
|
### 11 Gewährleistung und Mängelrüge
|
||||||
|
|
||||||
|
11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung der gelieferten Ware von den vereinbarten Spezifikationen, bei unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit, bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für diese und die daraus entstehenden Folgen ebenfalls keine Mängelansprüche.
|
||||||
|
|
||||||
|
11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf äußerlich erkennbare Transportschäden und Identität der gelieferten Ware mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in Textform geltend zu machen.
|
||||||
|
|
||||||
|
11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen Mängel, der betroffenen Lieferungen und wenn möglich, auch eine bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt, mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung anzufordern.
|
||||||
|
|
||||||
|
11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob Auffälligkeiten zu vermerken sind.
|
||||||
|
|
||||||
|
11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist, anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung) oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang unterstützen.
|
||||||
|
|
||||||
|
11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch den Kunden an einen anderen als den Ort der gewerblichen Niederlassung des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz 1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.
|
||||||
|
|
||||||
|
11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.
|
||||||
|
|
||||||
|
11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts oder der Tauglichkeit der gelieferten Waren ist der Rücktritt ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung angezeigt.
|
||||||
|
|
||||||
|
11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der Leistung verlangen kann.
|
||||||
|
|
||||||
|
11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts zu tragen, wenn das Gutachten einen Mangel feststellt.
|
||||||
|
|
||||||
|
11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438 Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz, grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit, oder jedweder wesentlicher Vertragspflichtverletzung, zwingender gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen Verschweigen eines Mangels. Die gesetzlichen Regelungen über Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.
|
||||||
|
|
||||||
|
### 12 Haftung
|
||||||
|
|
||||||
|
12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit. KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, sowie für arglistiges Verschweigen eines Mangels und Verletzung von garantierten Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher Vorschriften, wie etwa denen des Produkthaftungsgesetzes.
|
||||||
|
|
||||||
|
12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ typischerweise vorhersehbaren Schaden bzw. die typischerweise vorhersehbaren Aufwendungen. Vertragswesentlich sind die Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht auf Schadensersatz.
|
||||||
|
|
||||||
|
12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden, die bei bestimmungsgemäßer Verwendung der gelieferten Ware typischerweise zu erwarten sind.
|
||||||
|
|
||||||
|
12.4 Die Haftung entfällt für Schäden, die auf Reparaturen, Änderungen oder anderen Eingriffen oder auf die Verwendung von Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw. des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ vorgenommen hat.
|
||||||
|
|
||||||
|
12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist, gilt dies auch für die Haftung der Angestellten, Arbeitnehmer, Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.
|
||||||
|
|
||||||
|
### 13 Produkthaftung
|
||||||
|
|
||||||
|
13.1 Der Kunde wird die gelieferten Waren ausschließlich vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für den die Haftung auslösenden Fehler nicht verantwortlich.
|
||||||
|
|
||||||
|
13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der -warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht verantwortlich.
|
||||||
|
|
||||||
|
13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken bei der Verwendung der gelieferten Ware und mögliche Produktfehler in Textform informieren.
|
||||||
|
|
||||||
|
### 14 Exportkontrolle
|
||||||
|
|
||||||
|
14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder internationalen exportkontrollrechtlichen Vorschriften entgegenstehen. Sofern sich die Durchführung des Vertrages aufgrund von Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung nationaler oder internationalen Exportkontrollbestimmung erforderlich ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung ist ausgeschlossen.
|
||||||
|
|
||||||
|
14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und internationalen Exportkontrollrechts, insbesondere bei Weitergabe der gelieferten Ware an Dritte.
|
||||||
|
|
||||||
|
### 15 Urheberrechte und Datenschutz
|
||||||
|
|
||||||
|
15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen, Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen (im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums- und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ Dritten zugänglich gemacht werden und sind KLZ auf Verlangen unverzüglich zurückzugeben.
|
||||||
|
|
||||||
|
15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.
|
||||||
|
|
||||||
|
15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich gemäß den Bestimmungen der Datenschutzgrundverordnung und des Bundesdatenschutzgesetzes.
|
||||||
|
|
||||||
|
15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses erforderlich sind (Bestandsdaten), werden ausschließlich zur Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure) weitergegeben werden. Der Kunde erteilt für die vorbeschriebene Verwendung seiner personenbezogenen Daten hiermit eine entsprechende Einwilligung.
|
||||||
|
|
||||||
|
15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von Gründen- widerruflich. Der Widerruf ist zu richten an info@klz-cables.com .
|
||||||
|
|
||||||
|
15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen Einwilligung des Kunden.
|
||||||
|
|
||||||
|
15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung an die zuständige Stelle erteilen, soweit dies für Zwecke der Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte am geistigen Eigentum erforderlich ist.
|
||||||
|
|
||||||
|
### 16 Geheimhaltung
|
||||||
|
|
||||||
|
16.1 „Vertrauliche Informationen“ sind
|
||||||
|
(i) alle Informationen, Daten, Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen, Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten, gleich in welcher Verkörperung,
|
||||||
|
(ii) sowie alle nicht offenkundigen kaufmännischen, betrieblichen oder technischen Informationen oder Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt werden,
|
||||||
|
(iii) die entweder direkt als vertraulich gekennzeichnet wurden oder nachträglich schriftlich oder in Textform als vertraulich gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die Parteien nichts anderes in Schriftform vereinbart haben.
|
||||||
|
|
||||||
|
16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch nicht solche Informationen,
|
||||||
|
(i) die der Kunde von öffentlich zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf legalem Wege zugänglich sind und diese Quellen die Informationen ebenfalls auf legalem Wege erlangt haben,
|
||||||
|
(ii) die der Kunde von einem berechtigten Dritten rechtmäßig erhalten hat,
|
||||||
|
(iii) die der Kunde eigenständig entwickelt hat,
|
||||||
|
(iv) die kraft Gesetzes oder aufgrund gerichtlicher oder behördlicher Anordnung offengelegt wurden oder offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor einer solchen Offenlegung der Informationen unverzüglich zu informieren und zu versuchen, die Offenlegung zu verhindern, soweit dies mit angemessenen und gesetzlich zulässigen Mitteln möglich ist.
|
||||||
|
|
||||||
|
16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer 16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.
|
||||||
|
|
||||||
|
16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen durch den Kunden nur an solche Mitarbeiter oder andere Personen weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls zur Geheimhaltung hinsichtlich der Vertraulichen Informationen verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies rechtlich möglich ist.
|
||||||
|
|
||||||
|
16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu zerstören bzw. sicher und dauerhaft zu löschen und die vollständige Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.
|
||||||
|
|
||||||
|
16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder vollständigen sachgemäßen Zerstörung fort.
|
||||||
|
|
||||||
|
16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten für einen Zeitraum von fünf (5) Jahren nach Beendigung des zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.
|
||||||
|
|
||||||
|
16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das billige Ermessen von KLZ gestellt wird und im Streitfall durch das zuständige Gericht überprüft werden kann. Durch die Geltendmachung der Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ nicht ausgeschlossen.
|
||||||
|
|
||||||
|
### 17 Schlussbestimmungen
|
||||||
|
|
||||||
|
17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform. Dies gilt auch für die Abänderung des Textformerfordernisses selbst.
|
||||||
|
|
||||||
|
17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist Stuttgart.
|
||||||
|
|
||||||
|
17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer Regelungslücke.
|
||||||
|
|
||||||
|
Stand: April 2026
|
||||||
107
Dockerfile
107
Dockerfile
@@ -1,75 +1,82 @@
|
|||||||
|
# Stage 1: Builder
|
||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS deps
|
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
|
||||||
RUN apk add --no-cache libc6-compat curl
|
RUN apk add --no-cache libc6-compat curl
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Arguments for build-time configuration
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# Build-time environment variables for Next.js
|
|
||||||
# These are baked into the client bundle during build
|
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
ARG UMAMI_WEBSITE_ID
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
|
# Environment variables for Next.js build
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# Validate environment variables during build
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
# Configure private registry and install dependencies
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
|
pnpm store prune && \
|
||||||
|
pnpm install --no-frozen-lockfile && \
|
||||||
|
rm .npmrc
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Copy source code
|
||||||
FROM base AS runner
|
COPY . .
|
||||||
|
|
||||||
|
# Stage 2: Development (Hot-Reloading)
|
||||||
|
FROM base AS development
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
# Stage 3: Builder (Production)
|
||||||
|
FROM base AS builder
|
||||||
|
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
|
||||||
|
ENV RAYON_NUM_THREADS=3
|
||||||
|
ENV UV_THREADPOOL_SIZE=3
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 2: Runner
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install curl for health checks
|
# Install curl for health checks
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
chown -R nextjs:nodejs /app
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV PORT=3000
|
|
||||||
# set hostname to localhost
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Copy standalone output and static files
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
# server.js is created by next build from the standalone output
|
|
||||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install essential build tools if needed (e.g., for node-gyp)
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Enable corepack for pnpm
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Pre-set the pnpm store directory
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
|
# Set up pnpm store configuration
|
||||||
|
RUN pnpm config set store-dir /pnpm/store
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
BIN
KLZ_AVB_April_2026.docx
Normal file
BIN
KLZ_AVB_April_2026.docx
Normal file
Binary file not shown.
BIN
KLZ_AVB_April_2026.pdf
Normal file
BIN
KLZ_AVB_April_2026.pdf
Normal file
Binary file not shown.
@@ -462,3 +462,4 @@ Proprietary - KLZ Cables
|
|||||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0.0
|
||||||
**Last Updated**: December 27, 2025
|
**Last Updated**: December 27, 2025
|
||||||
|
Trigger rebuilding for x86 architecture.
|
||||||
|
|||||||
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { RootPage } from '@payloadcms/next/views';
|
||||||
|
import { importMap } from '../importMap';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
segments: string[];
|
||||||
|
}>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
[key: string]: string | string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = ({ params, searchParams }: Args) =>
|
||||||
|
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||||
|
|
||||||
|
export default Page;
|
||||||
57
app/(payload)/admin/importMap.js
Normal file
57
app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon'
|
||||||
|
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo'
|
||||||
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
|
export const importMap = {
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc,
|
||||||
|
"/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
||||||
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
|
}
|
||||||
14
app/(payload)/api/[...slug]/route.ts
Normal file
14
app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import config from '@payload-config';
|
||||||
|
import {
|
||||||
|
REST_GET,
|
||||||
|
REST_OPTIONS,
|
||||||
|
REST_PATCH,
|
||||||
|
REST_POST,
|
||||||
|
REST_DELETE,
|
||||||
|
} from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const GET = REST_GET(config);
|
||||||
|
export const POST = REST_POST(config);
|
||||||
|
export const DELETE = REST_DELETE(config);
|
||||||
|
export const PATCH = REST_PATCH(config);
|
||||||
|
export const OPTIONS = REST_OPTIONS(config);
|
||||||
4
app/(payload)/api/graphql/route.ts
Normal file
4
app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import config from '@payload-config';
|
||||||
|
import { GRAPHQL_POST } from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const POST = GRAPHQL_POST(config);
|
||||||
151
app/(payload)/custom.scss
Normal file
151
app/(payload)/custom.scss
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/* =================================================================
|
||||||
|
KLZ Cables – Payload Admin Theme
|
||||||
|
Strictly follows docs/STYLEGUIDE.md & tailwind.config.cjs
|
||||||
|
|
||||||
|
IMPORTANT: We use `html` selector (not `:root`) because Payload's
|
||||||
|
own CSS defines variables on `:root` and loads AFTER this file.
|
||||||
|
`html` has higher specificity than `:root`, so our values win.
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
COLOR OVERRIDES
|
||||||
|
Payload internally maps:
|
||||||
|
--theme-elevation-* → --color-base-*
|
||||||
|
--theme-success-* → --color-success-*
|
||||||
|
We override the SOURCE variables on `html` to beat Payload's `:root`.
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
html {
|
||||||
|
/* ---------------------------------------------------------------
|
||||||
|
KLZ Primary Blue (#011dff) → Buttons, links, active states
|
||||||
|
--------------------------------------------------------------- */
|
||||||
|
--color-success-50: #eef0ff !important;
|
||||||
|
--color-success-100: #dfe2ff !important;
|
||||||
|
--color-success-150: #cdd2ff !important;
|
||||||
|
--color-success-200: #b8bfff !important;
|
||||||
|
--color-success-250: #a0a9ff !important;
|
||||||
|
--color-success-300: #8892ff !important;
|
||||||
|
--color-success-350: #707bff !important;
|
||||||
|
--color-success-400: #5564ff !important;
|
||||||
|
--color-success-450: #3a4dff !important;
|
||||||
|
--color-success-500: #011dff !important;
|
||||||
|
/* KLZ Primary */
|
||||||
|
--color-success-550: #0119e6 !important;
|
||||||
|
--color-success-600: #0116cc !important;
|
||||||
|
--color-success-650: #0112b3 !important;
|
||||||
|
--color-success-700: #000e99 !important;
|
||||||
|
--color-success-750: #000b80 !important;
|
||||||
|
--color-success-800: #000866 !important;
|
||||||
|
--color-success-850: #00054d !important;
|
||||||
|
--color-success-900: #000333 !important;
|
||||||
|
--color-success-950: #00011a !important;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------
|
||||||
|
KLZ "Foundation Neutrals" → Backgrounds, cards, borders, text
|
||||||
|
Based on tailwind.config.cjs: neutral.light=#fff,
|
||||||
|
neutral.DEFAULT=#f8f9fa, neutral.dark=#263336, neutral.black=#0a0a0a
|
||||||
|
text.primary=#1a1a1a, text.secondary=#6c757d, text.light=#adb5bd
|
||||||
|
--------------------------------------------------------------- */
|
||||||
|
--color-base-0: #ffffff !important;
|
||||||
|
--color-base-50: #f8f9fa !important;
|
||||||
|
--color-base-100: #f1f3f5 !important;
|
||||||
|
--color-base-150: #e9ecef !important;
|
||||||
|
--color-base-200: #dee2e6 !important;
|
||||||
|
--color-base-250: #ced4da !important;
|
||||||
|
--color-base-300: #adb5bd !important;
|
||||||
|
--color-base-350: #9ba3ab !important;
|
||||||
|
--color-base-400: #868e96 !important;
|
||||||
|
--color-base-450: #6c757d !important;
|
||||||
|
--color-base-500: #5c636a !important;
|
||||||
|
--color-base-550: #4d5358 !important;
|
||||||
|
--color-base-600: #3d4246 !important;
|
||||||
|
--color-base-650: #343a40 !important;
|
||||||
|
--color-base-700: #2b3035 !important;
|
||||||
|
--color-base-750: #263336 !important;
|
||||||
|
--color-base-800: #212529 !important;
|
||||||
|
--color-base-850: #1a1a1a !important;
|
||||||
|
--color-base-900: #121212 !important;
|
||||||
|
--color-base-950: #0a0a0a !important;
|
||||||
|
--color-base-1000: #000000 !important;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-body: 'Inter', system-ui, -apple-system, sans-serif !important;
|
||||||
|
--font-headings: 'Inter', system-ui, -apple-system, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Body Application */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body) !important;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
Login / Setup Page
|
||||||
|
================================================================= */
|
||||||
|
.template-default.template-default--has-bg {
|
||||||
|
background: radial-gradient(circle at top right, #e6ebf5 0%, #f8f9fa 60%, #f3f4f6 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__wrap,
|
||||||
|
.create-first-user__wrap {
|
||||||
|
border-top: none !important;
|
||||||
|
padding: 3rem !important;
|
||||||
|
background: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--theme-elevation-150) !important;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
border-radius: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
Buttons – override Payload's dark buttons with KLZ Blue
|
||||||
|
Payload uses .btn--style-primary { --bg-color: var(--theme-elevation-800) }
|
||||||
|
which makes all primary buttons near-black. We override to KLZ Blue.
|
||||||
|
================================================================= */
|
||||||
|
.btn--style-primary,
|
||||||
|
.btn--style-pill {
|
||||||
|
--bg-color: #011dff !important;
|
||||||
|
--color: #ffffff !important;
|
||||||
|
--hover-bg: #0116cc !important;
|
||||||
|
--hover-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--style-primary.btn--disabled,
|
||||||
|
.btn--style-pill.btn--disabled {
|
||||||
|
--bg-color: #b8bfff !important;
|
||||||
|
--color: #ffffff !important;
|
||||||
|
--hover-bg: #b8bfff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Active Items */
|
||||||
|
[class*="nav-group__link--active"],
|
||||||
|
[class*="nav__link--active"] {
|
||||||
|
--theme-elevation-800: #011dff !important;
|
||||||
|
color: #011dff !important;
|
||||||
|
border-left-color: #011dff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--style-secondary {
|
||||||
|
--box-shadow: inset 0 0 0 1px #011dff !important;
|
||||||
|
--color: #011dff !important;
|
||||||
|
--hover-color: #0116cc !important;
|
||||||
|
--hover-box-shadow: inset 0 0 0 1px #0116cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
Logo & Icon
|
||||||
|
================================================================= */
|
||||||
|
.klz-admin-logo,
|
||||||
|
.klz-admin-icon {
|
||||||
|
display: block !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: 32px !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
object-fit: contain !important;
|
||||||
|
}
|
||||||
31
app/(payload)/layout.tsx
Normal file
31
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { RootLayout } from '@payloadcms/next/layouts';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import '@payloadcms/next/css';
|
||||||
|
import './custom.scss';
|
||||||
|
import { handleServerFunctions } from '@payloadcms/next/layouts';
|
||||||
|
import { importMap } from './admin/importMap';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverFunction: any = async function (args: any) {
|
||||||
|
'use server';
|
||||||
|
return handleServerFunctions({
|
||||||
|
...args,
|
||||||
|
config: configPromise,
|
||||||
|
importMap,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const Layout = ({ children }: Args) => {
|
||||||
|
return (
|
||||||
|
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
|
||||||
|
{children}
|
||||||
|
</RootLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -3,9 +3,16 @@ import { getPageBySlug } from '@/lib/pages';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
export default async function Image({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
@@ -15,17 +22,14 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
|||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={pageData.frontmatter.title}
|
title={pageData.frontmatter.title}
|
||||||
description={pageData.frontmatter.excerpt}
|
description={pageData.frontmatter.excerpt}
|
||||||
label="Information"
|
label="Information"
|
||||||
/>
|
/>,
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect, permanentRedirect } from 'next/navigation';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -15,42 +15,34 @@ interface PageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
const locales = ['en', 'de'];
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
for (const locale of locales) {
|
|
||||||
const pages = await getAllPages(locale);
|
|
||||||
for (const page of pages) {
|
|
||||||
params.push({ locale, slug: page.slug });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
|
|
||||||
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
|
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
||||||
|
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
||||||
|
|
||||||
|
// Determine correct localized slug based on current locale
|
||||||
|
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/${slug}`,
|
de: `${SITE_URL}/de/${deSlug}`,
|
||||||
en: `/en/${slug}`,
|
en: `${SITE_URL}/en/${enSlug}`,
|
||||||
'x-default': `/en/${slug}`,
|
'x-default': `${SITE_URL}/en/${enSlug}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
url: `${SITE_URL}/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -62,6 +54,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
|
|
||||||
export default async function StandardPage({ params }: PageProps) {
|
export default async function StandardPage({ params }: PageProps) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
@@ -69,6 +62,32 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
|
||||||
|
if (pageData.redirectUrl) {
|
||||||
|
if (pageData.redirectPermanent) {
|
||||||
|
permanentRedirect(pageData.redirectUrl);
|
||||||
|
} else {
|
||||||
|
redirect(pageData.redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect if accessed via a different locale's slug
|
||||||
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
|
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||||
|
if (correctSlug && correctSlug !== slug) {
|
||||||
|
redirect(`/${locale}/${correctSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
||||||
|
if (pageData.frontmatter.layout === 'fullBleed') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<PayloadRichText data={pageData.content} className="" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default article layout with hero, content, and support CTA
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-white">
|
<div className="flex flex-col min-h-screen bg-white">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -77,7 +96,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||||
{t('badge')}
|
{t('badge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -93,7 +112,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{pageData.frontmatter.excerpt && (
|
{pageData.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom">
|
<div className="mb-16">
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
{pageData.frontmatter.excerpt}
|
{pageData.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
@@ -101,8 +120,8 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with shared blog components */}
|
{/* Main content with shared blog components */}
|
||||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom">
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||||
<MDXRemote source={pageData.content} components={mdxComponents} />
|
<PayloadRichText data={pageData.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
@@ -111,15 +130,19 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||||
<a
|
<TrackedLink
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
|
eventProperties={{
|
||||||
|
location: 'generic_page_support_cta',
|
||||||
|
page_slug: slug,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('contactUs')}
|
{t('contactUs')}
|
||||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</TrackedLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getProductBySlug } from '@/lib/mdx';
|
import { getProductBySlug } from '@/lib/products';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ locale: string }> },
|
{ params }: { params: Promise<{ locale: string }> },
|
||||||
) {
|
) {
|
||||||
const { searchParams, origin } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get('slug');
|
const slug = searchParams.get('slug');
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ export async function GET(
|
|||||||
const featuredImage = product.frontmatter.images?.[0]
|
const featuredImage = product.frontmatter.images?.[0]
|
||||||
? product.frontmatter.images[0].startsWith('http')
|
? product.frontmatter.images[0].startsWith('http')
|
||||||
? product.frontmatter.images[0]
|
? product.frontmatter.images[0]
|
||||||
: `${origin}${product.frontmatter.images[0]}`
|
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
|
|||||||
@@ -4,13 +4,30 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
|||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(url: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
return `data:${contentType};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch OG image:', url, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Image({
|
export default async function Image({
|
||||||
params: { locale, slug },
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string; slug: string };
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
@@ -29,12 +46,19 @@ export default async function Image({
|
|||||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
|
||||||
|
// fetching remote URLs directly inside ImageResponse correctly in various environments.
|
||||||
|
let base64Image: string | undefined = undefined;
|
||||||
|
if (featuredImage) {
|
||||||
|
base64Image = await fetchImageAsBase64(featuredImage);
|
||||||
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={post.frontmatter.title}
|
title={post.frontmatter.title}
|
||||||
description={post.frontmatter.excerpt}
|
description={post.frontmatter.excerpt}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
image={featuredImage}
|
image={base64Image || featuredImage}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import {
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
getPostBySlug,
|
||||||
|
getAdjacentPosts,
|
||||||
|
getReadingTime,
|
||||||
|
extractLexicalHeadings,
|
||||||
|
getPostSlugs,
|
||||||
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import PostNavigation from '@/components/blog/PostNavigation';
|
import PostNavigation from '@/components/blog/PostNavigation';
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
import PowerCTA from '@/components/blog/PowerCTA';
|
||||||
import TableOfContents from '@/components/blog/TableOfContents';
|
import TableOfContents from '@/components/blog/TableOfContents';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
|
|
||||||
|
// Payload CMS Imports
|
||||||
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -26,16 +34,20 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
|
|
||||||
|
const slugs = await getPostSlugs(slug, locale);
|
||||||
|
const deSlug = slugs?.de || post.slug;
|
||||||
|
const enSlug = slugs?.en || post.slug;
|
||||||
|
|
||||||
const description = post.frontmatter.excerpt || '';
|
const description = post.frontmatter.excerpt || '';
|
||||||
return {
|
return {
|
||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/blog/${slug}`,
|
de: `${SITE_URL}/de/blog/${deSlug}`,
|
||||||
en: `/en/blog/${slug}`,
|
en: `${SITE_URL}/en/blog/${enSlug}`,
|
||||||
'x-default': `/en/blog/${slug}`,
|
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -44,8 +56,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -57,25 +68,55 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
|
|
||||||
export default async function BlogPost({ params }: BlogPostProps) {
|
export default async function BlogPost({ params }: BlogPostProps) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const headings = getHeadings(post.content);
|
// If the user accessed this post using a slug from a different locale
|
||||||
|
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
|
||||||
|
if (post.slug && post.slug !== slug) {
|
||||||
|
redirect(`/${locale}/blog/${post.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
// Extract headings for TOC
|
||||||
|
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||||
|
<BlogEngagementTracker
|
||||||
|
title={post.frontmatter.title}
|
||||||
|
slug={slug}
|
||||||
|
category={post.frontmatter.category}
|
||||||
|
readingTime={getReadingTime(rawTextContent)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Featured Image Header */}
|
{/* Featured Image Header */}
|
||||||
{post.frontmatter.featuredImage ? (
|
{post.frontmatter.featuredImage ? (
|
||||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||||
<div
|
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
<Image
|
||||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
quality={100}
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
@@ -83,27 +124,36 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<div className="overflow-hidden mb-6">
|
<div className="overflow-hidden mb-6">
|
||||||
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
|
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Heading
|
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
|
||||||
level={1}
|
|
||||||
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
|
|
||||||
>
|
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
|
locale === 'en' ? 'en-US' : 'de-DE',
|
||||||
|
{
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,16 +172,28 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<Heading level={1} className="mb-8">
|
<Heading level={1} className="mb-8">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
|
locale === 'en' ? 'en-US' : 'de-DE',
|
||||||
|
{
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</time>
|
</time>
|
||||||
|
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
|
||||||
|
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -144,16 +206,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<div className="sticky-narrative-content">
|
<div className="sticky-narrative-content">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{post.frontmatter.excerpt && (
|
{post.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
|
<div className="mb-16">
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with enhanced styling */}
|
{/* Main content with enhanced styling rendering Payload Lexical */}
|
||||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||||
<MDXRemote source={post.content} components={mdxComponents} />
|
<PayloadRichText data={post.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Power CTA */}
|
{/* Power CTA */}
|
||||||
@@ -163,7 +225,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
{/* Post Navigation */}
|
{/* Post Navigation */}
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<PostNavigation prev={prev} next={next} locale={locale} />
|
<PostNavigation
|
||||||
|
prev={prev}
|
||||||
|
next={next}
|
||||||
|
isPrevRandom={isPrevRandom}
|
||||||
|
isNextRandom={isNextRandom}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to blog link */}
|
{/* Back to blog link */}
|
||||||
@@ -190,9 +258,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sticky Sidebar */}
|
{/* Right Column: Sticky Sidebar - TOC */}
|
||||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 lg:sticky lg:top-32">
|
||||||
<TableOfContents headings={headings} locale={locale} />
|
<TableOfContents headings={headings} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -232,8 +300,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
},
|
},
|
||||||
articleSection: post.frontmatter.category,
|
articleSection: post.frontmatter.category,
|
||||||
wordCount: post.content.split(/\s+/).length,
|
wordCount: rawTextContent.split(/\s+/).length,
|
||||||
timeRequired: `PT${getReadingTime(post.content)}M`,
|
timeRequired: `PT${getReadingTime(rawTextContent)}M`,
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,23 +3,20 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={t('title')}
|
|
||||||
description={t('description')}
|
|
||||||
label="Blog"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -19,18 +21,17 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
|||||||
title: t('title'),
|
title: t('title'),
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog`,
|
canonical: `${SITE_URL}/${locale}/blog`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/blog',
|
de: `${SITE_URL}/de/blog`,
|
||||||
en: '/en/blog',
|
en: `${SITE_URL}/en/blog`,
|
||||||
'x-default': '/en/blog',
|
'x-default': `${SITE_URL}/en/blog`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${t('title')} | KLZ Cables`,
|
title: `${t('title')} | KLZ Cables`,
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
url: `${SITE_URL}/${locale}/blog`,
|
url: `${SITE_URL}/${locale}/blog`,
|
||||||
images: getOGImageMetadata('blog', t('title'), locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -42,6 +43,7 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
|||||||
|
|
||||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
|
|
||||||
@@ -57,29 +59,42 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
<div className="bg-neutral-light min-h-screen">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
{/* Hero Section - Immersive Magazine Feel */}
|
{/* Hero Section - Immersive Magazine Feel */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<>
|
<>
|
||||||
<img
|
<Image
|
||||||
src={featuredPost.frontmatter.featuredImage}
|
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={featuredPost.frontmatter.title}
|
alt={featuredPost.frontmatter.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
fill
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${featuredPost.frontmatter.focalX ?? 50}% ${featuredPost.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 bg-neutral-dark/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-6">
|
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||||
{t('featuredPost')}
|
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||||
</Badge>
|
{featuredPost &&
|
||||||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
|
featuredPost.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
<>
|
<>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{featuredPost.frontmatter.title}
|
{featuredPost.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl">
|
||||||
{featuredPost.frontmatter.excerpt}
|
{featuredPost.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -97,7 +112,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</article>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Section className="bg-neutral-light py-12 md:py-28">
|
<Section className="bg-neutral-light py-12 md:py-28">
|
||||||
@@ -138,50 +153,79 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Grid for remaining posts */}
|
{/* Grid for remaining posts */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
|
<div className="grid grid-cols-1 gap-12">
|
||||||
{remainingPosts.map((post, idx) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 50}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<Link
|
||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
{post.frontmatter.featuredImage && (
|
className="group block focus:outline-none"
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={post.frontmatter.featuredImage}
|
|
||||||
alt={post.frontmatter.title}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
{post.frontmatter.category && (
|
|
||||||
<Badge
|
|
||||||
variant="accent"
|
|
||||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
|
||||||
>
|
>
|
||||||
|
<Card
|
||||||
|
tag="article"
|
||||||
|
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]"
|
||||||
|
>
|
||||||
|
{post.frontmatter.featuredImage && (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
sizes="(max-width: 768px) 100vw, 100vw"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
|
{post.frontmatter.category && (
|
||||||
|
<Badge variant="accent" className="shadow-md">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
</div>
|
||||||
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||||
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
|
locale === 'en' ? 'en-US' : 'de-DE',
|
||||||
|
{
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
|
||||||
|
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
|
|
||||||
|
{post.frontmatter.excerpt && (
|
||||||
|
<p className="text-white/90 text-sm md:text-lg line-clamp-3 mb-6 max-w-4xl drop-shadow-sm leading-relaxed">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
)}
|
||||||
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
|
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
|
||||||
|
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
className="w-5 h-5 transition-transform group-hover:translate-x-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -202,21 +246,47 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Placeholder */}
|
{/* Pagination */}
|
||||||
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
|
<Button
|
||||||
|
href="#"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base pointer-events-none opacity-50"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-keyshortcuts="ArrowLeft"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
{t('prev')}
|
{t('prev')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
|
<Button
|
||||||
|
href={`/${locale}/blog?page=1`}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
aria-current="page"
|
||||||
|
>
|
||||||
1
|
1
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
<Button
|
||||||
|
href={`/${locale}/blog?page=2`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
>
|
||||||
2
|
2
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
<Button
|
||||||
|
href={`/${locale}/blog?page=2`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
aria-keyshortcuts="ArrowRight"
|
||||||
|
>
|
||||||
{t('next')}
|
{t('next')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<BlogPaginationKeyboardObserver currentPage={1} totalPages={2} locale={locale} />
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
@@ -13,16 +16,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Contact"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import JsonLd from '@/components/JsonLd';
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { Container, Heading, Section } from '@/components/ui';
|
import { Container, Heading, Section } from '@/components/ui';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import ContactMap from '@/components/ContactMap';
|
import ContactMap from '@/components/ContactMap';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -24,18 +25,18 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/contact`,
|
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de-DE': '/de/contact',
|
de: `${SITE_URL}/de/kontakt`,
|
||||||
'en-US': '/en/contact',
|
en: `${SITE_URL}/en/contact`,
|
||||||
|
'x-default': `${SITE_URL}/en/contact`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/contact`,
|
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||||
siteName: 'KLZ Cables',
|
siteName: 'KLZ Cables',
|
||||||
images: getOGImageMetadata('contact', title, locale),
|
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
@@ -43,7 +44,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -58,7 +58,23 @@ export async function generateStaticParams() {
|
|||||||
|
|
||||||
export default async function ContactPage({ params }: ContactPageProps) {
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
|
|
||||||
|
// Get translated slug to redirect if user used incorrect static slug
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const headersList = await headers();
|
||||||
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
|
const currentSlug = urlPath.split('/').pop();
|
||||||
|
|
||||||
|
if (currentSlug) {
|
||||||
|
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
|
||||||
|
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
|
||||||
|
const { redirect } = await import('next/navigation');
|
||||||
|
redirect(`/${locale}/${contactSlugDe}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -136,7 +152,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||||
{t('info.howToReachUs')}
|
{t('info.howToReachUs')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="space-y-4 md:space-y-8">
|
<address className="space-y-4 md:space-y-8 not-italic">
|
||||||
<div className="flex items-start gap-4 md:gap-6 group">
|
<div className="flex items-start gap-4 md:gap-6 group">
|
||||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
@@ -189,15 +205,13 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
{t('info.email')}
|
{t('info.email')}
|
||||||
</h4>
|
</h4>
|
||||||
<a
|
<ObfuscatedEmail
|
||||||
href="mailto:info@klz-cables.com"
|
email="info@klz-cables.com"
|
||||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||||
>
|
/>
|
||||||
info@klz-cables.com
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ export default function Error({
|
|||||||
const t = useTranslations('Error');
|
const t = useTranslations('Error');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Treat "Failed to find Server Action" as a deployment sync issue and reload
|
||||||
|
if (error?.message?.includes('Failed to find Server Action')) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
services.errors.captureException(error);
|
services.errors.captureException(error);
|
||||||
services.logger.error('Application error caught by boundary', {
|
services.logger.error('Application error caught by boundary', {
|
||||||
message: error.message,
|
message: error?.message || 'Unknown error',
|
||||||
stack: error.stack,
|
stack: error?.stack,
|
||||||
digest: error.digest
|
digest: error?.digest,
|
||||||
});
|
});
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
@@ -36,19 +42,14 @@ export default function Error({
|
|||||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
||||||
500
|
500
|
||||||
</Heading>
|
</Heading>
|
||||||
<Scribble
|
<Scribble variant="underline" className="w-full h-6 -bottom-2 left-0 text-saturated/40" />
|
||||||
variant="underline"
|
|
||||||
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button onClick={() => reset()} variant="saturated" size="lg">
|
<Button onClick={() => reset()} variant="saturated" size="lg">
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import SkipLink from '@/components/SkipLink';
|
||||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { config } from '@/lib/config';
|
import { config } from '@/lib/config';
|
||||||
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const inter = Inter({
|
||||||
metadataBase: new URL(SITE_URL),
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-inter',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
|
||||||
|
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
template: '%s | KLZ Cables',
|
||||||
|
default: 'KLZ Cables | Ihr Partner für Kabel & Leitungen',
|
||||||
|
},
|
||||||
|
metadataBase: new URL(baseUrl),
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.ico', sizes: 'any' },
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
@@ -20,47 +42,67 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 5,
|
||||||
userScalable: false,
|
userScalable: true,
|
||||||
viewportFit: 'cover',
|
viewportFit: 'cover',
|
||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function Layout(props: {
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
// Ensure locale is a valid string, fallback to 'en'
|
const { children } = props;
|
||||||
const supportedLocales = ['en', 'de'];
|
const supportedLocales = ['en', 'de'];
|
||||||
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||||
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||||
|
|
||||||
let messages = {};
|
setRequestLocale(safeLocale);
|
||||||
|
|
||||||
|
let messages: Record<string, any> = {};
|
||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
|
||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track pageview on the server with high-fidelity header context
|
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||||
|
const clientKeys = [
|
||||||
|
'Footer',
|
||||||
|
'Navigation',
|
||||||
|
'Contact',
|
||||||
|
'Products',
|
||||||
|
'Team',
|
||||||
|
'Home',
|
||||||
|
'Error',
|
||||||
|
'StandardPage',
|
||||||
|
];
|
||||||
|
const clientMessages: Record<string, any> = {};
|
||||||
|
for (const key of clientKeys) {
|
||||||
|
if (messages[key]) {
|
||||||
|
clientMessages[key] = messages[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
const serverServices = getServerAppServices();
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
|
try {
|
||||||
const { headers } = await import('next/headers');
|
const { headers } = await import('next/headers');
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
if ('setServerContext' in serverServices.analytics) {
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
// Skip setting server context for analytics in CI
|
||||||
|
} else if ('setServerContext' in serverServices.analytics) {
|
||||||
(serverServices.analytics as any).setServerContext({
|
(serverServices.analytics as any).setServerContext({
|
||||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
@@ -69,21 +111,44 @@ export default async function LocaleLayout({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track initial server-side pageview
|
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||||
serverServices.analytics.trackPageview();
|
// Client-side AnalyticsProvider handles all pageviews.
|
||||||
|
} catch {
|
||||||
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
|
console.warn(
|
||||||
|
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||||
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={safeLocale} className="scroll-smooth overflow-x-hidden">
|
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
||||||
|
</head>
|
||||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
|
<SkipLink />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="flex-grow animate-fade-in overflow-visible"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
<CMSConnectivityNotice />
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
<AnalyticsProvider />
|
<AnalyticsShell />
|
||||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,86 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Container, Button, Heading } from '@/components/ui';
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default async function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = await getTranslations('Error.notFound');
|
||||||
|
|
||||||
|
// Try to determine the requested path
|
||||||
|
const headersList = await headers();
|
||||||
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
|
|
||||||
|
let suggestedUrl = null;
|
||||||
|
let suggestedLang = null;
|
||||||
|
|
||||||
|
// If we have a path, try to see if the last segment (slug) exists in ANY locale
|
||||||
|
if (urlPath) {
|
||||||
|
const slug = urlPath.split('/').filter(Boolean).pop();
|
||||||
|
if (slug) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Check posts
|
||||||
|
const postRes = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check products
|
||||||
|
const productRes =
|
||||||
|
postRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
// Check pages
|
||||||
|
const pageRes =
|
||||||
|
postRes.docs.length === 0 && productRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
const anyDoc = postRes.docs[0] || productRes.docs[0] || pageRes.docs[0];
|
||||||
|
|
||||||
|
if (anyDoc) {
|
||||||
|
// If the doc exists, we can figure out its native locale or
|
||||||
|
// offer the alternative locale (if we are in 'de', offer 'en')
|
||||||
|
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
|
||||||
|
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
|
||||||
|
|
||||||
|
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
|
||||||
|
|
||||||
|
// Reconstruct the URL for the alternative locale
|
||||||
|
const pathParts = urlPath.split('/').filter(Boolean);
|
||||||
|
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
|
||||||
|
pathParts[0] = alternativeLocale;
|
||||||
|
} else {
|
||||||
|
pathParts.unshift(alternativeLocale);
|
||||||
|
}
|
||||||
|
suggestedUrl = '/' + pathParts.join('/');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore Payload errors in 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ClientNotFoundTracker path={urlPath} />
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
{/* Industrial Background Element */}
|
{/* Industrial Background Element */}
|
||||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||||
@@ -26,15 +101,30 @@ export default function NotFound() {
|
|||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('description')}
|
|
||||||
|
{suggestedUrl && (
|
||||||
|
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
|
||||||
|
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-primary font-bold mb-2 text-lg">
|
||||||
|
Did you mean to visit the {suggestedLang} version?
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-sm mb-4">
|
||||||
|
This page exists, but in another language.
|
||||||
</p>
|
</p>
|
||||||
|
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
|
||||||
|
Go to {suggestedLang} Version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="/contact" variant="outline" size="lg">
|
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||||
Contact Support
|
Contact Support
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,5 +132,6 @@ export default function NotFound() {
|
|||||||
{/* Decorative Industrial Line */}
|
{/* Decorative Industrial Line */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||||
</Container>
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,24 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={t('title')}
|
title={t('title')}
|
||||||
description={t('description')}
|
description={t('description')}
|
||||||
label="Reliable Energy Infrastructure"
|
label="Reliable Energy Infrastructure"
|
||||||
/>
|
/>,
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
import Hero from '@/components/home/Hero';
|
import Hero from '@/components/home/Hero';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import dynamic from 'next/dynamic';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
|
||||||
import RecentPosts from '@/components/home/RecentPosts';
|
|
||||||
import Experience from '@/components/home/Experience';
|
|
||||||
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
|
||||||
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|
||||||
import GallerySection from '@/components/home/GallerySection';
|
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
|
||||||
import CTA from '@/components/home/CTA';
|
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
|
||||||
|
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
|
||||||
|
|
||||||
|
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||||
|
const Experience = dynamic(() => import('@/components/home/Experience'));
|
||||||
|
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
||||||
|
const MeetTheTeam = dynamic(() => import('@/components/home/MeetTheTeam'));
|
||||||
|
const GallerySection = dynamic(() => import('@/components/home/GallerySection'));
|
||||||
|
const VideoSection = dynamic(() => import('@/components/home/VideoSection'));
|
||||||
|
const CTA = dynamic(() => import('@/components/home/CTA'));
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
export default async function HomePage({
|
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ locale: string }>;
|
|
||||||
}) {
|
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="breadcrumb-home"
|
id="breadcrumb-home"
|
||||||
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
The instruction refers to changing a class within the Hero component's paragraph.
|
||||||
|
Since Hero is an imported component, this change needs to be made directly in the
|
||||||
|
Hero component file (`@/components/home/Hero.tsx`) itself, not in this page file.
|
||||||
|
This file (`app/[locale]/page.tsx`) only renders the Hero component.
|
||||||
|
Therefore, no change is applied here.
|
||||||
|
*/}
|
||||||
<Hero />
|
<Hero />
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<ProductCategories />
|
<ProductCategories />
|
||||||
@@ -52,7 +59,7 @@ export default async function HomePage({
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<VideoSection />
|
<VideoSection />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal>
|
<Reveal className="content-visibility-auto">
|
||||||
<CTA />
|
<CTA />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,27 +77,29 @@ export async function generateMetadata({
|
|||||||
let t;
|
let t;
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If translations for Index.meta are not present, try generic Index namespace
|
// If translations for Index.meta are not present, try generic Index namespace
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index' });
|
t = await getTranslations({ locale, namespace: 'Index' });
|
||||||
} catch (e) {
|
} catch {
|
||||||
t = (key: string) => '';
|
t = () => '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = t('title') || 'KLZ Cables';
|
const title = t('title') || 'KLZ Cables';
|
||||||
const description = t('description') || '';
|
const description =
|
||||||
|
t('description') ||
|
||||||
|
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}`,
|
canonical: `${SITE_URL}/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de',
|
de: `${SITE_URL}/de`,
|
||||||
en: '/en',
|
en: `${SITE_URL}/en`,
|
||||||
'x-default': '/en',
|
'x-default': `${SITE_URL}/en`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import ProductSidebar from '@/components/ProductSidebar';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
import ProductTabs from '@/components/ProductTabs';
|
import ProductTabs from '@/components/ProductTabs';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
import RelatedProducts from '@/components/RelatedProducts';
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getDatasheetPath } from '@/lib/datasheets';
|
import { getDatasheetPath } from '@/lib/datasheets';
|
||||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -53,134 +53,82 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||||
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
|
||||||
title: `${categoryTitle} | KLZ Cables`,
|
|
||||||
description: categoryDesc,
|
|
||||||
url: `${SITE_URL}/${locale}/products/${productSlug}`,
|
|
||||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
title: `${categoryTitle} | KLZ Cables`,
|
|
||||||
description: categoryDesc,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const getLocalizedPath = async (lang: string) => {
|
||||||
|
const parts = await Promise.all([
|
||||||
|
mapFileSlugToTranslated('products', lang),
|
||||||
|
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
|
||||||
|
]);
|
||||||
|
return parts.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
|
const currentLocalePath = await getLocalizedPath(locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||||
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const components = {
|
|
||||||
ProductTechnicalData,
|
|
||||||
ProductTabs,
|
|
||||||
p: (props: any) => (
|
|
||||||
<p
|
|
||||||
{...props}
|
|
||||||
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h2: (props: any) => (
|
|
||||||
<div className="relative mb-16">
|
|
||||||
<h2
|
|
||||||
{...props}
|
|
||||||
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
|
|
||||||
/>
|
|
||||||
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
h3: (props: any) => (
|
|
||||||
<h3
|
|
||||||
{...props}
|
|
||||||
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
|
||||||
section: (props: any) => <div {...props} className="block" />,
|
|
||||||
li: (props: any) => (
|
|
||||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
|
||||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
|
||||||
<span
|
|
||||||
{...props}
|
|
||||||
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
|
||||||
table: (props: any) => (
|
|
||||||
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
|
|
||||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
th: (props: any) => (
|
|
||||||
<th
|
|
||||||
{...props}
|
|
||||||
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
td: (props: any) => (
|
|
||||||
<td
|
|
||||||
{...props}
|
|
||||||
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
|
||||||
blockquote: (props: any) => (
|
|
||||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
|
||||||
<div
|
|
||||||
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
// Check if it's a category page
|
|
||||||
const categories = [
|
const categories = [
|
||||||
'low-voltage-cables',
|
'low-voltage-cables',
|
||||||
'medium-voltage-cables',
|
'medium-voltage-cables',
|
||||||
'high-voltage-cables',
|
'high-voltage-cables',
|
||||||
'solar-cables',
|
'solar-cables',
|
||||||
];
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const translatedSlugsForLocale = await Promise.all(
|
||||||
|
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the requested slugs don't exactly match the translated slugs for the current locale
|
||||||
|
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
|
||||||
|
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
|
||||||
|
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSlug = fileSlugs[fileSlugs.length - 1];
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
@@ -191,14 +139,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
? t(`categories.${categoryKey}.title`)
|
? t(`categories.${categoryKey}.title`)
|
||||||
: fileSlug;
|
: fileSlug;
|
||||||
|
|
||||||
// Filter products for this category
|
const filteredProducts = allProducts.filter((p) => {
|
||||||
const filteredProducts = allProducts.filter((p) =>
|
const firstCat = p.frontmatter.categories[0] || '';
|
||||||
p.frontmatter.categories.some(
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
let pFileSlug = 'low-voltage-cables';
|
||||||
),
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
);
|
pFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
pFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
pFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
return pFileSlug === fileSlug;
|
||||||
|
});
|
||||||
|
|
||||||
// Get translated product slugs
|
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -212,8 +173,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
<Link
|
||||||
{t('title')}
|
href={`/${locale}/${productsSlug}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-3 opacity-30">/</span>
|
<span className="mx-3 opacity-30">/</span>
|
||||||
<span className="text-white/90">{categoryTitle}</span>
|
<span className="text-white/90">{categoryTitle}</span>
|
||||||
@@ -232,9 +196,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{productsWithTranslatedSlugs.map((product) => (
|
{productsWithTranslatedSlugs.map((product) => (
|
||||||
<Link
|
<Link
|
||||||
key={product.slug}
|
key={product.slug}
|
||||||
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
href={`/${locale}/${productsSlug}/${productSlug}/${product.translatedSlug}`}
|
||||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||||
>
|
>
|
||||||
|
<Card tag="article" className="premium-card-reset">
|
||||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||||
{product.frontmatter.images?.[0] && (
|
{product.frontmatter.images?.[0] && (
|
||||||
<>
|
<>
|
||||||
@@ -243,8 +208,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
{/* Subtle reflection/shadow effect */}
|
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -285,6 +250,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -300,17 +266,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract technical data for schema
|
// Extract technical data natively from the Lexical AST for Schema.org
|
||||||
const technicalDataMatch = product.content.match(
|
|
||||||
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
|
||||||
);
|
|
||||||
let technicalItems = [];
|
let technicalItems = [];
|
||||||
if (technicalDataMatch) {
|
if (product.content?.root?.children) {
|
||||||
try {
|
const productTabsBlock = product.content.root.children.find(
|
||||||
const data = JSON.parse(technicalDataMatch[1]);
|
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||||
technicalItems = data.technicalItems || [];
|
);
|
||||||
} catch (e) {
|
if (productTabsBlock && productTabsBlock.fields?.technicalItems) {
|
||||||
console.error('Failed to parse technical data for schema', e);
|
technicalItems = productTabsBlock.fields.technicalItems;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +288,56 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
? t(`categories.${categoryKey}.title`)
|
? t(`categories.${categoryKey}.title`)
|
||||||
: categoryFileSlug;
|
: categoryFileSlug;
|
||||||
|
|
||||||
|
// Split content into Description and Technical Data
|
||||||
|
const rootChildren = product.content?.root?.children || [];
|
||||||
|
const technicalBlocks = rootChildren.filter(
|
||||||
|
(node: any) =>
|
||||||
|
node.type === 'block' &&
|
||||||
|
(node.fields?.blockType === 'productTabs' ||
|
||||||
|
node.fields?.blockType === 'productTechnicalData'),
|
||||||
|
);
|
||||||
|
let descriptionChildren = rootChildren.filter(
|
||||||
|
(node: any) =>
|
||||||
|
!(
|
||||||
|
node.type === 'block' &&
|
||||||
|
(node.fields?.blockType === 'productTabs' ||
|
||||||
|
node.fields?.blockType === 'productTechnicalData')
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no standalone description nodes, extract from the productTabs block's embedded content
|
||||||
|
if (descriptionChildren.length === 0) {
|
||||||
|
const tabsBlock = rootChildren.find(
|
||||||
|
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||||
|
);
|
||||||
|
if (tabsBlock?.fields?.content?.root?.children) {
|
||||||
|
descriptionChildren = tabsBlock.fields.content.root.children.filter((node: any) => {
|
||||||
|
// Filter out MDX parsing artifacts like `}>`
|
||||||
|
if (node.type === 'paragraph' && node.children?.length === 1) {
|
||||||
|
const text = node.children[0]?.text?.trim();
|
||||||
|
return text !== '}>' && text !== '{' && text !== '}';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||||
|
|
||||||
|
const descriptionContent = {
|
||||||
|
root: {
|
||||||
|
...product.content.root,
|
||||||
|
children: descriptionChildren,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const technicalContent = {
|
||||||
|
root: {
|
||||||
|
...product.content.root,
|
||||||
|
children: technicalBlocks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<ProductSidebar
|
<ProductSidebar
|
||||||
productName={product.frontmatter.title}
|
productName={product.frontmatter.title}
|
||||||
@@ -333,45 +346,40 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const productComponents = {
|
|
||||||
...components,
|
|
||||||
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
|
|
||||||
const processedContent = product.content
|
|
||||||
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
|
|
||||||
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
|
|
||||||
.replace(/<p[^>]*>(.*?)<\/p>/g, '\n$1\n')
|
|
||||||
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
|
|
||||||
.replace(/<li[^>]*>(.*?)<\/li>/g, '\n- $1\n')
|
|
||||||
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
|
|
||||||
.replace(/<section[^>]*>/g, '')
|
|
||||||
.replace(/<\/section>/g, '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-white relative">
|
<div className="flex flex-col min-h-screen bg-white relative">
|
||||||
{/* Product Hero */}
|
{/* Product Hero */}
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<ProductEngagementTracker
|
||||||
|
productName={product.frontmatter.title}
|
||||||
|
productSlug={productSlug}
|
||||||
|
categories={product.frontmatter.categories}
|
||||||
|
sku={product.frontmatter.sku}
|
||||||
|
/>
|
||||||
|
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
|
||||||
{t('title')}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-4 opacity-20">/</span>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/products/${categorySlug}`}
|
href={`/${locale}/${productsSlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||||
|
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||||
>
|
>
|
||||||
{categoryTitle}
|
{categoryTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
@@ -382,7 +390,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{t('englishVersion')}
|
{t('englishVersion')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -393,10 +401,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Heading level={1} className="text-white mb-8 uppercase">
|
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||||
{product.frontmatter.title}
|
{product.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,15 +418,16 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative -mt-32 mb-32 animate-slide-up"
|
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{ animationDelay: '200ms' }}
|
||||||
>
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
@@ -437,6 +446,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
sizes="128px"
|
||||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -447,17 +457,40 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||||
<div className="w-full">
|
{/* Description Area Next to Sidebar */}
|
||||||
{/* Main Content Area */}
|
<div className="lg:col-span-8">
|
||||||
<div className="max-w-none">
|
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||||
<MDXRemote source={processedContent} components={productComponents} />
|
{descriptionChildren.length > 0 ? (
|
||||||
|
<PayloadRichText data={descriptionContent} />
|
||||||
|
) : product.frontmatter.description ? (
|
||||||
|
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{product.application?.root?.children?.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<PayloadRichText data={product.application} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
{/* Sidebar Column */}
|
||||||
|
<div className="lg:col-span-4 lg:sticky lg:top-32 h-fit">{sidebar}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-width Technical Data Below */}
|
||||||
|
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||||
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||||
|
<PayloadRichText data={technicalContent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datasheet Download Section */}
|
||||||
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
||||||
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
|
<div className="mt-16 pt-16 border-t-2 border-neutral-dark/5">
|
||||||
<div className="mb-12">
|
<div className="mb-8">
|
||||||
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||||
{t('downloadDatasheet')}
|
{t('downloadDatasheet')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -467,7 +500,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Structured Data */}
|
{/* Structured Data (Hidden) */}
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`jsonld-${product.slug}`}
|
id={`jsonld-${product.slug}`}
|
||||||
data={
|
data={
|
||||||
@@ -488,7 +521,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
availability: 'https://schema.org/InStock',
|
availability: 'https://schema.org/InStock',
|
||||||
priceCurrency: 'EUR',
|
priceCurrency: 'EUR',
|
||||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
itemCondition: 'https://schema.org/NewCondition',
|
itemCondition: 'https://schema.org/NewCondition',
|
||||||
},
|
},
|
||||||
additionalProperty: technicalItems.map((item: any) => ({
|
additionalProperty: technicalItems.map((item: any) => ({
|
||||||
@@ -499,16 +532,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
category: product.frontmatter.categories.join(', '),
|
category: product.frontmatter.categories.join(', '),
|
||||||
mainEntityOfPage: {
|
mainEntityOfPage: {
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
},
|
},
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
|
|||||||
@@ -3,27 +3,27 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
const title = t('meta.title') || t('title');
|
const title = t.has('meta.title')
|
||||||
|
? t('meta.title')
|
||||||
|
: t.has('breadcrumb')
|
||||||
|
? t('breadcrumb')
|
||||||
|
: 'Products';
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Products"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -18,24 +16,27 @@ interface ProductsPageProps {
|
|||||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t.has('meta.title')
|
||||||
|
? t('meta.title')
|
||||||
|
: t.has('breadcrumb')
|
||||||
|
? t('breadcrumb')
|
||||||
|
: 'Products';
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/products',
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||||
en: '/en/products',
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
'x-default': '/en/products',
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/products`,
|
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
images: getOGImageMetadata('products', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -47,6 +48,7 @@ export async function generateMetadata({ params }: ProductsPageProps): Promise<M
|
|||||||
|
|
||||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Get translated category slugs
|
// Get translated category slugs
|
||||||
@@ -55,41 +57,44 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
const contactSlug = await mapFileSlugToTranslated('contact', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
title: t('categories.lowVoltage.title'),
|
title: t('categories.lowVoltage.title'),
|
||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${locale}/products/${lowVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${locale}/products/${mediumVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${locale}/products/${highVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${locale}/products/${solarSlug}`,
|
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -100,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="circle"
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||||
@@ -134,7 +131,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Reveal key={idx} delay={idx * 100}>
|
<Reveal key={idx} delay={idx * 100}>
|
||||||
<Link key={idx} href={category.href} className="group block">
|
<TrackedLink
|
||||||
|
key={idx}
|
||||||
|
href={category.href}
|
||||||
|
className="group block"
|
||||||
|
eventProperties={{
|
||||||
|
category_title: category.title,
|
||||||
|
location: 'products_index',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
||||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
@@ -142,8 +147,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
||||||
|
|
||||||
@@ -195,7 +199,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</TrackedLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
@@ -218,13 +222,13 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${contactSlug}`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 px-6 md:px-10 text-sm md:text-xl"
|
||||||
>
|
>
|
||||||
{t('cta.button')}
|
{t('cta.button')}
|
||||||
<span className="ml-4 transition-transform group-hover:translate-x-2">
|
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
@@ -13,17 +16,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Our Team"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||||
|
|
||||||
interface TeamPageProps {
|
interface TeamPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -23,18 +23,17 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/team`,
|
canonical: `${SITE_URL}/${locale}/team`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/team',
|
de: `${SITE_URL}/de/team`,
|
||||||
en: '/en/team',
|
en: `${SITE_URL}/en/team`,
|
||||||
'x-default': '/en/team',
|
'x-default': `${SITE_URL}/en/team`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/team`,
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
images: getOGImageMetadata('team', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -46,6 +45,7 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
|
|||||||
|
|
||||||
export default async function TeamPage({ params }: TeamPageProps) {
|
export default async function TeamPage({ params }: TeamPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +93,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt="KLZ Team"
|
alt="KLZ Team"
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||||
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||||
@@ -113,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||||
<section className="relative bg-white overflow-hidden">
|
<article className="relative bg-white overflow-hidden">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||||
@@ -121,27 +122,32 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
{t('michael.role')}
|
{t('michael.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||||
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
{t('michael.quote')}
|
{t('michael.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||||
{t('michael.description')}
|
{t('michael.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Michael Bodemer',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('michael.linkedin')}
|
{t('michael.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||||
@@ -150,12 +156,13 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('michael.name')}
|
alt={t('michael.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Legacy Section - Immersive Background */}
|
{/* Legacy Section - Immersive Background */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -211,7 +218,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||||
<section className="relative bg-white overflow-hidden">
|
<article className="relative bg-white overflow-hidden">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||||
<Image
|
<Image
|
||||||
@@ -219,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('klaus.name')}
|
alt={t('klaus.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -229,31 +237,36 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
{t('klaus.role')}
|
{t('klaus.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
{t('klaus.quote')}
|
{t('klaus.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||||
{t('klaus.description')}
|
{t('klaus.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Klaus Mintel',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('klaus.linkedin')}
|
{t('klaus.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Manifesto Section - Modern Grid */}
|
{/* Manifesto Section - Modern Grid */}
|
||||||
<Section className="bg-white text-primary py-16 md:py-28">
|
<Section className="bg-white text-primary py-16 md:py-28">
|
||||||
@@ -281,9 +294,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
||||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
<div
|
<li
|
||||||
key={idx}
|
key={idx}
|
||||||
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||||
>
|
>
|
||||||
@@ -298,9 +311,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t(`manifesto.items.${idx}.description`)}
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
84
app/actions/brochure.ts
Normal file
84
app/actions/brochure.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { renderToBuffer } from '@react-pdf/renderer';
|
||||||
|
import { getAllProducts } from '@/lib/products';
|
||||||
|
import { getExcelTechnicalDataForProduct } from '@/lib/excel-products';
|
||||||
|
import { PDFBrochure } from '@/lib/pdf-brochure';
|
||||||
|
import { ProductData as PDFProductData } from '@/lib/pdf-datasheet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server action to generate a PDF brochure.
|
||||||
|
* Fetches products from Payload CMS and technical data from Excel.
|
||||||
|
* Returns the PDF as a base64 string.
|
||||||
|
*/
|
||||||
|
export async function generateBrochureAction(params: {
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
productSlugs?: string[];
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const { locale, productSlugs, title, subtitle } = params;
|
||||||
|
|
||||||
|
// 1. Fetch products from Payload
|
||||||
|
let products = await getAllProducts(locale);
|
||||||
|
|
||||||
|
// 2. Filter by slugs if provided
|
||||||
|
if (productSlugs && productSlugs.length > 0) {
|
||||||
|
products = products.filter((p) => productSlugs.includes(p.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Map Payload products to PDF internal model and attach Excel data
|
||||||
|
const pdfProducts: PDFProductData[] = products.map((p, index) => {
|
||||||
|
// Get technical data from Excel
|
||||||
|
const excelData = getExcelTechnicalDataForProduct({
|
||||||
|
slug: p.slug,
|
||||||
|
sku: p.frontmatter.sku,
|
||||||
|
name: p.frontmatter.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: index + 1,
|
||||||
|
name: p.frontmatter.title,
|
||||||
|
sku: p.frontmatter.sku,
|
||||||
|
shortDescriptionHtml: p.frontmatter.description,
|
||||||
|
descriptionHtml: p.frontmatter.description,
|
||||||
|
// Application text is usually part of description in Payload v3
|
||||||
|
applicationHtml: '',
|
||||||
|
featuredImage: p.frontmatter.images[0] || null,
|
||||||
|
images: p.frontmatter.images,
|
||||||
|
categories: p.frontmatter.categories.map((name) => ({ name })),
|
||||||
|
attributes: excelData?.attributes || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pdfProducts.length === 0) {
|
||||||
|
throw new Error('No products found for brochure generation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Render to PDF buffer
|
||||||
|
// Note: React 19 / Next 15+ might need specific handling for renderToBuffer in actions
|
||||||
|
const buffer = await renderToBuffer(
|
||||||
|
React.createElement(PDFBrochure, {
|
||||||
|
products: pdfProducts,
|
||||||
|
locale,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Convert to Base64 for returning to client
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: buffer.toString('base64'),
|
||||||
|
fileName: `KLZ_Cables_Brochure_${locale.toUpperCase()}.pdf`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Action] Brochure generation failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import client, { ensureAuthenticated } from '@/lib/directus';
|
|
||||||
import { createItem } from '@directus/sdk';
|
|
||||||
import { sendEmail } from '@/lib/mail/mailer';
|
import { sendEmail } from '@/lib/mail/mailer';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
@@ -41,31 +40,31 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
return { success: false, error: 'Missing required fields' };
|
return { success: false, error: 'Missing required fields' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Save to Directus
|
// 1. Save to CMS
|
||||||
try {
|
try {
|
||||||
await ensureAuthenticated();
|
const { getPayload } = await import('payload');
|
||||||
if (productName) {
|
const configPromise = (await import('@payload-config')).default;
|
||||||
await client.request(
|
const payload = await getPayload({ config: configPromise });
|
||||||
createItem('product_requests', {
|
|
||||||
product_name: productName,
|
await payload.create({
|
||||||
email,
|
collection: 'form-submissions',
|
||||||
message,
|
data: {
|
||||||
}),
|
|
||||||
);
|
|
||||||
logger.info('Product request stored in Directus');
|
|
||||||
} else {
|
|
||||||
await client.request(
|
|
||||||
createItem('contact_submissions', {
|
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
message,
|
message,
|
||||||
}),
|
type: productName ? 'product_quote' : 'contact',
|
||||||
);
|
productName: productName || undefined,
|
||||||
logger.info('Contact submission stored in Directus');
|
},
|
||||||
}
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Successfully saved form submission to Payload CMS', {
|
||||||
|
type: productName ? 'product_quote' : 'contact',
|
||||||
|
email,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to store submission in Directus', { error });
|
logger.error('Failed to store submission in Payload CMS', { error });
|
||||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
services.errors.captureException(error, { action: 'payload_store_submission' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Send Emails
|
// 2. Send Emails
|
||||||
@@ -75,6 +74,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: 'New Contact Form Submission';
|
: 'New Contact Form Submission';
|
||||||
const confirmationSubject = 'Thank you for your inquiry';
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
const isTestSubmission = email === 'testing@mintel.me';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2a. Send notification to Mintel/Client
|
// 2a. Send notification to Mintel/Client
|
||||||
@@ -87,6 +87,8 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isTestSubmission) {
|
||||||
|
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
|
||||||
const notificationResult = await sendEmail({
|
const notificationResult = await sendEmail({
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: notificationSubject,
|
subject: notificationSubject,
|
||||||
@@ -97,6 +99,23 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
logger.info('Notification email sent successfully', {
|
logger.info('Notification email sent successfully', {
|
||||||
messageId: notificationResult.messageId,
|
messageId: notificationResult.messageId,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Notification email DELIVERY FAILED', {
|
||||||
|
error: notificationResult.error,
|
||||||
|
subject: notificationSubject,
|
||||||
|
recipients: env.MAIL_RECIPIENTS,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||||
|
{
|
||||||
|
action: 'sendContactFormAction_notification',
|
||||||
|
email,
|
||||||
|
recipients: env.MAIL_RECIPIENTS
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping notification email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
@@ -108,6 +127,8 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isTestSubmission) {
|
||||||
|
logger.info('Sending customer confirmation', { to: email });
|
||||||
const confirmationResult = await sendEmail({
|
const confirmationResult = await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: confirmationSubject,
|
subject: confirmationSubject,
|
||||||
@@ -118,6 +139,19 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
logger.info('Confirmation email sent successfully', {
|
logger.info('Confirmation email sent successfully', {
|
||||||
messageId: confirmationResult.messageId,
|
messageId: confirmationResult.messageId,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Confirmation email DELIVERY FAILED', {
|
||||||
|
error: confirmationResult.error,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
to: email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_confirmation', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping confirmation email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
import { checkHealth } from '@/lib/directus';
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep CMS Health Check
|
||||||
|
* Validates that Payload CMS can actually query the database.
|
||||||
|
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||||
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const health = await checkHealth();
|
const checks: Record<string, string> = {};
|
||||||
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
|
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
checks.init = 'ok';
|
||||||
|
|
||||||
|
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||||
|
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
||||||
|
for (const collection of collections) {
|
||||||
|
try {
|
||||||
|
await payload.find({ collection, limit: 1, locale: 'en' });
|
||||||
|
checks[collection] = 'ok';
|
||||||
|
} catch (e: any) {
|
||||||
|
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: e.message?.substring(0, 200), checks },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/api/health/fix-avb-now/route.ts
Normal file
25
app/api/health/fix-avb-now/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getPayload } from 'payload';
|
||||||
|
import config from '@/payload.config';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
// Clear the excerpt for page ID 6
|
||||||
|
await payload.update({
|
||||||
|
collection: 'pages',
|
||||||
|
id: 6,
|
||||||
|
data: {
|
||||||
|
excerpt: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'AVB excerpt cleared successfully on production!' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fixing AVB:', error);
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
111
app/api/pages/[slug]/pdf/route.tsx
Normal file
111
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { renderToStream } from '@react-pdf/renderer';
|
||||||
|
import React from 'react';
|
||||||
|
import { PDFPage } from '@/lib/pdf-page';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// Handle AGBs specifically - either fetch from collection or use fallback file
|
||||||
|
if (slug === 'agbs') {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
const currentAgbs = await payload.find({
|
||||||
|
collection: 'agbs-collection',
|
||||||
|
where: {
|
||||||
|
isCurrent: { equals: true },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentAgbs.totalDocs > 0) {
|
||||||
|
const agb = currentAgbs.docs[0];
|
||||||
|
const media = agb.file as any;
|
||||||
|
if (media && media.url) {
|
||||||
|
// If it's a remote URL or absolute path, we might need to fetch it
|
||||||
|
// For now assume it's a local file in public/media
|
||||||
|
const filePath = path.join(process.cwd(), 'public', media.url);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${media.filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for hardcoded legacy AGB
|
||||||
|
const filePath = path.join(process.cwd(), 'public', 'AVB-KLZ-4-2026.pdf');
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="AVB-KLZ-4-2026.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Payload App
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Fetch the page
|
||||||
|
const pages = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
_status: { equals: 'published' },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.totalDocs === 0) {
|
||||||
|
return new NextResponse('Page not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pages.docs[0];
|
||||||
|
|
||||||
|
// Determine locale from searchParams or default to 'de'
|
||||||
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||||
|
|
||||||
|
// Render the React-PDF document into a stream
|
||||||
|
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||||
|
|
||||||
|
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||||
|
stream.on('end', () => controller.close());
|
||||||
|
stream.on('error', (err) => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
(stream as any).destroy?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${slug}.pdf`;
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
// Cache control if needed, skip for now.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Only allow in development
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Ensure we are in the project root by using process.cwd()
|
||||||
|
// Path: <project-root>/remotion/session.json
|
||||||
|
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||||
|
const filePath = path.join(remotionDir, 'session.json');
|
||||||
|
|
||||||
|
// Create remotion directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(remotionDir)) {
|
||||||
|
fs.mkdirSync(remotionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the JSON file
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, path: filePath });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save session:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ export async function POST(request: NextRequest) {
|
|||||||
const logger = services.logger.child({ component: 'sentry-relay' });
|
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Prevent 403 Forbidden console noise in local dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
const envelope = await request.text();
|
const envelope = await request.text();
|
||||||
|
|
||||||
// Sentry envelopes can contain multiple parts separated by newlines
|
// Sentry envelopes can contain multiple parts separated by newlines
|
||||||
@@ -25,7 +30,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = JSON.parse(lines[0]);
|
JSON.parse(lines[0]);
|
||||||
const realDsn = config.errors.glitchtip.dsn;
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
if (!realDsn) {
|
if (!realDsn) {
|
||||||
@@ -35,7 +40,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const dsnUrl = new URL(realDsn);
|
const dsnUrl = new URL(realDsn);
|
||||||
const projectId = dsnUrl.pathname.replace('/', '');
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
const sentryKey = dsnUrl.username;
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/?sentry_key=${sentryKey}`;
|
||||||
|
|
||||||
logger.debug('Relaying Sentry envelope', {
|
logger.debug('Relaying Sentry envelope', {
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
16
app/health/fix-avb/route.ts
Normal file
16
app/health/fix-avb/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: 'pages',
|
||||||
|
id: 6,
|
||||||
|
data: {
|
||||||
|
excerpt: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ success: true, message: 'AVB excerpt cleared' })
|
||||||
|
}
|
||||||
@@ -1,37 +1,55 @@
|
|||||||
import { config } from '@/lib/config';
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { getAllProductsMetadata } from '@/lib/mdx';
|
import { getAllProductsMetadata } from '@/lib/products';
|
||||||
import { getAllPostsMetadata } from '@/lib/blog';
|
import { getAllPostsMetadata } from '@/lib/blog';
|
||||||
import { getAllPagesMetadata } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
|
|
||||||
export const revalidate = 3600; // Revalidate every hour
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const routes = [
|
|
||||||
'',
|
|
||||||
'/blog',
|
|
||||||
'/contact',
|
|
||||||
'/team',
|
|
||||||
'/products',
|
|
||||||
'/products/low-voltage-cables',
|
|
||||||
'/products/medium-voltage-cables',
|
|
||||||
'/products/high-voltage-cables',
|
|
||||||
'/products/solar-cables',
|
|
||||||
];
|
|
||||||
|
|
||||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
|
// Helper to generate localized URL Segment
|
||||||
|
const getLocalizedRoute = async (pageKey: string) => {
|
||||||
|
if (pageKey === '') return '';
|
||||||
|
const translated = await mapFileSlugToTranslated(pageKey, locale);
|
||||||
|
return `/${translated}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Static routes
|
// Static routes
|
||||||
for (const route of routes) {
|
const staticPages = ['', 'blog', 'contact', 'team', 'products'];
|
||||||
|
for (const page of staticPages) {
|
||||||
|
const localizedRoute = await getLocalizedRoute(page);
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}${route}`,
|
url: `${baseUrl}/${locale}${localizedRoute}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: route === '' ? 'daily' : 'weekly',
|
changeFrequency: page === '' ? 'daily' : 'weekly',
|
||||||
priority: route === '' ? 1 : 0.8,
|
priority: page === '' ? 1 : 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories routes
|
||||||
|
const productCategories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
|
|
||||||
|
const translatedProducts = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
|
for (const category of productCategories) {
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
||||||
|
sitemapEntries.push({
|
||||||
|
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 0.8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +58,28 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
for (const product of productsMetadata) {
|
for (const product of productsMetadata) {
|
||||||
if (!product.frontmatter || !product.slug) continue;
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
|
|
||||||
const category =
|
const firstCat = product.frontmatter.categories[0] || '';
|
||||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
let categoryFileSlug = 'low-voltage-cables';
|
||||||
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
categoryFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
|
||||||
|
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
@@ -51,12 +87,15 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blog posts
|
// Blog posts
|
||||||
|
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
|
||||||
const postsMetadata = await getAllPostsMetadata(locale);
|
const postsMetadata = await getAllPostsMetadata(locale);
|
||||||
for (const post of postsMetadata) {
|
for (const post of postsMetadata) {
|
||||||
if (!post.frontmatter || !post.slug) continue;
|
if (!post.frontmatter || !post.slug) continue;
|
||||||
|
|
||||||
|
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`,
|
||||||
lastModified: new Date(post.frontmatter.date),
|
lastModified: new Date(post.frontmatter.date),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
@@ -68,8 +107,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
for (const page of pagesMetadata) {
|
for (const page of pagesMetadata) {
|
||||||
if (!page.slug) continue;
|
if (!page.slug) continue;
|
||||||
|
|
||||||
|
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
url: `${baseUrl}/${locale}/${translatedSlug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
|
|||||||
@@ -19,14 +19,39 @@ export async function POST(request: NextRequest) {
|
|||||||
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
// Prevent 400 Bad Request console noise in local dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch (_parseError) {
|
||||||
|
logger.warn('Received malformed or empty JSON in analytics proxy');
|
||||||
|
return NextResponse.json({ status: 'ignored_bad_payload' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
logger.warn('Received invalid body type in analytics proxy', { type: typeof body });
|
||||||
|
return NextResponse.json({ status: 'ignored_invalid_body' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
const { type, payload } = body;
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
if (!type || !payload) {
|
||||||
|
logger.warn('Received incomplete analytics payload', {
|
||||||
|
hasType: !!type,
|
||||||
|
hasPayload: !!payload
|
||||||
|
});
|
||||||
|
return NextResponse.json({ status: 'ignored_incomplete_payload' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
// Inject the secret websiteId from server config
|
// Inject the secret websiteId from server config
|
||||||
const websiteId = config.analytics.umami.websiteId;
|
const websiteId = config.analytics.umami.websiteId;
|
||||||
if (!websiteId) {
|
if (!websiteId) {
|
||||||
logger.warn('Umami tracking received but no Website ID configured on server');
|
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
return NextResponse.json({ status: 'ignored_no_config' }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the enhanced payload with the secret ID
|
// Prepare the enhanced payload with the secret ID
|
||||||
@@ -56,18 +81,41 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
if (!process.env.CI) {
|
||||||
logger.error('Umami API responded with error', {
|
logger.error('Umami API responded with error', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
error: errorText.slice(0, 100),
|
error: errorText.slice(0, 100),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return new NextResponse(errorText, { status: response.status });
|
return new NextResponse(errorText, { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ status: 'ok' });
|
return NextResponse.json({ status: 'ok' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to proxy analytics request', {
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
error: (error as Error).message,
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|
||||||
|
// Console error to ensure it appears in logs even if logger fails
|
||||||
|
if (!process.env.CI) {
|
||||||
|
console.error('CRITICAL PROXY ERROR:', {
|
||||||
|
message: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
endpoint: config.analytics.umami.apiEndpoint,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
|
||||||
|
logger.error('Failed to proxy analytics request', {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
details: errorMessage, // Expose error for debugging
|
||||||
|
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2129
ast_updated.json
Normal file
2129
ast_updated.json
Normal file
File diff suppressed because it is too large
Load Diff
39
check-data.ts
Normal file
39
check-data.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
|
async function checkData() {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const { docs: posts } = await payload.find({ collection: 'posts', limit: 3 });
|
||||||
|
const { docs: products } = await payload.find({ collection: 'products', limit: 3 });
|
||||||
|
const { docs: pages } = await payload.find({ collection: 'pages', limit: 3 });
|
||||||
|
|
||||||
|
const checkDocs = (name: string, docs: any[]) => {
|
||||||
|
console.log(`\n----- ${name.toUpperCase()} -----`);
|
||||||
|
docs.forEach((p) => {
|
||||||
|
console.log(`ID: ${p.id}, Slug: ${p.slug}`);
|
||||||
|
if (Array.isArray(p.content)) {
|
||||||
|
console.log(
|
||||||
|
'Content is ARRAY (Slate format!)',
|
||||||
|
JSON.stringify(p.content).substring(0, 100),
|
||||||
|
);
|
||||||
|
} else if (p.content && p.content.root) {
|
||||||
|
console.log('Content is Lexical format.');
|
||||||
|
} else {
|
||||||
|
console.log('Content is UNKNOWN format.');
|
||||||
|
console.log(JSON.stringify(p.content).substring(0, 100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDocs('posts', posts);
|
||||||
|
checkDocs('products', products);
|
||||||
|
checkDocs('pages', pages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkData();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['@commitlint/config-conventional'],
|
extends: ['@commitlint/config-conventional'],
|
||||||
rules: {
|
rules: {
|
||||||
76
components/AgbHistoryBlock.tsx
Normal file
76
components/AgbHistoryBlock.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { Container, Card, Heading } from '@/components/ui';
|
||||||
|
|
||||||
|
export const AgbHistoryBlock: React.FC<{ title: string }> = async ({ title }) => {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const agbs = await payload.find({
|
||||||
|
collection: 'agbs-collection',
|
||||||
|
sort: '-versionDate',
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (agbs.totalDocs === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-16 p-8 md:p-12 bg-neutral-light rounded-3xl shadow-sm border border-neutral-medium">
|
||||||
|
<Heading level={3} className="mb-8 text-saturated">
|
||||||
|
{title || 'Vorherige Versionen'}
|
||||||
|
</Heading>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{agbs.docs.map((agb: any) => {
|
||||||
|
const date = new Date(agb.versionDate).toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileUrl = typeof agb.file === 'object' ? agb.file.url : '';
|
||||||
|
const filename = typeof agb.file === 'object' ? agb.file.filename : 'agb.pdf';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agb.id}
|
||||||
|
className="flex items-center justify-between p-6 bg-white rounded-2xl shadow-sm border border-neutral-medium hover:border-primary transition-all group"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-saturated group-hover:text-primary transition-colors">
|
||||||
|
{agb.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Gültig ab {date}</p>
|
||||||
|
</div>
|
||||||
|
{fileUrl && (
|
||||||
|
<a
|
||||||
|
href={fileUrl}
|
||||||
|
download={filename}
|
||||||
|
className="p-3 bg-neutral-light text-primary rounded-full hover:bg-primary hover:text-white transition-all shadow-sm"
|
||||||
|
title="PDF herunterladen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" x2="12" y1="15" y2="3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { config } from '../lib/config';
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
export default function CMSConnectivityNotice() {
|
export default function CMSConnectivityNotice() {
|
||||||
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export default function CMSConnectivityNotice() {
|
|||||||
setStatus('ok');
|
setStatus('ok');
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If it's a connection error, only show if we are really debugging
|
// If it's a connection error, only show if we are really debugging
|
||||||
if (isDebug || isLocal) {
|
if (isDebug || isLocal) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const t = useTranslations('Contact');
|
const t = useTranslations('Contact');
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
form_name: 'Contact',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -29,17 +48,29 @@ export default function ContactForm() {
|
|||||||
(e.target as HTMLFormElement).reset();
|
(e.target as HTMLFormElement).reset();
|
||||||
} else {
|
} else {
|
||||||
console.error('Contact form submission failed:', { email, error: result.error });
|
console.error('Contact form submission failed:', { email, error: result.error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Contact form submission error:', { email, error });
|
console.error('Contact form submission error:', { email, error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
<Card
|
||||||
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-10 h-10 text-primary-dark"
|
className="w-10 h-10 text-primary-dark"
|
||||||
@@ -66,7 +97,11 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
<Card
|
||||||
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-10 h-10 text-destructive-foreground"
|
className="w-10 h-10 text-destructive-foreground"
|
||||||
@@ -105,38 +140,40 @@ export default function ContactForm() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="contact-name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.namePlaceholder')}
|
onFocus={() => handleFocus('contact-name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="email">{t('form.email')}</Label>
|
<Label htmlFor="contact-email">{t('form.email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="contact-email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.emailPlaceholder')}
|
placeholder={t('form.emailPlaceholder')}
|
||||||
|
onFocus={() => handleFocus('contact-email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="message">{t('form.message')}</Label>
|
<Label htmlFor="contact-message">{t('form.message')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="contact-message"
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
|
onFocus={() => handleFocus('contact-message')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
interface DatasheetDownloadProps {
|
interface DatasheetDownloadProps {
|
||||||
datasheetPath: string;
|
datasheetPath: string;
|
||||||
@@ -10,13 +12,21 @@ interface DatasheetDownloadProps {
|
|||||||
|
|
||||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
||||||
<a
|
<a
|
||||||
href={datasheetPath}
|
href={datasheetPath}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||||
|
file_name: datasheetPath.split('/').pop(),
|
||||||
|
file_path: datasheetPath,
|
||||||
|
location: 'product_page',
|
||||||
|
})
|
||||||
|
}
|
||||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
{/* Animated Background Gradient */}
|
{/* Animated Background Gradient */}
|
||||||
@@ -32,6 +42,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -45,7 +56,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||||
|
PDF Datasheet
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||||
{t('downloadDatasheet')}
|
{t('downloadDatasheet')}
|
||||||
@@ -57,8 +70,19 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
components/FeedbackClientWrapper.tsx
Normal file
18
components/FeedbackClientWrapper.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const FeedbackOverlay = dynamic(
|
||||||
|
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FeedbackClientWrapperProps {
|
||||||
|
feedbackEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
|
||||||
|
if (!feedbackEnabled) return null;
|
||||||
|
|
||||||
|
return <FeedbackOverlay />;
|
||||||
|
}
|
||||||
@@ -1,93 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
const navT = useTranslations('Navigation');
|
const navT = useTranslations('Navigation');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden">
|
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<h2 className="sr-only">Footer Navigation</h2>
|
||||||
{/* Brand Column */}
|
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||||
<div className="lg:col-span-4 space-y-8">
|
{/* Brand Column – full width on mobile */}
|
||||||
<Link href={`/${locale}`} className="inline-block group">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-block group"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
target: 'home_logo',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logo-white.svg"
|
src="/logo-white.svg"
|
||||||
alt={t('products')}
|
alt="KLZ Vertriebs GmbH"
|
||||||
width={150}
|
width={150}
|
||||||
height={40}
|
height={40}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
||||||
{t('tagline')}
|
{t('tagline')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<a href="https://www.linkedin.com/company/klz-vertriebs-gmbh/" target="_blank" rel="noopener noreferrer" className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10">
|
<a
|
||||||
|
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
type: 'social',
|
||||||
|
target: 'linkedin',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10"
|
||||||
|
>
|
||||||
<span className="sr-only">LinkedIn</span>
|
<span className="sr-only">LinkedIn</span>
|
||||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links Columns */}
|
{/* Legal Column */}
|
||||||
<div className="lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('legal')}</h4>
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
|
{t('legal')}
|
||||||
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li><Link href={`/${locale}/${t('legalNoticeSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('legalNotice')}</Link></li>
|
<li>
|
||||||
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('privacyPolicy')}</Link></li>
|
<Link
|
||||||
<li><Link href={`/${locale}/${t('termsSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('terms')}</Link></li>
|
href={`/${locale}/${t('legalNoticeSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('legalNotice'),
|
||||||
|
href: t('legalNoticeSlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('legalNotice')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${t('privacyPolicySlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('privacyPolicy'),
|
||||||
|
href: t('privacyPolicySlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('privacyPolicy')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${t('termsSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('terms'),
|
||||||
|
href: t('termsSlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('terms')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Company Column */}
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('company')}</h4>
|
<div className="col-span-1 lg:col-span-2">
|
||||||
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
|
{t('company')}
|
||||||
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li><Link href={`/${locale}/team`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('team')}</Link></li>
|
<li>
|
||||||
<li><Link href={`/${locale}/products`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('products')}</Link></li>
|
<Link
|
||||||
<li><Link href={`/${locale}/blog`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('blog')}</Link></li>
|
href={`/${locale}/team`}
|
||||||
<li><Link href={`/${locale}/contact`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('contact')}</Link></li>
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('team'),
|
||||||
|
href: '/team',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('team')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('products'),
|
||||||
|
href: locale === 'de' ? '/produkte' : '/products',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('products')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('blog'),
|
||||||
|
href: '/blog',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('blog')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('contact'),
|
||||||
|
href: locale === 'de' ? '/kontakt' : '/contact',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('recentPosts')}</h4>
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
|
{t('recentPosts')}
|
||||||
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: locale === 'de'
|
title:
|
||||||
? "Windparkbau im Fokus: drei typische Kabelherausforderungen"
|
locale === 'de'
|
||||||
: "Focus on wind farm construction: three typical cable challenges",
|
? 'Windparkbau im Fokus: drei typische Kabelherausforderungen'
|
||||||
slug: locale === 'de'
|
: 'Focus on wind farm construction: three typical cable challenges',
|
||||||
? "windparkbau-im-fokus-drei-typische-kabelherausforderungen"
|
slug:
|
||||||
: "focus-on-wind-farm-construction-three-typical-cable-challenges"
|
locale === 'de'
|
||||||
|
? 'windparkbau-im-fokus-drei-typische-kabelherausforderungen'
|
||||||
|
: 'focus-on-wind-farm-construction-three-typical-cable-challenges',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: locale === 'de'
|
title:
|
||||||
? "Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist"
|
locale === 'de'
|
||||||
: "Why the N2XS(F)2Y is the ideal cable for your energy project",
|
? 'Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist'
|
||||||
slug: locale === 'de'
|
: 'Why the N2XS(F)2Y is the ideal cable for your energy project',
|
||||||
? "n2xsf2y-mittelspannungskabel-energieprojekt"
|
slug:
|
||||||
: "why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project"
|
locale === 'de'
|
||||||
}
|
? 'n2xsf2y-mittelspannungskabel-energieprojekt'
|
||||||
|
: 'why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||||
|
},
|
||||||
].map((post, i) => (
|
].map((post, i) => (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block text-white/80"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
location: 'footer_recent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||||
{post.title}
|
{post.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-white/40 uppercase tracking-widest">{t('readArticle')} →</span>
|
<span className="text-xs text-white/70 uppercase tracking-widest">
|
||||||
|
{t('readArticle')} →
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -95,11 +243,37 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link href="/en" locale="en" className="hover:text-white transition-colors">English</Link>
|
<Link
|
||||||
<Link href="/de" locale="de" className="hover:text-white transition-colors">Deutsch</Link>
|
href="/en"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: locale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/de"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: locale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
const currentLocale = pathname.split('/')[1] || 'en';
|
const currentLocale = pathname.split('/')[1] || 'en';
|
||||||
@@ -30,366 +33,440 @@ export default function Header() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Prevent scroll when mobile menu is open and handle focus trap
|
||||||
useEffect(() => {
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
// Prevent scroll when mobile menu is open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileMenuOpen) {
|
if (isMobileMenuOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
// Focus trap logic
|
||||||
|
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (focusableElements && focusableElements.length > 0) {
|
||||||
|
const firstElement = focusableElements[0] as HTMLElement;
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||||
|
|
||||||
|
const handleTabKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleTabKey);
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
|
||||||
|
// Focus the first element when menu opens
|
||||||
|
setTimeout(() => firstElement.focus(), 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleTabKey);
|
||||||
|
document.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
}
|
}
|
||||||
}, [isMobileMenuOpen]);
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
// Function to get path for a different locale
|
// Function to get path for a different locale with segment translation
|
||||||
const getPathForLocale = (newLocale: string) => {
|
const getPathForLocale = (newLocale: string) => {
|
||||||
const segments = pathname.split('/');
|
const segments = pathname.split('/');
|
||||||
|
const originLocale = segments[1] || 'en';
|
||||||
|
|
||||||
|
// Translation map for localized URL segments
|
||||||
|
const segmentMap: Record<string, Record<string, string>> = {
|
||||||
|
de: {
|
||||||
|
produkte: 'products',
|
||||||
|
kontakt: 'contact',
|
||||||
|
impressum: 'legal-notice',
|
||||||
|
datenschutz: 'privacy-policy',
|
||||||
|
agbs: 'terms',
|
||||||
|
niederspannungskabel: 'low-voltage-cables',
|
||||||
|
mittelspannungskabel: 'medium-voltage-cables',
|
||||||
|
hochspannungskabel: 'high-voltage-cables',
|
||||||
|
solarkabel: 'solar-cables',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
products: 'produkte',
|
||||||
|
contact: 'kontakt',
|
||||||
|
'legal-notice': 'impressum',
|
||||||
|
'privacy-policy': 'datenschutz',
|
||||||
|
terms: 'agbs',
|
||||||
|
'low-voltage-cables': 'niederspannungskabel',
|
||||||
|
'medium-voltage-cables': 'mittelspannungskabel',
|
||||||
|
'high-voltage-cables': 'hochspannungskabel',
|
||||||
|
'solar-cables': 'solarkabel',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace the locale segment
|
||||||
segments[1] = newLocale;
|
segments[1] = newLocale;
|
||||||
return segments.join('/');
|
|
||||||
|
// Translate other segments if they exist in our map
|
||||||
|
const translatedSegments = segments.map((segment, index) => {
|
||||||
|
if (index <= 1) return segment; // Skip empty and locale segments
|
||||||
|
|
||||||
|
const mapping = segmentMap[originLocale as keyof typeof segmentMap];
|
||||||
|
return mapping && mapping[segment] ? mapping[segment] : segment;
|
||||||
|
});
|
||||||
|
|
||||||
|
return translatedSegments.join('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ label: t('home'), href: '/' },
|
{ label: t('home'), href: '/' },
|
||||||
{ label: t('team'), href: '/team' },
|
{ label: t('team'), href: '/team' },
|
||||||
{ label: t('products'), href: '/products' },
|
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
|
||||||
{ label: t('blog'), href: '/blog' },
|
{ label: t('blog'), href: '/blog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const headerClass = cn(
|
const headerClass = cn(
|
||||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu",
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu fill-mode-both',
|
||||||
{
|
{
|
||||||
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||||
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen,
|
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
}
|
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||||
|
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const textColorClass = "text-white";
|
const textColorClass = 'text-white';
|
||||||
const logoSrc = "/logo-white.svg";
|
const logoSrc = '/logo-white.svg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.header
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||||
className={headerClass}
|
|
||||||
initial={{ y: -100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
||||||
>
|
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<motion.div
|
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||||
className="flex-shrink-0 group touch-target"
|
<Link
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
href={`/${currentLocale}`}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
onClick={() =>
|
||||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
target: 'home_logo',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Link href={`/${currentLocale}`}>
|
|
||||||
<Image
|
<Image
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt={t('home')}
|
alt={t('home')}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||||
priority
|
priority
|
||||||
unoptimized
|
fetchPriority="high"
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div className="flex items-center gap-4 md:gap-12">
|
||||||
className="flex items-center gap-4 md:gap-12"
|
<nav className="hidden lg:flex items-center space-x-10">
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
variants={{
|
|
||||||
visible: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.08,
|
|
||||||
delayChildren: 0.3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.nav
|
|
||||||
className="hidden lg:flex items-center space-x-10"
|
|
||||||
variants={navVariants}
|
|
||||||
>
|
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<motion.div
|
<div
|
||||||
key={item.href}
|
key={item.href}
|
||||||
variants={navLinkVariants}
|
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||||
|
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||||
>
|
>
|
||||||
|
{(() => {
|
||||||
|
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||||
|
const isActive =
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(fullHref);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={fullHref}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'header_nav',
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
textColorClass,
|
textColorClass,
|
||||||
"hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5"
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
|
isActive && 'text-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
|
||||||
|
isActive ? 'w-full' : 'w-0 group-hover:w-full',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</motion.nav>
|
</nav>
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
className={cn("hidden lg:flex items-center space-x-8", textColorClass)}
|
className={cn(
|
||||||
variants={headerRightVariants}
|
'hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both',
|
||||||
|
textColorClass,
|
||||||
|
)}
|
||||||
|
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<div
|
||||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
|
||||||
initial={{ opacity: 0, x: 20 }}
|
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.6 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.65 }}
|
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('en')}
|
href={getPathForLocale('en')}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div
|
<div className="w-px h-4 bg-current opacity-30" />
|
||||||
className="w-px h-4 bg-current opacity-20"
|
<div>
|
||||||
initial={{ scaleY: 0 }}
|
|
||||||
animate={{ scaleY: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.75 }}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('de')}
|
href={getPathForLocale('de')}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="white"
|
variant="white"
|
||||||
size="md"
|
size="md"
|
||||||
className="px-8 shadow-xl"
|
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('contact'),
|
||||||
|
location: 'header_cta',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('contact')}
|
{t('contact')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<motion.button
|
<button
|
||||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)}
|
className={cn(
|
||||||
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||||
|
textColorClass,
|
||||||
|
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
||||||
|
)}
|
||||||
aria-label={t('toggleMenu')}
|
aria-label={t('toggleMenu')}
|
||||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
aria-expanded={isMobileMenuOpen}
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
aria-controls="mobile-menu"
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 300, damping: 20, delay: 0.5 }}
|
onClick={() => {
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
const newState = !isMobileMenuOpen;
|
||||||
|
setIsMobileMenuOpen(newState);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: newState ? 'open' : 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<motion.svg
|
<svg
|
||||||
className="w-7 h-7"
|
className="w-7 h-7 transition-transform duration-300"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.6 }}
|
|
||||||
>
|
>
|
||||||
{isMobileMenuOpen ? (
|
{isMobileMenuOpen ? (
|
||||||
<motion.path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.svg>
|
</svg>
|
||||||
</motion.button>
|
</button>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div className={cn(
|
<div
|
||||||
"fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col",
|
className={cn(
|
||||||
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
)}>
|
isMobileMenuOpen
|
||||||
<motion.div
|
? 'opacity-100 translate-y-0'
|
||||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
initial="closed"
|
)}
|
||||||
animate={isMobileMenuOpen ? "open" : "closed"}
|
id="mobile-menu"
|
||||||
variants={{
|
role="dialog"
|
||||||
open: {
|
aria-modal="true"
|
||||||
transition: {
|
aria-label={t('menu')}
|
||||||
staggerChildren: 0.1,
|
ref={mobileMenuRef}
|
||||||
delayChildren: 0.2
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
}
|
>
|
||||||
}
|
{/* Close Button inside overlay */}
|
||||||
|
<div className="flex justify-end p-6 pt-8">
|
||||||
|
<button
|
||||||
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||||
|
aria-label={t('toggleMenu')}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: 'close',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<motion.div
|
<div
|
||||||
key={item.href}
|
key={item.href}
|
||||||
variants={{
|
className={cn(
|
||||||
closed: { opacity: 0, y: 50, scale: 0.9 },
|
'transition-all duration-500 transform',
|
||||||
open: {
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
opacity: 1,
|
)}
|
||||||
y: 0,
|
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: "easeOut",
|
|
||||||
delay: idx * 0.08
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
aria-current={
|
||||||
|
(
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||||
|
)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'mobile_menu',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||||
|
(item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
className={cn(
|
||||||
initial={{ opacity: 0, y: 30 }}
|
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||||
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
transition={{ duration: 0.5, delay: 0.8 }}
|
)}
|
||||||
>
|
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||||
<motion.div
|
|
||||||
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.9 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 1.0 }}
|
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||||
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('en')}
|
href={getPathForLocale('en')}
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div
|
<div className="w-px h-6 bg-white/30" />
|
||||||
className="w-px h-6 bg-white/20"
|
<div>
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 1.05 }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 1.1 }}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('de')}
|
href={getPathForLocale('de')}
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div className="w-full max-w-xs">
|
||||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 20, delay: 1.2 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
>
|
>
|
||||||
{t('contact')}
|
{t('contact')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
{/* Bottom Branding */}
|
||||||
<motion.div
|
<div
|
||||||
className="p-12 flex justify-center opacity-20"
|
className={cn(
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
'p-12 flex justify-center transition-all duration-700',
|
||||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
||||||
transition={{ duration: 0.5, delay: 1.4 }}
|
)}
|
||||||
>
|
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.5 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, delay: 1.5 }}
|
|
||||||
>
|
>
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.header>
|
</nav>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.06,
|
|
||||||
delayChildren: 0.1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const navLinkVariants = {
|
|
||||||
hidden: { opacity: 0, y: 20, scale: 0.9 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: "easeOut"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const headerRightVariants = {
|
|
||||||
hidden: { opacity: 0, x: 30 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
x: 0,
|
|
||||||
transition: { duration: 0.6, ease: "easeOut" }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|||||||
@@ -7,16 +7,14 @@ interface JsonLdProps {
|
|||||||
|
|
||||||
export default function JsonLd({ id, data }: JsonLdProps) {
|
export default function JsonLd({ id, data }: JsonLdProps) {
|
||||||
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
|
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
|
||||||
const schemaData = data || [
|
const rawData = data || [
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
url: 'https://klz-cables.com',
|
url: 'https://klz-cables.com',
|
||||||
logo: 'https://klz-cables.com/logo-blue.svg',
|
logo: 'https://klz-cables.com/logo-blue.svg',
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||||
'https://www.linkedin.com/company/klz-cables',
|
|
||||||
],
|
|
||||||
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
|
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
|
||||||
address: {
|
address: {
|
||||||
'@type': 'PostalAddress',
|
'@type': 'PostalAddress',
|
||||||
@@ -36,15 +34,32 @@ export default function JsonLd({ id, data }: JsonLdProps) {
|
|||||||
},
|
},
|
||||||
'query-input': 'required name=search_term_string',
|
'query-input': 'required name=search_term_string',
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Harden schema items: Ensure each item is an object and has @context
|
||||||
|
const schemaData = (Array.isArray(rawData) ? rawData : [rawData])
|
||||||
|
.filter((item): item is WithContext<Thing> => {
|
||||||
|
// Basic sanity check: must be an object and not null
|
||||||
|
return typeof item === 'object' && item !== null;
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
// Ensure @context is present and correctly typed for consumer libraries
|
||||||
|
if (!item['@context']) {
|
||||||
|
return {
|
||||||
|
...(item as Record<string, any>),
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
} as WithContext<Thing>;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<script
|
<script
|
||||||
id={id}
|
id={id}
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: JSON.stringify(schemaData),
|
__html: JSON.stringify(schemaData.length === 1 ? schemaData[0] : schemaData),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
// Fix for default marker icon in Leaflet with Next.js
|
// Fix for default marker icon in Leaflet with Next.js
|
||||||
const DefaultIcon = L.icon({
|
if (typeof window !== 'undefined') {
|
||||||
|
const DefaultIcon = L.icon({
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
iconSize: [25, 41],
|
iconSize: [25, 41],
|
||||||
iconAnchor: [12, 41],
|
iconAnchor: [12, 41],
|
||||||
});
|
});
|
||||||
|
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
}
|
||||||
|
|
||||||
interface LeafletMapProps {
|
interface LeafletMapProps {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -21,25 +21,46 @@ interface LeafletMapProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
||||||
const position: [number, number] = [lat, lng];
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<MapContainer
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
center={position}
|
|
||||||
zoom={15}
|
// Initialize map
|
||||||
scrollWheelZoom={false}
|
const map = L.map(mapRef.current, {
|
||||||
className="h-full w-full z-0"
|
center: [lat, lng],
|
||||||
>
|
zoom: 15,
|
||||||
<TileLayer
|
scrollWheelZoom: false,
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
});
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
// Add tiles
|
||||||
<Marker position={position}>
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
<Popup>
|
attribution:
|
||||||
<div className="text-primary font-bold">KLZ Cables</div>
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
<div className="text-sm whitespace-pre-line">{address}</div>
|
}).addTo(map);
|
||||||
</Popup>
|
|
||||||
</Marker>
|
// Add marker
|
||||||
</MapContainer>
|
const marker = L.marker([lat, lng]).addTo(map);
|
||||||
);
|
|
||||||
|
// Create popup content
|
||||||
|
const popupContent = `
|
||||||
|
<div class="text-primary font-bold">KLZ Cables</div>
|
||||||
|
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [lat, lng, address]);
|
||||||
|
|
||||||
|
return <div ref={mapRef} className="h-full w-full z-0" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
@@ -19,13 +19,16 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
return () => setMounted(false);
|
return () => setMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateUrl = useCallback((index: number | null) => {
|
const updateUrl = useCallback(
|
||||||
|
(index: number | null) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (index !== null) {
|
if (index !== null) {
|
||||||
params.set('photo', index.toString());
|
params.set('photo', index.toString());
|
||||||
@@ -33,7 +36,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
params.delete('photo');
|
params.delete('photo');
|
||||||
}
|
}
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
}, [pathname, router, searchParams]);
|
},
|
||||||
|
[pathname, router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const prevImage = useCallback(() => {
|
const prevImage = useCallback(() => {
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
@@ -56,11 +61,16 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (photoParam !== null) {
|
if (photoParam !== null) {
|
||||||
const index = parseInt(photoParam, 10);
|
const index = parseInt(photoParam, 10);
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, images.length]);
|
}, [searchParams, images.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
updateUrl(null);
|
||||||
|
onClose();
|
||||||
|
}, [updateUrl, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updateUrl(currentIndex);
|
updateUrl(currentIndex);
|
||||||
@@ -68,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
}, [isOpen, currentIndex, updateUrl]);
|
}, [isOpen, currentIndex, updateUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
|
if (previousFocusRef.current) {
|
||||||
|
previousFocusRef.current.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture previous focus
|
||||||
|
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
|
// Focus close button on open
|
||||||
|
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') handleClose();
|
if (e.key === 'Escape') handleClose();
|
||||||
if (e.key === 'ArrowLeft') prevImage();
|
if (e.key === 'ArrowLeft') prevImage();
|
||||||
if (e.key === 'ArrowRight') nextImage();
|
if (e.key === 'ArrowRight') nextImage();
|
||||||
|
|
||||||
|
// Focus Trap
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const focusableElements = document.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
const modalElements = Array.from(focusableElements).filter((el) =>
|
||||||
|
document.querySelector('[role="dialog"]')?.contains(el),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modalElements.length === 0) return;
|
||||||
|
|
||||||
|
const firstElement = modalElements[0] as HTMLElement;
|
||||||
|
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
lastElement.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
firstElement.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lock scroll
|
// Lock scroll
|
||||||
@@ -86,20 +134,20 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
document.body.style.overflow = originalStyle;
|
document.body.style.overflow = originalStyle;
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpen, prevImage, nextImage]);
|
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
updateUrl(null);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
|
<div
|
||||||
<motion.div
|
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -108,11 +156,12 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<motion.button
|
<m.button
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.5 }}
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
transition={{ delay: 0.1, duration: 0.4 }}
|
transition={{ delay: 0.1, duration: 0.4 }}
|
||||||
|
ref={closeButtonRef}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||||
aria-label="Close lightbox"
|
aria-label="Close lightbox"
|
||||||
@@ -120,9 +169,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
||||||
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</m.button>
|
||||||
|
|
||||||
<motion.button
|
<m.button
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -20 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
@@ -131,10 +180,12 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||||
aria-label="Previous image"
|
aria-label="Previous image"
|
||||||
>
|
>
|
||||||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">‹</span>
|
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||||
</motion.button>
|
‹
|
||||||
|
</span>
|
||||||
|
</m.button>
|
||||||
|
|
||||||
<motion.button
|
<m.button
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: 20 }}
|
exit={{ opacity: 0, x: 20 }}
|
||||||
@@ -143,10 +194,12 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||||
aria-label="Next image"
|
aria-label="Next image"
|
||||||
>
|
>
|
||||||
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">›</span>
|
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
||||||
</motion.button>
|
›
|
||||||
|
</span>
|
||||||
|
</m.button>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
@@ -156,7 +209,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
|
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
|
||||||
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
|
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
<motion.div
|
<m.div
|
||||||
key={currentIndex}
|
key={currentIndex}
|
||||||
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
||||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||||
@@ -171,7 +224,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
|
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
||||||
@@ -181,7 +234,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
exit={{ opacity: 0, y: 10 }}
|
||||||
@@ -193,12 +246,13 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-12 bg-white/20" />
|
<div className="h-px w-12 bg-white/20" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>,
|
</AnimatePresence>
|
||||||
document.body
|
</LazyMotion>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function OGImageTemplate({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -182,4 +183,3 @@ export function OGImageTemplate({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
components/ObfuscatedEmail.tsx
Normal file
38
components/ObfuscatedEmail.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedEmailProps {
|
||||||
|
email: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect email addresses from simple spambots.
|
||||||
|
* It uses client-side mounting to render the actual email address,
|
||||||
|
* making it harder for static crawlers to harvest.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once mounted on the client, render the real mailto link
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className={className}>
|
||||||
|
{children || email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/ObfuscatedPhone.tsx
Normal file
41
components/ObfuscatedPhone.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedPhoneProps {
|
||||||
|
phone: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect phone numbers from simple spambots.
|
||||||
|
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format phone number for tel: link (remove spaces, etc.)
|
||||||
|
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
// e.g. +49 881 925 [at] 37298
|
||||||
|
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || obscured}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={telLink} className={className}>
|
||||||
|
{children || phone}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Extract slug from pathname
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||||
|
// We want the page slug.
|
||||||
|
const slug = segments[segments.length - 1] || 'home';
|
||||||
|
|
||||||
|
const href = `/api/pages/${slug}/pdf`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-8">
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||||
|
style === 'primary'
|
||||||
|
? 'bg-primary text-white hover:bg-primary-dark'
|
||||||
|
: style === 'secondary'
|
||||||
|
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||||
|
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1203
components/PayloadRichText.tsx
Normal file
1203
components/PayloadRichText.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,16 @@ interface ProductSidebarProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
|
export default function ProductSidebar({
|
||||||
|
productName,
|
||||||
|
productImage,
|
||||||
|
datasheetPath,
|
||||||
|
className,
|
||||||
|
}: ProductSidebarProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-4 animate-slight-fade-in-from-bottom", className)}>
|
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
|
||||||
{/* Request Quote Form Card */}
|
{/* Request Quote Form Card */}
|
||||||
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||||
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||||
@@ -30,7 +35,7 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
|||||||
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||||
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||||
<Image
|
<Image
|
||||||
src={productImage}
|
src={productImage.split('?')[0]}
|
||||||
alt={productName}
|
alt={productName}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||||
@@ -64,9 +69,7 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datasheet Download */}
|
{/* Datasheet Download */}
|
||||||
{datasheetPath && (
|
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
</aside>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,26 +31,33 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
const { technicalItems = [], voltageTables = [] } = data;
|
const { technicalItems = [], voltageTables = [] } = data;
|
||||||
|
|
||||||
const toggleTable = (idx: number) => {
|
const toggleTable = (idx: number) => {
|
||||||
setExpandedTables(prev => ({
|
setExpandedTables((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[idx]: !prev[idx]
|
[idx]: !prev[idx],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-8 md:space-y-16">
|
||||||
{technicalItems.length > 0 && (
|
{technicalItems.length > 0 && (
|
||||||
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
General Data
|
General Data
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex flex-col group">
|
<div key={idx} className="flex flex-col group">
|
||||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">{item.label}</dt>
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||||
|
{item.label}
|
||||||
|
</dt>
|
||||||
<dd className="text-lg font-semibold text-text-primary">
|
<dd className="text-lg font-semibold text-text-primary">
|
||||||
{item.value} {item.unit && <span className="text-sm font-normal text-text-secondary ml-1">{item.unit}</span>}
|
{item.value}{' '}
|
||||||
|
{item.unit && (
|
||||||
|
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -63,39 +70,57 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
const hasManyRows = table.rows.length > 10;
|
const hasManyRows = table.rows.length > 10;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||||
|
>
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
{table.voltageLabel !== 'Voltage unknown' &&
|
||||||
|
table.voltageLabel !== 'Spannung unbekannt'
|
||||||
? table.voltageLabel
|
? table.voltageLabel
|
||||||
: 'Technical Specifications'}
|
: 'Technical Specifications'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{table.metaItems.length > 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||||
{table.metaItems.map((item, mIdx) => (
|
{table.metaItems.map((item, mIdx) => (
|
||||||
<div key={mIdx}>
|
<div key={mIdx}>
|
||||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
|
{item.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-bold text-primary">
|
||||||
|
{item.value} {item.unit}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{/* Scroll hint gradient on right edge for mobile */}
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||||
<div
|
<div
|
||||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
id={`voltage-table-${idx}`}
|
||||||
|
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<table className="min-w-full border-separate border-spacing-0">
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10"
|
||||||
|
>
|
||||||
Config.
|
Config.
|
||||||
</th>
|
</th>
|
||||||
{table.columns.map((col, cIdx) => (
|
{table.columns.map((col, cIdx) => (
|
||||||
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
|
<th
|
||||||
|
key={cIdx}
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10"
|
||||||
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -107,9 +132,14 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
|
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
|
||||||
{row.configuration}
|
{row.configuration}
|
||||||
</td>
|
</td>
|
||||||
{row.cells.map((cell, cellIdx) => (
|
{row.cells.map((cell: any, cellIdx: number) => (
|
||||||
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
|
<td
|
||||||
{cell}
|
key={cellIdx}
|
||||||
|
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{typeof cell === 'object' && cell !== null && 'value' in cell
|
||||||
|
? cell.value
|
||||||
|
: cell}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -127,6 +157,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTable(idx)}
|
onClick={() => toggleTable(idx)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={`voltage-table-${idx}`}
|
||||||
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
||||||
>
|
>
|
||||||
{isExpanded ? t('showLess') : t('showMore')}
|
{isExpanded ? t('showLess') : t('showMore')}
|
||||||
|
|||||||
39
components/RelatedProductLink.tsx
Normal file
39
components/RelatedProductLink.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface RelatedProductLinkProps {
|
||||||
|
href: string;
|
||||||
|
productSlug: string;
|
||||||
|
productTitle: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedProductLink({
|
||||||
|
href,
|
||||||
|
productSlug,
|
||||||
|
productTitle,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: RelatedProductLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: productSlug,
|
||||||
|
product_name: productTitle,
|
||||||
|
location: 'related_products',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProducts } from '@/lib/products';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import { RelatedProductLink } from './RelatedProductLink';
|
||||||
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
|
|
||||||
interface RelatedProductsProps {
|
interface RelatedProductsProps {
|
||||||
currentSlug: string;
|
currentSlug: string;
|
||||||
@@ -10,20 +10,51 @@ interface RelatedProductsProps {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
export default async function RelatedProducts({
|
||||||
const allProducts = await getAllProducts(locale);
|
currentSlug,
|
||||||
|
categories,
|
||||||
|
locale,
|
||||||
|
}: RelatedProductsProps) {
|
||||||
|
const products = await getAllProducts(locale);
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
// Filter products: same category, not current product
|
// Filter products: same category, not current product
|
||||||
const related = allProducts
|
const related = products
|
||||||
.filter(p =>
|
.filter(
|
||||||
p.slug !== currentSlug &&
|
(p) =>
|
||||||
p.frontmatter.categories.some(cat => categories.includes(cat))
|
p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)),
|
||||||
)
|
)
|
||||||
.slice(0, 3); // Limit to 3 for better spacing
|
.slice(0, 3); // Limit to 3 for better spacing
|
||||||
|
|
||||||
if (related.length === 0) return null;
|
if (related.length === 0) return null;
|
||||||
|
|
||||||
|
// Pre-calculate translated slugs for related products
|
||||||
|
const relatedWithTranslatedSlugs = await Promise.all(
|
||||||
|
related.map(async (product) => {
|
||||||
|
// Find the category slug for the link
|
||||||
|
const categorySlugs = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
|
const catFileSlug =
|
||||||
|
categorySlugs.find((slug) => {
|
||||||
|
return product.frontmatter.categories.some(
|
||||||
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
|
||||||
|
);
|
||||||
|
}) || 'low-voltage-cables';
|
||||||
|
|
||||||
|
const catSlug = await mapFileSlugToTranslated(catFileSlug, locale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...product,
|
||||||
|
catSlug,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className="flex items-end justify-between mb-12">
|
<div className="flex items-end justify-between mb-12">
|
||||||
@@ -36,30 +67,20 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{related.map(async (product) => {
|
{relatedWithTranslatedSlugs.map((product) => {
|
||||||
// Find the category slug for the link
|
|
||||||
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
|
||||||
const catSlug = categorySlugs.find(slug => {
|
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const title = t(`categories.${key}.title`);
|
|
||||||
return product.frontmatter.categories.some(cat =>
|
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
|
|
||||||
);
|
|
||||||
}) || 'low-voltage-cables';
|
|
||||||
|
|
||||||
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<RelatedProductLink
|
||||||
key={product.slug}
|
key={product.slug}
|
||||||
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
href={`/${locale}/${productsSlug}/${product.catSlug}/${product.slug}`}
|
||||||
|
productSlug={product.slug}
|
||||||
|
productTitle={product.frontmatter.title}
|
||||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||||
>
|
>
|
||||||
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
||||||
{product.frontmatter.images?.[0] ? (
|
{product.frontmatter.images?.[0] ? (
|
||||||
<>
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0].split('?')[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"
|
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||||
@@ -74,8 +95,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
|
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
|
||||||
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -87,12 +111,23 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
||||||
{t('details')}
|
{t('details')}
|
||||||
</span>
|
</span>
|
||||||
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</RelatedProductLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Input, Textarea, Button } from '@/components/ui';
|
import { Input, Textarea, Button } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
interface RequestQuoteFormProps {
|
interface RequestQuoteFormProps {
|
||||||
productName: string;
|
productName: string;
|
||||||
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [request, setRequest] = useState('');
|
const [request, setRequest] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
form_name: 'Product Quote Inquiry',
|
||||||
|
product_name: productName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
product_name: productName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,17 +60,34 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setRequest('');
|
setRequest('');
|
||||||
} else {
|
} else {
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
product_name: productName,
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Form submission error:', error);
|
console.error('Form submission error:', error);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
product_name: productName,
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emailId = React.useId();
|
||||||
|
const requestId = React.useId();
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
<div
|
||||||
|
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||||
<svg
|
<svg
|
||||||
@@ -87,7 +125,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
<div
|
||||||
|
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||||
<svg
|
<svg
|
||||||
@@ -125,24 +167,32 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||||
<div className="space-y-2 !mt-0">
|
<div className="space-y-2 !mt-0">
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
|
<label htmlFor={emailId} className="sr-only">
|
||||||
|
{t('email')}
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id={emailId}
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onFocus={() => handleFocus('quote-email')}
|
||||||
placeholder={t('email')}
|
placeholder={t('email')}
|
||||||
className="h-9 text-xs !mt-0"
|
className="h-9 text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
|
<label htmlFor={requestId} className="sr-only">
|
||||||
|
{t('message')}
|
||||||
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="request"
|
id={requestId}
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
value={request}
|
value={request}
|
||||||
onChange={(e) => setRequest(e.target.value)}
|
onChange={(e) => setRequest(e.target.value)}
|
||||||
|
onFocus={() => handleFocus('quote-request')}
|
||||||
placeholder={t('message')}
|
placeholder={t('message')}
|
||||||
className="text-xs !mt-0"
|
className="text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, Variants } from 'framer-motion';
|
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface ScribbleProps {
|
interface ScribbleProps {
|
||||||
@@ -11,31 +10,18 @@ interface ScribbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
||||||
const pathVariants: Variants = {
|
|
||||||
hidden: { pathLength: 0, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
pathLength: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 1.8,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn("absolute pointer-events-none", className)}
|
className={cn('absolute pointer-events-none', className)}
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
viewBox="0 0 800 350"
|
viewBox="0 0 800 350"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<motion.path
|
<path
|
||||||
variants={pathVariants}
|
className="animate-draw-stroke"
|
||||||
initial="hidden"
|
pathLength="1"
|
||||||
whileInView="visible"
|
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||||
viewport={{ once: true }}
|
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
strokeLinejoin="miter"
|
strokeLinejoin="miter"
|
||||||
fillOpacity="0"
|
fillOpacity="0"
|
||||||
@@ -52,16 +38,15 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
if (variant === 'underline') {
|
if (variant === 'underline') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn("absolute pointer-events-none", className)}
|
className={cn('absolute pointer-events-none', className)}
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
viewBox="-400 -55 730 60"
|
viewBox="-400 -55 730 60"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<motion.path
|
<path
|
||||||
variants={pathVariants}
|
className="animate-draw-stroke"
|
||||||
initial="hidden"
|
pathLength="1"
|
||||||
whileInView="visible"
|
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||||
viewport={{ once: true }}
|
|
||||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth="20"
|
strokeWidth="20"
|
||||||
|
|||||||
16
components/SkipLink.tsx
Normal file
16
components/SkipLink.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export default function SkipLink() {
|
||||||
|
const t = useTranslations('Navigation');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="fixed -top-full left-4 z-[100] px-6 py-3 bg-white text-primary-dark font-bold rounded-lg shadow-xl outline-none ring-2 ring-accent transition-all duration-300 focus:top-4"
|
||||||
|
>
|
||||||
|
{t('skipToContent')}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/analytics/AnalyticsShell.tsx
Normal file
43
components/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AnalyticsShell() {
|
||||||
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
||||||
|
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||||
|
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
||||||
|
} else {
|
||||||
|
const timer = setTimeout(() => setShouldLoad(true), 2500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!shouldLoad) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DynamicAnalyticsProvider />
|
||||||
|
<DynamicScrollDepthTracker />
|
||||||
|
<DynamicWebVitalsTracker />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/analytics/BlogEngagementTracker.tsx
Normal file
53
components/analytics/BlogEngagementTracker.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface BlogEngagementTrackerProps {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string;
|
||||||
|
readingTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogEngagementTracker
|
||||||
|
* Tracks reading time and article completion.
|
||||||
|
*/
|
||||||
|
export default function BlogEngagementTracker({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
readingTime,
|
||||||
|
}: BlogEngagementTrackerProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Article start
|
||||||
|
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
estimated_reading_time: readingTime,
|
||||||
|
location: 'blog_post_pdp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
|
||||||
|
// We only consider it a "read" if they stay a reasonable amount of time
|
||||||
|
// or if they scroll (covered by ScrollDepthTracker)
|
||||||
|
trackEvent('blog_dwell_time', {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
seconds: dwellTime,
|
||||||
|
reading_time_completion: Math.min(100, Math.round((dwellTime / (readingTime * 60)) * 100)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [title, slug, category, readingTime, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
export default function ClientNotFoundTracker({ path }: { path: string }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
import('@sentry/nextjs').then((Sentry) => {
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag('status_code', '404');
|
||||||
|
scope.setTag('path', path);
|
||||||
|
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [trackEvent, path]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -136,18 +136,14 @@ function AddToCartButton({ product, quantity = 1 }) {
|
|||||||
product_category: product.category,
|
product_category: product.category,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
cart_total: 150.00, // Current cart total
|
cart_total: 150.0, // Current cart total
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actual add to cart logic
|
// Actual add to cart logic
|
||||||
// addToCart(product, quantity);
|
// addToCart(product, quantity);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||||
<button onClick={handleAddToCart}>
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,7 +167,7 @@ function CheckoutComplete({ order }) {
|
|||||||
transaction_tax: order.tax,
|
transaction_tax: order.tax,
|
||||||
transaction_shipping: order.shipping,
|
transaction_shipping: order.shipping,
|
||||||
product_count: order.items.length,
|
product_count: order.items.length,
|
||||||
products: order.items.map(item => ({
|
products: order.items.map((item) => ({
|
||||||
product_id: item.product.id,
|
product_id: item.product.id,
|
||||||
product_name: item.product.name,
|
product_name: item.product.name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -200,25 +196,19 @@ function WishlistButton({ product }) {
|
|||||||
const newState = !isInWishlist;
|
const newState = !isInWishlist;
|
||||||
|
|
||||||
trackEvent(
|
trackEvent(
|
||||||
newState
|
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||||
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
|
|
||||||
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
|
||||||
{
|
{
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
product_category: product.category,
|
product_category: product.category,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setIsInWishlist(newState);
|
setIsInWishlist(newState);
|
||||||
// Update wishlist in backend
|
// Update wishlist in backend
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>;
|
||||||
<button onClick={toggleWishlist}>
|
|
||||||
{isInWishlist ? '❤️' : '🤍'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -268,7 +258,7 @@ function ContactForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
}));
|
}));
|
||||||
@@ -310,9 +300,7 @@ function NewsletterSignup() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input placeholder="Enter email" />
|
<input placeholder="Enter email" />
|
||||||
<button onClick={() => handleSubscribe('user@example.com')}>
|
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button>
|
||||||
Subscribe
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -396,10 +384,12 @@ function LoginForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleLogin('user@example.com', 'password');
|
handleLogin('user@example.com', 'password');
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -418,11 +408,7 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
|||||||
function SignupForm() {
|
function SignupForm() {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const handleSignup = (userData: {
|
const handleSignup = (userData: { email: string; name: string; company?: string }) => {
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
company?: string;
|
|
||||||
}) => {
|
|
||||||
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
||||||
user_email: userData.email,
|
user_email: userData.email,
|
||||||
user_name: userData.name,
|
user_name: userData.name,
|
||||||
@@ -436,14 +422,16 @@ function SignupForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSignup({
|
handleSignup({
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
company: 'ACME Corp',
|
company: 'ACME Corp',
|
||||||
});
|
});
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<button type="submit">Sign Up</button>
|
<button type="submit">Sign Up</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -631,11 +619,7 @@ function VideoPlayer({ videoId, videoTitle }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}>
|
||||||
onPlay={handlePlay}
|
|
||||||
onPause={handlePause}
|
|
||||||
onEnded={handleComplete}
|
|
||||||
>
|
|
||||||
<source src="/video.mp4" type="video/mp4" />
|
<source src="/video.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
);
|
);
|
||||||
@@ -665,11 +649,7 @@ function DownloadButton({ fileName, fileType, fileSize }) {
|
|||||||
// window.location.href = `/downloads/${fileName}`;
|
// window.location.href = `/downloads/${fileName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleDownload}>Download {fileName}</button>;
|
||||||
<button onClick={handleDownload}>
|
|
||||||
Download {fileName}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -963,15 +943,9 @@ function CableProductPage({ cable }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{cable.name}</h1>
|
<h1>{cable.name}</h1>
|
||||||
<button onClick={handleTechnicalSpecDownload}>
|
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button>
|
||||||
Download Technical Specs
|
<button onClick={handleRequestQuote}>Request Quote</button>
|
||||||
</button>
|
<button onClick={handleBrochureDownload}>Download Brochure</button>
|
||||||
<button onClick={handleRequestQuote}>
|
|
||||||
Request Quote
|
|
||||||
</button>
|
|
||||||
<button onClick={handleBrochureDownload}>
|
|
||||||
Download Brochure
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1010,12 +984,8 @@ function WindFarmProjectPage({ project }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{project.name}</h1>
|
<h1>{project.name}</h1>
|
||||||
<button onClick={handleProjectInquiry}>
|
<button onClick={handleProjectInquiry}>Request Project Consultation</button>
|
||||||
Request Project Consultation
|
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button>
|
||||||
</button>
|
|
||||||
<button onClick={handleCableCalculation}>
|
|
||||||
Calculate Cable Requirements
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1066,7 +1036,7 @@ test('tracks button click', () => {
|
|||||||
// [Umami] Tracked pageview: /products/123
|
// [Umami] Tracked pageview: /products/123
|
||||||
|
|
||||||
// To test without sending data to Umami:
|
// To test without sending data to Umami:
|
||||||
// 1. Remove NEXT_PUBLIC_UMAMI_WEBSITE_ID from .env
|
// 1. Remove UMAMI_WEBSITE_ID from .env
|
||||||
// 2. Or set it to an empty string
|
// 2. Or set it to an empty string
|
||||||
// 3. Check console logs to verify events are being tracked
|
// 3. Check console logs to verify events are being tracked
|
||||||
```
|
```
|
||||||
@@ -1169,7 +1139,9 @@ function WebVitalsTracker() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
|
observer.observe({
|
||||||
|
entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1194,6 +1166,7 @@ This examples file demonstrates how to implement comprehensive analytics trackin
|
|||||||
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
||||||
|
|
||||||
Remember to:
|
Remember to:
|
||||||
|
|
||||||
1. Use the `useAnalytics` hook for client-side tracking
|
1. Use the `useAnalytics` hook for client-side tracking
|
||||||
2. Import events from `AnalyticsEvents` for consistency
|
2. Import events from `AnalyticsEvents` for consistency
|
||||||
3. Include relevant context in your events
|
3. Include relevant context in your events
|
||||||
|
|||||||
50
components/analytics/ProductEngagementTracker.tsx
Normal file
50
components/analytics/ProductEngagementTracker.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface ProductEngagementTrackerProps {
|
||||||
|
productName: string;
|
||||||
|
productSlug: string;
|
||||||
|
categories: string[];
|
||||||
|
sku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductEngagementTracker
|
||||||
|
* Deep analytics for product pages.
|
||||||
|
* Tracks specific view events with full metadata for sales analysis.
|
||||||
|
*/
|
||||||
|
export default function ProductEngagementTracker({
|
||||||
|
productName,
|
||||||
|
productSlug,
|
||||||
|
categories,
|
||||||
|
sku,
|
||||||
|
}: ProductEngagementTrackerProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Standardized product view event for "High-Fidelity" sales insights
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: productSlug,
|
||||||
|
product_name: productName,
|
||||||
|
product_sku: sku,
|
||||||
|
product_categories: categories.join(', '),
|
||||||
|
location: 'pdp_standard',
|
||||||
|
});
|
||||||
|
|
||||||
|
// We can also track "Engagement Start" to measure dwell time later
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
trackEvent('pdp_dwell_time', {
|
||||||
|
product_id: productSlug,
|
||||||
|
seconds: dwellTime,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [productName, productSlug, categories, sku, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Setup Checklist
|
## Setup Checklist
|
||||||
|
|
||||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||||
- [ ] Verify `UmamiScript` is in your layout
|
- [ ] Verify `UmamiScript` is in your layout
|
||||||
- [ ] Verify `AnalyticsProvider` is in your layout
|
- [ ] Verify `AnalyticsProvider` is in your layout
|
||||||
- [ ] Test in development mode
|
- [ ] Test in development mode
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required
|
# Required
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -87,7 +87,7 @@ function ProductCard({ product }) {
|
|||||||
## Common Events
|
## Common Events
|
||||||
|
|
||||||
| Event | When to Use | Example Properties |
|
| Event | When to Use | Example Properties |
|
||||||
|-------|-------------|-------------------|
|
| --------------------- | ------------------- | ------------------------------------------------- |
|
||||||
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||||
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||||
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||||
@@ -112,7 +112,7 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local
|
# .env.local
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -120,8 +120,9 @@ In development, you'll see console logs:
|
|||||||
### Analytics Not Working?
|
### Analytics Not Working?
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify script is loading:**
|
2. **Verify script is loading:**
|
||||||
@@ -137,7 +138,7 @@ In development, you'll see console logs:
|
|||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
| Issue | Solution |
|
| Issue | Solution |
|
||||||
|-------|----------|
|
| ------------------- | ----------------------------------- |
|
||||||
| No data in Umami | Check website ID and script URL |
|
| No data in Umami | Check website ID and script URL |
|
||||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||||
| Script not loading | Check network connection, CORS |
|
| Script not loading | Check network connection, CORS |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Add these to your `.env` file:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required: Your Umami website ID
|
# Required: Your Umami website ID
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -32,7 +32,7 @@ The `docker-compose.yml` already includes the environment variables:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,11 +75,7 @@ function ProductCard({ product }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||||
<button onClick={handleAddToCart}>
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -277,11 +273,7 @@ function ErrorBoundary({ children }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>;
|
||||||
<ErrorBoundary onError={handleError}>
|
|
||||||
{children}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -290,7 +282,7 @@ function ErrorBoundary({ children }) {
|
|||||||
### Common Events
|
### Common Events
|
||||||
|
|
||||||
| Event Name | Description | Example Properties |
|
| Event Name | Description | Example Properties |
|
||||||
|------------|-------------|-------------------|
|
| --------------------- | --------------------- | ------------------------------------------------------------ |
|
||||||
| `pageview` | Page view | `{ url: '/products/123' }` |
|
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||||
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||||
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||||
@@ -385,8 +377,9 @@ The analytics system includes development mode logging:
|
|||||||
### Analytics Not Working
|
### Analytics Not Working
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify the script is loading:**
|
2. **Verify the script is loading:**
|
||||||
@@ -405,11 +398,11 @@ In development mode, you'll see console logs for all tracked events. This helps
|
|||||||
|
|
||||||
### Disabling Analytics
|
### Disabling Analytics
|
||||||
|
|
||||||
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
|
To disable analytics (e.g., for local development), simply remove the `UMAMI_WEBSITE_ID` environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local (not committed to git)
|
# .env.local (not committed to git)
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
@@ -438,6 +431,7 @@ The analytics implementation is optimized for performance:
|
|||||||
## Support
|
## Support
|
||||||
|
|
||||||
For issues or questions about the analytics implementation, check:
|
For issues or questions about the analytics implementation, check:
|
||||||
|
|
||||||
1. This README for usage examples
|
1. This README for usage examples
|
||||||
2. The component source code for implementation details
|
2. The component source code for implementation details
|
||||||
3. The Umami documentation for platform-specific questions
|
3. The Umami documentation for platform-specific questions
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ The project already had a solid foundation:
|
|||||||
## What Was Enhanced
|
## What Was Enhanced
|
||||||
|
|
||||||
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
||||||
|
|
||||||
- ✅ Added TypeScript props interface for customization
|
- ✅ Added TypeScript props interface for customization
|
||||||
- ✅ Added JSDoc documentation with usage examples
|
- ✅ Added JSDoc documentation with usage examples
|
||||||
- ✅ Added error handling for script loading failures
|
- ✅ Added error handling for script loading failures
|
||||||
@@ -23,11 +24,13 @@ The project already had a solid foundation:
|
|||||||
- ✅ Improved type safety and comments
|
- ✅ Improved type safety and comments
|
||||||
|
|
||||||
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
||||||
|
|
||||||
- ✅ Added comprehensive JSDoc documentation
|
- ✅ Added comprehensive JSDoc documentation
|
||||||
- ✅ Added development mode logging
|
- ✅ Added development mode logging
|
||||||
- ✅ Improved code comments
|
- ✅ Improved code comments
|
||||||
|
|
||||||
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
||||||
|
|
||||||
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
||||||
- ✅ `trackEvent()` method for custom events
|
- ✅ `trackEvent()` method for custom events
|
||||||
- ✅ `trackPageview()` method for manual pageview tracking
|
- ✅ `trackPageview()` method for manual pageview tracking
|
||||||
@@ -35,12 +38,14 @@ The project already had a solid foundation:
|
|||||||
- ✅ Development mode logging
|
- ✅ Development mode logging
|
||||||
|
|
||||||
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
||||||
|
|
||||||
- ✅ Centralized event constants for consistency
|
- ✅ Centralized event constants for consistency
|
||||||
- ✅ Type-safe event names
|
- ✅ Type-safe event names
|
||||||
- ✅ Helper functions for common event properties
|
- ✅ Helper functions for common event properties
|
||||||
- ✅ 30+ predefined events for various use cases
|
- ✅ 30+ predefined events for various use cases
|
||||||
|
|
||||||
### 5. **Comprehensive Documentation**
|
### 5. **Comprehensive Documentation**
|
||||||
|
|
||||||
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
||||||
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
||||||
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
||||||
@@ -63,12 +68,14 @@ components/analytics/
|
|||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### 🚀 Modern Implementation
|
### 🚀 Modern Implementation
|
||||||
|
|
||||||
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
||||||
- TypeScript for type safety
|
- TypeScript for type safety
|
||||||
- React hooks for clean API
|
- React hooks for clean API
|
||||||
- Environment variable configuration
|
- Environment variable configuration
|
||||||
|
|
||||||
### 📊 Comprehensive Tracking
|
### 📊 Comprehensive Tracking
|
||||||
|
|
||||||
- Automatic pageview tracking on route changes
|
- Automatic pageview tracking on route changes
|
||||||
- Custom event tracking with properties
|
- Custom event tracking with properties
|
||||||
- E-commerce events (products, cart, purchases)
|
- E-commerce events (products, cart, purchases)
|
||||||
@@ -77,6 +84,7 @@ components/analytics/
|
|||||||
- Error and performance tracking
|
- Error and performance tracking
|
||||||
|
|
||||||
### 🎯 Developer Experience
|
### 🎯 Developer Experience
|
||||||
|
|
||||||
- Type-safe event tracking
|
- Type-safe event tracking
|
||||||
- Centralized event definitions
|
- Centralized event definitions
|
||||||
- Development mode logging
|
- Development mode logging
|
||||||
@@ -84,6 +92,7 @@ components/analytics/
|
|||||||
- 20+ practical examples
|
- 20+ practical examples
|
||||||
|
|
||||||
### 🔒 Privacy & Performance
|
### 🔒 Privacy & Performance
|
||||||
|
|
||||||
- No PII tracking by default
|
- No PII tracking by default
|
||||||
- Script loads after page is interactive
|
- Script loads after page is interactive
|
||||||
- Minimal performance impact
|
- Minimal performance impact
|
||||||
@@ -95,7 +104,7 @@ The project is already configured in `docker-compose.yml`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,7 +113,7 @@ environment:
|
|||||||
Add to your `.env` file:
|
Add to your `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
@@ -188,7 +197,7 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local
|
# .env.local
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -196,8 +205,9 @@ In development, you'll see console logs:
|
|||||||
### Analytics Not Working?
|
### Analytics Not Working?
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify script is loading:**
|
2. **Verify script is loading:**
|
||||||
@@ -213,7 +223,7 @@ In development, you'll see console logs:
|
|||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
| Issue | Solution |
|
| Issue | Solution |
|
||||||
|-------|----------|
|
| ------------------- | ----------------------------------- |
|
||||||
| No data in Umami | Check website ID and script URL |
|
| No data in Umami | Check website ID and script URL |
|
||||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||||
| Script not loading | Check network connection, CORS |
|
| Script not loading | Check network connection, CORS |
|
||||||
@@ -239,13 +249,13 @@ In development, you'll see console logs:
|
|||||||
1. ✅ **Setup complete** - All files are in place
|
1. ✅ **Setup complete** - All files are in place
|
||||||
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
||||||
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
||||||
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
4. 📝 **Add to .env** - Set `UMAMI_WEBSITE_ID`
|
||||||
5. 🧪 **Test in development** - Verify events are tracked
|
5. 🧪 **Test in development** - Verify events are tracked
|
||||||
6. 🚀 **Deploy** - Analytics will work in production
|
6. 🚀 **Deploy** - Analytics will work in production
|
||||||
|
|
||||||
## Quick Start Checklist
|
## Quick Start Checklist
|
||||||
|
|
||||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||||
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
||||||
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
||||||
- [ ] Test in development mode (check console logs)
|
- [ ] Test in development mode (check console logs)
|
||||||
|
|||||||
62
components/analytics/ScrollDepthTracker.tsx
Normal file
62
components/analytics/ScrollDepthTracker.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollDepthTracker
|
||||||
|
* Tracks user scroll progress across pages.
|
||||||
|
* Fires events at 25%, 50%, 75%, and 100% depth.
|
||||||
|
*/
|
||||||
|
export default function ScrollDepthTracker() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const trackedDepths = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Reset tracking when path changes
|
||||||
|
useEffect(() => {
|
||||||
|
trackedDepths.current.clear();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
// Calculate how far the user has scrolled in percentage
|
||||||
|
// documentHeight - windowHeight is the total scrollable distance
|
||||||
|
const totalScrollable = documentHeight - windowHeight;
|
||||||
|
if (totalScrollable <= 0) return; // Not scrollable
|
||||||
|
|
||||||
|
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
|
||||||
|
|
||||||
|
// We only care about specific milestones
|
||||||
|
const milestones = [25, 50, 75, 100];
|
||||||
|
|
||||||
|
milestones.forEach((milestone) => {
|
||||||
|
if (scrollPercentage >= milestone && !trackedDepths.current.has(milestone)) {
|
||||||
|
trackedDepths.current.add(milestone);
|
||||||
|
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
|
||||||
|
depth: milestone,
|
||||||
|
path: pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use passive listener for better performance
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Initial check (in case page is short or already scrolled)
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [pathname, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
34
components/analytics/TrackedButton.tsx
Normal file
34
components/analytics/TrackedButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button, ButtonProps } from '../ui/Button';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface TrackedButtonProps extends ButtonProps {
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the project's Button component that tracks click events.
|
||||||
|
* Safe to use in server components.
|
||||||
|
*/
|
||||||
|
export default function TrackedButton({
|
||||||
|
eventName = AnalyticsEvents.BUTTON_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: TrackedButtonProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
...eventProperties,
|
||||||
|
label: typeof props.children === 'string' ? props.children : eventProperties.label,
|
||||||
|
});
|
||||||
|
if (onClick) onClick(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Button {...props} onClick={handleClick} />;
|
||||||
|
}
|
||||||
48
components/analytics/TrackedLink.tsx
Normal file
48
components/analytics/TrackedLink.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface TrackedLinkProps {
|
||||||
|
href: string;
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around next/link that tracks the click event.
|
||||||
|
* Useful for adding tracking to server components.
|
||||||
|
*/
|
||||||
|
export default function TrackedLink({
|
||||||
|
href,
|
||||||
|
eventName = AnalyticsEvents.LINK_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: TrackedLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
try {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
href,
|
||||||
|
...eventProperties,
|
||||||
|
});
|
||||||
|
} catch (_e) {
|
||||||
|
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
||||||
|
}
|
||||||
|
if (onClick) onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} onClick={handleClick}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
components/analytics/WebVitalsTracker.tsx
Normal file
54
components/analytics/WebVitalsTracker.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReportWebVitals } from 'next/web-vitals';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVitalsTracker component.
|
||||||
|
*
|
||||||
|
* Captures Next.js Web Vitals and reports them to Umami as custom events.
|
||||||
|
* This provides "meaningful" page speed tracking by measuring real user
|
||||||
|
* experiences (LCP, CLS, INP, etc.).
|
||||||
|
*/
|
||||||
|
export default function WebVitalsTracker() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useReportWebVitals((metric) => {
|
||||||
|
const { name, value, id, label } = metric;
|
||||||
|
|
||||||
|
// Determine rating (simplified version of web-vitals standards)
|
||||||
|
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
|
||||||
|
|
||||||
|
if (name === 'LCP') {
|
||||||
|
if (value > 4000) rating = 'poor';
|
||||||
|
else if (value > 2500) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'CLS') {
|
||||||
|
if (value > 0.25) rating = 'poor';
|
||||||
|
else if (value > 0.1) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FID') {
|
||||||
|
if (value > 300) rating = 'poor';
|
||||||
|
else if (value > 100) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FCP') {
|
||||||
|
if (value > 3000) rating = 'poor';
|
||||||
|
else if (value > 1800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'TTFB') {
|
||||||
|
if (value > 1500) rating = 'poor';
|
||||||
|
else if (value > 800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'INP') {
|
||||||
|
if (value > 500) rating = 'poor';
|
||||||
|
else if (value > 200) rating = 'needs-improvement';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Umami
|
||||||
|
trackEvent('web-vital', {
|
||||||
|
metric: name,
|
||||||
|
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
|
||||||
|
rating,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export const AnalyticsEvents = {
|
|||||||
PAGE_VIEW: 'pageview',
|
PAGE_VIEW: 'pageview',
|
||||||
PAGE_SCROLL: 'page_scroll',
|
PAGE_SCROLL: 'page_scroll',
|
||||||
PAGE_EXIT: 'page_exit',
|
PAGE_EXIT: 'page_exit',
|
||||||
|
SCROLL_DEPTH: 'scroll_depth',
|
||||||
|
|
||||||
// User Interaction Events
|
// User Interaction Events
|
||||||
BUTTON_CLICK: 'button_click',
|
BUTTON_CLICK: 'button_click',
|
||||||
@@ -38,6 +39,7 @@ export const AnalyticsEvents = {
|
|||||||
FORM_SUBMIT: 'form_submit',
|
FORM_SUBMIT: 'form_submit',
|
||||||
FORM_START: 'form_start',
|
FORM_START: 'form_start',
|
||||||
FORM_ERROR: 'form_error',
|
FORM_ERROR: 'form_error',
|
||||||
|
FORM_FIELD_FOCUS: 'form_field_focus',
|
||||||
|
|
||||||
// E-commerce Events
|
// E-commerce Events
|
||||||
PRODUCT_VIEW: 'product_view',
|
PRODUCT_VIEW: 'product_view',
|
||||||
@@ -46,6 +48,7 @@ export const AnalyticsEvents = {
|
|||||||
PRODUCT_PURCHASE: 'product_purchase',
|
PRODUCT_PURCHASE: 'product_purchase',
|
||||||
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
||||||
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
||||||
|
PRODUCT_TAB_SWITCH: 'product_tab_switch',
|
||||||
|
|
||||||
// Search & Filter Events
|
// Search & Filter Events
|
||||||
SEARCH: 'search',
|
SEARCH: 'search',
|
||||||
@@ -71,6 +74,7 @@ export const AnalyticsEvents = {
|
|||||||
TOGGLE_SWITCH: 'toggle_switch',
|
TOGGLE_SWITCH: 'toggle_switch',
|
||||||
ACCORDION_TOGGLE: 'accordion_toggle',
|
ACCORDION_TOGGLE: 'accordion_toggle',
|
||||||
TAB_SWITCH: 'tab_switch',
|
TAB_SWITCH: 'tab_switch',
|
||||||
|
TOC_CLICK: 'toc_click',
|
||||||
|
|
||||||
// Error & Performance Events
|
// Error & Performance Events
|
||||||
ERROR: 'error',
|
ERROR: 'error',
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function AnimatedImage({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ threshold: 0.1 }
|
{ threshold: 0.1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
@@ -49,10 +49,12 @@ export default function AnimatedImage({
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
|
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
|
||||||
>
|
>
|
||||||
<div className={`
|
<div
|
||||||
|
className={`
|
||||||
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
|
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
|
||||||
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
|
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
|
||||||
`} />
|
`}
|
||||||
|
/>
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
@@ -70,14 +72,6 @@ export default function AnimatedImage({
|
|||||||
|
|
||||||
{/* Subtle reflection overlay */}
|
{/* Subtle reflection overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
|
||||||
|
|
||||||
{alt && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/60 to-transparent translate-y-full group-hover:translate-y-0 transition-transform duration-500">
|
|
||||||
<p className="text-sm text-white font-medium italic">
|
|
||||||
{alt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
components/blog/BlogPaginationKeyboardObserver.tsx
Normal file
42
components/blog/BlogPaginationKeyboardObserver.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface BlogPaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlogPaginationKeyboardObserver({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
locale,
|
||||||
|
}: BlogPaginationProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Don't trigger if user is typing in an input
|
||||||
|
if (
|
||||||
|
document.activeElement?.tagName === 'INPUT' ||
|
||||||
|
document.activeElement?.tagName === 'TEXTAREA' ||
|
||||||
|
document.activeElement?.tagName === 'SELECT'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowLeft' && currentPage > 1) {
|
||||||
|
router.push(`/${locale}/blog?page=${currentPage - 1}`);
|
||||||
|
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
|
||||||
|
router.push(`/${locale}/blog?page=${currentPage + 1}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [currentPage, totalPages, locale, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
|
||||||
import { Callout } from '@/components/ui';
|
|
||||||
import HighlightBox from '@/components/blog/HighlightBox';
|
|
||||||
import Stats from '@/components/blog/Stats';
|
|
||||||
import AnimatedImage from '@/components/blog/AnimatedImage';
|
|
||||||
import ChatBubble from '@/components/blog/ChatBubble';
|
|
||||||
import SplitHeading from '@/components/blog/SplitHeading';
|
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
|
||||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
|
||||||
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
|
||||||
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
|
||||||
|
|
||||||
export const mdxComponents = {
|
|
||||||
VisualLinkPreview,
|
|
||||||
Callout,
|
|
||||||
HighlightBox,
|
|
||||||
Stats,
|
|
||||||
AnimatedImage,
|
|
||||||
ChatBubble,
|
|
||||||
PowerCTA,
|
|
||||||
SplitHeading,
|
|
||||||
StickyNarrative,
|
|
||||||
TechnicalGrid,
|
|
||||||
ComparisonGrid,
|
|
||||||
h1: () => null,
|
|
||||||
a: ({ href, children, ...props }: any) => {
|
|
||||||
// Special handling for PDF downloads to make them prominent
|
|
||||||
if (href?.endsWith('.pdf')) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
{...props}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>{children}</span>
|
|
||||||
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">(PDF)</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (href?.startsWith('/')) {
|
|
||||||
return (
|
|
||||||
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
{...props}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
img: (props: any) => (
|
|
||||||
<AnimatedImage src={props.src} alt={props.alt} />
|
|
||||||
),
|
|
||||||
h2: ({ children, ...props }: any) => {
|
|
||||||
const id = typeof children === 'string'
|
|
||||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
|
||||||
: props.id;
|
|
||||||
return (
|
|
||||||
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
|
||||||
{children}
|
|
||||||
</SplitHeading>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
h3: ({ children, ...props }: any) => {
|
|
||||||
const id = typeof children === 'string'
|
|
||||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
|
||||||
: props.id;
|
|
||||||
return (
|
|
||||||
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
p: ({ children, ...props }: any) => (
|
|
||||||
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
ul: ({ children, ...props }: any) => (
|
|
||||||
<ul {...props} className="my-8 space-y-3">
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children, ...props }: any) => (
|
|
||||||
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
li: ({ children, ...props }: any) => (
|
|
||||||
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
|
||||||
<span className="text-primary mt-1.5 flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">{children}</span>
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
blockquote: ({ children, ...props }: any) => (
|
|
||||||
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
|
|
||||||
<div className="text-lg text-text-primary italic">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</blockquote>
|
|
||||||
),
|
|
||||||
strong: ({ children, ...props }: any) => (
|
|
||||||
<strong {...props} className="font-bold text-primary">
|
|
||||||
{children}
|
|
||||||
</strong>
|
|
||||||
),
|
|
||||||
code: ({ children, ...props }: any) => (
|
|
||||||
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
),
|
|
||||||
pre: ({ children, ...props }: any) => (
|
|
||||||
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
table: ({ children, ...props }: any) => (
|
|
||||||
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
|
|
||||||
<table {...props} className="w-full text-left text-sm text-text-secondary">
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
thead: ({ children, ...props }: any) => (
|
|
||||||
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
|
|
||||||
{children}
|
|
||||||
</thead>
|
|
||||||
),
|
|
||||||
tbody: ({ children, ...props }: any) => (
|
|
||||||
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
|
|
||||||
{children}
|
|
||||||
</tbody>
|
|
||||||
),
|
|
||||||
tr: ({ children, ...props }: any) => (
|
|
||||||
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
|
|
||||||
{children}
|
|
||||||
</tr>
|
|
||||||
),
|
|
||||||
th: ({ children, ...props }: any) => (
|
|
||||||
<th {...props} className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{children}
|
|
||||||
</th>
|
|
||||||
),
|
|
||||||
td: ({ children, ...props }: any) => (
|
|
||||||
<td {...props} className="px-6 py-4">
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PostMdx } from '@/lib/blog';
|
import { PostData } from '@/lib/blog';
|
||||||
|
|
||||||
interface PostNavigationProps {
|
interface PostNavigationProps {
|
||||||
prev: PostMdx | null;
|
prev: PostData | null;
|
||||||
next: PostMdx | null;
|
next: PostData | null;
|
||||||
|
isPrevRandom?: boolean;
|
||||||
|
isNextRandom?: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
|
export default function PostNavigation({
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
isPrevRandom,
|
||||||
|
isNextRandom,
|
||||||
|
locale,
|
||||||
|
}: PostNavigationProps) {
|
||||||
if (!prev && !next) return null;
|
if (!prev && !next) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,8 +30,11 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
{prev.frontmatter.featuredImage ? (
|
{prev.frontmatter.featuredImage ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
|
style={{
|
||||||
|
backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})`,
|
||||||
|
backgroundPosition: `${prev.frontmatter.focalX ?? 50}% ${prev.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-neutral-100" />
|
<div className="absolute inset-0 bg-neutral-100" />
|
||||||
@@ -34,8 +45,14 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
||||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90">
|
||||||
{locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
|
{isPrevRandom
|
||||||
|
? locale === 'de'
|
||||||
|
? 'Weiterer Artikel'
|
||||||
|
: 'More Article'
|
||||||
|
: locale === 'de'
|
||||||
|
? 'Vorheriger Beitrag'
|
||||||
|
: 'Previous Post'}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
{prev.frontmatter.title}
|
{prev.frontmatter.title}
|
||||||
@@ -43,9 +60,14 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:-translate-x-2 transition-all duration-300">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 group-hover:text-white group-hover:-translate-x-2 transition-all duration-300">
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -62,8 +84,11 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
{next.frontmatter.featuredImage ? (
|
{next.frontmatter.featuredImage ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
|
style={{
|
||||||
|
backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})`,
|
||||||
|
backgroundPosition: `${next.frontmatter.focalX ?? 50}% ${next.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-neutral-100" />
|
<div className="absolute inset-0 bg-neutral-100" />
|
||||||
@@ -74,8 +99,14 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
||||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90">
|
||||||
{locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
|
{isNextRandom
|
||||||
|
? locale === 'de'
|
||||||
|
? 'Weiterer Artikel'
|
||||||
|
: 'More Article'
|
||||||
|
: locale === 'de'
|
||||||
|
? 'Nächster Beitrag'
|
||||||
|
: 'Next Post'}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
{next.frontmatter.title}
|
{next.frontmatter.title}
|
||||||
|
|||||||
@@ -28,24 +28,38 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
|||||||
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
|
<p className="text-xl text-white/90 mb-10 leading-relaxed max-w-2xl">
|
||||||
{isDe
|
{isDe
|
||||||
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
|
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
|
||||||
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'
|
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||||
{[
|
{[
|
||||||
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
||||||
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
||||||
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
|
isDe
|
||||||
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
|
? 'Expertenberatung für Großprojekte'
|
||||||
|
: 'Expert consulting for large-scale projects',
|
||||||
|
isDe
|
||||||
|
? 'Zertifizierte Qualität nach EU-Standards'
|
||||||
|
: 'Certified quality according to EU standards',
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-4 text-white/80">
|
<div key={i} className="flex items-center gap-4 text-white/90">
|
||||||
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||||
<svg className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
className="w-3 h-3 text-accent"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{item}</span>
|
<span className="text-sm font-medium">{item}</span>
|
||||||
@@ -59,12 +73,25 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
|||||||
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
||||||
>
|
>
|
||||||
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
||||||
<svg className="w-5 h-5 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-5 h-5 transition-transform group-hover/btn:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/50 text-sm font-medium">
|
<p className="text-white/80 text-sm font-medium">
|
||||||
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
|
{isDe
|
||||||
|
? 'Kostenlose Erstberatung für Ihr Vorhaben.'
|
||||||
|
: 'Free initial consultation for your project.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ interface SplitHeadingProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SplitHeading({ children, className = '', id }: SplitHeadingProps) {
|
export default function SplitHeading({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
id,
|
||||||
|
level: Level = 'h2',
|
||||||
|
}: SplitHeadingProps & { level?: any }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div id={id} className={className}>
|
||||||
id={id}
|
<Level className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<h2 className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
|
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</Level>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user