Compare commits
369 Commits
v1.0.0-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
|
|||||||
43
.env
43
.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,22 +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
|
||||||
DIRECTUS_DB_PASSWORD=directus
|
|
||||||
# Local Development
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
PROJECT_NAME=klz-cables
|
# Hetzner S3 Object Storage
|
||||||
GATEKEEPER_BYPASS_ENABLED=true
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
TRAEFIK_HOST=klz.localhost
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
DIRECTUS_HOST=cms.klz.localhost
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
COOKIE_DOMAIN=localhost
|
S3_BUCKET=mintel
|
||||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
S3_REGION=fsn1
|
||||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
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
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ name: CI - Lint, Typecheck & Test
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-pipeline
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-assurance:
|
quality-assurance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -10,27 +14,38 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
- name: 🔐 Configure Private Registry
|
- name: 🔐 Configure Private Registry
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install --no-frozen-lockfile
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
run: pnpm lint && pnpm typecheck && pnpm test
|
env:
|
||||||
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
|
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
|
- name: 🏗️ Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: ♿ Accessibility Check
|
||||||
|
run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000"
|
||||||
|
|
||||||
|
- name: ♿ WCAG Sitemap Audit
|
||||||
|
run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000"
|
||||||
|
# monitor trigger
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ on:
|
|||||||
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_name == 'main' && 'testing' || github.ref_name) }}
|
group: deploy-pipeline
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -31,12 +35,18 @@ jobs:
|
|||||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "Purging old build layers and dangling images..."
|
||||||
|
docker image prune -f
|
||||||
|
docker builder prune -f --filter "until=24h"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -76,27 +86,68 @@ jobs:
|
|||||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Standardize Traefik Rule
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%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
|
||||||
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
TRAEFIK_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=$PRIMARY_HOST"
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
echo "traefik_rule=$TRAEFIK_RULE"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "gatekeeper_host=$GATEKEEPER_HOST"
|
||||||
echo "next_public_url=https://$PRIMARY_HOST"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
echo "directus_url=https://cms.$PRIMARY_HOST"
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
echo "project_name=klz-cablescom"
|
||||||
|
elif [[ "$TARGET" == "branch" ]]; then
|
||||||
|
echo "project_name=$PRJ-branch-$SLUG"
|
||||||
|
else
|
||||||
echo "project_name=$PRJ-$TARGET"
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
fi
|
||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
} >> "$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" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
|
||||||
|
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
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: QA (Lint, Typecheck, Test)
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -110,26 +161,31 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
|
- name: 🔒 Security Audit
|
||||||
|
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
if: github.event.inputs.skip_checks != 'true'
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
run: |
|
env:
|
||||||
pnpm lint
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
pnpm typecheck
|
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push
|
# JOB 3: Build & Push
|
||||||
@@ -153,17 +209,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/arm64
|
provenance: false
|
||||||
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
|
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max
|
|
||||||
secrets: |
|
secrets: |
|
||||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy
|
# JOB 4: Deploy
|
||||||
@@ -179,18 +235,14 @@ jobs:
|
|||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||||
|
|
||||||
# Secrets mapping (Directus)
|
# Secrets mapping (Payload CMS)
|
||||||
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
||||||
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
|
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
||||||
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD) || secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
|
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' }}
|
||||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
|
||||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
|
||||||
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
|
||||||
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
|
|
||||||
|
|
||||||
# Secrets mapping (Mail)
|
# Secrets mapping (Mail)
|
||||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
@@ -205,6 +257,10 @@ jobs:
|
|||||||
|
|
||||||
# Gatekeeper
|
# 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
|
||||||
@@ -212,50 +268,75 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
# Generate Environment File
|
# 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" )
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||||
|
|
||||||
cat > .env.deploy << EOF
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
# Generated by CI - $TARGET
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
COMPOSE_PROFILES=""
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
else
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||||
LOG_LEVEL=$LOG_LEVEL
|
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||||
MAIL_HOST=$MAIL_HOST
|
COMPOSE_PROFILES="gatekeeper"
|
||||||
MAIL_PORT=$MAIL_PORT
|
fi
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
|
||||||
MAIL_FROM=$MAIL_FROM
|
|
||||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
|
||||||
|
|
||||||
# Directus
|
# Gatekeeper Origin
|
||||||
DIRECTUS_URL=$DIRECTUS_URL
|
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||||
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
|
{
|
||||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
echo "# Generated by CI - $TARGET"
|
||||||
AUTH_COOKIE_NAME=klz_gatekeeper_session
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
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
|
||||||
|
|
||||||
TARGET=$TARGET
|
echo "--- Generated .env.deploy ---"
|
||||||
SENTRY_ENVIRONMENT=$TARGET
|
cat .env.deploy
|
||||||
PROJECT_NAME=$PROJECT_NAME
|
echo "----------------------------"
|
||||||
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# AUTH_MIDDLEWARE logic
|
|
||||||
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> .env.deploy
|
|
||||||
|
|
||||||
- name: 🚀 SSH Deploy
|
- name: 🚀 SSH Deploy
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -268,40 +349,239 @@ jobs:
|
|||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
# Transfer and Restart
|
# Transfer and Restart
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
elif [[ "$TARGET" == "testing" ]]; then
|
||||||
|
SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
|
||||||
|
elif [[ "$TARGET" == "staging" ]]; then
|
||||||
|
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
||||||
|
else
|
||||||
|
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
|
||||||
|
fi
|
||||||
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||||
|
|
||||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
scp -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
|
|
||||||
|
|
||||||
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 && 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' 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"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
|
|
||||||
# Apply Directus Schema Snapshot if available
|
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
|
# 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
|
||||||
|
|
||||||
|
# Restart app to pick up clean migration state
|
||||||
|
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||||
|
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
|
if: always()
|
||||||
|
run: docker builder prune -f --filter "until=1h"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 5: Notifications
|
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
post_deploy_checks:
|
||||||
|
name: 🧪 Post-Deploy Verification
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
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: Install dependencies
|
||||||
|
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)
|
||||||
|
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
|
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
|
||||||
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --allow-downgrades chromium
|
||||||
|
fi
|
||||||
|
[ -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
|
||||||
|
|
||||||
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
|
- name: 🏥 CMS Deep Health Check
|
||||||
|
env:
|
||||||
|
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: |
|
||||||
|
echo "Waiting 10s for app to fully start..."
|
||||||
|
sleep 10
|
||||||
|
echo "Checking basic health..."
|
||||||
|
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||||
|
echo "✅ Basic health OK"
|
||||||
|
echo "Checking CMS DB connectivity..."
|
||||||
|
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
|
||||||
|
echo "❌ CMS health check failed!"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
echo ""
|
||||||
|
echo "This usually means Payload CMS migrations failed or DB tables are missing."
|
||||||
|
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✅ CMS health: $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' }}
|
||||||
|
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' }}
|
||||||
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
|
run: pnpm run check:forms
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy]
|
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: 🔔 Gotify
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
STATUS="${{ needs.deploy.result }}"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
TITLE="klz-cables.com: $STATUS"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
PERF="${{ needs.post_deploy_checks.result }}"
|
||||||
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
|
||||||
|
# Gotify priority scale:
|
||||||
|
# 1-3 = low (silent/info)
|
||||||
|
# 4-5 = normal
|
||||||
|
# 6-7 = high (warning)
|
||||||
|
# 8-10 = critical (alarm)
|
||||||
|
if [[ "$DEPLOY" != "success" ]]; then
|
||||||
|
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
|
||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
-F "message=$MESSAGE" \
|
||||||
-F "priority=$PRIORITY" || 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
|
||||||
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
|
||||||
55
Dockerfile
55
Dockerfile
@@ -1,45 +1,74 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
FROM node:20-alpine AS base
|
||||||
WORKDIR /app
|
RUN apk add --no-cache libc6-compat curl
|
||||||
|
|
||||||
# Clean the workspace in case the base image is dirty
|
# Enable pnpm
|
||||||
RUN rm -rf ./*
|
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
ARG NPM_TOKEN
|
ARG UMAMI_WEBSITE_ID
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
# Environment variables for Next.js build
|
# Environment variables for Next.js build
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV 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 SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
ENV CI=true
|
ENV CI=true
|
||||||
|
|
||||||
# Enable pnpm
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
# Copy lockfile and manifest for dependency installation caching
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
|
||||||
# Install dependencies with cache mount
|
# Configure private registry and install dependencies
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
pnpm install --frozen-lockfile
|
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
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Stage 2: Development (Hot-Reloading)
|
||||||
|
FROM base AS development
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
# Build application
|
# 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
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -50,6 +79,4 @@ 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/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
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
|
||||||
@@ -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, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug, 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',
|
||||||
@@ -70,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 */}
|
||||||
@@ -78,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>
|
||||||
@@ -94,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>
|
||||||
@@ -102,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 */}
|
||||||
@@ -112,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 JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_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 { 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',
|
||||||
@@ -59,24 +70,53 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
setRequestLocale(locale);
|
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">
|
||||||
@@ -84,27 +124,33 @@ 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 || '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>
|
||||||
@@ -123,16 +169,25 @@ 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 || '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>
|
||||||
@@ -145,16 +200,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 */}
|
||||||
@@ -164,7 +219,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 */}
|
||||||
@@ -191,9 +252,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>
|
||||||
@@ -233,8 +294,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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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<{
|
||||||
@@ -20,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',
|
||||||
@@ -43,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);
|
||||||
|
|
||||||
@@ -58,31 +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 && (
|
||||||
<>
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={featuredPost.frontmatter.featuredImage}
|
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={featuredPost.frontmatter.title}
|
alt={featuredPost.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||||
unoptimized
|
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
|
||||||
@@ -100,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">
|
||||||
@@ -141,52 +153,76 @@ 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}`}
|
||||||
|
className="group block focus:outline-none"
|
||||||
|
>
|
||||||
|
<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 && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
unoptimized
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
sizes="(max-width: 768px) 100vw, 100vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<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 && (
|
{post.frontmatter.category && (
|
||||||
<Badge
|
<Badge variant="accent" className="shadow-md">
|
||||||
variant="accent"
|
|
||||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
|
||||||
>
|
|
||||||
{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 || '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"
|
||||||
@@ -207,21 +243,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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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,
|
||||||
@@ -60,6 +60,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
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
|
||||||
@@ -137,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
|
||||||
@@ -190,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,9 +1,9 @@
|
|||||||
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';
|
||||||
@@ -11,10 +11,30 @@ 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 { 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' },
|
||||||
@@ -22,52 +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';
|
||||||
|
|
||||||
setRequestLocale(safeLocale);
|
setRequestLocale(safeLocale);
|
||||||
|
|
||||||
let messages = {};
|
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();
|
||||||
|
|
||||||
// We wrap this in a try-catch to allow static rendering during build
|
|
||||||
// headers() and cookies() force dynamic rendering in Next.js
|
|
||||||
try {
|
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,
|
||||||
@@ -76,10 +111,9 @@ 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 {
|
} catch {
|
||||||
// Falls back to noop or client-side only during static generation
|
|
||||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||||
@@ -87,20 +121,34 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<AnalyticsShell />
|
||||||
<AnalyticsProvider />
|
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||||
</Suspense>
|
|
||||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
|
||||||
</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,16 +1,19 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
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 { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
@@ -24,6 +27,13 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
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 />
|
||||||
@@ -49,7 +59,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<VideoSection />
|
<VideoSection />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal>
|
<Reveal className="content-visibility-auto">
|
||||||
<CTA />
|
<CTA />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,17 +87,19 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
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: {
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ 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, setRequestLocale } 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<{
|
||||||
@@ -52,135 +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);
|
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, setRequestLocale } 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',
|
||||||
@@ -56,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
|
||||||
@@ -101,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">
|
||||||
@@ -135,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
|
||||||
@@ -143,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" />
|
||||||
|
|
||||||
@@ -196,7 +199,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</TrackedLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,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">
|
||||||
@@ -219,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,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ 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',
|
||||||
@@ -94,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" />
|
||||||
@@ -114,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" />
|
||||||
@@ -122,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">
|
||||||
@@ -151,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>
|
||||||
@@ -212,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
|
||||||
@@ -220,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" />
|
||||||
@@ -230,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">
|
||||||
@@ -282,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"
|
||||||
>
|
>
|
||||||
@@ -299,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>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'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 { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -41,31 +39,30 @@ 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');
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
|
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 +72,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 +85,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isTestSubmission) {
|
||||||
const notificationResult = await sendEmail({
|
const notificationResult = await sendEmail({
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: notificationSubject,
|
subject: notificationSubject,
|
||||||
@@ -97,6 +96,19 @@ 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 FAILED', {
|
||||||
|
error: notificationResult.error,
|
||||||
|
subject: notificationSubject,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_notification', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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 +120,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isTestSubmission) {
|
||||||
const confirmationResult = await sendEmail({
|
const confirmationResult = await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: confirmationSubject,
|
subject: confirmationSubject,
|
||||||
@@ -118,6 +131,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 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { renderToStream } from '@react-pdf/renderer';
|
||||||
|
import React from 'react';
|
||||||
|
import { PDFPage } from '@/lib/pdf-page';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// Get Payload App
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Fetch the page
|
||||||
|
const pages = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
_status: { equals: 'published' },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.totalDocs === 0) {
|
||||||
|
return new NextResponse('Page not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pages.docs[0];
|
||||||
|
|
||||||
|
// Determine locale from searchParams or default to 'de'
|
||||||
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||||
|
|
||||||
|
// Render the React-PDF document into a stream
|
||||||
|
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||||
|
|
||||||
|
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||||
|
stream.on('end', () => controller.close());
|
||||||
|
stream.on('error', (err) => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
(stream as any).destroy?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${slug}.pdf`;
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
// Cache control if needed, skip for now.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +19,11 @@ 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 {
|
||||||
|
// Prevent 400 Bad Request console noise in local dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { type, payload } = body;
|
const { type, payload } = body;
|
||||||
|
|
||||||
@@ -56,18 +61,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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
|
|
||||||
> klz-cables-nextjs@1.0.0 build /Users/marcmintel/Projects/klz-2026
|
|
||||||
> next build
|
|
||||||
|
|
||||||
▲ Next.js 16.1.6 (Turbopack)
|
|
||||||
- Environments: .env.production, .env
|
|
||||||
- Experiments (use with caution):
|
|
||||||
· clientTraceMetadata
|
|
||||||
|
|
||||||
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
|
|
||||||
Creating an optimized production build ...
|
|
||||||
✓ Compiled successfully in 5.2s
|
|
||||||
Running next.config.js provided runAfterProductionCompile ...
|
|
||||||
✓ Completed runAfterProductionCompile in 329ms
|
|
||||||
Running TypeScript ...
|
|
||||||
Collecting page data using 15 workers ...
|
|
||||||
Generating static pages using 15 workers (0/21) ...
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop notification service initialized (notifications disabled)"}
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
|
|
||||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
|
|
||||||
Generating static pages using 15 workers (5/21)
|
|
||||||
Generating static pages using 15 workers (10/21)
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
|
|
||||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Notification service initialized (noop)"}
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
|
|
||||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
|
|
||||||
Generating static pages using 15 workers (15/21)
|
|
||||||
✓ Generating static pages using 15 workers (21/21) in 512.4ms
|
|
||||||
Finalizing page optimization ...
|
|
||||||
|
|
||||||
Route (app)
|
|
||||||
┌ ○ /_not-found
|
|
||||||
├ ƒ /[locale]
|
|
||||||
├ ƒ /[locale]/[slug]
|
|
||||||
├ ƒ /[locale]/[slug]/opengraph-image
|
|
||||||
├ ƒ /[locale]/api/og/product
|
|
||||||
├ ƒ /[locale]/blog
|
|
||||||
├ ƒ /[locale]/blog/[slug]
|
|
||||||
├ ƒ /[locale]/blog/[slug]/opengraph-image
|
|
||||||
├ ƒ /[locale]/blog/opengraph-image
|
|
||||||
├ ƒ /[locale]/contact
|
|
||||||
├ ƒ /[locale]/contact/opengraph-image
|
|
||||||
├ ƒ /[locale]/opengraph-image
|
|
||||||
├ ƒ /[locale]/products
|
|
||||||
├ ƒ /[locale]/products/[...slug]
|
|
||||||
├ ƒ /[locale]/products/opengraph-image
|
|
||||||
├ ƒ /[locale]/team
|
|
||||||
├ ƒ /[locale]/team/opengraph-image
|
|
||||||
├ ƒ /api/feedback
|
|
||||||
├ ƒ /api/health/cms
|
|
||||||
├ ƒ /api/whoami
|
|
||||||
├ ƒ /errors/api/relay
|
|
||||||
├ ƒ /health
|
|
||||||
├ ○ /manifest.webmanifest
|
|
||||||
├ ○ /robots.txt
|
|
||||||
├ ƒ /sitemap.xml
|
|
||||||
└ ƒ /stats/api/send
|
|
||||||
|
|
||||||
|
|
||||||
ƒ Proxy (Middleware)
|
|
||||||
|
|
||||||
○ (Static) prerendered as static content
|
|
||||||
ƒ (Dynamic) server-rendered on demand
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -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,34 +33,116 @@ export default function Header() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Prevent scroll when mobile menu is open
|
// Prevent scroll when mobile menu is open and handle focus trap
|
||||||
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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,334 +151,322 @@ export default function Header() {
|
|||||||
|
|
||||||
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"
|
{menuItems.map((item, idx) => (
|
||||||
animate="visible"
|
<div
|
||||||
variants={{
|
key={item.href}
|
||||||
visible: {
|
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||||
transition: {
|
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||||
staggerChildren: 0.08,
|
|
||||||
delayChildren: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
{(() => {
|
||||||
{menuItems.map((item, _idx) => (
|
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||||
<motion.div key={item.href} variants={navLinkVariants}>
|
const isActive =
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(fullHref);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={fullHref}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
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(
|
className={cn(
|
||||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||||
textColorClass,
|
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={{
|
onClick={() => {
|
||||||
duration: 0.6,
|
const newState = !isMobileMenuOpen;
|
||||||
type: 'spring',
|
setIsMobileMenuOpen(newState);
|
||||||
stiffness: 300,
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
damping: 20,
|
type: 'mobile_menu',
|
||||||
delay: 0.5,
|
action: newState ? 'open' : 'close',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
||||||
>
|
>
|
||||||
<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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
isMobileMenuOpen
|
isMobileMenuOpen
|
||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0'
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
)}
|
)}
|
||||||
|
id="mobile-menu"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('menu')}
|
||||||
|
ref={mobileMenuRef}
|
||||||
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
>
|
>
|
||||||
<motion.div
|
{/* Close Button inside overlay */}
|
||||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
<div className="flex justify-end p-6 pt-8">
|
||||||
initial="closed"
|
<button
|
||||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||||
variants={{
|
aria-label={t('toggleMenu')}
|
||||||
open: {
|
onClick={() => {
|
||||||
transition: {
|
setIsMobileMenuOpen(false);
|
||||||
staggerChildren: 0.1,
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
delayChildren: 0.2,
|
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}`}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
aria-current={
|
||||||
className="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}`)
|
||||||
|
)
|
||||||
|
? '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;
|
|
||||||
|
|||||||
@@ -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,6 +19,8 @@ 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); // eslint-disable-line react-hooks/set-state-in-effect
|
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
@@ -76,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
|
||||||
@@ -99,10 +139,15 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||||
<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 }}
|
||||||
@@ -111,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"
|
||||||
@@ -123,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 }}
|
||||||
@@ -137,9 +183,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||||
‹
|
‹
|
||||||
</span>
|
</span>
|
||||||
</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 }}
|
||||||
@@ -151,9 +197,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
||||||
›
|
›
|
||||||
</span>
|
</span>
|
||||||
</motion.button>
|
</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 }}
|
||||||
@@ -163,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)' }}
|
||||||
@@ -178,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 */}
|
||||||
@@ -188,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 }}
|
||||||
@@ -200,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>
|
||||||
|
</LazyMotion>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1200
components/PayloadRichText.tsx
Normal file
1200
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
|
||||||
interface TocItem {
|
interface TocItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,11 +18,12 @@ interface TableOfContentsProps {
|
|||||||
|
|
||||||
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observerOptions = {
|
const observerOptions = {
|
||||||
rootMargin: '-10% 0% -70% 0%',
|
rootMargin: '-10% 0% -70% 0%',
|
||||||
threshold: 0
|
threshold: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -50,7 +53,7 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
|||||||
return (
|
return (
|
||||||
<nav className="hidden lg:block w-full ml-12">
|
<nav className="hidden lg:block w-full ml-12">
|
||||||
<div className="relative pl-6 border-l border-neutral-200">
|
<div className="relative pl-6 border-l border-neutral-200">
|
||||||
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/50 mb-6">
|
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/70 mb-6">
|
||||||
{locale === 'de' ? 'Inhalt' : 'Table of Contents'}
|
{locale === 'de' ? 'Inhalt' : 'Table of Contents'}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
@@ -66,15 +69,20 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
|||||||
<a
|
<a
|
||||||
href={`#${heading.id}`}
|
href={`#${heading.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug",
|
'text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug',
|
||||||
activeId === heading.id
|
activeId === heading.id
|
||||||
? "text-primary font-bold translate-x-1"
|
? 'text-primary font-bold translate-x-1'
|
||||||
: "text-text-secondary font-medium hover:translate-x-1"
|
: 'text-text-secondary font-medium hover:translate-x-1',
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const element = document.getElementById(heading.id);
|
const element = document.getElementById(heading.id);
|
||||||
if (element) {
|
if (element) {
|
||||||
|
trackEvent(AnalyticsEvents.TOC_CLICK, {
|
||||||
|
heading_id: heading.id,
|
||||||
|
heading_text: heading.text,
|
||||||
|
location: 'blog_sidebar',
|
||||||
|
});
|
||||||
const yOffset = -100;
|
const yOffset = -100;
|
||||||
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||||
|
|||||||
@@ -19,12 +19,17 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
|
<Link
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block my-12 no-underline group"
|
||||||
|
>
|
||||||
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
||||||
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
||||||
{image ? (
|
{image ? (
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image.split('?')[0]}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -32,8 +37,18 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
||||||
<svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
className="w-12 h-12 text-primary/20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -46,10 +61,10 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/80 bg-primary/10 px-2 py-0.5 rounded">
|
||||||
External Link
|
External Link
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/80">
|
||||||
{hostname}
|
{hostname}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,8 +79,18 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
||||||
<span>Read more</span>
|
<span>Read more</span>
|
||||||
<svg className="w-4 h-4 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 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Section, Container, Button, Heading } from '../../components/ui';
|
import { Section, Container, Button, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function CTA() {
|
export default function CTA({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.cta');
|
const t = useTranslations('Home.cta');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
@@ -14,16 +14,16 @@ export default function CTA() {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-16">
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-16">
|
||||||
<div className="max-w-3xl text-center lg:text-left">
|
<div className="max-w-3xl text-center lg:text-left">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-6">
|
||||||
<span className="text-white">{t('title')}</span>
|
<span className="text-white">{data?.title || t('title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
|
||||||
{t('description')}
|
{data?.description || t('description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12">
|
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12">
|
||||||
{t('button')}
|
{data?.buttonLabel || t('button')}
|
||||||
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Section, Container, Heading } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function Experience() {
|
export default function Experience({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.experience');
|
const t = useTranslations('Home.experience');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -11,10 +11,11 @@ export default function Experience() {
|
|||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||||
alt={t('subtitle')}
|
alt={data?.subtitle || t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||||
@@ -22,28 +23,34 @@ export default function Experience() {
|
|||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white">
|
||||||
<span className="text-white">{t('title')}</span>
|
<span className="text-white">{data?.title || t('title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
|
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
|
||||||
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
||||||
{t('p1')}
|
{data?.paragraph1 || t('p1')}
|
||||||
</p>
|
|
||||||
<p className="pl-9">
|
|
||||||
{t('p2')}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="pl-9">{data?.paragraph2 || t('p2')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('certifiedQuality')}</div>
|
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('vdeApproved')}</div>
|
{data?.badge1 || t('certifiedQuality')}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
|
{data?.badge1Text || t('vdeApproved')}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('fullSpectrum')}</div>
|
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('solutionsRange')}</div>
|
{data?.badge2 || t('fullSpectrum')}
|
||||||
</div>
|
</dt>
|
||||||
|
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
|
{data?.badge2Text || t('solutionsRange')}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import React from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Section, Container, Heading } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
import Lightbox from '../Lightbox';
|
import dynamic from 'next/dynamic';
|
||||||
|
const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false });
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
export default function GallerySection() {
|
export default function GallerySection({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.gallery');
|
const t = useTranslations('Home.gallery');
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const images = [
|
const images = [
|
||||||
@@ -25,14 +26,16 @@ export default function GallerySection() {
|
|||||||
return (
|
return (
|
||||||
<Section className="bg-white text-white py-32">
|
<Section className="bg-white text-white py-32">
|
||||||
<Container>
|
<Container>
|
||||||
<Heading level={2} subtitle={t('subtitle')} align="center">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
||||||
{t('title')}
|
{data?.title || t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{images.map((src, idx) => (
|
{images.map((src, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t('alt')} ${idx + 1}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('photo', idx.toString());
|
params.set('photo', idx.toString());
|
||||||
@@ -46,7 +49,8 @@ export default function GallerySection() {
|
|||||||
alt={`${t('alt')} ${idx + 1}`}
|
alt={`${t('alt')} ${idx + 1}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
unoptimized
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
||||||
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||||
|
|||||||
@@ -1,168 +1,98 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { motion } from 'framer-motion';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { useTranslations } from 'next-intl';
|
import dynamic from 'next/dynamic';
|
||||||
import HeroIllustration from './HeroIllustration';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<motion.div
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
className="max-w-5xl mx-auto md:mx-0"
|
<div>
|
||||||
initial="hidden"
|
<Heading
|
||||||
animate="visible"
|
level={1}
|
||||||
variants={containerVariants}
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||||
>
|
>
|
||||||
<motion.div variants={headingVariants}>
|
{data?.title ? (
|
||||||
<Heading level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]">
|
<span
|
||||||
{t.rich('title', {
|
dangerouslySetInnerHTML={{
|
||||||
green: (chunks) => (
|
__html: data.title
|
||||||
<span className="relative inline-block">
|
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||||
<motion.span
|
.replace(/<\/green>/g, '</span>'),
|
||||||
className="relative z-10 text-accent italic"
|
|
||||||
variants={accentVariants}
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</motion.span>
|
|
||||||
<motion.div
|
|
||||||
variants={scribbleVariants}
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</motion.div>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Heading>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div variants={subtitleVariants}>
|
|
||||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
|
||||||
variants={buttonContainerVariants}
|
|
||||||
>
|
|
||||||
<motion.div variants={buttonVariants}>
|
|
||||||
<Button href="/contact" variant="accent" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg">
|
|
||||||
{t('cta')}
|
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div variants={buttonVariants}>
|
|
||||||
<Button href="/products" variant="white" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none">
|
|
||||||
{t('exploreProducts')}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
|
||||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
|
||||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
|
||||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
|
||||||
>
|
|
||||||
<HeroIllustration />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, ease: "easeOut", delay: 3 }}
|
|
||||||
>
|
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
|
||||||
<motion.div
|
|
||||||
className="w-1 h-2 bg-white rounded-full"
|
|
||||||
animate={{ y: [0, -10, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
t.rich('title', {
|
||||||
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
|
{data?.subtitle || t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.ctaLabel || t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data?.ctaLabel || t('cta')}
|
||||||
|
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data?.secondaryCtaLabel || t('exploreProducts')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||||
|
<HeroIllustration />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
||||||
|
style={{ animationDelay: '2000ms' }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
|
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 1 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.12,
|
|
||||||
delayChildren: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const headingVariants = {
|
|
||||||
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const accentVariants = {
|
|
||||||
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const scribbleVariants = {
|
|
||||||
hidden: { opacity: 0, scale: 0, rotate: 180 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: { duration: 1, type: "spring", stiffness: 300, damping: 20 }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const subtitleVariants = {
|
|
||||||
hidden: { opacity: 0, y: 40, scale: 0.95 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const buttonContainerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.15,
|
|
||||||
delayChildren: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const buttonVariants = {
|
|
||||||
hidden: { opacity: 0, y: 30, scale: 0.9 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: { type: "spring", stiffness: 400, damping: 20 }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
|||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Section, Container, Button, Heading } from '../../components/ui';
|
import { Section, Container, Button, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function MeetTheTeam() {
|
export default function MeetTheTeam({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.meetTheTeam');
|
const t = useTranslations('Home.meetTheTeam');
|
||||||
const teamT = useTranslations('Team');
|
const teamT = useTranslations('Team');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@@ -13,10 +13,11 @@ export default function MeetTheTeam() {
|
|||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||||
alt={t('subtitle')}
|
alt={data?.subtitle || t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||||
@@ -24,34 +25,44 @@ export default function MeetTheTeam() {
|
|||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl text-white animate-slide-up">
|
<div className="max-w-3xl text-white animate-slide-up">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
|
||||||
<span className="text-white">{t('title')}</span>
|
<span className="text-white">{data?.title || t('title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="relative mb-12">
|
<div className="relative mb-12">
|
||||||
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
||||||
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
||||||
"{t('description')}"
|
"{data?.description || t('description')}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-8 items-center">
|
<div className="flex flex-wrap gap-8 items-center">
|
||||||
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
|
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
|
||||||
{t('cta')}
|
{data?.ctaLabel || t('cta')}
|
||||||
<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>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex -space-x-4">
|
<div className="flex -space-x-4">
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
||||||
<Image src="/uploads/2024/12/DSC07768-Large.webp" alt={teamT('michael.name')} fill className="object-cover" />
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||||
|
alt={teamT('michael.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
||||||
<Image src="/uploads/2024/12/DSC07963-Large.webp" alt={teamT('klaus.name')} fill className="object-cover" />
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||||
|
alt={teamT('klaus.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
||||||
{t('andNetwork')}
|
{data?.networkLabel || t('andNetwork')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,67 +4,90 @@ import Image from 'next/image';
|
|||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Section } from '../../components/ui';
|
import { Section } from '../../components/ui';
|
||||||
|
|
||||||
export default function ProductCategories() {
|
export default function ProductCategories({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const productsBase = locale === 'de' ? 'produkte' : 'products';
|
||||||
|
|
||||||
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/low-voltage-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'niederspannungskabel' : 'low-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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/medium-voltage-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'mittelspannungskabel' : 'medium-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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/high-voltage-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'hochspannungskabel' : 'high-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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/solar-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||||
|
{(data?.title || t.has('title')) && (
|
||||||
|
<h2 className="sr-only">
|
||||||
|
{data?.title ? (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: data.title }} />
|
||||||
|
) : (
|
||||||
|
t.rich('title', { green: (chunks: any) => <span>{chunks}</span> })
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Link key={idx} href={category.href} className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0">
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={category.href}
|
||||||
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={category.img}
|
src={category.img}
|
||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 24vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
|
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
|
||||||
<Image src={category.icon} alt="" width={40} height={40} className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert" unoptimized />
|
<Image
|
||||||
|
src={category.icon}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">{category.title}</h3>
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">
|
||||||
|
{category.title}
|
||||||
|
</h3>
|
||||||
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
|
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
|
||||||
{category.desc}
|
{category.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
{t('exploreCategory')} <span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
{t('exploreCategory')}{' '}
|
||||||
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -3,90 +3,105 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
interface RecentPostsProps {
|
interface RecentPostsProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RecentPosts({ locale }: RecentPostsProps) {
|
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const recentPosts = posts.slice(0, 3);
|
const recentPosts = posts.slice(0, 4);
|
||||||
|
|
||||||
if (recentPosts.length === 0) return null;
|
if (recentPosts.length === 0) return null;
|
||||||
|
|
||||||
|
const title = data?.title || t('allArticles');
|
||||||
|
const subtitle = data?.subtitle || t('latestNews');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral py-16 md:py-24">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
|
||||||
<Container>
|
<Container className="py-12 md:py-16">
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
|
||||||
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
||||||
{t('allArticles')}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/blog`}
|
href={`/${locale}/blog`}
|
||||||
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
|
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
|
||||||
>
|
>
|
||||||
{t('allArticles')}
|
{title}
|
||||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
|
||||||
{recentPosts.map((post) => (
|
{recentPosts.map((post, idx) => (
|
||||||
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
|
<li key={`${post.slug}-${idx}`} className="block">
|
||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
|
||||||
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-64 overflow-hidden">
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
unoptimized
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<time
|
||||||
)}
|
dateTime={post.frontmatter.date}
|
||||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
suppressHydrationWarning
|
||||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
|
</time>
|
||||||
|
{(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>
|
</div>
|
||||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
</div>
|
||||||
{t('readMore')}
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
<svg
|
{t('readMore')}{' '}
|
||||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
<span className="ml-2 transition-transform group-hover:translate-x-2">
|
||||||
fill="none"
|
→
|
||||||
stroke="currentColor"
|
</span>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</Container>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,57 @@
|
|||||||
import Scribble from '@/components/Scribble';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection() {
|
export default function VideoSection({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.video');
|
const t = useTranslations('Home.video');
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sectionRef.current) {
|
||||||
|
observer.observe(sectionRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[70vh] overflow-hidden bg-primary">
|
<section ref={sectionRef} className="relative h-[70vh] overflow-hidden bg-primary">
|
||||||
<video
|
{isVisible && (
|
||||||
className="w-full h-full object-cover opacity-60"
|
<video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
|
||||||
autoPlay
|
<source
|
||||||
muted
|
src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm"
|
||||||
loop
|
type="video/webm"
|
||||||
playsInline
|
/>
|
||||||
>
|
|
||||||
<source src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm" type="video/mp4" />
|
|
||||||
</video>
|
</video>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center">
|
)}
|
||||||
<div className="max-w-5xl px-6 text-center animate-slide-up">
|
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{t.rich('title', {
|
{data?.title ? (
|
||||||
future: (chunks) => (
|
<span
|
||||||
<span className="relative inline-block mx-2">
|
dangerouslySetInnerHTML={{
|
||||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
__html: data.title
|
||||||
<Scribble variant="underline" className="w-full h-4 -bottom-2 left-0 text-accent/40" />
|
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||||
</span>
|
.replace(/<\/future>/g, '</span>'),
|
||||||
)
|
}}
|
||||||
})}
|
/>
|
||||||
|
) : (
|
||||||
|
t.rich('title', {
|
||||||
|
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||||
|
})
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,30 +2,35 @@ import React from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Section, Container, Heading } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function WhatWeDo() {
|
export default function WhatWeDo({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.whatWeDo');
|
const t = useTranslations('Home.whatWeDo');
|
||||||
|
|
||||||
|
const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({
|
||||||
|
title: t(`items.${idx}.title`),
|
||||||
|
description: t(`items.${idx}.description`)
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white">
|
<Section className="bg-white">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="sticky-narrative-container">
|
<div className="sticky-narrative-container">
|
||||||
<div className="sticky-narrative-sidebar">
|
<div className="sticky-narrative-sidebar">
|
||||||
<div className="lg:sticky lg:top-32">
|
<div className="lg:sticky lg:top-32">
|
||||||
<Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
|
<Heading level={2} subtitle={data?.expertiseLabel || t('expertise')} className="text-primary-dark">
|
||||||
{t('title')}
|
{data?.title || t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t('subtitle')}
|
{data?.subtitle || t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
|
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
|
||||||
<p className="text-saturated font-bold text-base md:text-base italic">
|
<p className="text-saturated font-bold text-base md:text-base italic">
|
||||||
"{t('quote')}"
|
"{data?.quote || t('quote')}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20">
|
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20">
|
||||||
{[0, 1, 2, 3].map((idx) => (
|
{items.map((item: any, idx: number) => (
|
||||||
<div key={idx} className="group">
|
<div key={idx} className="group">
|
||||||
<div className="flex items-center gap-4 mb-4 md:mb-6">
|
<div className="flex items-center gap-4 mb-4 md:mb-6">
|
||||||
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
|
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
|
||||||
@@ -33,8 +38,8 @@ export default function WhatWeDo() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="h-px flex-grow bg-neutral-medium" />
|
<div className="h-px flex-grow bg-neutral-medium" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
|
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{item.title}</h3>
|
||||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
<p className="text-text-secondary text-base md:text-base leading-relaxed">{item.description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,47 +2,72 @@ import React from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Section, Container, Heading } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function WhyChooseUs() {
|
export default function WhyChooseUs({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.whyChooseUs');
|
const t = useTranslations('Home.whyChooseUs');
|
||||||
|
|
||||||
|
const features = data?.features?.length ? data.features.map((f: any) => f.feature) : [0, 1, 2, 3].map(i => t(`features.${i}`));
|
||||||
|
const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({ title: t(`items.${idx}.title`), description: t(`items.${idx}.description`) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light">
|
<Section className="bg-neutral-light">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
|
||||||
<div className="lg:col-span-4 order-1 lg:order-2">
|
<div className="lg:col-span-4 order-1 lg:order-2">
|
||||||
<div className="sticky top-32">
|
<div className="sticky top-32">
|
||||||
<Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
|
<Heading level={2} subtitle={data?.tagline || t('whyKlz')} className="text-primary-dark">
|
||||||
{t('title')}
|
{data?.title || t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t('subtitle')}
|
{data?.subtitle || t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-12 space-y-6">
|
<ul className="mt-12 space-y-6 list-none p-0">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{features.map((featureText: string, i: number) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<li key={i} className="flex items-center gap-4">
|
||||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
||||||
<svg className="w-4 h-4 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
className="w-4 h-4 text-primary-dark"
|
||||||
|
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="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
|
<span className="font-bold text-primary-dark text-base md:text-base">
|
||||||
</div>
|
{featureText}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
|
||||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
|
{items.map((item: any, idx: number) => (
|
||||||
{[0, 1, 2, 3].map((idx) => (
|
<li
|
||||||
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
|
key={idx}
|
||||||
|
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
|
||||||
|
>
|
||||||
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
||||||
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
|
<span className="text-white font-bold text-lg group-hover:text-primary-dark">
|
||||||
</div>
|
0{idx + 1}
|
||||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
|
</span>
|
||||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-4 text-primary-dark">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-base md:text-base leading-relaxed">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Lightbox from '@/components/Lightbox';
|
import dynamic from 'next/dynamic';
|
||||||
|
const Lightbox = dynamic(() => import('@/components/Lightbox'), { ssr: false });
|
||||||
import { Section, Container, Heading } from '@/components/ui';
|
import { Section, Container, Heading } from '@/components/ui';
|
||||||
|
|
||||||
export default function Gallery() {
|
export default function Gallery() {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user