Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f989e0604f | |||
| 4a2d094cbd | |||
| 77181fc983 | |||
| 32b56696a6 | |||
| 9b28dd20d9 | |||
| 1970ae310f | |||
| 7bc8811a60 | |||
| 530503fa09 | |||
| a539e3c498 | |||
| 14574fb312 | |||
| fdeb34ea3c | |||
| ac189f84f5 | |||
| 06dac33ef8 | |||
| 529696ba8e | |||
| 7922210ef0 | |||
| 5021259d22 | |||
| 666bcf0b9d | |||
| c3068f23e7 | |||
| 2c9263999a | |||
| 89e3cc462a | |||
| 03a3073263 | |||
| 1a7c342fbe | |||
| fc03399285 | |||
| 2445e7d968 | |||
| c05a8eef16 | |||
| c32969767d | |||
| 004c922eca | |||
| 52e18b10d6 | |||
| 65efbd147c | |||
| d713ef6900 | |||
| a38bee9af2 | |||
| 7cb3763125 | |||
| 64ec24f8b2 | |||
| 0091b56dd1 | |||
| e9e03c217a | |||
| dcd099f5b1 | |||
| db754a6325 | |||
| 9dc553b76c | |||
| 48101cc421 | |||
| 615f762d5e | |||
| 0c4c6e8dc0 | |||
| 3046a19113 | |||
| a4df12ddb3 | |||
| 73542237d5 | |||
| 52b66da16b | |||
| d75a83ccf2 | |||
| 5991bd8392 | |||
| 6207e04bf5 | |||
| 8ffb5967d3 | |||
| 8ba1c7ea38 | |||
| a546ffe69c | |||
| 15740db51e | |||
| 13ab755857 | |||
| 1a68af0eec | |||
| 275784745d | |||
| 4aef49cf2c | |||
| 8ad3abb6f3 | |||
| 1d75b60236 | |||
| 3dff19eca2 | |||
| 07b01c622a | |||
| 50de18c09c | |||
| dbee0cd8bc | |||
| f30f8ddd8d | |||
| bb9fd65dbb | |||
| 036fba8b53 | |||
| 3e8d5ad8b6 | |||
| 70ad2e3041 | |||
| 5376b939d5 | |||
| 6f80e72c1d | |||
| d9334f558d | |||
| cb436d31d0 | |||
| 4b3ef49522 | |||
| 301e112488 | |||
| 2d4919cc1f | |||
| 6a748a3ac8 | |||
| d69e0eebe6 | |||
| 1577bfd2ec | |||
| 6440d893f0 | |||
| d8e3c7d9a3 | |||
| aa14f39dba | |||
| 1cfc0523f3 | |||
| 3ff20fd2c9 | |||
| 549ee34490 | |||
| 8a8e30400c | |||
| 4faed38f47 | |||
| 1e0886144f | |||
| c933d9b886 | |||
| 5c56d8babf | |||
| c4c6fb3b07 | |||
| ff685b9933 | |||
| 980258af5c | |||
| 57b6963efe | |||
| 1a136540d0 | |||
| 92bc88dfbd | |||
| fb3ec6e10a | |||
| acf642d7e6 | |||
| d5da2a91c8 | |||
| ebe664f984 | |||
| 9c7324ee92 | |||
| 0c8d9ea669 | |||
| 1bb0efc85b | |||
| 4adf547265 | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 | |||
| a2cf9791ae | |||
| aa4e3aab4f | |||
| ce719a1d70 | |||
| bd2f92125b | |||
| eebe7972e0 | |||
| a9c7fa7c5e | |||
| 85e7ff71d5 | |||
| 2acb0c1608 | |||
| 082733c4f4 | |||
| af67ae7994 | |||
| 1fd247e358 | |||
| 44401cf546 | |||
| 7f106b1fa7 | |||
| 08425a3a42 | |||
| 62f1e9a89c | |||
| a5718c5013 | |||
| 82bb7240d5 | |||
| 9e7f6ec76f | |||
| b3057d8be0 | |||
| 3b45a967f7 | |||
| cadb104917 | |||
| 0be885428d | |||
| 009f12a3bf | |||
| 8e2a06d6f2 | |||
| 4f2bf3fa51 | |||
| 064ebf45e3 | |||
| e6dfeaffef | |||
| 7cdfe5d7f8 | |||
| 83f4b8eea8 | |||
| 97e76c7cac | |||
| 6caa850045 | |||
| 04ce0ecedd | |||
| 083859d52d | |||
| a13074902b | |||
| 4280f11772 | |||
| 3049c1b6e7 | |||
| 647f9a5f19 | |||
| a2872be02e | |||
| 9c3c7bd34b | |||
| 45602db7ff | |||
| 89405e6e18 | |||
| 57d54231eb | |||
| 5c4225d0a9 | |||
| e1101f2e60 | |||
| 0be6076512 | |||
| 62400943c2 | |||
| 4c60029e21 | |||
| b3c5b911d9 | |||
| 89f00c79a1 | |||
| 98ac3dbd10 | |||
| 0db4c819ff | |||
| 08a3b0be7b | |||
| a953820241 | |||
| fa02ac597f | |||
| 925765233e | |||
| 0487bd8ebe | |||
| 87b2624ab3 | |||
| 7cad437eb4 | |||
| f8b7d4f59d | |||
| 7fb4d306c3 | |||
| 294907977d | |||
| 3de13b4fb3 | |||
| 7d65237ee9 | |||
| 1963a93123 | |||
| 44d3e8585b | |||
| 5652f27c71 | |||
| c769da5f26 | |||
| ef5e749056 | |||
| 9c2344afd9 | |||
| 0b3de9f98c | |||
| 5813b4bd49 | |||
| 33f0238d58 | |||
| d5da64cb76 | |||
| c3111a04d8 | |||
| 2fabfc4445 | |||
| fb62113a32 | |||
| bdde7c242c | |||
| 90f657ce8d | |||
| a168f96f3c | |||
| 2db2a3aff9 | |||
| 2ba67af68a | |||
| b0f088a1dc | |||
| f358492a99 | |||
| 32576b5391 | |||
| 1e9cf7d9ab | |||
| f0f840ad5a | |||
| ca352fea3a | |||
| 323886443f | |||
| c5851370bf | |||
| 0186dd2dc9 | |||
| 82156d30f7 | |||
| 3dcde28071 | |||
| c4fca24eca | |||
| 2435b968cc | |||
| b6a1ebd236 | |||
| aa0c9cd9f5 | |||
| a3899f6cdd | |||
| a960a7b139 | |||
| 824ee3cb75 | |||
| 28633f187c | |||
| 51e0d86a6c | |||
| 923ff2071b | |||
| 30eb2e6e0e | |||
| dd830f9077 | |||
| ba16f1d7aa | |||
| 0842c136a6 | |||
| 36b8e64d69 | |||
| 4833af81f4 | |||
| 5f766589c4 | |||
| 56a7613e85 | |||
| c7c345eaad | |||
| ec99dc0317 | |||
| a6dd7913a7 | |||
| 388b90ddb0 | |||
| d57700d322 | |||
| f7aa880d9f | |||
| 2bac8d6e8a | |||
| 5bd95bca4f | |||
| 6f8d63200a | |||
| 4742630260 | |||
| a5d77fc69b | |||
| 41cfe19cbf | |||
| e4f68713e7 | |||
| 2fbcce0990 | |||
| c414a7614b | |||
| 6a0269facc | |||
| 477a3bb8ce | |||
| b1859c15ce | |||
| 6085cc05dc | |||
| bcf2d60da6 | |||
| f4fdb89ba4 | |||
| 9de3931e33 | |||
| b10dbcb23f | |||
| 65bb9c620a | |||
| 63853ffa89 | |||
| 9694c77ef7 | |||
| 2c11b5026a | |||
| eaa90c65f1 | |||
| 2a47d22e26 | |||
| 33d2d67774 | |||
| 3de62dba04 | |||
| fb2354d2cc | |||
| 70984b9021 | |||
| e1b441e8e7 | |||
| 470e532d2c | |||
| 1d24a8fb7a | |||
| 73c4988eb2 | |||
| 4a75db5f54 | |||
| d76fadd6e8 | |||
| 4b2638caed | |||
| a6dcc64833 | |||
| a55680ed41 | |||
| 1a39e9c0e4 | |||
| 16723a04b7 | |||
| 639e25276f | |||
| ad2936bf93 | |||
| f0522ff3b7 | |||
| d6c799078c | |||
| d11dae5f85 | |||
| dd7e800ec4 | |||
| 046ad4475e | |||
| b29e08e954 | |||
| 36d193f8ec | |||
| b8f04d3595 | |||
| 5f7dd838ac | |||
| 8c9f51b74a | |||
| cef86717d9 | |||
| a97a00b7fd | |||
| f696e55600 | |||
| 36455ef479 | |||
| a5384134e7 | |||
| 4965e4ae26 | |||
| 1153a79eb6 | |||
| 678c803408 | |||
| 21288a4a45 | |||
| b514125e0d | |||
| 55a084e762 | |||
| 2b09cfc5d9 | |||
| 927ce977f2 | |||
| 85bc03b9d2 | |||
| c4bc10ef76 | |||
| e95f7c6dd2 | |||
| 17a91e48e6 | |||
| 4d0a94d288 | |||
| 3568c13941 | |||
| d538d7b9ec | |||
| 8c08b552cf | |||
| 1dd74a3861 | |||
| 8d77ca45f7 | |||
| c646815a3a |
@@ -1,5 +1,13 @@
|
||||
node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
.gitea
|
||||
.github
|
||||
.turbo
|
||||
reference/
|
||||
.next
|
||||
!.next/cache
|
||||
.git
|
||||
.DS_Store
|
||||
@@ -8,3 +16,5 @@ node_modules
|
||||
docs
|
||||
reference
|
||||
public/datasheets/*.pdf
|
||||
.pnpm-store
|
||||
.gitea
|
||||
|
||||
40
.env
40
.env
@@ -7,6 +7,7 @@ SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
LOG_LEVEL=info
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
@@ -16,23 +17,22 @@ MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=http://klz-cms:8055
|
||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=klz_db_user
|
||||
DIRECTUS_DB_PASSWORD=klz_db_pass
|
||||
# Local Development
|
||||
PROJECT_NAME=klz-cables
|
||||
GATEKEEPER_BYPASS_ENABLED=true
|
||||
TRAEFIK_HOST=klz.localhost
|
||||
DIRECTUS_HOST=cms.klz.localhost
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
COOKIE_DOMAIN=localhost
|
||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Hetzner S3 Object Storage
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||
S3_BUCKET=mintel
|
||||
S3_REGION=fsn1
|
||||
S3_PREFIX=klz-cables
|
||||
12
.env.example
12
.env.example
@@ -10,7 +10,6 @@
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
DIRECTUS_PORT=8055
|
||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||
TARGET=development
|
||||
@@ -47,9 +46,18 @@ MAIL_RECIPIENTS=info@klz-cables.com
|
||||
LOG_LEVEL=info
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
SENTRY_DSN=
|
||||
# For Directus Error Tracking
|
||||
# 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)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,6 +3,10 @@ name: CI - Lint, Typecheck & Test
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: deploy-pipeline
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality-assurance:
|
||||
runs-on: docker
|
||||
@@ -10,33 +14,38 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 🔐 Configure Private Registry
|
||||
run: |
|
||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
run: pnpm check:mdx && 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 check:a11y
|
||||
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
|
||||
default: 'false'
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
@@ -31,7 +35,6 @@ jobs:
|
||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||
project_name: ${{ steps.determine.outputs.project_name }}
|
||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||
container:
|
||||
@@ -42,7 +45,7 @@ jobs:
|
||||
run: |
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
docker builder prune -f --filter "until=24h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -83,14 +86,16 @@ jobs:
|
||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||
fi
|
||||
|
||||
# Standardize Traefik Rule
|
||||
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||
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')
|
||||
else
|
||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
|
||||
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
@@ -98,10 +103,12 @@ jobs:
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$PRIMARY_HOST"
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "gatekeeper_host=$GATEKEEPER_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"
|
||||
fi
|
||||
@@ -117,13 +124,13 @@ jobs:
|
||||
|
||||
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}')
|
||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" 2>/dev/null | grep "$TAG_TO_WAIT" | awk '{print $1}' | tail -n1 || echo "")
|
||||
|
||||
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
exit 1
|
||||
echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
else
|
||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||
fi
|
||||
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 }}"
|
||||
@@ -154,26 +161,31 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- 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://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --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
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
env:
|
||||
TURBO_TELEMETRY_DISABLED: "1"
|
||||
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
@@ -197,19 +209,17 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
provenance: false
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -225,27 +235,22 @@ jobs:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||
|
||||
# Secrets mapping (Directus)
|
||||
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 }}
|
||||
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 }}
|
||||
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' }}
|
||||
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 }}
|
||||
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 (Payload CMS)
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
||||
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
||||
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
|
||||
|
||||
# Secrets mapping (Mail)
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
@@ -254,7 +259,7 @@ jobs:
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -283,7 +288,7 @@ jobs:
|
||||
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||
|
||||
# Gatekeeper Origin
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||
|
||||
{
|
||||
echo "# Generated by CI - $TARGET"
|
||||
@@ -299,21 +304,11 @@ jobs:
|
||||
echo "MAIL_FROM=$MAIL_FROM"
|
||||
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||
echo ""
|
||||
echo "# Directus"
|
||||
echo "DIRECTUS_URL=$DIRECTUS_URL"
|
||||
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
|
||||
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
|
||||
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
|
||||
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
|
||||
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
|
||||
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
|
||||
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
|
||||
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
|
||||
echo "DIRECTUS_DB_CLIENT=pg"
|
||||
echo "DIRECTUS_DB_HOST=directus-db"
|
||||
echo "DIRECTUS_DB_PORT=5432"
|
||||
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
|
||||
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
|
||||
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"
|
||||
@@ -321,7 +316,7 @@ jobs:
|
||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||
echo ""
|
||||
echo "# Analytics"
|
||||
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
@@ -329,6 +324,7 @@ jobs:
|
||||
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"
|
||||
@@ -353,19 +349,69 @@ jobs:
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Transfer and Restart
|
||||
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"
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
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 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 && 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"
|
||||
|
||||
# Apply Directus Schema Snapshot if available
|
||||
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"
|
||||
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
||||
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||
echo "⏳ Waiting for database container to be ready..."
|
||||
for i in $(seq 1 15); do
|
||||
if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then
|
||||
echo "✅ Database is ready."
|
||||
break
|
||||
fi
|
||||
echo " Attempt $i/15..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "🔧 Sanitizing payload_migrations table (if exists)..."
|
||||
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
|
||||
# Auto-detect migrations from src/migrations/*.ts
|
||||
BATCH=1
|
||||
VALUES=""
|
||||
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
||||
NAME=$(basename "$f" .ts)
|
||||
[ -n "$VALUES" ] && VALUES="$VALUES,"
|
||||
VALUES="$VALUES ('$NAME', $BATCH)"
|
||||
((BATCH++))
|
||||
done
|
||||
|
||||
if [ -n "$VALUES" ]; then
|
||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||
DO \\\$\\\$ BEGIN
|
||||
DELETE FROM payload_migrations WHERE batch = -1;
|
||||
INSERT INTO payload_migrations (name, batch)
|
||||
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||
EXCEPTION WHEN undefined_table THEN
|
||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||
END \\\$\\\$;
|
||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||
fi
|
||||
|
||||
# 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'"
|
||||
|
||||
@@ -374,123 +420,176 @@ jobs:
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Smoke Test (OG Images)
|
||||
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
smoke_test:
|
||||
name: 🧪 Smoke Test
|
||||
post_deploy_checks:
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success'
|
||||
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 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- 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://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🚀 Run OG Image Check
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
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
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Lighthouse (Performance & Accessibility)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: ⚡ Lighthouse
|
||||
needs: [prepare, deploy]
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: 💾 Cache Chromium
|
||||
id: cache-chromium
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
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
|
||||
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
else
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
# Fetch PPA key
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
|
||||
# Add PPA repository
|
||||
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
|
||||
|
||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
|
||||
# Standardize binary paths
|
||||
[ -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
|
||||
- name: ⚡ Run Lighthouse CI
|
||||
|
||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||
- name: 🏥 CMS Deep Health Check
|
||||
continue-on-error: true
|
||||
env:
|
||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: |
|
||||
echo "Waiting 60s for app to fully start (cold start / migrations / hydration)..."
|
||||
sleep 60
|
||||
|
||||
echo "Checking basic health..."
|
||||
curl -sfL --retry 5 --retry-delay 10 "$DEPLOY_URL/health" || { echo "❌ Basic health check failed after retries"; exit 1; }
|
||||
echo "✅ Basic health OK"
|
||||
|
||||
echo "Checking CMS DB connectivity..."
|
||||
# We use -v to see if we hit Gatekeeper (307) or 503/504
|
||||
URL="$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS&cb=$(date +%s)"
|
||||
RESPONSE=$(curl -sfL --retry 5 --retry-delay 10 "$URL" 2>&1) || {
|
||||
echo "⚠️ CMS health check failed, but site might still be fine."
|
||||
echo "--- DEBUG INFO ---"
|
||||
curl -svI "$URL"
|
||||
echo "--- RESPONSE BODY ---"
|
||||
curl -sL "$URL" | head -c 1000
|
||||
echo ""
|
||||
echo "Proceeding to smoke tests to confirm actual site functionality."
|
||||
}
|
||||
echo "✅ CMS health output: $RESPONSE"
|
||||
|
||||
|
||||
- name: 🚀 OG Image Check
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||
with:
|
||||
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
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' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run pagespeed:test
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
run: pnpm run check:forms
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||
needs: [prepare, deploy, post_deploy_checks]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
TITLE="klz-cables.com: $STATUS"
|
||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
|
||||
DEPLOY="${{ needs.deploy.result }}"
|
||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||
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 }}" \
|
||||
-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
|
||||
|
||||
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
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,14 +1,30 @@
|
||||
node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
.pnpm-store
|
||||
public/uploads
|
||||
public/media
|
||||
|
||||
# Lighthouse CI
|
||||
.lighthouseci/
|
||||
lighthouserc.cjs
|
||||
.lighthouserc.json
|
||||
|
||||
# Directus
|
||||
# Legacy (Directus) cleanup
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
!directus/schema/
|
||||
!directus/migrations/
|
||||
|
||||
.next-docker
|
||||
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
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}
|
||||
648
AVB_2026.html
Normal file
648
AVB_2026.html
Normal file
@@ -0,0 +1,648 @@
|
||||
<h1 id="allgemeine-verkaufsbedingungen">Allgemeine
|
||||
Verkaufsbedingungen</h1>
|
||||
<h3 id="geltungsbereich">1 Geltungsbereich</h3>
|
||||
<p>1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen
|
||||
(nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr
|
||||
(nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH,
|
||||
Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und
|
||||
Unternehmern in Ausübung ihrer gewerblichen oder selbständigen
|
||||
beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts
|
||||
(nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder
|
||||
entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird
|
||||
hiermit widersprochen.</p>
|
||||
<p>1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an
|
||||
den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen
|
||||
AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden
|
||||
ausführt.</p>
|
||||
<p>1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch
|
||||
dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich
|
||||
erneut in den Vertrag einbezogen wurden.</p>
|
||||
<p>1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax
|
||||
oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen
|
||||
nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten
|
||||
die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht
|
||||
und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der
|
||||
Mitteilung über die Änderung der Geschäftsbedingungen gesondert
|
||||
hingewiesen.</p>
|
||||
<p>1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen
|
||||
dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige
|
||||
Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in
|
||||
diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen
|
||||
werden.</p>
|
||||
<h3 id="vertragsschluss">2 Vertragsschluss</h3>
|
||||
<p>2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich
|
||||
durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail
|
||||
oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform
|
||||
z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der
|
||||
Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag
|
||||
oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des
|
||||
Angebots bei KLZ vorbehaltlos ausführt.</p>
|
||||
<p>2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich
|
||||
Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen –
|
||||
freibleibend und unverbindlich, sofern sie nicht ausdrücklich in
|
||||
Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen
|
||||
unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen
|
||||
Selbstbelieferung von KLZ.</p>
|
||||
<p>2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem
|
||||
jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.</p>
|
||||
<p>2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen,
|
||||
Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige
|
||||
Beschreibungen der zu liefernden Ware in dem Angebot oder der
|
||||
Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht
|
||||
ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden.
|
||||
Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer
|
||||
entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware
|
||||
dar, es sei denn, sie wurden ausdrücklich in Textform als solche
|
||||
vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich
|
||||
der zu liefernden Ware oder deren Verwendung.</p>
|
||||
<p>2.5 Handelsübliche und branchenübliche Abweichungen vom
|
||||
Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit
|
||||
sie die Verwendbarkeit der zu liefernden Ware zum vertraglich
|
||||
vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder
|
||||
Unterlieferungen. Abweichungen der zu liefernden Ware, die durch
|
||||
zwingende rechtliche oder technische Normen, die nach
|
||||
Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit
|
||||
sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu
|
||||
einer technischen Verbesserung der zu liefernden Ware führen, sind stets
|
||||
zulässig.</p>
|
||||
<p>2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer
|
||||
Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ.
|
||||
Mehraufwände und sonstige Kosten, die durch die Änderung des
|
||||
Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden
|
||||
gesondert zu vergüten.</p>
|
||||
<p>2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ
|
||||
offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das
|
||||
Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht
|
||||
verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.</p>
|
||||
<p>2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden
|
||||
wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder
|
||||
vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse
|
||||
abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag
|
||||
zurückzutreten.</p>
|
||||
<h3 id="abrufaufträge">3 Abrufaufträge</h3>
|
||||
<p>3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die
|
||||
Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist
|
||||
der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen,
|
||||
dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der
|
||||
Ware mindestens vierzehn (14) Werktage und zwischen Eingang der
|
||||
Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig
|
||||
(90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen
|
||||
es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz
|
||||
von KLZ handelt.</p>
|
||||
<h3 id="preise-und-zahlungsbedingungen">4 Preise und
|
||||
Zahlungsbedingungen</h3>
|
||||
<p>4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ
|
||||
angegebenen Preise gelten für den in dem Angebot oder der
|
||||
Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich
|
||||
grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem
|
||||
jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und
|
||||
zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht
|
||||
ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem
|
||||
Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise
|
||||
verstehen sich darüber hinaus zuzüglich Metallzuschlag und
|
||||
auftragsspezifischer Schnittkosten.</p>
|
||||
<p>4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur
|
||||
Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen
|
||||
erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet
|
||||
und vergütet.</p>
|
||||
<p>4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der
|
||||
vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie,
|
||||
Transport, Arbeitskraft oder Material zwischen Vertragsschluss und
|
||||
Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien
|
||||
partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw.
|
||||
des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen
|
||||
Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab
|
||||
Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung
|
||||
gefunden wird.</p>
|
||||
<p>4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung
|
||||
der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung
|
||||
fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart
|
||||
sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10)
|
||||
Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem
|
||||
welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu
|
||||
bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des
|
||||
ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf
|
||||
der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des
|
||||
ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer
|
||||
gesonderten Mahnung von KLZ bedarf.</p>
|
||||
<p>4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ
|
||||
berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der
|
||||
Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt
|
||||
auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden.
|
||||
Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund
|
||||
dessen Zahlungsverzug uneingeschränkt zu.</p>
|
||||
<p>4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen
|
||||
in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten
|
||||
und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem
|
||||
Kunden hieraus Ansprüche entstehen.</p>
|
||||
<p>4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit
|
||||
Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als
|
||||
seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind,
|
||||
oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis
|
||||
zueinanderstehen.</p>
|
||||
<h3 id="metallnotierung">5 Metallnotierung</h3>
|
||||
<p>5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des
|
||||
Liefervormonats der Notierung „LME Copper official price cash offer“,
|
||||
zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.</p>
|
||||
<p>5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt
|
||||
des Liefervormonats der Notierung „LME Aluminium official price cash
|
||||
offer” zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.</p>
|
||||
<p>5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR
|
||||
umgerechnet. Die entsprechenden Notierungen können der Webseite
|
||||
www.westmetall.com entnommen werden. Die Prämienzuschläge können stark
|
||||
variieren und KLZ behält sich das Recht vor, diese fristgerecht
|
||||
anzupassen, ungeachtet der Angebotslegung.</p>
|
||||
<h3 id="metallzahl">6 Metallzahl</h3>
|
||||
<p>6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung
|
||||
ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für
|
||||
den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels
|
||||
eingeht.</p>
|
||||
<p>6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden
|
||||
eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen.
|
||||
Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel
|
||||
enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer
|
||||
Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel
|
||||
verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden
|
||||
hiermit ausdrücklich darauf hin, final nur den Vollpreis für
|
||||
Vergleichszwecke heranzuziehen.</p>
|
||||
<p>6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu
|
||||
bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher
|
||||
Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des
|
||||
Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.</p>
|
||||
<h3 id="lieferung">7 Lieferung</h3>
|
||||
<p>7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern
|
||||
die Parteien keine anderweitige Vereinbarung in Textform getroffen
|
||||
haben.</p>
|
||||
<p>7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen
|
||||
vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen,
|
||||
wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens
|
||||
von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten
|
||||
würde.</p>
|
||||
<p>7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind
|
||||
grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss
|
||||
ausdrücklich in Textform als solche gekennzeichnet werden.</p>
|
||||
<p>7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung
|
||||
bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige
|
||||
Klärung aller kaufmännischen und technischen Fragen, sowie die
|
||||
Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen
|
||||
Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht
|
||||
rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies
|
||||
gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten
|
||||
hat.</p>
|
||||
<p>7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter
|
||||
dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer
|
||||
und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten.
|
||||
KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum
|
||||
Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden
|
||||
unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom
|
||||
Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des
|
||||
Kunden zurück.</p>
|
||||
<p>7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware
|
||||
dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort
|
||||
innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten
|
||||
hat.</p>
|
||||
<p>7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt
|
||||
KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ
|
||||
nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der
|
||||
Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen
|
||||
ist.</p>
|
||||
<p>7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf
|
||||
Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware,
|
||||
oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu
|
||||
vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der
|
||||
vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2)
|
||||
% des jeweiligen Nettoauftragswertes für jeden angefangenen Monat,
|
||||
maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung
|
||||
zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des
|
||||
Nettoauftragswertes pro Vertrag beschränkt.</p>
|
||||
<p>7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden
|
||||
Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der
|
||||
Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende
|
||||
Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen
|
||||
verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der
|
||||
Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer
|
||||
Schaden entstanden ist.</p>
|
||||
<p>7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert.
|
||||
Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen
|
||||
eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu
|
||||
beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige
|
||||
Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität
|
||||
schließt KLZ aus.</p>
|
||||
<p>7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in
|
||||
Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.</p>
|
||||
<h3 id="gefahrübergang">8 Gefahrübergang</h3>
|
||||
<p>8.1 Die Gefahr des zufälligen Untergangs und der zufälligen
|
||||
Verschlechterung der zu liefernden Ware geht grundsätzlich DAP
|
||||
(INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine
|
||||
anderweitige Vereinbarung in Textform getroffen haben.</p>
|
||||
<p>8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen,
|
||||
die der Kunde zu vertreten hat, geht die Gefahr des zufälligen
|
||||
Untergangs und der zufälligen Verschlechterung der Ware mit der
|
||||
Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen
|
||||
Öffnungszeiten des Kunden durch KLZ auf den Kunden über.</p>
|
||||
<h3 id="eigentumsvorbehalt">9 Eigentumsvorbehalt</h3>
|
||||
<p>9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen
|
||||
Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung
|
||||
zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend
|
||||
„Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die
|
||||
Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere
|
||||
auch Saldoforderungen aus laufender Rechnung.</p>
|
||||
<p>9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des
|
||||
ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder
|
||||
weiterzuverarbeiten.</p>
|
||||
<p>9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den
|
||||
Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der
|
||||
neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer
|
||||
einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt
|
||||
oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum
|
||||
an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den
|
||||
anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung,
|
||||
Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung,
|
||||
Vermengung oder sonstige Verbindung dergestalt, dass die Sache des
|
||||
Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt
|
||||
dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem
|
||||
Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu
|
||||
übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für
|
||||
Vorbehaltsware.</p>
|
||||
<p>9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für
|
||||
KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen
|
||||
Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt
|
||||
seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab;
|
||||
KLZ nimmt die Abtretung an.</p>
|
||||
<p>9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen
|
||||
Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern.
|
||||
Verpfändungen und Sicherungsübereignungen hinsichtlich der
|
||||
Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung
|
||||
oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt
|
||||
werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde
|
||||
hat den Dritten darüber hinaus unverzüglich in Textform darauf
|
||||
hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der
|
||||
Dritte nicht in der Lage ist, KLZ die gerichtlichen und
|
||||
außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten,
|
||||
haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende
|
||||
Ansprüche gegen den Kunden bleiben unberührt.</p>
|
||||
<p>9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der
|
||||
Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen
|
||||
Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches
|
||||
gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware
|
||||
treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B.
|
||||
Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei
|
||||
Verlust oder Zerstörung.</p>
|
||||
<p>9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit
|
||||
anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit
|
||||
einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der
|
||||
Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine
|
||||
Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten
|
||||
Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.</p>
|
||||
<p>9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ
|
||||
einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen
|
||||
Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht
|
||||
selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ
|
||||
zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der
|
||||
Forderung an Dritte, einschließlich Forderungsverkauf an Factoring
|
||||
Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von
|
||||
KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine
|
||||
gleichwertige Sicherheit gestellt wird.</p>
|
||||
<p>9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung
|
||||
widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug
|
||||
befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren
|
||||
über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen
|
||||
wurde.</p>
|
||||
<p>9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10)
|
||||
Werktagen auf ein von KLZ benanntes Konto zu überweisen.</p>
|
||||
<p>9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von
|
||||
dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern
|
||||
anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner
|
||||
Abnehmer bekanntzugeben und alle erforderlichen Unterlagen
|
||||
herauszugeben, die für die Einziehung der Forderung durch KLZ selbst
|
||||
erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet,
|
||||
die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich
|
||||
mitzuteilen.</p>
|
||||
<p>9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden
|
||||
Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der
|
||||
gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl
|
||||
der danach freizugebenden Gegenstände liegt bei KLZ.</p>
|
||||
<p>9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden,
|
||||
insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist
|
||||
KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in
|
||||
diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die
|
||||
erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an
|
||||
KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend
|
||||
zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden
|
||||
oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie
|
||||
bereits verarbeitet ist, und eine Aufstellung aller abgetretenen
|
||||
Forderungen nebst Rechnungsgutschriften.</p>
|
||||
<h3 id="höhere-gewalt">10 Höhere Gewalt</h3>
|
||||
<p>10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder
|
||||
Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer
|
||||
vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem
|
||||
Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis
|
||||
außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt
|
||||
des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c)
|
||||
die Auswirkungen des Hindernisses von der betroffenen Partei nicht in
|
||||
zumutbarer Weise hätte vermieden oder überwunden werden können.</p>
|
||||
<p>10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht
|
||||
abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im
|
||||
Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs-
|
||||
und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen,
|
||||
Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise-
|
||||
und Exportverbote oder staatliche Einreise- und Importverbote,
|
||||
Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis,
|
||||
Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder
|
||||
Versammlungen, Ereignisse, die das Passieren von wichtigen
|
||||
Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere
|
||||
Boykott, Streik und Aussperrungen, Energieknappheit oder
|
||||
Beeinträchtigung von Transportmitteln.</p>
|
||||
<p>10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist
|
||||
ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung
|
||||
unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen
|
||||
Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen
|
||||
vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern
|
||||
der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei
|
||||
unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten
|
||||
Hindernisses oder Ereignisses vorübergehend, so gelten die eben
|
||||
dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die
|
||||
Vertragserfüllung durch die betroffene Partei verhindert sowie einer
|
||||
angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer
|
||||
Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die
|
||||
andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung
|
||||
bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige
|
||||
Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts
|
||||
sind ausgeschlossen.</p>
|
||||
<p>10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb
|
||||
einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ
|
||||
eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von
|
||||
diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der
|
||||
Ware besteht.</p>
|
||||
<p>10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die
|
||||
wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware
|
||||
erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt,
|
||||
werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von
|
||||
Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht
|
||||
vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten.
|
||||
Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach
|
||||
Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem
|
||||
Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden
|
||||
eine Verlängerung der Lieferzeit vereinbart war.</p>
|
||||
<h3 id="gewährleistung-und-mängelrüge">11 Gewährleistung und
|
||||
Mängelrüge</h3>
|
||||
<p>11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung
|
||||
der gelieferten Ware von den vereinbarten Spezifikationen, bei
|
||||
unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit,
|
||||
bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung
|
||||
ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer
|
||||
äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren
|
||||
Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß
|
||||
Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für
|
||||
diese und die daraus entstehenden Folgen ebenfalls keine
|
||||
Mängelansprüche.</p>
|
||||
<p>11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der
|
||||
Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht
|
||||
nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf
|
||||
äußerlich erkennbare Transportschäden und Identität der gelieferten Ware
|
||||
mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind
|
||||
unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab
|
||||
Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer
|
||||
Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in
|
||||
Textform geltend zu machen.</p>
|
||||
<p>11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen
|
||||
Mängel, der betroffenen Lieferungen und wenn möglich, auch eine
|
||||
bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt,
|
||||
mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung
|
||||
anzufordern.</p>
|
||||
<p>11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in
|
||||
Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger
|
||||
vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob
|
||||
Auffälligkeiten zu vermerken sind.</p>
|
||||
<p>11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist,
|
||||
anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung)
|
||||
oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der
|
||||
Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang
|
||||
unterstützen.</p>
|
||||
<p>11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei
|
||||
berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für
|
||||
Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch
|
||||
den Kunden an einen anderen als den Ort der gewerblichen Niederlassung
|
||||
des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht
|
||||
dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz
|
||||
1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass
|
||||
der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.</p>
|
||||
<p>11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder
|
||||
grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu
|
||||
verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.</p>
|
||||
<p>11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der
|
||||
Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten
|
||||
Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts
|
||||
oder der Tauglichkeit der gelieferten Waren ist der Rücktritt
|
||||
ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet
|
||||
oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ
|
||||
gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz
|
||||
wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom
|
||||
Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der
|
||||
Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung
|
||||
angezeigt.</p>
|
||||
<p>11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde
|
||||
unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz
|
||||
verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher
|
||||
Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der
|
||||
Leistung verlangen kann.</p>
|
||||
<p>11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel
|
||||
an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige
|
||||
Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für
|
||||
die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ
|
||||
ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts
|
||||
zu tragen, wenn das Gutachten einen Mangel feststellt.</p>
|
||||
<p>11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden
|
||||
verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438
|
||||
Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon
|
||||
unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz,
|
||||
grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit,
|
||||
oder jedweder wesentlicher Vertragspflichtverletzung, zwingender
|
||||
gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei
|
||||
Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen
|
||||
Verschweigen eines Mangels. Die gesetzlichen Regelungen über
|
||||
Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.</p>
|
||||
<h3 id="haftung">12 Haftung</h3>
|
||||
<p>12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit.
|
||||
KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des
|
||||
Lebens, des Körpers oder der Gesundheit, sowie für arglistiges
|
||||
Verschweigen eines Mangels und Verletzung von garantierten
|
||||
Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher
|
||||
Vorschriften, wie etwa denen des Produkthaftungsgesetzes.</p>
|
||||
<p>12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ
|
||||
im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ
|
||||
typischerweise vorhersehbaren Schaden bzw. die typischerweise
|
||||
vorhersehbaren Aufwendungen. Vertragswesentlich sind die
|
||||
Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung
|
||||
erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch
|
||||
vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht
|
||||
auf Schadensersatz.</p>
|
||||
<p>12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge
|
||||
von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden,
|
||||
die bei bestimmungsgemäßer Verwendung der gelieferten Ware
|
||||
typischerweise zu erwarten sind.</p>
|
||||
<p>12.4 Die Haftung entfällt für Schäden, die auf Reparaturen,
|
||||
Änderungen oder anderen Eingriffen oder auf die Verwendung von
|
||||
Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw.
|
||||
des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ
|
||||
vorgenommen hat.</p>
|
||||
<p>12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist,
|
||||
gilt dies auch für die Haftung der Angestellten, Arbeitnehmer,
|
||||
Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.</p>
|
||||
<h3 id="produkthaftung">13 Produkthaftung</h3>
|
||||
<p>13.1 Der Kunde wird die gelieferten Waren ausschließlich
|
||||
vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ
|
||||
verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren
|
||||
bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei
|
||||
Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von
|
||||
Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für
|
||||
den die Haftung auslösenden Fehler nicht verantwortlich.</p>
|
||||
<p>13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf
|
||||
oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften
|
||||
bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig
|
||||
hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der
|
||||
-warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den
|
||||
eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht
|
||||
verantwortlich.</p>
|
||||
<p>13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken
|
||||
bei der Verwendung der gelieferten Ware und mögliche Produktfehler in
|
||||
Textform informieren.</p>
|
||||
<h3 id="exportkontrolle">14 Exportkontrolle</h3>
|
||||
<p>14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass
|
||||
der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder
|
||||
internationalen exportkontrollrechtlichen Vorschriften entgegenstehen.
|
||||
Sofern sich die Durchführung des Vertrages aufgrund von
|
||||
Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden
|
||||
Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von
|
||||
der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu
|
||||
kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung
|
||||
nationaler oder internationalen Exportkontrollbestimmung erforderlich
|
||||
ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen
|
||||
Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung
|
||||
ist ausgeschlossen.</p>
|
||||
<p>14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und
|
||||
internationalen Exportkontrollrechts, insbesondere bei Weitergabe der
|
||||
gelieferten Ware an Dritte.</p>
|
||||
<h3 id="urheberrechte-und-datenschutz">15 Urheberrechte und
|
||||
Datenschutz</h3>
|
||||
<p>15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen,
|
||||
Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen
|
||||
(im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums-
|
||||
und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die
|
||||
Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ
|
||||
Dritten zugänglich gemacht werden und sind KLZ auf Verlangen
|
||||
unverzüglich zurückzugeben.</p>
|
||||
<p>15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde
|
||||
nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.</p>
|
||||
<p>15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich
|
||||
gemäß den Bestimmungen der Datenschutzgrundverordnung und des
|
||||
Bundesdatenschutzgesetzes.</p>
|
||||
<p>15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche
|
||||
Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses
|
||||
erforderlich sind (Bestandsdaten), werden ausschließlich zur
|
||||
Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages
|
||||
verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten
|
||||
im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure)
|
||||
weitergegeben werden. Der Kunde erteilt für die vorbeschriebene
|
||||
Verwendung seiner personenbezogenen Daten hiermit eine entsprechende
|
||||
Einwilligung.</p>
|
||||
<p>15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von
|
||||
Gründen- widerruflich. Der Widerruf ist zu richten an
|
||||
info@klz-cables.com .</p>
|
||||
<p>15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden
|
||||
für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten
|
||||
Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen
|
||||
Einwilligung des Kunden.</p>
|
||||
<p>15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall
|
||||
Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung
|
||||
an die zuständige Stelle erteilen, soweit dies für Zwecke der
|
||||
Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen
|
||||
Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte
|
||||
am geistigen Eigentum erforderlich ist.</p>
|
||||
<h3 id="geheimhaltung">16 Geheimhaltung</h3>
|
||||
<p>16.1 „Vertrauliche Informationen“ sind (i) alle Informationen, Daten,
|
||||
Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige
|
||||
Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von
|
||||
Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen,
|
||||
Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten,
|
||||
gleich in welcher Verkörperung, (ii) sowie alle nicht offenkundigen
|
||||
kaufmännischen, betrieblichen oder technischen Informationen oder
|
||||
Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen
|
||||
der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt
|
||||
werden, (iii) die entweder direkt als vertraulich gekennzeichnet wurden
|
||||
oder nachträglich schriftlich oder in Textform als vertraulich
|
||||
gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als
|
||||
vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder
|
||||
aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die
|
||||
Parteien nichts anderes in Schriftform vereinbart haben.</p>
|
||||
<p>16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch
|
||||
nicht solche Informationen, (i) die der Kunde von öffentlich
|
||||
zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf
|
||||
legalem Wege zugänglich sind und diese Quellen die Informationen
|
||||
ebenfalls auf legalem Wege erlangt haben, (ii) die der Kunde von einem
|
||||
berechtigten Dritten rechtmäßig erhalten hat, (iii) die der Kunde
|
||||
eigenständig entwickelt hat, (iv) die kraft Gesetzes oder aufgrund
|
||||
gerichtlicher oder behördlicher Anordnung offengelegt wurden oder
|
||||
offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor
|
||||
einer solchen Offenlegung der Informationen unverzüglich zu informieren
|
||||
und zu versuchen, die Offenlegung zu verhindern, soweit dies mit
|
||||
angemessenen und gesetzlich zulässigen Mitteln möglich ist.</p>
|
||||
<p>16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer
|
||||
16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf
|
||||
das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.</p>
|
||||
<p>16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen
|
||||
strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des
|
||||
Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen
|
||||
durch den Kunden nur an solche Mitarbeiter oder andere Personen
|
||||
weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend
|
||||
erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von
|
||||
Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls
|
||||
zur Geheimhaltung hinsichtlich der Vertraulichen Informationen
|
||||
verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für
|
||||
die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem
|
||||
Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies
|
||||
rechtlich möglich ist.</p>
|
||||
<p>16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ
|
||||
und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des
|
||||
zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter
|
||||
diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen
|
||||
und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu
|
||||
zerstören bzw. sicher und dauerhaft zu löschen und die vollständige
|
||||
Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere
|
||||
und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.</p>
|
||||
<p>16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die
|
||||
zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in
|
||||
automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen
|
||||
Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen
|
||||
Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung
|
||||
bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder
|
||||
vollständigen sachgemäßen Zerstörung fort.</p>
|
||||
<p>16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten
|
||||
für einen Zeitraum von fünf (5) Jahren nach Beendigung des
|
||||
zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.</p>
|
||||
<p>16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die
|
||||
Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur
|
||||
Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das
|
||||
billige Ermessen von KLZ gestellt wird und im Streitfall durch das
|
||||
zuständige Gericht überprüft werden kann. Durch die Geltendmachung der
|
||||
Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ
|
||||
nicht ausgeschlossen.</p>
|
||||
<h3 id="schlussbestimmungen">17 Schlussbestimmungen</h3>
|
||||
<p>17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform.
|
||||
Dies gilt auch für die Abänderung des Textformerfordernisses selbst.</p>
|
||||
<p>17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser
|
||||
AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden
|
||||
gilt ausschließlich das Recht der Bundesrepublik Deutschland unter
|
||||
Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort
|
||||
sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im
|
||||
Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist
|
||||
Stuttgart.</p>
|
||||
<p>17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder
|
||||
undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller
|
||||
sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die
|
||||
unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien
|
||||
einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem
|
||||
wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren
|
||||
Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer
|
||||
Regelungslücke.</p>
|
||||
<p>Stand: April 2026</p>
|
||||
238
AVB_2026.md
Normal file
238
AVB_2026.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Allgemeine Verkaufsbedingungen
|
||||
|
||||
### 1 Geltungsbereich
|
||||
|
||||
1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen (nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr (nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH, Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und Unternehmern in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts (nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird hiermit widersprochen.
|
||||
|
||||
1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden ausführt.
|
||||
|
||||
1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich erneut in den Vertrag einbezogen wurden.
|
||||
|
||||
1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der Mitteilung über die Änderung der Geschäftsbedingungen gesondert hingewiesen.
|
||||
|
||||
1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden.
|
||||
|
||||
### 2 Vertragsschluss
|
||||
|
||||
2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des Angebots bei KLZ vorbehaltlos ausführt.
|
||||
|
||||
2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen – freibleibend und unverbindlich, sofern sie nicht ausdrücklich in Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen Selbstbelieferung von KLZ.
|
||||
|
||||
2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.
|
||||
|
||||
2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen, Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige Beschreibungen der zu liefernden Ware in dem Angebot oder der Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden. Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware dar, es sei denn, sie wurden ausdrücklich in Textform als solche vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich der zu liefernden Ware oder deren Verwendung.
|
||||
|
||||
2.5 Handelsübliche und branchenübliche Abweichungen vom Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit sie die Verwendbarkeit der zu liefernden Ware zum vertraglich vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder Unterlieferungen. Abweichungen der zu liefernden Ware, die durch zwingende rechtliche oder technische Normen, die nach Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu einer technischen Verbesserung der zu liefernden Ware führen, sind stets zulässig.
|
||||
|
||||
2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ. Mehraufwände und sonstige Kosten, die durch die Änderung des Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden gesondert zu vergüten.
|
||||
|
||||
2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.
|
||||
|
||||
2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag zurückzutreten.
|
||||
|
||||
### 3 Abrufaufträge
|
||||
|
||||
3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der Ware mindestens vierzehn (14) Werktage und zwischen Eingang der Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig (90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz von KLZ handelt.
|
||||
|
||||
### 4 Preise und Zahlungsbedingungen
|
||||
|
||||
4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ angegebenen Preise gelten für den in dem Angebot oder der Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise verstehen sich darüber hinaus zuzüglich Metallzuschlag und auftragsspezifischer Schnittkosten.
|
||||
|
||||
4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet und vergütet.
|
||||
|
||||
4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie, Transport, Arbeitskraft oder Material zwischen Vertragsschluss und Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw. des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung gefunden wird.
|
||||
|
||||
4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10) Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer gesonderten Mahnung von KLZ bedarf.
|
||||
|
||||
4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden. Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund dessen Zahlungsverzug uneingeschränkt zu.
|
||||
|
||||
4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem Kunden hieraus Ansprüche entstehen.
|
||||
|
||||
4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind, oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis zueinanderstehen.
|
||||
|
||||
### 5 Metallnotierung
|
||||
|
||||
5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Copper official price cash offer“, zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.
|
||||
|
||||
5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Aluminium official price cash offer" zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.
|
||||
|
||||
5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können der Webseite www.westmetall.com entnommen werden. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
|
||||
### 6 Metallzahl
|
||||
|
||||
6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels eingeht.
|
||||
|
||||
6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen. Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden hiermit ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen.
|
||||
|
||||
6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.
|
||||
|
||||
### 7 Lieferung
|
||||
|
||||
7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
|
||||
|
||||
7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen, wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten würde.
|
||||
|
||||
7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss ausdrücklich in Textform als solche gekennzeichnet werden.
|
||||
|
||||
7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige Klärung aller kaufmännischen und technischen Fragen, sowie die Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten hat.
|
||||
|
||||
7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten. KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des Kunden zurück.
|
||||
|
||||
7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten hat.
|
||||
|
||||
7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen ist.
|
||||
|
||||
7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware, oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2) % des jeweiligen Nettoauftragswertes für jeden angefangenen Monat, maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des Nettoauftragswertes pro Vertrag beschränkt.
|
||||
|
||||
7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer Schaden entstanden ist.
|
||||
|
||||
7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließt KLZ aus.
|
||||
|
||||
7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.
|
||||
|
||||
### 8 Gefahrübergang
|
||||
|
||||
8.1 Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der zu liefernden Ware geht grundsätzlich DAP (INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
|
||||
|
||||
8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen, die der Kunde zu vertreten hat, geht die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware mit der Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen Öffnungszeiten des Kunden durch KLZ auf den Kunden über.
|
||||
|
||||
### 9 Eigentumsvorbehalt
|
||||
|
||||
9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend „Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere auch Saldoforderungen aus laufender Rechnung.
|
||||
|
||||
9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder weiterzuverarbeiten.
|
||||
|
||||
9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung, Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung, Vermengung oder sonstige Verbindung dergestalt, dass die Sache des Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für Vorbehaltsware.
|
||||
|
||||
9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab; KLZ nimmt die Abtretung an.
|
||||
|
||||
9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern. Verpfändungen und Sicherungsübereignungen hinsichtlich der Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde hat den Dritten darüber hinaus unverzüglich in Textform darauf hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der Dritte nicht in der Lage ist, KLZ die gerichtlichen und außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten, haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende Ansprüche gegen den Kunden bleiben unberührt.
|
||||
|
||||
9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B. Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei Verlust oder Zerstörung.
|
||||
|
||||
9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.
|
||||
|
||||
9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der Forderung an Dritte, einschließlich Forderungsverkauf an Factoring Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine gleichwertige Sicherheit gestellt wird.
|
||||
|
||||
9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen wurde.
|
||||
|
||||
9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10) Werktagen auf ein von KLZ benanntes Konto zu überweisen.
|
||||
|
||||
9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner Abnehmer bekanntzugeben und alle erforderlichen Unterlagen herauszugeben, die für die Einziehung der Forderung durch KLZ selbst erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet, die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich mitzuteilen.
|
||||
|
||||
9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl der danach freizugebenden Gegenstände liegt bei KLZ.
|
||||
|
||||
9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden, insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie bereits verarbeitet ist, und eine Aufstellung aller abgetretenen Forderungen nebst Rechnungsgutschriften.
|
||||
|
||||
### 10 Höhere Gewalt
|
||||
|
||||
10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c) die Auswirkungen des Hindernisses von der betroffenen Partei nicht in zumutbarer Weise hätte vermieden oder überwunden werden können.
|
||||
|
||||
10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs- und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen, Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise- und Exportverbote oder staatliche Einreise- und Importverbote, Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis, Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder Versammlungen, Ereignisse, die das Passieren von wichtigen Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere Boykott, Streik und Aussperrungen, Energieknappheit oder Beeinträchtigung von Transportmitteln.
|
||||
|
||||
10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten Hindernisses oder Ereignisses vorübergehend, so gelten die eben dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die Vertragserfüllung durch die betroffene Partei verhindert sowie einer angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts sind ausgeschlossen.
|
||||
|
||||
10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der Ware besteht.
|
||||
|
||||
10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt, werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten. Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden eine Verlängerung der Lieferzeit vereinbart war.
|
||||
|
||||
### 11 Gewährleistung und Mängelrüge
|
||||
|
||||
11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung der gelieferten Ware von den vereinbarten Spezifikationen, bei unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit, bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für diese und die daraus entstehenden Folgen ebenfalls keine Mängelansprüche.
|
||||
|
||||
11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf äußerlich erkennbare Transportschäden und Identität der gelieferten Ware mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in Textform geltend zu machen.
|
||||
|
||||
11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen Mängel, der betroffenen Lieferungen und wenn möglich, auch eine bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt, mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung anzufordern.
|
||||
|
||||
11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob Auffälligkeiten zu vermerken sind.
|
||||
|
||||
11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist, anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung) oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang unterstützen.
|
||||
|
||||
11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch den Kunden an einen anderen als den Ort der gewerblichen Niederlassung des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz 1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.
|
||||
|
||||
11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.
|
||||
|
||||
11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts oder der Tauglichkeit der gelieferten Waren ist der Rücktritt ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung angezeigt.
|
||||
|
||||
11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der Leistung verlangen kann.
|
||||
|
||||
11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts zu tragen, wenn das Gutachten einen Mangel feststellt.
|
||||
|
||||
11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438 Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz, grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit, oder jedweder wesentlicher Vertragspflichtverletzung, zwingender gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen Verschweigen eines Mangels. Die gesetzlichen Regelungen über Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.
|
||||
|
||||
### 12 Haftung
|
||||
|
||||
12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit. KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, sowie für arglistiges Verschweigen eines Mangels und Verletzung von garantierten Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher Vorschriften, wie etwa denen des Produkthaftungsgesetzes.
|
||||
|
||||
12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ typischerweise vorhersehbaren Schaden bzw. die typischerweise vorhersehbaren Aufwendungen. Vertragswesentlich sind die Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht auf Schadensersatz.
|
||||
|
||||
12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden, die bei bestimmungsgemäßer Verwendung der gelieferten Ware typischerweise zu erwarten sind.
|
||||
|
||||
12.4 Die Haftung entfällt für Schäden, die auf Reparaturen, Änderungen oder anderen Eingriffen oder auf die Verwendung von Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw. des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ vorgenommen hat.
|
||||
|
||||
12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist, gilt dies auch für die Haftung der Angestellten, Arbeitnehmer, Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.
|
||||
|
||||
### 13 Produkthaftung
|
||||
|
||||
13.1 Der Kunde wird die gelieferten Waren ausschließlich vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für den die Haftung auslösenden Fehler nicht verantwortlich.
|
||||
|
||||
13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der -warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht verantwortlich.
|
||||
|
||||
13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken bei der Verwendung der gelieferten Ware und mögliche Produktfehler in Textform informieren.
|
||||
|
||||
### 14 Exportkontrolle
|
||||
|
||||
14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder internationalen exportkontrollrechtlichen Vorschriften entgegenstehen. Sofern sich die Durchführung des Vertrages aufgrund von Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung nationaler oder internationalen Exportkontrollbestimmung erforderlich ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung ist ausgeschlossen.
|
||||
|
||||
14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und internationalen Exportkontrollrechts, insbesondere bei Weitergabe der gelieferten Ware an Dritte.
|
||||
|
||||
### 15 Urheberrechte und Datenschutz
|
||||
|
||||
15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen, Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen (im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums- und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ Dritten zugänglich gemacht werden und sind KLZ auf Verlangen unverzüglich zurückzugeben.
|
||||
|
||||
15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.
|
||||
|
||||
15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich gemäß den Bestimmungen der Datenschutzgrundverordnung und des Bundesdatenschutzgesetzes.
|
||||
|
||||
15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses erforderlich sind (Bestandsdaten), werden ausschließlich zur Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure) weitergegeben werden. Der Kunde erteilt für die vorbeschriebene Verwendung seiner personenbezogenen Daten hiermit eine entsprechende Einwilligung.
|
||||
|
||||
15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von Gründen- widerruflich. Der Widerruf ist zu richten an info@klz-cables.com .
|
||||
|
||||
15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen Einwilligung des Kunden.
|
||||
|
||||
15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung an die zuständige Stelle erteilen, soweit dies für Zwecke der Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte am geistigen Eigentum erforderlich ist.
|
||||
|
||||
### 16 Geheimhaltung
|
||||
|
||||
16.1 „Vertrauliche Informationen“ sind
|
||||
(i) alle Informationen, Daten, Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen, Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten, gleich in welcher Verkörperung,
|
||||
(ii) sowie alle nicht offenkundigen kaufmännischen, betrieblichen oder technischen Informationen oder Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt werden,
|
||||
(iii) die entweder direkt als vertraulich gekennzeichnet wurden oder nachträglich schriftlich oder in Textform als vertraulich gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die Parteien nichts anderes in Schriftform vereinbart haben.
|
||||
|
||||
16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch nicht solche Informationen,
|
||||
(i) die der Kunde von öffentlich zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf legalem Wege zugänglich sind und diese Quellen die Informationen ebenfalls auf legalem Wege erlangt haben,
|
||||
(ii) die der Kunde von einem berechtigten Dritten rechtmäßig erhalten hat,
|
||||
(iii) die der Kunde eigenständig entwickelt hat,
|
||||
(iv) die kraft Gesetzes oder aufgrund gerichtlicher oder behördlicher Anordnung offengelegt wurden oder offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor einer solchen Offenlegung der Informationen unverzüglich zu informieren und zu versuchen, die Offenlegung zu verhindern, soweit dies mit angemessenen und gesetzlich zulässigen Mitteln möglich ist.
|
||||
|
||||
16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer 16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.
|
||||
|
||||
16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen durch den Kunden nur an solche Mitarbeiter oder andere Personen weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls zur Geheimhaltung hinsichtlich der Vertraulichen Informationen verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies rechtlich möglich ist.
|
||||
|
||||
16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu zerstören bzw. sicher und dauerhaft zu löschen und die vollständige Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.
|
||||
|
||||
16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder vollständigen sachgemäßen Zerstörung fort.
|
||||
|
||||
16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten für einen Zeitraum von fünf (5) Jahren nach Beendigung des zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.
|
||||
|
||||
16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das billige Ermessen von KLZ gestellt wird und im Streitfall durch das zuständige Gericht überprüft werden kann. Durch die Geltendmachung der Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ nicht ausgeschlossen.
|
||||
|
||||
### 17 Schlussbestimmungen
|
||||
|
||||
17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform. Dies gilt auch für die Abänderung des Textformerfordernisses selbst.
|
||||
|
||||
17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist Stuttgart.
|
||||
|
||||
17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer Regelungslücke.
|
||||
|
||||
Stand: April 2026
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,5 +1,9 @@
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
||||
FROM node:20-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Arguments for build-time configuration
|
||||
@@ -8,7 +12,6 @@ ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
@@ -25,10 +28,11 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
# Configure private registry and install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm store prune && \
|
||||
pnpm install --no-frozen-lockfile && \
|
||||
rm .npmrc
|
||||
|
||||
# Copy source code
|
||||
@@ -42,15 +46,27 @@ CMD ["pnpm", "dev:local"]
|
||||
# Build application
|
||||
# Stage 3: Builder (Production)
|
||||
FROM base AS builder
|
||||
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
||||
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||
|
||||
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
|
||||
ENV RAYON_NUM_THREADS=3
|
||||
ENV UV_THREADPOOL_SIZE=3
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||
# Stage 2: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
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)
|
||||
USER root
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install essential build tools if needed (e.g., for node-gyp)
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Pre-set the pnpm store directory
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
# Set up pnpm store configuration
|
||||
RUN pnpm config set store-dir /pnpm/store
|
||||
|
||||
EXPOSE 3000
|
||||
BIN
KLZ_AVB_April_2026.docx
Normal file
BIN
KLZ_AVB_April_2026.docx
Normal file
Binary file not shown.
BIN
KLZ_AVB_April_2026.pdf
Normal file
BIN
KLZ_AVB_April_2026.pdf
Normal file
Binary file not shown.
@@ -462,3 +462,4 @@ Proprietary - KLZ Cables
|
||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||
**Version**: 1.0.0
|
||||
**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;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { notFound, redirect, permanentRedirect } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
@@ -15,41 +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> {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
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 {
|
||||
title: pageData.frontmatter.title,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${slug}`,
|
||||
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${slug}`,
|
||||
en: `${SITE_URL}/en/${slug}`,
|
||||
'x-default': `${SITE_URL}/en/${slug}`,
|
||||
de: `${SITE_URL}/de/${deSlug}`,
|
||||
en: `${SITE_URL}/en/${enSlug}`,
|
||||
'x-default': `${SITE_URL}/en/${enSlug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `${SITE_URL}/${locale}/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -69,6 +62,32 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
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 (
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
@@ -77,7 +96,7 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||
</div>
|
||||
<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">
|
||||
{t('badge')}
|
||||
</Badge>
|
||||
@@ -93,7 +112,7 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{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">
|
||||
{pageData.frontmatter.excerpt}
|
||||
</p>
|
||||
@@ -101,8 +120,8 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<MDXRemote source={pageData.content} components={mdxComponents} />
|
||||
<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">
|
||||
<PayloadRichText data={pageData.content} />
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
@@ -112,7 +131,7 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
<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>
|
||||
<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"
|
||||
eventProperties={{
|
||||
location: 'generic_page_support_cta',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getProductBySlug } from '@/lib/products';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
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({
|
||||
params,
|
||||
}: {
|
||||
@@ -32,12 +46,19 @@ export default async function Image({
|
||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: 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(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
image={base64Image || featuredImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
||||
import {
|
||||
getPostBySlug,
|
||||
getAdjacentPosts,
|
||||
getReadingTime,
|
||||
extractLexicalHeadings,
|
||||
getPostSlugs,
|
||||
} from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||
|
||||
// Payload CMS Imports
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
@@ -26,12 +34,21 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
|
||||
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 || '';
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/blog/${deSlug}`,
|
||||
en: `${SITE_URL}/en/blog/${enSlug}`,
|
||||
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
@@ -39,7 +56,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -53,13 +70,25 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
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 (
|
||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||
@@ -67,16 +96,27 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
title={post.frontmatter.title}
|
||||
slug={slug}
|
||||
category={post.frontmatter.category}
|
||||
readingTime={getReadingTime(post.content)}
|
||||
readingTime={getReadingTime(rawTextContent)}
|
||||
/>
|
||||
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||
<Image
|
||||
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>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
|
||||
|
||||
{/* Title overlay on image */}
|
||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||
@@ -84,27 +124,36 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
<div className="max-w-4xl">
|
||||
{post.frontmatter.category && (
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
|
||||
>
|
||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
|
||||
{post.frontmatter.title}
|
||||
</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]">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
</time>
|
||||
<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>
|
||||
@@ -123,16 +172,28 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
<Heading level={1} className="mb-8">
|
||||
{post.frontmatter.title}
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||
<span>{getReadingTime(post.content)} min read</span>
|
||||
<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="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>
|
||||
</header>
|
||||
@@ -145,16 +206,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
<div className="sticky-narrative-content">
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{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">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content with enhanced styling */}
|
||||
<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]">
|
||||
<MDXRemote source={post.content} components={mdxComponents} />
|
||||
{/* 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">
|
||||
<PayloadRichText data={post.content} />
|
||||
</div>
|
||||
|
||||
{/* Power CTA */}
|
||||
@@ -164,7 +225,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
|
||||
{/* Post Navigation */}
|
||||
<div className="mt-16">
|
||||
<PostNavigation prev={prev} next={next} locale={locale} />
|
||||
<PostNavigation
|
||||
prev={prev}
|
||||
next={next}
|
||||
isPrevRandom={isPrevRandom}
|
||||
isNextRandom={isNextRandom}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Back to blog link */}
|
||||
@@ -191,9 +258,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sticky Sidebar */}
|
||||
{/* Right Column: Sticky Sidebar - TOC */}
|
||||
<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} />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -233,8 +300,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
articleSection: post.frontmatter.category,
|
||||
wordCount: post.content.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(post.content)}M`,
|
||||
wordCount: rawTextContent.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(rawTextContent)}M`,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Reveal from '@/components/Reveal';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: Promise<{
|
||||
@@ -62,28 +63,38 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
src={featuredPost.frontmatter.featuredImage}
|
||||
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={featuredPost.frontmatter.title}
|
||||
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"
|
||||
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">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">
|
||||
{t('featuredPost')}
|
||||
</Badge>
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||
<Badge variant="saturated">{t('featuredPost')}</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 && (
|
||||
<>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{featuredPost.frontmatter.title}
|
||||
</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}
|
||||
</p>
|
||||
<Button
|
||||
@@ -142,55 +153,79 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
</Reveal>
|
||||
|
||||
{/* 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) => (
|
||||
<Reveal key={post.slug} delay={idx * 100}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||
<Reveal key={post.slug} delay={idx * 50}>
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.slug}`}
|
||||
className="group block focus:outline-none"
|
||||
>
|
||||
<Card
|
||||
tag="article"
|
||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
||||
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 && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<>
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
style={{
|
||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||
}}
|
||||
sizes="(max-width: 768px) 100vw, 100vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 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 && (
|
||||
<Badge
|
||||
variant="accent"
|
||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
||||
>
|
||||
<Badge variant="accent" className="shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
{(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 className="p-5 md:p-10 flex flex-col flex-1">
|
||||
<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, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
</time>
|
||||
</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}
|
||||
</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>
|
||||
<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">
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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')}
|
||||
</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
|
||||
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"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -211,21 +246,47 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Placeholder */}
|
||||
{/* Pagination */}
|
||||
<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')}
|
||||
</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
|
||||
</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
|
||||
</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')}
|
||||
</Button>
|
||||
</div>
|
||||
<BlogPaginationKeyboardObserver currentPage={1} totalPages={2} locale={locale} />
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SITE_URL } from '@/lib/schema';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } from 'react';
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: Promise<{
|
||||
@@ -24,9 +25,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/contact`,
|
||||
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/contact`,
|
||||
de: `${SITE_URL}/de/kontakt`,
|
||||
en: `${SITE_URL}/en/contact`,
|
||||
'x-default': `${SITE_URL}/en/contact`,
|
||||
},
|
||||
@@ -34,7 +35,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/contact`,
|
||||
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||
siteName: 'KLZ Cables',
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
@@ -59,6 +60,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
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 (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<JsonLd
|
||||
@@ -189,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.email')}
|
||||
</h4>
|
||||
<a
|
||||
href="mailto:info@klz-cables.com"
|
||||
<ObfuscatedEmail
|
||||
email="info@klz-cables.com"
|
||||
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>
|
||||
</address>
|
||||
|
||||
@@ -16,12 +16,18 @@ export default function Error({
|
||||
const t = useTranslations('Error');
|
||||
|
||||
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();
|
||||
services.errors.captureException(error);
|
||||
services.logger.error('Application error caught by boundary', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
digest: error.digest
|
||||
message: error?.message || 'Unknown error',
|
||||
stack: error?.stack,
|
||||
digest: error?.digest,
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
@@ -36,19 +42,14 @@ export default function Error({
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
||||
500
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
|
||||
/>
|
||||
<Scribble variant="underline" className="w-full h-6 -bottom-2 left-0 text-saturated/40" />
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
||||
{t('description')}
|
||||
</p>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button onClick={() => reset()} variant="saturated" size="lg">
|
||||
|
||||
@@ -3,18 +3,15 @@ import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import SkipLink from '@/components/SkipLink';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
@@ -30,16 +27,14 @@ export async function generateMetadata(props: {
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
|
||||
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
canonical: locale === 'en' ? '/' : `/${locale}`,
|
||||
languages: {
|
||||
de: '/de',
|
||||
en: '/en',
|
||||
},
|
||||
title: {
|
||||
template: '%s | KLZ Cables',
|
||||
default: 'KLZ Cables | Ihr Partner für Kabel & Leitungen',
|
||||
},
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
@@ -76,12 +71,20 @@ export default async function Layout(props: {
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||
messages = {};
|
||||
}
|
||||
|
||||
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home'];
|
||||
const clientKeys = [
|
||||
'Footer',
|
||||
'Navigation',
|
||||
'Contact',
|
||||
'Products',
|
||||
'Team',
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
if (messages[key]) {
|
||||
@@ -96,7 +99,10 @@ export default async function Layout(props: {
|
||||
const { headers } = await import('next/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({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
@@ -116,11 +122,10 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
return (
|
||||
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||
<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" />
|
||||
@@ -128,26 +133,22 @@ export default async function Layout(props: {
|
||||
</head>
|
||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||
<RecordModeVisuals>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</RecordModeVisuals>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<CMSConnectivityNotice />
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<AnalyticsShell />
|
||||
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||
</RecordModeProvider>
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,56 +1,137 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useEffect } from 'react';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { headers } from 'next/headers';
|
||||
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('Error.notFound');
|
||||
const { trackEvent } = useAnalytics();
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations('Error.notFound');
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(AnalyticsEvents.ERROR, {
|
||||
type: '404_not_found',
|
||||
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
|
||||
});
|
||||
}, [trackEvent]);
|
||||
// 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 (
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">404</span>
|
||||
</div>
|
||||
<>
|
||||
<ClientNotFoundTracker path={urlPath} />
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">404</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||
404
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||
404
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
{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>
|
||||
<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">
|
||||
<Button href="/" variant="accent" size="lg">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button href="/contact" variant="outline" size="lg">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
</Container>
|
||||
{/* 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" />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
console.log('🖼️ OG Image Handler Called');
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -27,6 +27,13 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
||||
id="breadcrumb-home"
|
||||
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 />
|
||||
<Reveal>
|
||||
<ProductCategories />
|
||||
|
||||
@@ -7,16 +7,16 @@ import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
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 {
|
||||
params: Promise<{
|
||||
@@ -53,135 +53,82 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: categoryTitle,
|
||||
description: categoryDesc,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, '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);
|
||||
if (!product) return {};
|
||||
|
||||
const currentLocalePath = await getLocalizedPath(locale);
|
||||
|
||||
return {
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||
title: product.frontmatter.title,
|
||||
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) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-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)) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
@@ -192,14 +139,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: fileSlug;
|
||||
|
||||
// Filter products for this category
|
||||
const filteredProducts = allProducts.filter((p) =>
|
||||
p.frontmatter.categories.some(
|
||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||
),
|
||||
);
|
||||
const filteredProducts = allProducts.filter((p) => {
|
||||
const firstCat = p.frontmatter.categories[0] || '';
|
||||
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||
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(
|
||||
filteredProducts.map(async (p) => ({
|
||||
...p,
|
||||
@@ -214,10 +174,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<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">
|
||||
<Link
|
||||
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||
href={`/${locale}/${productsSlug}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
>
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||
</Link>
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<span className="text-white/90">{categoryTitle}</span>
|
||||
@@ -236,7 +196,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{productsWithTranslatedSlugs.map((product) => (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<Card tag="article" className="premium-card-reset">
|
||||
@@ -250,7 +210,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
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" />
|
||||
</>
|
||||
)}
|
||||
@@ -307,17 +266,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Extract technical data for schema
|
||||
const technicalDataMatch = product.content.match(
|
||||
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
||||
);
|
||||
// Extract technical data natively from the Lexical AST for Schema.org
|
||||
let technicalItems = [];
|
||||
if (technicalDataMatch) {
|
||||
try {
|
||||
const data = JSON.parse(technicalDataMatch[1]);
|
||||
technicalItems = data.technicalItems || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to parse technical data for schema', e);
|
||||
if (product.content?.root?.children) {
|
||||
const productTabsBlock = product.content.root.children.find(
|
||||
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (productTabsBlock && productTabsBlock.fields?.technicalItems) {
|
||||
technicalItems = productTabsBlock.fields.technicalItems;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +288,56 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: 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 = (
|
||||
<ProductSidebar
|
||||
productName={product.frontmatter.title}
|
||||
@@ -340,22 +346,6 @@ 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 (
|
||||
<div className="flex flex-col min-h-screen bg-white relative">
|
||||
{/* Product Hero */}
|
||||
@@ -365,29 +355,31 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
categories={product.frontmatter.categories}
|
||||
sku={product.frontmatter.sku}
|
||||
/>
|
||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
||||
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||
{/* 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-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<Container className="relative z-10">
|
||||
<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}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
href={`/${locale}/${productsSlug}`}
|
||||
className="hover:text-accent transition-colors shrink-0"
|
||||
>
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||
</Link>
|
||||
<span className="mx-4 opacity-20">/</span>
|
||||
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||
<Link
|
||||
href={`/${locale}/products/${categorySlug}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||
>
|
||||
{categoryTitle}
|
||||
</Link>
|
||||
<span className="mx-4 opacity-20">/</span>
|
||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||
{product.frontmatter.title}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||
@@ -398,7 +390,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{t('englishVersion')}
|
||||
</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) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
@@ -409,10 +401,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</Badge>
|
||||
))}
|
||||
</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}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -426,15 +418,16 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{/* Large Product Image Section */}
|
||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||
<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' }}
|
||||
>
|
||||
<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="relative w-full aspect-[21/9]">
|
||||
<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-[4/3] md:aspect-[21/9]">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
@@ -453,6 +446,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
src={img}
|
||||
alt=""
|
||||
fill
|
||||
sizes="128px"
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
@@ -463,68 +457,90 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="w-full">
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-none">
|
||||
<MDXRemote source={processedContent} components={productComponents} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||
{/* Description Area Next to Sidebar */}
|
||||
<div className="lg:col-span-8">
|
||||
<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">
|
||||
{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}
|
||||
|
||||
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
||||
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
||||
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||
{t('downloadDatasheet')}
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
{product.application?.root?.children?.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<PayloadRichText data={product.application} />
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured Data */}
|
||||
<JsonLd
|
||||
id={`jsonld-${product.slug}`}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0]
|
||||
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mt-16 pt-16 border-t-2 border-neutral-dark/5">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||
{t('downloadDatasheet')}
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured Data (Hidden) */}
|
||||
<JsonLd
|
||||
id={`jsonld-${product.slug}`}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0]
|
||||
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
|
||||
@@ -12,7 +12,11 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||
const title = t.has('meta.title')
|
||||
? t('meta.title')
|
||||
: t.has('breadcrumb')
|
||||
? t('breadcrumb')
|
||||
: 'Products';
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -17,7 +16,11 @@ interface ProductsPageProps {
|
||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||
const title = t.has('meta.title')
|
||||
? t('meta.title')
|
||||
: t.has('breadcrumb')
|
||||
? t('breadcrumb')
|
||||
: 'Products';
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
return {
|
||||
title,
|
||||
@@ -55,6 +58,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
const contactSlug = await mapFileSlugToTranslated('contact', locale);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
@@ -90,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
{/* 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">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge
|
||||
@@ -101,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<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>
|
||||
),
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
})}
|
||||
</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">
|
||||
@@ -218,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="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">
|
||||
<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')}
|
||||
</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
@@ -226,13 +222,13 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
href={`/${locale}/contact`}
|
||||
href={`/${locale}/${contactSlug}`}
|
||||
variant="accent"
|
||||
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')}
|
||||
<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>
|
||||
</Button>
|
||||
|
||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||
{t('michael.role')}
|
||||
</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>
|
||||
</Heading>
|
||||
<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" />
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('michael.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
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" />
|
||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('klaus.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
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" />
|
||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||
{t('klaus.role')}
|
||||
</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')}
|
||||
</Heading>
|
||||
<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" />
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
84
app/actions/brochure.ts
Normal file
84
app/actions/brochure.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
'use server';
|
||||
|
||||
import * as React from 'react';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { getAllProducts } from '@/lib/products';
|
||||
import { getExcelTechnicalDataForProduct } from '@/lib/excel-products';
|
||||
import { PDFBrochure } from '@/lib/pdf-brochure';
|
||||
import { ProductData as PDFProductData } from '@/lib/pdf-datasheet';
|
||||
|
||||
/**
|
||||
* Server action to generate a PDF brochure.
|
||||
* Fetches products from Payload CMS and technical data from Excel.
|
||||
* Returns the PDF as a base64 string.
|
||||
*/
|
||||
export async function generateBrochureAction(params: {
|
||||
locale: 'en' | 'de';
|
||||
productSlugs?: string[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
try {
|
||||
const { locale, productSlugs, title, subtitle } = params;
|
||||
|
||||
// 1. Fetch products from Payload
|
||||
let products = await getAllProducts(locale);
|
||||
|
||||
// 2. Filter by slugs if provided
|
||||
if (productSlugs && productSlugs.length > 0) {
|
||||
products = products.filter((p) => productSlugs.includes(p.slug));
|
||||
}
|
||||
|
||||
// 3. Map Payload products to PDF internal model and attach Excel data
|
||||
const pdfProducts: PDFProductData[] = products.map((p, index) => {
|
||||
// Get technical data from Excel
|
||||
const excelData = getExcelTechnicalDataForProduct({
|
||||
slug: p.slug,
|
||||
sku: p.frontmatter.sku,
|
||||
name: p.frontmatter.title,
|
||||
});
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: p.frontmatter.title,
|
||||
sku: p.frontmatter.sku,
|
||||
shortDescriptionHtml: p.frontmatter.description,
|
||||
descriptionHtml: p.frontmatter.description,
|
||||
// Application text is usually part of description in Payload v3
|
||||
applicationHtml: '',
|
||||
featuredImage: p.frontmatter.images[0] || null,
|
||||
images: p.frontmatter.images,
|
||||
categories: p.frontmatter.categories.map((name) => ({ name })),
|
||||
attributes: excelData?.attributes || [],
|
||||
};
|
||||
});
|
||||
|
||||
if (pdfProducts.length === 0) {
|
||||
throw new Error('No products found for brochure generation.');
|
||||
}
|
||||
|
||||
// 4. Render to PDF buffer
|
||||
// Note: React 19 / Next 15+ might need specific handling for renderToBuffer in actions
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products: pdfProducts,
|
||||
locale,
|
||||
title,
|
||||
subtitle,
|
||||
})
|
||||
);
|
||||
|
||||
// 5. Convert to Base64 for returning to client
|
||||
return {
|
||||
success: true,
|
||||
data: buffer.toString('base64'),
|
||||
fileName: `KLZ_Cables_Brochure_${locale.toUpperCase()}.pdf`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Action] Brochure generation failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||
import { createItem } from '@directus/sdk';
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { env } from '@/lib/env';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
@@ -41,31 +40,31 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
// 1. Save to Directus
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
if (productName) {
|
||||
await client.request(
|
||||
createItem('product_requests', {
|
||||
product_name: productName,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Product request stored in Directus');
|
||||
} else {
|
||||
await client.request(
|
||||
createItem('contact_submissions', {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Contact submission stored in Directus');
|
||||
}
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
productName: productName || undefined,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
logger.info('Successfully saved form submission to Payload CMS', {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to store submission in Directus', { error });
|
||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||
logger.error('Failed to store submission in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_submission' });
|
||||
}
|
||||
|
||||
// 2. Send Emails
|
||||
@@ -75,6 +74,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
? `Product Inquiry: ${productName}`
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
const isTestSubmission = email === 'testing@mintel.me';
|
||||
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
@@ -87,16 +87,35 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
}),
|
||||
);
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
if (!isTestSubmission) {
|
||||
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Notification email DELIVERY FAILED', {
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||
{
|
||||
action: 'sendContactFormAction_notification',
|
||||
email,
|
||||
recipients: env.MAIL_RECIPIENTS
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping notification email for test submission', { email });
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||
@@ -108,16 +127,31 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
}),
|
||||
);
|
||||
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
if (!isTestSubmission) {
|
||||
logger.info('Sending customer confirmation', { to: email });
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Confirmation email DELIVERY FAILED', {
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||
{ action: 'sendContactFormAction_confirmation', email },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping confirmation email for test submission', { email });
|
||||
}
|
||||
|
||||
// Notify via Gotify (Internal)
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
import { checkHealth } from '@/lib/directus';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
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() {
|
||||
const health = await checkHealth();
|
||||
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
|
||||
const checks: Record<string, string> = {};
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
81
app/api/pages/[slug]/pdf/route.tsx
Normal file
81
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { PDFPage } from '@/lib/pdf-page';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Hardcoded bypass for AGBs to use the original uploaded PDF.
|
||||
if (slug === 'agbs') {
|
||||
const filePath = path.join(process.cwd(), 'public', 'AVB-KLZ-4-2026.pdf');
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="AVB-KLZ-4-2026.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get Payload App
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Fetch the page
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (pages.totalDocs === 0) {
|
||||
return new NextResponse('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const page = pages.docs[0];
|
||||
|
||||
// Determine locale from searchParams or default to 'de'
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||
|
||||
// Render the React-PDF document into a stream
|
||||
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||
|
||||
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
(stream as any).destroy?.();
|
||||
},
|
||||
});
|
||||
|
||||
const filename = `${slug}.pdf`;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
// Cache control if needed, skip for now.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export async function POST(request: NextRequest) {
|
||||
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||
|
||||
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();
|
||||
|
||||
// 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 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', {
|
||||
projectId,
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||
import { getAllProductsMetadata } from '@/lib/products';
|
||||
import { getAllPostsMetadata } from '@/lib/blog';
|
||||
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> {
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
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 = [];
|
||||
|
||||
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
|
||||
for (const route of routes) {
|
||||
const staticPages = ['', 'blog', 'contact', 'team', 'products'];
|
||||
for (const page of staticPages) {
|
||||
const localizedRoute = await getLocalizedRoute(page);
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}${route}`,
|
||||
url: `${baseUrl}/${locale}${localizedRoute}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: route === '' ? 'daily' : 'weekly',
|
||||
priority: route === '' ? 1 : 0.8,
|
||||
changeFrequency: page === '' ? 'daily' : 'weekly',
|
||||
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) {
|
||||
if (!product.frontmatter || !product.slug) continue;
|
||||
|
||||
const category =
|
||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||
const firstCat = product.frontmatter.categories[0] || '';
|
||||
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({
|
||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
||||
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
@@ -51,12 +87,15 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Blog posts
|
||||
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
|
||||
const postsMetadata = await getAllPostsMetadata(locale);
|
||||
for (const post of postsMetadata) {
|
||||
if (!post.frontmatter || !post.slug) continue;
|
||||
|
||||
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`,
|
||||
lastModified: new Date(post.frontmatter.date),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
@@ -68,8 +107,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
for (const page of pagesMetadata) {
|
||||
if (!page.slug) continue;
|
||||
|
||||
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||
url: `${baseUrl}/${locale}/${translatedSlug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
|
||||
@@ -19,14 +19,39 @@ export async function POST(request: NextRequest) {
|
||||
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Prevent 400 Bad Request console noise in local dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (_parseError) {
|
||||
logger.warn('Received malformed or empty JSON in analytics proxy');
|
||||
return NextResponse.json({ status: 'ignored_bad_payload' }, { status: 200 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
logger.warn('Received invalid body type in analytics proxy', { type: typeof body });
|
||||
return NextResponse.json({ status: 'ignored_invalid_body' }, { status: 200 });
|
||||
}
|
||||
|
||||
const { type, payload } = body;
|
||||
|
||||
if (!type || !payload) {
|
||||
logger.warn('Received incomplete analytics payload', {
|
||||
hasType: !!type,
|
||||
hasPayload: !!payload
|
||||
});
|
||||
return NextResponse.json({ status: 'ignored_incomplete_payload' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Inject the secret websiteId from server config
|
||||
const websiteId = config.analytics.umami.websiteId;
|
||||
if (!websiteId) {
|
||||
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
return NextResponse.json({ status: 'ignored_no_config' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Prepare the enhanced payload with the secret ID
|
||||
@@ -56,10 +81,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Umami API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
if (!process.env.CI) {
|
||||
logger.error('Umami API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
@@ -69,16 +96,18 @@ export async function POST(request: NextRequest) {
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
// Console error to ensure it appears in logs even if logger fails
|
||||
console.error('CRITICAL PROXY ERROR:', {
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
endpoint: config.analytics.umami.apiEndpoint,
|
||||
});
|
||||
if (!process.env.CI) {
|
||||
console.error('CRITICAL PROXY ERROR:', {
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
endpoint: config.analytics.umami.apiEndpoint,
|
||||
});
|
||||
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
2129
ast_updated.json
Normal file
2129
ast_updated.json
Normal file
File diff suppressed because it is too large
Load Diff
39
check-data.ts
Normal file
39
check-data.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
async function checkData() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const { docs: posts } = await payload.find({ collection: 'posts', limit: 3 });
|
||||
const { docs: products } = await payload.find({ collection: 'products', limit: 3 });
|
||||
const { docs: pages } = await payload.find({ collection: 'pages', limit: 3 });
|
||||
|
||||
const checkDocs = (name: string, docs: any[]) => {
|
||||
console.log(`\n----- ${name.toUpperCase()} -----`);
|
||||
docs.forEach((p) => {
|
||||
console.log(`ID: ${p.id}, Slug: ${p.slug}`);
|
||||
if (Array.isArray(p.content)) {
|
||||
console.log(
|
||||
'Content is ARRAY (Slate format!)',
|
||||
JSON.stringify(p.content).substring(0, 100),
|
||||
);
|
||||
} else if (p.content && p.content.root) {
|
||||
console.log('Content is Lexical format.');
|
||||
} else {
|
||||
console.log('Content is UNKNOWN format.');
|
||||
console.log(JSON.stringify(p.content).substring(0, 100));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkDocs('posts', posts);
|
||||
checkDocs('products', products);
|
||||
checkDocs('pages', pages);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
checkData();
|
||||
@@ -148,7 +148,6 @@ export default function ContactForm() {
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
onFocus={() => handleFocus('contact-name')}
|
||||
aria-label={t('form.name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -163,7 +162,6 @@ export default function ContactForm() {
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('contact-email')}
|
||||
aria-label={t('form.email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -176,7 +174,6 @@ export default function ContactForm() {
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('contact-message')}
|
||||
aria-label={t('form.message')}
|
||||
required
|
||||
/>
|
||||
</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 />;
|
||||
}
|
||||
@@ -15,13 +15,14 @@ export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
||||
<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" />
|
||||
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<h2 className="sr-only">Footer Navigation</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||
{/* Brand Column – full width on mobile */}
|
||||
<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"
|
||||
@@ -37,6 +38,7 @@ export default function Footer() {
|
||||
alt="KLZ Vertriebs GmbH"
|
||||
width={150}
|
||||
height={40}
|
||||
style={{ width: 'auto' }}
|
||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
</Link>
|
||||
@@ -65,9 +67,9 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{/* Legal Column */}
|
||||
<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('legal')}
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
@@ -119,8 +121,9 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{/* Company Column */}
|
||||
<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">
|
||||
@@ -171,12 +174,12 @@ export default function Footer() {
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
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: '/contact',
|
||||
href: locale === 'de' ? '/kontakt' : '/contact',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
@@ -187,9 +190,9 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts Column */}
|
||||
<div className="lg:col-span-4">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{/* Recent Posts Column – full width on mobile */}
|
||||
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||
<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">
|
||||
@@ -240,12 +243,11 @@ export default function Footer() {
|
||||
</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/70 text-xs md:text-sm font-medium">
|
||||
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
<Link
|
||||
href="/en"
|
||||
locale="en"
|
||||
className="hover:text-white transition-colors"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
@@ -260,7 +262,6 @@ export default function Footer() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/de"
|
||||
locale="de"
|
||||
className="hover:text-white transition-colors"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
@@ -18,7 +17,6 @@ export default function Header() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
@@ -35,7 +33,6 @@ export default function Header() {
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
@@ -87,11 +84,49 @@ export default function Header() {
|
||||
}
|
||||
}, [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 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;
|
||||
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 = [
|
||||
@@ -102,10 +137,12 @@ export default function Header() {
|
||||
];
|
||||
|
||||
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 py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -114,393 +151,322 @@ export default function Header() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<m.header
|
||||
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">
|
||||
<m.div
|
||||
className="flex-shrink-0 group touch-target"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
width={120}
|
||||
height={120}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</m.div>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
width={120}
|
||||
height={120}
|
||||
style={{ width: 'auto' }}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<m.div
|
||||
className="flex items-center gap-4 md:gap-12"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
<div className="flex items-center gap-4 md:gap-12">
|
||||
<nav className="hidden lg:flex items-center space-x-10">
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||
>
|
||||
{(() => {
|
||||
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(fullHref);
|
||||
return (
|
||||
<Link
|
||||
href={fullHref}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
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',
|
||||
isActive && 'text-accent',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'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' }}
|
||||
>
|
||||
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||
{menuItems.map((item, _idx) => (
|
||||
<m.div key={item.href} variants={navLinkVariants}>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{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)]" />
|
||||
</Link>
|
||||
</m.div>
|
||||
))}
|
||||
</m.nav>
|
||||
|
||||
<m.div
|
||||
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||
variants={headerRightVariants}
|
||||
<div
|
||||
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"
|
||||
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
|
||||
>
|
||||
<m.div
|
||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.65 }}
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
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-60'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
</m.div>
|
||||
<m.div
|
||||
className="w-px h-4 bg-current opacity-20"
|
||||
initial={{ scaleY: 0 }}
|
||||
animate={{ scaleY: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.7 }}
|
||||
/>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.75 }}
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
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-60'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl"
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('contact'),
|
||||
location: 'header_cta',
|
||||
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'}`}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-current opacity-30" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<m.button
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||
textColorClass,
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
delay: 0.5,
|
||||
}}
|
||||
onClick={() => {
|
||||
const newState = !isMobileMenuOpen;
|
||||
setIsMobileMenuOpen(newState);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: newState ? 'open' : 'close',
|
||||
});
|
||||
}}
|
||||
<div
|
||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||
>
|
||||
<m.svg
|
||||
className="w-7 h-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.6 }}
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('contact'),
|
||||
location: 'header_cta',
|
||||
})
|
||||
}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.7 }}
|
||||
/>
|
||||
) : (
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.7 }}
|
||||
/>
|
||||
)}
|
||||
</m.svg>
|
||||
</m.button>
|
||||
</m.div>
|
||||
</div>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<m.nav
|
||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||
initial="closed"
|
||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||
variants={{
|
||||
open: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||
textColorClass,
|
||||
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => {
|
||||
const newState = !isMobileMenuOpen;
|
||||
setIsMobileMenuOpen(newState);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: newState ? 'open' : 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item, idx) => (
|
||||
<m.div
|
||||
key={item.href}
|
||||
variants={{
|
||||
closed: { opacity: 0, y: 50, scale: 0.9 },
|
||||
open: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeOut',
|
||||
delay: idx * 0.08,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'mobile_menu',
|
||||
});
|
||||
}}
|
||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</m.div>
|
||||
))}
|
||||
|
||||
<m.div
|
||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
<svg
|
||||
className="w-7 h-7 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<m.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 }}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 1.0 }}
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
</m.div>
|
||||
<m.div
|
||||
className="w-px h-6 bg-white/20"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4, delay: 1.05 }}
|
||||
{isMobileMenuOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 1.1 }}
|
||||
>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
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
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<m.div
|
||||
className="p-12 flex justify-center opacity-20"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.5, delay: 1.4 }}
|
||||
>
|
||||
<m.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 />
|
||||
</m.div>
|
||||
</m.div>
|
||||
</m.nav>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</m.header>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: '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}
|
||||
>
|
||||
{/* Close Button inside overlay */}
|
||||
<div className="flex justify-end p-6 pt-8">
|
||||
<button
|
||||
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||
aria-label={t('toggleMenu')}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className={cn(
|
||||
'transition-all duration-500 transform',
|
||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
aria-current={
|
||||
(
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||
)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'mobile_menu',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||
(item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||
>
|
||||
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-white/30" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs">
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<div
|
||||
className={cn(
|
||||
'p-12 flex justify-center transition-all duration-700',
|
||||
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
||||
)}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const navVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const navLinkVariants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeOut',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headerRightVariants = {
|
||||
hidden: { opacity: 0, x: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease: 'easeOut' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -7,16 +7,14 @@ interface JsonLdProps {
|
||||
|
||||
export default function JsonLd({ id, data }: JsonLdProps) {
|
||||
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
|
||||
const schemaData = data || [
|
||||
const rawData = data || [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
logo: 'https://klz-cables.com/logo-blue.svg',
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/klz-cables',
|
||||
],
|
||||
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
@@ -36,15 +34,32 @@ export default function JsonLd({ id, data }: JsonLdProps) {
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// Harden schema items: Ensure each item is an object and has @context
|
||||
const schemaData = (Array.isArray(rawData) ? rawData : [rawData])
|
||||
.filter((item): item is WithContext<Thing> => {
|
||||
// Basic sanity check: must be an object and not null
|
||||
return typeof item === 'object' && item !== null;
|
||||
})
|
||||
.map((item) => {
|
||||
// Ensure @context is present and correctly typed for consumer libraries
|
||||
if (!item['@context']) {
|
||||
return {
|
||||
...(item as Record<string, any>),
|
||||
'@context': 'https://schema.org',
|
||||
} as WithContext<Thing>;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return (
|
||||
<script
|
||||
id={id}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(schemaData),
|
||||
__html: JSON.stringify(schemaData.length === 1 ? schemaData[0] : schemaData),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
interface LightboxProps {
|
||||
@@ -139,7 +139,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
38
components/ObfuscatedEmail.tsx
Normal file
38
components/ObfuscatedEmail.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ObfuscatedEmailProps {
|
||||
email: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that helps protect email addresses from simple spambots.
|
||||
* It uses client-side mounting to render the actual email address,
|
||||
* making it harder for static crawlers to harvest.
|
||||
*/
|
||||
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
// Show a placeholder or obscured version during SSR
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Once mounted on the client, render the real mailto link
|
||||
return (
|
||||
<a href={`mailto:${email}`} className={className}>
|
||||
{children || email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
41
components/ObfuscatedPhone.tsx
Normal file
41
components/ObfuscatedPhone.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ObfuscatedPhoneProps {
|
||||
phone: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that helps protect phone numbers from simple spambots.
|
||||
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||
*/
|
||||
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Format phone number for tel: link (remove spaces, etc.)
|
||||
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||
|
||||
if (!mounted) {
|
||||
// Show a placeholder or obscured version during SSR
|
||||
// e.g. +49 881 925 [at] 37298
|
||||
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{children || obscured}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={telLink} className={className}>
|
||||
{children || phone}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1200
components/PayloadRichText.tsx
Normal file
1200
components/PayloadRichText.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ export default function ProductSidebar({
|
||||
<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">
|
||||
<Image
|
||||
src={productImage}
|
||||
src={productImage.split('?')[0]}
|
||||
alt={productName}
|
||||
fill
|
||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||
|
||||
@@ -38,14 +38,14 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="space-y-8 md:space-y-16">
|
||||
{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">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</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) => (
|
||||
<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">
|
||||
@@ -72,7 +72,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||
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">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
@@ -83,7 +83,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
</h3>
|
||||
|
||||
{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) => (
|
||||
<div key={mIdx}>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||
@@ -98,9 +98,11 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
)}
|
||||
|
||||
<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
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
>
|
||||
@@ -130,12 +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">
|
||||
{row.configuration}
|
||||
</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"
|
||||
>
|
||||
{cell}
|
||||
{typeof cell === 'object' && cell !== null && 'value' in cell
|
||||
? cell.value
|
||||
: cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getAllProducts } from '@/lib/mdx';
|
||||
import { getAllProducts } from '@/lib/products';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import { RelatedProductLink } from './RelatedProductLink';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
|
||||
interface RelatedProductsProps {
|
||||
currentSlug: string;
|
||||
@@ -16,6 +17,7 @@ export default async function RelatedProducts({
|
||||
}: RelatedProductsProps) {
|
||||
const products = await getAllProducts(locale);
|
||||
const t = await getTranslations('Products');
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
// Filter products: same category, not current product
|
||||
const related = products
|
||||
@@ -27,6 +29,32 @@ export default async function RelatedProducts({
|
||||
|
||||
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 (
|
||||
<div className="">
|
||||
<div className="flex items-end justify-between mb-12">
|
||||
@@ -39,29 +67,11 @@ export default async function RelatedProducts({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{related.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';
|
||||
|
||||
{relatedWithTranslatedSlugs.map((product) => {
|
||||
return (
|
||||
<RelatedProductLink
|
||||
key={product.slug}
|
||||
href={`/${locale}/products/${catSlug}/${product.slug}`}
|
||||
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"
|
||||
@@ -70,7 +80,7 @@ export default async function RelatedProducts({
|
||||
{product.frontmatter.images?.[0] ? (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
src={product.frontmatter.images[0].split('?')[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
|
||||
@@ -78,6 +78,9 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
}
|
||||
};
|
||||
|
||||
const emailId = React.useId();
|
||||
const requestId = React.useId();
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
@@ -164,29 +167,33 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
<div className="space-y-2 !mt-0">
|
||||
<div className="space-y-1 !mt-0">
|
||||
<label htmlFor={emailId} className="sr-only">
|
||||
{t('email')}
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
id="quote-email"
|
||||
id={emailId}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => handleFocus('quote-email')}
|
||||
placeholder={t('email')}
|
||||
aria-label={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 !mt-0">
|
||||
<label htmlFor={requestId} className="sr-only">
|
||||
{t('message')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="quote-request"
|
||||
id={requestId}
|
||||
required
|
||||
rows={3}
|
||||
value={request}
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
onFocus={() => handleFocus('quote-request')}
|
||||
placeholder={t('message')}
|
||||
aria-label={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
interface ScribbleProps {
|
||||
@@ -11,67 +10,49 @@ interface 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') {
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<m.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
</LazyMotion>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
className="animate-draw-stroke"
|
||||
pathLength="1"
|
||||
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<m.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
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"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</LazyMotion>
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
className="animate-draw-stroke"
|
||||
pathLength="1"
|
||||
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||
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}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-white focus:text-primary-dark focus:font-bold focus:rounded-lg focus:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
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>
|
||||
|
||||
@@ -9,11 +9,19 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||
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 });
|
||||
@@ -29,6 +37,7 @@ export default function AnalyticsShell() {
|
||||
<Suspense fallback={null}>
|
||||
<DynamicAnalyticsProvider />
|
||||
<DynamicScrollDepthTracker />
|
||||
<DynamicWebVitalsTracker />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
price: product.price,
|
||||
quantity: quantity,
|
||||
cart_total: 150.00, // Current cart total
|
||||
cart_total: 150.0, // Current cart total
|
||||
});
|
||||
|
||||
// Actual add to cart logic
|
||||
// addToCart(product, quantity);
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleAddToCart}>
|
||||
Add to Cart
|
||||
</button>
|
||||
);
|
||||
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -171,7 +167,7 @@ function CheckoutComplete({ order }) {
|
||||
transaction_tax: order.tax,
|
||||
transaction_shipping: order.shipping,
|
||||
product_count: order.items.length,
|
||||
products: order.items.map(item => ({
|
||||
products: order.items.map((item) => ({
|
||||
product_id: item.product.id,
|
||||
product_name: item.product.name,
|
||||
quantity: item.quantity,
|
||||
@@ -198,27 +194,21 @@ function WishlistButton({ product }) {
|
||||
|
||||
const toggleWishlist = () => {
|
||||
const newState = !isInWishlist;
|
||||
|
||||
|
||||
trackEvent(
|
||||
newState
|
||||
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
|
||||
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||
{
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
product_category: product.category,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
setIsInWishlist(newState);
|
||||
// Update wishlist in backend
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={toggleWishlist}>
|
||||
{isInWishlist ? '❤️' : '🤍'}
|
||||
</button>
|
||||
);
|
||||
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -268,7 +258,7 @@ function ContactForm() {
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
@@ -310,9 +300,7 @@ function NewsletterSignup() {
|
||||
return (
|
||||
<div>
|
||||
<input placeholder="Enter email" />
|
||||
<button onClick={() => handleSubscribe('user@example.com')}>
|
||||
Subscribe
|
||||
</button>
|
||||
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -396,10 +384,12 @@ function LoginForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin('user@example.com', 'password');
|
||||
}}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin('user@example.com', 'password');
|
||||
}}
|
||||
>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
@@ -418,11 +408,7 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
function SignupForm() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleSignup = (userData: {
|
||||
email: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
}) => {
|
||||
const handleSignup = (userData: { email: string; name: string; company?: string }) => {
|
||||
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
||||
user_email: userData.email,
|
||||
user_name: userData.name,
|
||||
@@ -436,14 +422,16 @@ function SignupForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignup({
|
||||
email: 'user@example.com',
|
||||
name: 'John Doe',
|
||||
company: 'ACME Corp',
|
||||
});
|
||||
}}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignup({
|
||||
email: 'user@example.com',
|
||||
name: 'John Doe',
|
||||
company: 'ACME Corp',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
@@ -483,7 +471,7 @@ function SearchBar() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
@@ -549,7 +537,7 @@ function ProductFilters() {
|
||||
<option value="cables">Cables</option>
|
||||
<option value="connectors">Connectors</option>
|
||||
</select>
|
||||
|
||||
|
||||
<button onClick={handleClearFilters}>Clear Filters</button>
|
||||
</div>
|
||||
);
|
||||
@@ -631,11 +619,7 @@ function VideoPlayer({ videoId, videoTitle }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<video
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handleComplete}
|
||||
>
|
||||
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}>
|
||||
<source src="/video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
);
|
||||
@@ -665,11 +649,7 @@ function DownloadButton({ fileName, fileType, fileSize }) {
|
||||
// window.location.href = `/downloads/${fileName}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDownload}>
|
||||
Download {fileName}
|
||||
</button>
|
||||
);
|
||||
return <button onClick={handleDownload}>Download {fileName}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -700,7 +680,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps> {
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
|
||||
trackEvent(AnalyticsEvents.ERROR, {
|
||||
error_message: error.message,
|
||||
error_stack: error.stack,
|
||||
@@ -742,14 +722,14 @@ function ApiClient() {
|
||||
const fetchData = async (endpoint: string) => {
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
trackEvent(AnalyticsEvents.API_ERROR, {
|
||||
endpoint: endpoint,
|
||||
status_code: response.status,
|
||||
error_message: response.statusText,
|
||||
});
|
||||
|
||||
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -765,7 +745,7 @@ function ApiClient() {
|
||||
error_message: error.message,
|
||||
error_type: error.name,
|
||||
});
|
||||
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -963,15 +943,9 @@ function CableProductPage({ cable }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{cable.name}</h1>
|
||||
<button onClick={handleTechnicalSpecDownload}>
|
||||
Download Technical Specs
|
||||
</button>
|
||||
<button onClick={handleRequestQuote}>
|
||||
Request Quote
|
||||
</button>
|
||||
<button onClick={handleBrochureDownload}>
|
||||
Download Brochure
|
||||
</button>
|
||||
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button>
|
||||
<button onClick={handleRequestQuote}>Request Quote</button>
|
||||
<button onClick={handleBrochureDownload}>Download Brochure</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1010,12 +984,8 @@ function WindFarmProjectPage({ project }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
<button onClick={handleProjectInquiry}>
|
||||
Request Project Consultation
|
||||
</button>
|
||||
<button onClick={handleCableCalculation}>
|
||||
Calculate Cable Requirements
|
||||
</button>
|
||||
<button onClick={handleProjectInquiry}>Request Project Consultation</button>
|
||||
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1066,7 +1036,7 @@ test('tracks button click', () => {
|
||||
// [Umami] Tracked pageview: /products/123
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
Remember to:
|
||||
|
||||
1. Use the `useAnalytics` hook for client-side tracking
|
||||
2. Import events from `AnalyticsEvents` for consistency
|
||||
3. Include relevant context in your events
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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 `AnalyticsProvider` is in your layout
|
||||
- [ ] Test in development mode
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
@@ -86,16 +86,16 @@ function ProductCard({ product }) {
|
||||
|
||||
## Common Events
|
||||
|
||||
| Event | When to Use | Example Properties |
|
||||
|-------|-------------|-------------------|
|
||||
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
| Event | When to Use | Example Properties |
|
||||
| --------------------- | ------------------- | ------------------------------------------------- |
|
||||
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -112,7 +112,7 @@ In development, you'll see console logs:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
# UMAMI_WEBSITE_ID=
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -120,8 +120,9 @@ In development, you'll see console logs:
|
||||
### Analytics Not Working?
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify script is loading:**
|
||||
@@ -136,12 +137,12 @@ In development, you'll see console logs:
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
| Issue | Solution |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
|
||||
## Performance Tips
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Add these to your `.env` file:
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
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
|
||||
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}
|
||||
```
|
||||
|
||||
@@ -75,11 +75,7 @@ function ProductCard({ product }) {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleAddToCart}>
|
||||
Add to Cart
|
||||
</button>
|
||||
);
|
||||
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -96,7 +92,7 @@ function CustomNavigation() {
|
||||
const navigateToCustomPage = () => {
|
||||
// Track a custom pageview
|
||||
trackPageview('/custom-path?param=value');
|
||||
|
||||
|
||||
// Then perform navigation
|
||||
window.location.href = '/custom-path?param=value';
|
||||
};
|
||||
@@ -277,11 +273,7 @@ function ErrorBoundary({ children }) {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary onError={handleError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -289,20 +281,20 @@ function ErrorBoundary({ children }) {
|
||||
|
||||
### Common Events
|
||||
|
||||
| Event Name | Description | Example Properties |
|
||||
|------------|-------------|-------------------|
|
||||
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
||||
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
| Event Name | Description | Example Properties |
|
||||
| --------------------- | --------------------- | ------------------------------------------------------------ |
|
||||
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
||||
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
||||
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
||||
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
||||
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
||||
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
||||
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
||||
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||
|
||||
### Custom Events
|
||||
|
||||
@@ -385,8 +377,9 @@ The analytics system includes development mode logging:
|
||||
### Analytics Not Working
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
# .env.local (not committed to git)
|
||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
# UMAMI_WEBSITE_ID=
|
||||
```
|
||||
|
||||
## Performance
|
||||
@@ -438,6 +431,7 @@ The analytics implementation is optimized for performance:
|
||||
## Support
|
||||
|
||||
For issues or questions about the analytics implementation, check:
|
||||
|
||||
1. This README for usage examples
|
||||
2. The component source code for implementation details
|
||||
3. The Umami documentation for platform-specific questions
|
||||
|
||||
@@ -16,6 +16,7 @@ The project already had a solid foundation:
|
||||
## What Was Enhanced
|
||||
|
||||
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
||||
|
||||
- ✅ Added TypeScript props interface for customization
|
||||
- ✅ Added JSDoc documentation with usage examples
|
||||
- ✅ Added error handling for script loading failures
|
||||
@@ -23,11 +24,13 @@ The project already had a solid foundation:
|
||||
- ✅ Improved type safety and comments
|
||||
|
||||
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
||||
|
||||
- ✅ Added comprehensive JSDoc documentation
|
||||
- ✅ Added development mode logging
|
||||
- ✅ Improved code comments
|
||||
|
||||
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
||||
|
||||
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
||||
- ✅ `trackEvent()` method for custom events
|
||||
- ✅ `trackPageview()` method for manual pageview tracking
|
||||
@@ -35,12 +38,14 @@ The project already had a solid foundation:
|
||||
- ✅ Development mode logging
|
||||
|
||||
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
||||
|
||||
- ✅ Centralized event constants for consistency
|
||||
- ✅ Type-safe event names
|
||||
- ✅ Helper functions for common event properties
|
||||
- ✅ 30+ predefined events for various use cases
|
||||
|
||||
### 5. **Comprehensive Documentation**
|
||||
|
||||
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
||||
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
||||
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
||||
@@ -63,12 +68,14 @@ components/analytics/
|
||||
## Key Features
|
||||
|
||||
### 🚀 Modern Implementation
|
||||
|
||||
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
||||
- TypeScript for type safety
|
||||
- React hooks for clean API
|
||||
- Environment variable configuration
|
||||
|
||||
### 📊 Comprehensive Tracking
|
||||
|
||||
- Automatic pageview tracking on route changes
|
||||
- Custom event tracking with properties
|
||||
- E-commerce events (products, cart, purchases)
|
||||
@@ -77,6 +84,7 @@ components/analytics/
|
||||
- Error and performance tracking
|
||||
|
||||
### 🎯 Developer Experience
|
||||
|
||||
- Type-safe event tracking
|
||||
- Centralized event definitions
|
||||
- Development mode logging
|
||||
@@ -84,6 +92,7 @@ components/analytics/
|
||||
- 20+ practical examples
|
||||
|
||||
### 🔒 Privacy & Performance
|
||||
|
||||
- No PII tracking by default
|
||||
- Script loads after page is interactive
|
||||
- Minimal performance impact
|
||||
@@ -95,7 +104,7 @@ The project is already configured in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
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}
|
||||
```
|
||||
|
||||
@@ -104,7 +113,7 @@ environment:
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
@@ -188,7 +197,7 @@ In development, you'll see console logs:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
# UMAMI_WEBSITE_ID=
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -196,8 +205,9 @@ In development, you'll see console logs:
|
||||
### Analytics Not Working?
|
||||
|
||||
1. **Check environment variables:**
|
||||
|
||||
```bash
|
||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
echo $UMAMI_WEBSITE_ID
|
||||
```
|
||||
|
||||
2. **Verify script is loading:**
|
||||
@@ -212,12 +222,12 @@ In development, you'll see console logs:
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
| Issue | Solution |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| No data in Umami | Check website ID and script URL |
|
||||
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||
| Script not loading | Check network connection, CORS |
|
||||
| Wrong data | Verify event properties are correct |
|
||||
|
||||
## Performance Tips
|
||||
|
||||
@@ -239,13 +249,13 @@ In development, you'll see console logs:
|
||||
1. ✅ **Setup complete** - All files are in place
|
||||
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
||||
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
|
||||
6. 🚀 **Deploy** - Analytics will work in production
|
||||
|
||||
## 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 `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
||||
- [ ] Test in development mode (check console logs)
|
||||
|
||||
@@ -29,10 +29,14 @@ export default function TrackedLink({
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
trackEvent(eventName, {
|
||||
href,
|
||||
...eventProperties,
|
||||
});
|
||||
try {
|
||||
trackEvent(eventName, {
|
||||
href,
|
||||
...eventProperties,
|
||||
});
|
||||
} catch (_e) {
|
||||
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
||||
}
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default function AnimatedImage({
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
if (containerRef.current) {
|
||||
@@ -45,15 +45,17 @@ export default function AnimatedImage({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={containerRef}
|
||||
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
|
||||
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
|
||||
`} />
|
||||
|
||||
`}
|
||||
/>
|
||||
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
@@ -67,17 +69,9 @@ export default function AnimatedImage({
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
priority={priority}
|
||||
/>
|
||||
|
||||
|
||||
{/* 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" />
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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,51 +1,73 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PostMdx } from '@/lib/blog';
|
||||
import { PostData } from '@/lib/blog';
|
||||
|
||||
interface PostNavigationProps {
|
||||
prev: PostMdx | null;
|
||||
next: PostMdx | null;
|
||||
prev: PostData | null;
|
||||
next: PostData | null;
|
||||
isPrevRandom?: boolean;
|
||||
isNextRandom?: boolean;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 w-full mt-16">
|
||||
{/* Previous Post (Older) */}
|
||||
{prev ? (
|
||||
<Link
|
||||
<Link
|
||||
href={`/${locale}/blog/${prev.slug}`}
|
||||
className="group relative h-64 md:h-80 overflow-hidden block"
|
||||
>
|
||||
{/* Background Image */}
|
||||
{prev.frontmatter.featuredImage ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
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" />
|
||||
)}
|
||||
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
||||
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
{locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
|
||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90">
|
||||
{isPrevRandom
|
||||
? locale === 'de'
|
||||
? 'Weiterer Artikel'
|
||||
: 'More Article'
|
||||
: locale === 'de'
|
||||
? 'Vorheriger Beitrag'
|
||||
: 'Previous Post'}
|
||||
</span>
|
||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||
{prev.frontmatter.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -55,33 +77,42 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
||||
|
||||
{/* Next Post (Newer) */}
|
||||
{next ? (
|
||||
<Link
|
||||
<Link
|
||||
href={`/${locale}/blog/${next.slug}`}
|
||||
className="group relative h-64 md:h-80 overflow-hidden block"
|
||||
>
|
||||
{/* Background Image */}
|
||||
{next.frontmatter.featuredImage ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||
style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
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" />
|
||||
)}
|
||||
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
||||
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
{locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
|
||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90">
|
||||
{isNextRandom
|
||||
? locale === 'de'
|
||||
? 'Weiterer Artikel'
|
||||
: 'More Article'
|
||||
: locale === 'de'
|
||||
? 'Nächster Beitrag'
|
||||
: 'Next Post'}
|
||||
</span>
|
||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||
{next.frontmatter.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 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">
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
||||
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||
</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
|
||||
? '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.'}
|
||||
@@ -45,7 +45,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
||||
? 'Zertifizierte Qualität nach EU-Standards'
|
||||
: 'Certified quality according to EU standards',
|
||||
].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">
|
||||
<svg
|
||||
className="w-3 h-3 text-accent"
|
||||
@@ -88,7 +88,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
||||
/>
|
||||
</svg>
|
||||
</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.'}
|
||||
|
||||
@@ -8,15 +8,17 @@ interface SplitHeadingProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function SplitHeading({ children, className = '', id }: SplitHeadingProps) {
|
||||
export default function SplitHeading({
|
||||
children,
|
||||
className = '',
|
||||
id,
|
||||
level: Level = 'h2',
|
||||
}: SplitHeadingProps & { level?: any }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={className}
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
|
||||
<div id={id} className={className}>
|
||||
<Level className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
|
||||
{children}
|
||||
</h2>
|
||||
</Level>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
||||
return (
|
||||
<nav className="hidden lg:block w-full ml-12">
|
||||
<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'}
|
||||
</h4>
|
||||
<ul className="space-y-4">
|
||||
|
||||
@@ -19,53 +19,78 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
||||
})();
|
||||
|
||||
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="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
<Image
|
||||
src={image.split('?')[0]}
|
||||
alt={title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
{/* Industrial overlay */}
|
||||
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-8 flex flex-col justify-center relative">
|
||||
{/* Industrial accent corner */}
|
||||
<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">
|
||||
<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
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
|
||||
<p className="text-text-secondary text-base line-clamp-2 leading-relaxed mb-4">
|
||||
{summary}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
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 locale = useLocale();
|
||||
|
||||
@@ -10,20 +10,20 @@ export default function CTA() {
|
||||
<Section className="bg-primary text-white py-32 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-16">
|
||||
<div className="max-w-3xl text-center lg:text-left">
|
||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
|
||||
<span className="text-white">{t('title')}</span>
|
||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-6">
|
||||
<span className="text-white">{data?.title || t('title')}</span>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
|
||||
{t('description')}
|
||||
{data?.description || t('description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
|
||||
export default function Experience() {
|
||||
export default function Experience({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.experience');
|
||||
|
||||
return (
|
||||
@@ -11,10 +11,11 @@ export default function Experience() {
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||
alt={t('subtitle')}
|
||||
alt={data?.subtitle || t('subtitle')}
|
||||
fill
|
||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<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" />
|
||||
@@ -22,31 +23,31 @@ export default function Experience() {
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-3xl">
|
||||
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
||||
<span className="text-white">{t('title')}</span>
|
||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white">
|
||||
<span className="text-white">{data?.title || t('title')}</span>
|
||||
</Heading>
|
||||
<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">
|
||||
{t('p1')}
|
||||
{data?.paragraph1 || t('p1')}
|
||||
</p>
|
||||
<p className="pl-9">{t('p2')}</p>
|
||||
<p className="pl-9">{data?.paragraph2 || t('p2')}</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="animate-fade-in">
|
||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('certifiedQuality')}
|
||||
{data?.badge1 || t('certifiedQuality')}
|
||||
</dt>
|
||||
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('vdeApproved')}
|
||||
{data?.badge1Text || t('vdeApproved')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('fullSpectrum')}
|
||||
{data?.badge2 || t('fullSpectrum')}
|
||||
</dt>
|
||||
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('solutionsRange')}
|
||||
{data?.badge2Text || t('solutionsRange')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -3,10 +3,11 @@ import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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';
|
||||
|
||||
export default function GallerySection() {
|
||||
export default function GallerySection({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.gallery');
|
||||
const searchParams = useSearchParams();
|
||||
const images = [
|
||||
@@ -25,8 +26,8 @@ export default function GallerySection() {
|
||||
return (
|
||||
<Section className="bg-white text-white py-32">
|
||||
<Container>
|
||||
<Heading level={2} subtitle={t('subtitle')} align="center">
|
||||
{t('title')}
|
||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
||||
{data?.title || t('title')}
|
||||
</Heading>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
|
||||
@@ -1,200 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
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 locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
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">
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<m.div
|
||||
className="max-w-5xl mx-auto md:mx-0"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<m.div variants={headingVariants}>
|
||||
<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]"
|
||||
>
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<m.span
|
||||
className="relative z-10 text-accent italic"
|
||||
variants={accentVariants}
|
||||
>
|
||||
{chunks}
|
||||
</m.span>
|
||||
<m.div
|
||||
variants={scribbleVariants}
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
||||
>
|
||||
<Scribble variant="circle" />
|
||||
</m.div>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
</m.div>
|
||||
<m.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>
|
||||
</m.div>
|
||||
<m.div
|
||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
||||
variants={buttonContainerVariants}
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<div className="max-w-5xl mx-auto md:mx-0">
|
||||
<div>
|
||||
<Heading
|
||||
level={1}
|
||||
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"
|
||||
>
|
||||
<m.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"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('cta'),
|
||||
location: 'home_hero_primary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('cta')}
|
||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||
</Button>
|
||||
</m.div>
|
||||
<m.div variants={buttonVariants}>
|
||||
<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"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('exploreProducts'),
|
||||
location: 'home_hero_secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('exploreProducts')}
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
</Container>
|
||||
|
||||
<m.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 />
|
||||
</m.div>
|
||||
|
||||
<m.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">
|
||||
<m.div
|
||||
className="w-1 h-2 bg-white rounded-full"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
{data?.title ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||
.replace(/<\/green>/g, '</span>'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
})
|
||||
)}
|
||||
</Heading>
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
<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>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 1 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headingVariants = {
|
||||
hidden: { opacity: 1, y: 10, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.4, 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: 1, y: 20, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buttonContainerVariants = {
|
||||
hidden: { opacity: 1 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
delayChildren: 0.4,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buttonVariants = {
|
||||
hidden: { opacity: 1, y: 30, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: 'spring', stiffness: 400, damping: 20 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -125,8 +125,9 @@ export default function HeroIllustration() {
|
||||
}, []);
|
||||
|
||||
const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100';
|
||||
const scale = isMobile ? 1.44 : 1;
|
||||
const opacity = isMobile ? 0.6 : 0.85;
|
||||
// Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance
|
||||
const scale = isMobile ? 1.6 : 1;
|
||||
const opacity = isMobile ? 0.9 : 0.85;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
|
||||
@@ -154,15 +155,15 @@ export default function HeroIllustration() {
|
||||
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1" result="blur" />
|
||||
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
@@ -215,10 +216,10 @@ export default function HeroIllustration() {
|
||||
</g>
|
||||
|
||||
{/* ANIMATED ENERGY FLOW */}
|
||||
<g>
|
||||
<g filter="url(#glow)">
|
||||
{POWER_LINES.map((line, i) => {
|
||||
// Only animate a small subset of lines to reduce main-thread work significantly
|
||||
if (i % 5 !== 0) return null;
|
||||
// Only animate a subset of lines to reduce main-thread work
|
||||
if (i % 2 !== 0) return null;
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
|
||||
@@ -232,12 +233,16 @@ export default function HeroIllustration() {
|
||||
stroke="url(#energy-pulse)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
strokeDasharray: `${length * 0.2} ${length * 0.8}`,
|
||||
strokeDashoffset: length,
|
||||
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from={length}
|
||||
to={0}
|
||||
dur={`${1.5 + (i % 3) * 0.5}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
@@ -263,13 +268,14 @@ export default function HeroIllustration() {
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
r="3"
|
||||
fill="#82ed20"
|
||||
fillOpacity="0.3"
|
||||
filter="url(#soft-glow)"
|
||||
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }}
|
||||
/>
|
||||
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate
|
||||
attributeName="fillOpacity"
|
||||
values="0.2;0.5;0.2"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -289,26 +295,28 @@ export default function HeroIllustration() {
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<g transform="translate(0, -60)">
|
||||
<g
|
||||
style={{
|
||||
transformOrigin: '0px 0px',
|
||||
animation: `spin-slow ${3 + i}s linear infinite`,
|
||||
}}
|
||||
>
|
||||
{[0, 120, 240].map((angle, j) => (
|
||||
<line
|
||||
key={`blade-${i}-${j}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-30"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.4"
|
||||
transform={`rotate(${angle})`}
|
||||
{[0, 120, 240].map((angle, j) => (
|
||||
<line
|
||||
key={`blade-${i}-${j}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-30"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.4"
|
||||
transform={`rotate(${angle})`}
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from={`${angle} 0 0`}
|
||||
to={`${angle + 360} 0 0`}
|
||||
dur={`${3 + i}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</line>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
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 teamT = useTranslations('Team');
|
||||
const locale = useLocale();
|
||||
@@ -13,10 +13,11 @@ export default function MeetTheTeam() {
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||
alt={t('subtitle')}
|
||||
alt={data?.subtitle || t('subtitle')}
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<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" />
|
||||
@@ -24,20 +25,20 @@ export default function MeetTheTeam() {
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-3xl text-white animate-slide-up">
|
||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
||||
<span className="text-white">{t('title')}</span>
|
||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
|
||||
<span className="text-white">{data?.title || t('title')}</span>
|
||||
</Heading>
|
||||
|
||||
<div className="relative mb-12">
|
||||
<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">
|
||||
"{t('description')}"
|
||||
"{data?.description || t('description')}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-8 items-center">
|
||||
<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>
|
||||
</Button>
|
||||
|
||||
@@ -61,7 +62,7 @@ export default function MeetTheTeam() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
||||
{t('andNetwork')}
|
||||
{data?.networkLabel || t('andNetwork')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Section } from '../../components/ui';
|
||||
|
||||
export default function ProductCategories() {
|
||||
export default function ProductCategories({ data }: { data?: any }) {
|
||||
const t = useTranslations('Products');
|
||||
const locale = useLocale();
|
||||
|
||||
@@ -43,7 +43,15 @@ export default function ProductCategories() {
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||
<h2 className="sr-only">{t('title')}</h2>
|
||||
{(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">
|
||||
{categories.map((category, idx) => (
|
||||
<Link
|
||||
|
||||
@@ -3,99 +3,108 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
|
||||
interface RecentPostsProps {
|
||||
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 posts = await getAllPosts(locale);
|
||||
const recentPosts = posts.slice(0, 3);
|
||||
const recentPosts = posts.slice(0, 4);
|
||||
|
||||
if (recentPosts.length === 0) return null;
|
||||
|
||||
const title = data?.title || t('allArticles');
|
||||
const subtitle = data?.subtitle || t('latestNews');
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral py-16 md:py-24">
|
||||
<Container>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
||||
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
||||
{t('allArticles')}
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
|
||||
<Container className="py-12 md:py-16">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
|
||||
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
||||
{title}
|
||||
</Heading>
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
||||
{recentPosts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
||||
<Card
|
||||
tag="article"
|
||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</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">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||
{t('readMore')}
|
||||
<svg
|
||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
|
||||
{recentPosts.map((post, idx) => (
|
||||
<li key={`${post.slug}-${idx}`} className="block">
|
||||
<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 && (
|
||||
<>
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
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 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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={post.frontmatter.date}
|
||||
suppressHydrationWarning
|
||||
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
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>
|
||||
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
</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">
|
||||
{t('readMore')}{' '}
|
||||
<span className="ml-2 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function VideoSection() {
|
||||
export default function VideoSection({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.video');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
@@ -40,17 +39,19 @@ export default function VideoSection() {
|
||||
<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]">
|
||||
{t.rich('title', {
|
||||
future: (chunks) => (
|
||||
<span className="relative inline-block mx-2">
|
||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
{data?.title ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||
.replace(/<\/future>/g, '</span>'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||
})
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,30 +2,35 @@ import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
|
||||
export default function WhatWeDo() {
|
||||
export default function WhatWeDo({ data }: { data?: any }) {
|
||||
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 (
|
||||
<Section className="bg-white">
|
||||
<Container>
|
||||
<div className="sticky-narrative-container">
|
||||
<div className="sticky-narrative-sidebar">
|
||||
<div className="lg:sticky lg:top-32">
|
||||
<Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
|
||||
{t('title')}
|
||||
<Heading level={2} subtitle={data?.expertiseLabel || t('expertise')} className="text-primary-dark">
|
||||
{data?.title || t('title')}
|
||||
</Heading>
|
||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||
{t('subtitle')}
|
||||
{data?.subtitle || t('subtitle')}
|
||||
</p>
|
||||
<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">
|
||||
"{t('quote')}"
|
||||
"{data?.quote || t('quote')}"
|
||||
</p>
|
||||
</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">
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
{items.map((item: any, idx: number) => (
|
||||
<div key={idx} className="group">
|
||||
<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">
|
||||
@@ -33,8 +38,8 @@ export default function WhatWeDo() {
|
||||
</span>
|
||||
<div className="h-px flex-grow bg-neutral-medium" />
|
||||
</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>
|
||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
||||
<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">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,24 +2,27 @@ import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
|
||||
export default function WhyChooseUs() {
|
||||
export default function WhyChooseUs({ data }: { data?: any }) {
|
||||
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 (
|
||||
<Section className="bg-neutral-light">
|
||||
<Container>
|
||||
<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="sticky top-32">
|
||||
<Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
|
||||
{t('title')}
|
||||
<Heading level={2} subtitle={data?.tagline || t('whyKlz')} className="text-primary-dark">
|
||||
{data?.title || t('title')}
|
||||
</Heading>
|
||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||
{t('subtitle')}
|
||||
{data?.subtitle || t('subtitle')}
|
||||
</p>
|
||||
|
||||
<ul className="mt-12 space-y-6 list-none p-0">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
{features.map((featureText: string, i: number) => (
|
||||
<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">
|
||||
<svg
|
||||
@@ -38,7 +41,7 @@ export default function WhyChooseUs() {
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-primary-dark text-base md:text-base">
|
||||
{t(`features.${i}`)}
|
||||
{featureText}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -46,7 +49,7 @@ export default function WhyChooseUs() {
|
||||
</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">
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
{items.map((item: any, idx: number) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
|
||||
@@ -57,10 +60,10 @@ export default function WhyChooseUs() {
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">
|
||||
{t(`items.${idx}.title`)}
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">
|
||||
{t(`items.${idx}.description`)}
|
||||
{item.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { finder } from '@medv/finder';
|
||||
|
||||
export function PickingHelper() {
|
||||
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'START_PICKING') {
|
||||
setPickingMode(e.data.mode);
|
||||
} else if (e.data.type === 'STOP_PICKING') {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
|
||||
const selector = e.data.selector;
|
||||
if (selector) {
|
||||
const el = document.querySelector(selector) as HTMLElement;
|
||||
setHoveredElement(el || null);
|
||||
} else {
|
||||
setHoveredElement(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickingMode) return;
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (hoveredElement) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const selector = finder(hoveredElement, {
|
||||
root: document.body,
|
||||
seedMinLength: 3,
|
||||
optimizedMinLength: 2,
|
||||
className: (name) =>
|
||||
!name.startsWith('record-mode-') &&
|
||||
!name.startsWith('feedback-') &&
|
||||
!name.includes('[') &&
|
||||
!name.includes('/') &&
|
||||
!name.match(/^[a-z]-[0-9]/) &&
|
||||
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
|
||||
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
|
||||
});
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'ELEMENT_SELECTED',
|
||||
selector,
|
||||
rect: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
},
|
||||
tagName: hoveredElement.tagName.toLowerCase()
|
||||
}, '*');
|
||||
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mouseover', handleMouseOver);
|
||||
window.addEventListener('click', handleClick, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseover', handleMouseOver);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [pickingMode, hoveredElement]);
|
||||
|
||||
if (!hoveredElement) return null;
|
||||
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
|
||||
// but DO show if we have a hoveredElement (from message or mouseover)
|
||||
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/15 transition-all z-[9999] shadow-[0_0_20px_rgba(130,237,32,0.3)] rounded-sm"
|
||||
style={{
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
|
||||
{hoveredElement.tagName.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function PlaybackCursor() {
|
||||
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
|
||||
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Track scroll so cursor stays locked to the correct element
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||
};
|
||||
|
||||
handleScroll(); // Init
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isPlaying]);
|
||||
|
||||
if (!isPlaying) return null;
|
||||
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<m.div
|
||||
className="fixed z-[10000] pointer-events-none"
|
||||
animate={{
|
||||
x: cursorPosition.x,
|
||||
y: cursorPosition.y,
|
||||
scale: isClicking ? 0.8 : 1,
|
||||
rotateX: isClicking ? 15 : 0,
|
||||
rotateY: isClicking ? -15 : 0,
|
||||
}}
|
||||
transition={{
|
||||
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
}}
|
||||
style={{ perspective: '1000px' }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isClicking && (
|
||||
<m.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 2.5, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Outer Pulse Ring */}
|
||||
<div
|
||||
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||
/>
|
||||
|
||||
{/* Visual Cursor */}
|
||||
<div className="relative">
|
||||
{/* Soft Glow */}
|
||||
<div
|
||||
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||
/>
|
||||
|
||||
{/* Pointer Arrow */}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||
>
|
||||
<path
|
||||
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||
fill={isClicking ? '#82ed20' : 'white'}
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
className="transition-colors duration-150"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
||||
|
||||
interface RecordModeContextType {
|
||||
isActive: boolean;
|
||||
setIsActive: (active: boolean) => void;
|
||||
events: RecordEvent[];
|
||||
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||
removeEvent: (id: string) => void;
|
||||
clearEvents: () => void;
|
||||
setEvents: (events: RecordEvent[]) => void;
|
||||
isPlaying: boolean;
|
||||
playEvents: () => void;
|
||||
stopPlayback: () => void;
|
||||
cursorPosition: { x: number; y: number };
|
||||
zoomLevel: number;
|
||||
isBlurry: boolean;
|
||||
currentSession: RecordingSession | null;
|
||||
saveSession: (name: string) => void;
|
||||
isFeedbackActive: boolean;
|
||||
setIsFeedbackActive: (active: boolean) => void;
|
||||
reorderEvents: (startIndex: number, endIndex: number) => void;
|
||||
hoveredEventId: string | null;
|
||||
setHoveredEventId: (id: string | null) => void;
|
||||
isClicking: boolean;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
|
||||
export function useRecordMode(): RecordModeContextType {
|
||||
const context = useContext(RecordModeContext);
|
||||
if (!context) {
|
||||
return {
|
||||
isActive: false,
|
||||
setIsActive: () => {},
|
||||
events: [],
|
||||
addEvent: () => {},
|
||||
updateEvent: () => {},
|
||||
removeEvent: () => {},
|
||||
clearEvents: () => {},
|
||||
isPlaying: false,
|
||||
playEvents: () => {},
|
||||
stopPlayback: () => {},
|
||||
cursorPosition: { x: 0, y: 0 },
|
||||
zoomLevel: 1,
|
||||
isBlurry: false,
|
||||
currentSession: null,
|
||||
isFeedbackActive: false,
|
||||
setIsFeedbackActive: () => {},
|
||||
saveSession: () => {},
|
||||
reorderEvents: () => {},
|
||||
hoveredEventId: null,
|
||||
setHoveredEventId: () => {},
|
||||
setEvents: () => {},
|
||||
isClicking: false,
|
||||
isEnabled: false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function RecordModeProvider({
|
||||
children,
|
||||
isEnabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isEnabled?: boolean;
|
||||
}) {
|
||||
const [isActive, setIsActiveState] = useState(false);
|
||||
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [isBlurry, setIsBlurry] = useState(false);
|
||||
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
||||
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
|
||||
const [isClicking, setIsClicking] = useState(false);
|
||||
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
|
||||
}, [isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
const embedded =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.search.includes('embedded=true') ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.self !== window.top);
|
||||
setIsEmbedded(embedded);
|
||||
}, [isEnabled]);
|
||||
|
||||
const setIsActive = (active: boolean) => {
|
||||
if (!isEnabled) return;
|
||||
setIsActiveState(active);
|
||||
if (active) setIsFeedbackActiveState(false);
|
||||
};
|
||||
|
||||
const setIsFeedbackActive = (active: boolean) => {
|
||||
setIsFeedbackActiveState(active);
|
||||
if (active && isEnabled) setIsActiveState(false);
|
||||
};
|
||||
|
||||
const isPlayingRef = useRef(false);
|
||||
const isLoadedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
const savedEvents = localStorage.getItem('klz-record-events');
|
||||
const savedActive = localStorage.getItem('klz-record-active');
|
||||
if (savedEvents) setEvents(JSON.parse(savedEvents));
|
||||
if (savedActive) setIsActive(JSON.parse(savedActive));
|
||||
isLoadedRef.current = true;
|
||||
}, [isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || !isLoadedRef.current) return;
|
||||
localStorage.setItem('klz-record-events', JSON.stringify(events));
|
||||
}, [events, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
|
||||
}, [isActive, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (isEmbedded) {
|
||||
const handlePlaybackMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'PLAY_EVENT') {
|
||||
const { event } = e.data;
|
||||
const el = event.selector
|
||||
? (document.querySelector(event.selector) as HTMLElement)
|
||||
: null;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else if (event.type === 'mouse') {
|
||||
const currentRect = el.getBoundingClientRect();
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
if (event.clickOrigin === 'top-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'top-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'bottom-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
} else if (event.clickOrigin === 'bottom-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handlePlaybackMessage);
|
||||
return () => window.removeEventListener('message', handlePlaybackMessage);
|
||||
}
|
||||
}, [isEmbedded, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || isEmbedded || !isActive) return;
|
||||
const event = events.find((e) => e.id === hoveredEventId);
|
||||
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
|
||||
|
||||
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||
if (!isEnabled) return;
|
||||
const newEvent: RecordEvent = {
|
||||
realClick: false,
|
||||
...event,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setEvents((prev) => [...prev, newEvent]);
|
||||
};
|
||||
|
||||
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
||||
if (!isEnabled) return;
|
||||
setEvents((prev) =>
|
||||
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
|
||||
);
|
||||
};
|
||||
|
||||
const reorderEvents = (startIndex: number, endIndex: number) => {
|
||||
if (!isEnabled) return;
|
||||
const result = Array.from(events);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
setEvents(result);
|
||||
};
|
||||
|
||||
const removeEvent = (id: string) => {
|
||||
if (!isEnabled) return;
|
||||
setEvents((prev) => prev.filter((event) => event.id !== id));
|
||||
};
|
||||
|
||||
const clearEvents = () => {
|
||||
if (!isEnabled) return;
|
||||
if (confirm('Clear all recorded events?')) setEvents([]);
|
||||
};
|
||||
|
||||
const currentSession: RecordingSession | null =
|
||||
events.length > 0
|
||||
? {
|
||||
id: 'draft',
|
||||
name: 'Draft Session',
|
||||
events,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const saveSession = (name: string) => {
|
||||
if (!isEnabled) return;
|
||||
console.log('Saving session:', name, events);
|
||||
};
|
||||
|
||||
const playEvents = async () => {
|
||||
if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (!isPlayingRef.current) break;
|
||||
if (event.rect && !isEmbedded) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
const iframeRect = iframe?.getBoundingClientRect();
|
||||
setCursorPosition({
|
||||
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
|
||||
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.selector) {
|
||||
if (!isEmbedded) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow)
|
||||
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
|
||||
} else {
|
||||
const el = document.querySelector(event.selector) as HTMLElement;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else if (event.type === 'mouse') {
|
||||
const currentRect = el.getBoundingClientRect();
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
if (event.clickOrigin === 'top-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'top-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'bottom-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
} else if (event.clickOrigin === 'bottom-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.zoom) setZoomLevel(event.zoom);
|
||||
if (event.motionBlur) setIsBlurry(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
|
||||
setIsBlurry(false);
|
||||
}
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
};
|
||||
|
||||
const stopPlayback = () => {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
setIsBlurry(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecordModeContext.Provider
|
||||
value={{
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
clearEvents,
|
||||
setEvents,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
stopPlayback,
|
||||
cursorPosition,
|
||||
zoomLevel,
|
||||
isBlurry,
|
||||
currentSession,
|
||||
saveSession,
|
||||
isFeedbackActive,
|
||||
setIsFeedbackActive,
|
||||
reorderEvents,
|
||||
hoveredEventId,
|
||||
setHoveredEventId,
|
||||
isClicking,
|
||||
isEnabled,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecordModeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
MousePointer2,
|
||||
Scroll,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit2,
|
||||
X,
|
||||
Check,
|
||||
Download,
|
||||
Settings2,
|
||||
GripVertical,
|
||||
Clock,
|
||||
Maximize2,
|
||||
Box,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { RecordEvent } from '@/types/record-mode';
|
||||
import { PlaybackCursor } from './PlaybackCursor';
|
||||
|
||||
export function RecordModeOverlay() {
|
||||
const {
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
saveSession,
|
||||
clearEvents,
|
||||
reorderEvents,
|
||||
setHoveredEventId,
|
||||
setEvents, // Added setEvents here
|
||||
} = useRecordMode();
|
||||
|
||||
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !isActive) return;
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||
const { selector, rect, tagName } = e.data;
|
||||
|
||||
if (pickingMode === 'mouse') {
|
||||
addEvent({
|
||||
type: 'mouse',
|
||||
interactionType: lastInteractionType,
|
||||
selector,
|
||||
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||
zoom: 1,
|
||||
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||
motionBlur: false,
|
||||
realClick: false,
|
||||
rect,
|
||||
});
|
||||
} else if (pickingMode === 'scroll') {
|
||||
addEvent({
|
||||
type: 'scroll',
|
||||
selector,
|
||||
duration: 1500,
|
||||
zoom: 1,
|
||||
description: `Scroll to ${tagName}`,
|
||||
motionBlur: false,
|
||||
rect,
|
||||
});
|
||||
}
|
||||
setPickingMode(null);
|
||||
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||
setPickingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
if (pickingMode) {
|
||||
// Find the iframe and signal start picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||
}
|
||||
} else {
|
||||
// Signal stop picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [isActive, pickingMode, addEvent, mounted]);
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingEventId) {
|
||||
updateEvent(editingEventId, editForm);
|
||||
setEditingEventId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [showEvents, setShowEvents] = useState(true);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
if (!isActive) {
|
||||
// Failsafe: Never render host toggle in embedded mode
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.self !== window.top ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.location.search.includes('embedded=true'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsActive(true)}
|
||||
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMotion strict features={domAnimation}>
|
||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||
{/* Identity Tag */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||
Event Builder
|
||||
</span>
|
||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||
Manual Mode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Action Tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode('mouse');
|
||||
setLastInteractionType('click');
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
<span>Mouse</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
<span>Scroll</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
addEvent({
|
||||
type: 'wait',
|
||||
duration: 2000,
|
||||
zoom: 1,
|
||||
description: 'Wait for 2s',
|
||||
motionBlur: false,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Sequence Controls */}
|
||||
<div className="flex items-center gap-1 p-0.5">
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isPlaying || events.length === 0}
|
||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||
title="Preview Sequence"
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
{events.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||
{events.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
const session = {
|
||||
events,
|
||||
name: 'Recording',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/save-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(session),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Visual feedback could be improved, but alert is fine for dev tool
|
||||
alert('Session saved to remotion/session.json');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Failed to save: ${err.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error saving session');
|
||||
}
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||
title="Save to Project (Dev)"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify(
|
||||
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'remotion-session.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||
title="Download JSON"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setIsActive(false)}
|
||||
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||
title="Exit Studio"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Event Timeline Popover */}
|
||||
{showEvents && (
|
||||
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||
{events.length} Actions Recorded
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={events}
|
||||
onReorder={setEvents}
|
||||
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||
<Plus size={40} strokeWidth={1} />
|
||||
<p className="text-xs mt-4">Timeline is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, index) => (
|
||||
<Reorder.Item
|
||||
key={event.id}
|
||||
value={event}
|
||||
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||
onMouseLeave={() => setHoveredEventId(null)}
|
||||
>
|
||||
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||
{event.type === 'mouse'
|
||||
? `Mouse (${event.interactionType})`
|
||||
: event.type}
|
||||
</span>
|
||||
{event.clickOrigin &&
|
||||
event.clickOrigin !== 'center' &&
|
||||
event.interactionType === 'click' && (
|
||||
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{event.clickOrigin}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||
{event.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||
{event.selector || 'system:wait'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingEventId(event.id);
|
||||
setEditForm(event);
|
||||
}}
|
||||
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeEvent(event.id)}
|
||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))
|
||||
)}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||
|
||||
{/* Picking Tooltip */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||
<span className="font-black uppercase tracking-widest text-xs">
|
||||
Assigning {pickingMode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-primary-dark/20" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}}
|
||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||
>
|
||||
ESC to Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlaybackCursor />
|
||||
|
||||
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||
<AnimatePresence>
|
||||
{editingEventId && (
|
||||
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||
Event Options
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setEditingEventId(null)}
|
||||
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||
{/* Type Display */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Interaction Type
|
||||
</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
type: 'mouse',
|
||||
interactionType: 'click',
|
||||
}))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<MousePointer2 size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Click</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
type: 'mouse',
|
||||
interactionType: 'hover',
|
||||
}))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Scroll size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Precise Click Origin */}
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Click Origin
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||
{[
|
||||
{ id: 'top-left', label: 'TL' },
|
||||
{ id: 'top-right', label: 'TR' },
|
||||
{ id: 'center', label: 'CTR' },
|
||||
{ id: 'bottom-left', label: 'BL' },
|
||||
{ id: 'bottom-right', label: 'BR' },
|
||||
].map((origin) => (
|
||||
<button
|
||||
key={origin.id}
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||
}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||
>
|
||||
{origin.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||
<span>Timeline Allocation</span>
|
||||
<span className="text-accent">{editForm.duration}ms</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={editForm.duration || 1000}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||
}
|
||||
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom & Effects */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<Maximize2 size={18} className="text-white/40" />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Zoom Shift
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="1"
|
||||
max="3"
|
||||
value={editForm.zoom || 1}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||
}
|
||||
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
|
||||
}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Box size={18} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">
|
||||
Motion Blur
|
||||
</span>
|
||||
</div>
|
||||
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
|
||||
}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExternalLink size={18} />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-xs font-bold uppercase tracking-wider">
|
||||
Trigger Navigation
|
||||
</span>
|
||||
<span className="text-[8px] opacity-60">
|
||||
Allows URL transitions in Studio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||
>
|
||||
Commit Changes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [isEmbedded, setIsEmbedded] = React.useState(false);
|
||||
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
// Explicit non-magical detection
|
||||
const embedded =
|
||||
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
|
||||
setIsEmbedded(embedded);
|
||||
|
||||
if (!embedded) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('embedded', 'true');
|
||||
setIframeUrl(url.toString());
|
||||
}
|
||||
}, [isEmbedded]);
|
||||
|
||||
// Hydration Guard: Match server on first render
|
||||
if (!mounted) return <>{children}</>;
|
||||
|
||||
// Recursion Guard: If we are already in an embedded iframe,
|
||||
// strictly return just the children to prevent Inception.
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
|
||||
#nextjs-portal,
|
||||
#nextjs-portal-root,
|
||||
[data-nextjs-toast-wrapper],
|
||||
.nextjs-static-indicator,
|
||||
[data-nextjs-indicator],
|
||||
[class*="nextjs-"],
|
||||
[id*="nextjs-"],
|
||||
nextjs-portal,
|
||||
#feedback-overlay,
|
||||
.feedback-ui-root,
|
||||
.feedback-ui-ignore,
|
||||
[class*="z-[9999]"],
|
||||
[class*="z-[10000]"],
|
||||
[style*="z-index: 9999"],
|
||||
[style*="z-index: 10000"],
|
||||
.fixed.bottom-6.left-6,
|
||||
.fixed.bottom-6.left-1/2,
|
||||
.feedback-ui-overlay,
|
||||
[id^="feedback-"],
|
||||
[class^="feedback-"] {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
z-index: -10000 !important;
|
||||
}
|
||||
|
||||
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
|
||||
* {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
border-radius: 3rem;
|
||||
background: #050505 !important;
|
||||
color: white !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Global Style for Body Lock */}
|
||||
{isActive && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
height: 100vh !important;
|
||||
position: fixed !important;
|
||||
width: 100vw !important;
|
||||
}
|
||||
/* Kill Next.js Dev tools on host while Studio is active */
|
||||
#nextjs-portal,
|
||||
[data-nextjs-toast-wrapper],
|
||||
.nextjs-static-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}
|
||||
>
|
||||
{/* Studio Background - Only visible when active */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
|
||||
<div
|
||||
className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, #10b981 0%, transparent 70%)',
|
||||
filter: 'blur(160px)',
|
||||
animation: 'mesh-float-1 18s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)',
|
||||
filter: 'blur(150px)',
|
||||
animation: 'mesh-float-2 22s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)',
|
||||
filter: 'blur(130px)',
|
||||
animation: 'mesh-float-3 14s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)',
|
||||
filter: 'blur(140px)',
|
||||
animation: 'mesh-float-4 20s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '128px 128px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
|
||||
style={{
|
||||
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
|
||||
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
|
||||
filter: isBlurry ? 'blur(4px)' : 'none',
|
||||
willChange: 'transform, filter',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isActive
|
||||
? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate'
|
||||
: 'w-full h-full'
|
||||
}
|
||||
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}
|
||||
>
|
||||
{isActive && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
|
||||
<div
|
||||
className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))',
|
||||
animation: 'pulse-ring 4s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
isActive
|
||||
? 'w-full h-full rounded-[3rem] overflow-hidden relative'
|
||||
: 'w-full h-full relative'
|
||||
}
|
||||
style={{
|
||||
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
|
||||
transform: isActive ? 'translateZ(0)' : 'none',
|
||||
}}
|
||||
>
|
||||
{isActive && iframeUrl ? (
|
||||
<iframe
|
||||
src={iframeUrl}
|
||||
name="record-mode-iframe"
|
||||
className="w-full h-full border-0 block"
|
||||
style={{
|
||||
backgroundColor: '#050505',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
isActive
|
||||
? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700'
|
||||
: 'transition-all duration-700'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
|
||||
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
|
||||
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
|
||||
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
|
||||
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
|
||||
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
|
||||
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const FeedbackOverlay = dynamic(
|
||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const RecordModeOverlay = dynamic(
|
||||
() => import('./RecordModeOverlay').then((mod) => mod.RecordModeOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { PickingHelper } from './PickingHelper';
|
||||
|
||||
interface ToolCoordinatorProps {
|
||||
isEmbedded?: boolean;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCoordinator({
|
||||
isEmbedded: isEmbeddedProp,
|
||||
feedbackEnabled = false,
|
||||
}: ToolCoordinatorProps) {
|
||||
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
|
||||
useRecordMode();
|
||||
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const embedded =
|
||||
isEmbeddedProp ||
|
||||
window.location.search.includes('embedded=true') ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.self !== window.top;
|
||||
setIsEmbedded(embedded);
|
||||
}, [isEmbeddedProp]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// Nothing enabled → render nothing
|
||||
if (!feedbackEnabled && !isEnabled) return null;
|
||||
|
||||
// Iframe → only PickingHelper
|
||||
if (isEmbedded) return <PickingHelper />;
|
||||
|
||||
// Record Mode active and enabled
|
||||
if (isActive && isEnabled) return <RecordModeOverlay />;
|
||||
|
||||
// Feedback active and enabled
|
||||
if (isFeedbackActive && feedbackEnabled) {
|
||||
return (
|
||||
<FeedbackOverlay
|
||||
isActive={isFeedbackActive}
|
||||
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Baseline: toggle buttons
|
||||
return (
|
||||
<div className="feedback-ui-ignore">
|
||||
{feedbackEnabled && (
|
||||
<FeedbackOverlay
|
||||
isActive={false}
|
||||
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||
/>
|
||||
)}
|
||||
{isEnabled && <RecordModeOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
export default function Gallery() {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Button({
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||
@@ -45,8 +45,8 @@ export function Button({
|
||||
const sizes = {
|
||||
sm: 'h-9 px-4 text-sm md:text-base',
|
||||
md: 'h-11 px-6 text-base md:text-lg',
|
||||
lg: 'h-14 px-8 text-base md:text-lg',
|
||||
xl: 'h-16 px-10 text-lg md:text-xl',
|
||||
lg: 'h-14 px-5 md:px-8 text-base md:text-lg',
|
||||
xl: 'h-16 px-6 md:px-10 text-lg md:text-xl',
|
||||
};
|
||||
|
||||
const styles = cn(baseStyles, variants[variant], sizes[size], className);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"categories:performance": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
"minScore": 0.7
|
||||
}
|
||||
],
|
||||
"categories:accessibility": [
|
||||
|
||||
38
create_pdf.mjs
Normal file
38
create_pdf.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'fs';
|
||||
|
||||
async function generatePDF() {
|
||||
const pandocHtml = fs.readFileSync('AVB_2026.html', 'utf-8');
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 10pt; line-height: 1.5; color: #000; margin: 40px; }
|
||||
h2 { font-size: 16pt; margin-bottom: 20px; font-weight: bold; }
|
||||
h3 { font-size: 12pt; margin-top: 25px; margin-bottom: 10px; font-weight: bold; color: #222; }
|
||||
p { margin-bottom: 12px; text-align: justify; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${pandocHtml}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
await page.pdf({
|
||||
path: 'KLZ_AVB_April_2026.pdf',
|
||||
format: 'A4',
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||
printBackground: true
|
||||
});
|
||||
await browser.close();
|
||||
console.log('PDF generated: KLZ_AVB_April_2026.pdf');
|
||||
}
|
||||
|
||||
generatePDF().catch(console.error);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user