Compare commits

..

3 Commits

Author SHA1 Message Date
8652dd722e perf(ci): optimize pipeline via parallelization, caching and conditional audits
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🏗️ Build (push) Successful in 6m35s
Build & Deploy / 🧪 QA (push) Successful in 15m18s
Build & Deploy / 🚀 Deploy (push) Successful in 36s
Build & Deploy / 🧪 Smoke Test (push) Failing after 10s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
2026-02-23 12:42:05 +01:00
5e48c75a83 fix(routing): resolve 404 on German product pages via rewrites and localized links
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 3m7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has started running
2026-02-23 12:36:37 +01:00
50fc8a0554 fix(analytics): remove conflicting next config rewrite and enable proxy client
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 7m35s
Build & Deploy / 🚀 Deploy (push) Successful in 37s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m8s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m25s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-20 15:00:18 +01:00
554 changed files with 233182 additions and 53231 deletions

View File

@@ -1,13 +1,5 @@
node_modules node_modules
.next .next
.DS_Store
.git
.gitignore
.gitea
.github
.turbo
reference/
.next
!.next/cache !.next/cache
.git .git
.DS_Store .DS_Store
@@ -16,5 +8,3 @@ reference/
docs docs
reference reference
public/datasheets/*.pdf public/datasheets/*.pdf
.pnpm-store
.gitea

40
.env
View File

@@ -7,7 +7,6 @@ SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=false NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false NEXT_PUBLIC_RECORD_MODE_ENABLED=false
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
# SMTP Configuration # SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org MAIL_HOST=smtp.eu.mailgun.org
@@ -17,22 +16,23 @@ MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>" MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# ──────────────────────────────────────────────────────────────────────────── # Directus
# Payload Infrastructure (Dockerized) DIRECTUS_URL=http://klz-cms:8055
# ──────────────────────────────────────────────────────────────────────────── DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
# by docker-compose.yml using these base DB credentials, so you don't need to DIRECTUS_ADMIN_EMAIL=marc@mintel.me
# manually write the connection strings here. DIRECTUS_ADMIN_PASSWORD=Tim300493.
PAYLOAD_DB_NAME=payload DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
PAYLOAD_DB_USER=payload DIRECTUS_DB_NAME=directus
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon DIRECTUS_DB_USER=klz_db_user
DIRECTUS_DB_PASSWORD=klz_db_pass
# ──────────────────────────────────────────────────────────────────────────── # Local Development
# Hetzner S3 Object Storage PROJECT_NAME=klz-cables
# ──────────────────────────────────────────────────────────────────────────── GATEKEEPER_BYPASS_ENABLED=true
S3_ENDPOINT=https://fsn1.your-objectstorage.com TRAEFIK_HOST=klz.localhost
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS DIRECTUS_HOST=cms.klz.localhost
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3 GATEKEEPER_PASSWORD=klz2026
S3_BUCKET=mintel COOKIE_DOMAIN=localhost
S3_REGION=fsn1 INFRA_DIRECTUS_URL=http://localhost:8059
S3_PREFIX=klz-cables INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
GATEKEEPER_ORIGIN=http://klz.localhost

View File

@@ -10,6 +10,7 @@
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000
DIRECTUS_PORT=8055
# TARGET is used to differentiate between environments (testing, staging, production) # TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend # NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development TARGET=development
@@ -46,18 +47,9 @@ MAIL_RECIPIENTS=info@klz-cables.com
LOG_LEVEL=info LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026 GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN= SENTRY_DSN=
# For Directus Error Tracking
# SENTRY_ENVIRONMENT is set automatically by CI # SENTRY_ENVIRONMENT is set automatically by CI
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
# by docker-compose.yml using these base DB credentials, so you don't need to
# manually write the connection strings here.
PAYLOAD_DB_NAME=payload
PAYLOAD_DB_USER=payload
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Deployment Configuration (CI/CD only) # Deployment Configuration (CI/CD only)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────

43
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,43 @@
name: CI - Lint, Typecheck & Test
on:
pull_request:
jobs:
quality-assurance:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- 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
- name: Install dependencies
run: pnpm install
env:
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🧪 QA Checks
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
- name: 🏗️ Build
run: pnpm build
- name: ♿ Accessibility Check
run: pnpm check:a11y

View File

@@ -13,12 +13,8 @@ on:
required: false required: false
default: 'false' default: 'false'
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
concurrency: concurrency:
group: deploy-pipeline group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@@ -35,10 +31,9 @@ jobs:
traefik_host: ${{ steps.determine.outputs.traefik_host }} traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }} traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
next_public_url: ${{ steps.determine.outputs.next_public_url }} next_public_url: ${{ steps.determine.outputs.next_public_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
project_name: ${{ steps.determine.outputs.project_name }} project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }} short_sha: ${{ steps.determine.outputs.short_sha }}
slug: ${{ steps.determine.outputs.slug }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
@@ -85,37 +80,32 @@ jobs:
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}" IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}" ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com" TRAEFIK_HOST="${SLUG}.branch.mintel.me"
fi fi
# Standardize Traefik Rule (escaped backticks for Traefik v3) # Standardize Traefik Rule
if [[ "$TRAEFIK_HOST" == *","* ]]; then if [[ "$TRAEFIK_HOST" == *","* ]]; then
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}') TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g') PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else else
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)' TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
PRIMARY_HOST="$TRAEFIK_HOST" PRIMARY_HOST="$TRAEFIK_HOST"
fi fi
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
{ {
echo "target=$TARGET" echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG" echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE" echo "env_file=$ENV_FILE"
echo "traefik_host=$PRIMARY_HOST" echo "traefik_host=$PRIMARY_HOST"
echo "traefik_rule=$TRAEFIK_RULE" echo "traefik_rule=$TRAEFIK_RULE"
echo "gatekeeper_host=$GATEKEEPER_HOST"
echo "next_public_url=https://$PRIMARY_HOST" echo "next_public_url=https://$PRIMARY_HOST"
echo "directus_url=https://cms.$PRIMARY_HOST"
if [[ "$TARGET" == "production" ]]; then if [[ "$TARGET" == "production" ]]; then
echo "project_name=klz-cablescom" echo "project_name=klz-cablescom"
elif [[ "$TARGET" == "branch" ]]; then
echo "project_name=$PRJ-branch-$SLUG"
else else
echo "project_name=$PRJ-$TARGET" echo "project_name=$PRJ-$TARGET"
fi fi
echo "short_sha=$SHORT_SHA" echo "short_sha=$SHORT_SHA"
echo "slug=$SLUG"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged # ⏳ Wait for Upstream Packages/Images if Tagged
@@ -159,45 +149,39 @@ jobs:
needs: prepare needs: prepare
if: needs.prepare.outputs.target != 'skip' if: needs.prepare.outputs.target != 'skip'
runs-on: docker runs-on: docker
env:
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
run: | run: pnpm install --frozen-lockfile
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧪 QA Checks - name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true' if: github.event.inputs.skip_checks != 'true'
env: run: |
TURBO_TELEMETRY_DISABLED: "1" pnpm lint
run: npx turbo run lint typecheck test --cache-dir=".turbo" pnpm typecheck
pnpm test
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push # JOB 3: Build & Push
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
build: build:
name: 🏗️ Build name: 🏗️ Build
needs: [prepare, qa] needs: [prepare]
if: needs.prepare.outputs.target != 'skip' if: needs.prepare.outputs.target != 'skip'
runs-on: docker runs-on: docker
container: container:
@@ -208,24 +192,25 @@ jobs:
- name: 🐳 Set up Docker Buildx - name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login - name: 🔐 Registry Login
run: | run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ Build and Push - name: 🏗️ Build and Push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
provenance: false platforms: linux/arm64
platforms: linux/amd64
build-args: | build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} 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' }} UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
NPM_TOKEN=${{ secrets.NPM_TOKEN }} NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }} 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
secrets: | secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }} "NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy # JOB 4: Deploy
@@ -241,23 +226,27 @@ jobs:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }} TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
SLUG: ${{ needs.prepare.outputs.slug }}
# Secrets mapping (Payload CMS) # Secrets mapping (Directus)
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }} DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }} DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }} DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }} DIRECTUS_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 (Mail) # Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }} MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }} MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }} MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }} MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }} MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }} MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Monitoring # Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
@@ -268,15 +257,6 @@ jobs:
# Analytics # Analytics
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' }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Search & AI
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
# Container Registry (standalone)
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -304,7 +284,7 @@ jobs:
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW" AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
# Gatekeeper Origin # Gatekeeper Origin
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper" GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
{ {
echo "# Generated by CI - $TARGET" echo "# Generated by CI - $TARGET"
@@ -320,11 +300,21 @@ jobs:
echo "MAIL_FROM=$MAIL_FROM" echo "MAIL_FROM=$MAIL_FROM"
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS" echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
echo "" echo ""
echo "# Payload CMS" echo "# Directus"
echo "PAYLOAD_SECRET=$PAYLOAD_SECRET" echo "DIRECTUS_URL=$DIRECTUS_URL"
echo "PAYLOAD_DB_NAME=$PAYLOAD_DB_NAME" echo "DIRECTUS_HOST=$DIRECTUS_HOST"
echo "PAYLOAD_DB_USER=$PAYLOAD_DB_USER" echo "DIRECTUS_KEY=$DIRECTUS_KEY"
echo "PAYLOAD_DB_PASSWORD=$PAYLOAD_DB_PASSWORD" 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 "" echo ""
echo "# Gatekeeper" echo "# Gatekeeper"
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD" echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
@@ -335,18 +325,11 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo "" echo ""
echo "# Search & AI"
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
echo "QDRANT_URL=$QDRANT_URL"
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
echo "REDIS_URL=$REDIS_URL"
echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME" echo "PROJECT_NAME=$PROJECT_NAME"
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE" printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
echo "TRAEFIK_HOST=$TRAEFIK_HOST" echo "TRAEFIK_HOST=$TRAEFIK_HOST"
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
echo "TRAEFIK_ENTRYPOINT=websecure" echo "TRAEFIK_ENTRYPOINT=websecure"
echo "TRAEFIK_TLS=true" echo "TRAEFIK_TLS=true"
echo "TRAEFIK_CERT_RESOLVER=le" echo "TRAEFIK_CERT_RESOLVER=le"
@@ -360,39 +343,9 @@ jobs:
cat .env.deploy cat .env.deploy
echo "----------------------------" echo "----------------------------"
- name: 🔐 Registry Auth
id: auth
run: |
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
VALID_TOKEN=""
VALID_USER=""
for T in $TOKENS; do
if [ -n "$T" ]; then
for U in $USERS; do
if [ -n "$U" ]; then
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
VALID_TOKEN="$T"
VALID_USER="$U"
break 2
fi
fi
done
fi
done
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
echo "token=$VALID_TOKEN" >> $GITHUB_OUTPUT
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
- name: 🚀 SSH Deploy - name: 🚀 SSH Deploy
shell: bash shell: bash
env: env:
TARGET: ${{ needs.prepare.outputs.target }}
SLUG: ${{ needs.prepare.outputs.slug }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }} ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
@@ -400,65 +353,20 @@ jobs:
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Determine deployment paths
echo "Preparing deployment for $TARGET..."
# Transfer and Restart # Transfer and Restart
if [[ "$TARGET" == "production" ]]; then SITE_DIR="/home/deploy/sites/klz-cables.com"
SITE_DIR="/home/deploy/sites/klz-cables.com" ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
elif [[ "$TARGET" == "testing" ]]; then
SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
elif [[ "$TARGET" == "staging" ]]; then
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
else
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
fi
# Transfer files
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
scp -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1" ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1" 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"
# Branch Seeding Logic (Production -> Branch) # Apply Directus Schema Snapshot if available
if [[ "$TARGET" == "branch" ]]; then 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"
echo "🌱 Seeding Branch Environment from Production Database..."
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d klz-db"
# Wait for DB to be healthy with a 60s timeout
echo "⏳ Waiting for branch database to be ready..."
ssh root@alpha.mintel.me "
for i in {1..30}; do
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
exit 0
fi
sleep 2
done
echo '❌ Database failed to become ready after 60 seconds'
exit 1
" || exit 1
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
echo "📦 Syncing Production DB into Branch DB..."
ssh root@alpha.mintel.me "
set -e -o pipefail
docker exec klz-cablescom-klz-db-1 pg_dump -U payload -d payload --clean --if-exists | docker exec -i $DB_CONTAINER psql -U payload -d payload --quiet
rsync -a --delete /var/lib/docker/volumes/klz-cablescom_klz_media_data/_data/ /var/lib/docker/volumes/${{ needs.prepare.outputs.project_name }}_klz_media_data/_data/
" || exit 1
echo "✅ Branch database and media synced successfully."
fi
# Execute remote commands — alpha is pre-logged into registry.infra.mintel.me
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans"
# Restart app to pick up clean migration state
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
# Generate Excel Datasheets
echo "📊 Generating Excel Datasheets on live container..."
ssh root@alpha.mintel.me "docker exec $APP_CONTAINER pnpm run excel:datasheets" || echo "⚠️ Excel generation failed (non-blocking)"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'" ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
@@ -467,177 +375,125 @@ jobs:
run: docker builder prune -f --filter "until=1h" run: docker builder prune -f --filter "until=1h"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates) # JOB 5: Smoke Test (OG Images)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
post_deploy_checks: smoke_test:
name: 🧪 Post-Deploy Verification name: 🧪 Smoke Test
needs: [prepare, deploy] needs: [prepare, deploy]
if: needs.deploy.result == 'success' && true if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
id: deps run: pnpm install --frozen-lockfile
run: | - name: 🚀 Run OG Image Check
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 📦 Cache APT Packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
- name: 💾 Cache Chromium
id: cache-chromium
uses: actions/cache@v4
with:
path: /usr/bin/chromium
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
- name: 🔍 Install Chromium (Native & ARM64)
if: steps.cache-chromium.outputs.cache-hit != 'true'
run: |
rm -f /etc/apt/apt.conf.d/docker-clean
apt-get update
apt-get install -y gnupg wget ca-certificates
OS_ID=$(. /etc/os-release && echo $ID)
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
if [ "$OS_ID" = "debian" ]; then
apt-get install -y chromium
else
mkdir -p /etc/apt/keyrings
KEY_ID="82BB6851C64F6880"
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update
apt-get install -y --allow-downgrades chromium
fi
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
- name: 🏥 CMS Deep Health Check
env:
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: |
echo "Waiting 10s for app to fully start..."
sleep 10
echo "Checking basic health..."
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
echo "✅ Basic health OK"
echo "Checking CMS DB connectivity..."
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
echo "❌ CMS health check failed!"
echo "$RESPONSE"
echo ""
echo "This usually means Payload CMS migrations failed or DB tables are missing."
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
exit 1
}
echo "✅ CMS health: $RESPONSE"
- name: 🚀 OG Image Check
if: always() && steps.deps.outcome == 'success'
env: env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }} TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: pnpm run check:og 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: 📊 Excel Datasheet Accessibility Check
if: always() && steps.deps.outcome == 'success'
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: |
echo "Checking if datasheets directory is reachable..."
# This checks if the /datasheets/ directory returns a valid response (200, 403, or 404 is technically reachable, but we'd prefer 200/403)
# Since the files are in public/datasheets/products/, we check that path.
curl -I -L -s -o /dev/null -w "%{http_code}" "$TEST_URL/datasheets/products/" | grep -E "200|301|302|403|404"
- name: 📝 E2E Form Submission Test # ──────────────────────────────────────────────────────────────────────────────
if: always() && steps.deps.outcome == 'success' # JOB 6: Lighthouse (Performance & Accessibility)
# ──────────────────────────────────────────────────────────────────────────────
lighthouse:
name: ⚡ Lighthouse
needs: [prepare, deploy]
if: success() && needs.prepare.outputs.target != 'skip' && 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
cache: 'pnpm'
- 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
- name: 🔍 Install Chromium (Native & ARM64)
run: |
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
env: env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium CHROME_PATH: /usr/bin/chromium
run: pnpm run check:forms PAGESPEED_LIMIT: 8
run: pnpm run pagespeed:test
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 7: Notifications # JOB 7: Notifications
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
notifications: notifications:
name: 🔔 Notify name: 🔔 Notify
needs: [prepare, deploy, post_deploy_checks] needs: [prepare, deploy, smoke_test, lighthouse]
if: always() if: always()
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
- name: 🔔 Gotify - name: 🔔 Gotify
shell: bash
run: | run: |
DEPLOY="${{ needs.deploy.result }}" STATUS="${{ needs.deploy.result }}"
SMOKE="${{ needs.post_deploy_checks.result }}" TITLE="klz-cables.com: $STATUS"
PERF="${{ needs.post_deploy_checks.result }}" [[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
TARGET="${{ needs.prepare.outputs.target }}"
VERSION="${{ needs.prepare.outputs.image_tag }}"
URL="${{ needs.prepare.outputs.next_public_url }}"
# Gotify priority scale:
# 1-3 = low (silent/info)
# 4-5 = normal
# 6-7 = high (warning)
# 8-10 = critical (alarm)
if [[ "$DEPLOY" != "success" ]]; then
PRIORITY=10
EMOJI="🚨"
STATUS_LINE="DEPLOY FAILED"
elif [[ "$SMOKE" != "success" ]]; then
PRIORITY=8
EMOJI="⚠️"
STATUS_LINE="Smoke tests failed"
elif [[ "$PERF" != "success" ]]; then
PRIORITY=5
EMOJI="📉"
STATUS_LINE="Performance degraded"
else
PRIORITY=2
EMOJI="✅"
STATUS_LINE="All checks passed"
fi
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
MESSAGE="$STATUS_LINE
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL"
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
echo "⚠️ Gotify credentials missing, skipping notification."
exit 0
fi
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \ -F "title=$TITLE" \
-F "message=$MESSAGE" \ -F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
-F "priority=$PRIORITY" || true -F "priority=$PRIORITY" || true

View File

@@ -1,236 +0,0 @@
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: failure()
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
View File

@@ -1,30 +1,14 @@
node_modules node_modules
.next .next
.DS_Store .DS_Store
.pnpm-store
public/uploads
public/media
# Lighthouse CI # Lighthouse CI
.lighthouseci/ .lighthouseci/
lighthouserc.cjs lighthouserc.cjs
.lighthouserc.json .lighthouserc.json
# Legacy (Directus) cleanup # Directus
directus/uploads directus/uploads
!directus/extensions/
.next-docker !directus/schema/
!directus/migrations/
# Pa11y CI
.pa11yci/
.htmlvalidate-tmp
# Turborepo
.turbo
# Test Outputs
html-errors*.json
reference/
# Database backups
backups/

View File

@@ -1,26 +0,0 @@
{
"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
View File

@@ -1,2 +0,0 @@
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}

View File

@@ -1,16 +1,19 @@
# Stage 1: Builder # Stage 1: Builder
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
WORKDIR /app WORKDIR /app
# Arguments for build-time configuration # Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT ARG UMAMI_API_ENDPOINT
ARG NPM_TOKEN
# Environment variables for Next.js build # Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV SKIP_RUNTIME_ENV_VALIDATION=true ENV SKIP_RUNTIME_ENV_VALIDATION=true
@@ -22,11 +25,10 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
# Configure private registry and install dependencies # Configure private registry and install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \ --mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \ export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \ echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \ echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
pnpm store prune && \ pnpm install --frozen-lockfile && \
pnpm install --no-frozen-lockfile && \
rm .npmrc rm .npmrc
# Copy source code # Copy source code
@@ -40,20 +42,17 @@ CMD ["pnpm", "dev:local"]
# Build application # Build application
# Stage 3: Builder (Production) # Stage 3: Builder (Production)
FROM base AS builder FROM base AS builder
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
ENV NODE_OPTIONS="--max-old-space-size=1024"
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
ENV RAYON_NUM_THREADS=3
ENV UV_THREADPOOL_SIZE=3
RUN pnpm build RUN pnpm build
# Excel generation moved to post-deploy
# Stage 2: Runner # Stage 3: Runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
WORKDIR /app WORKDIR /app
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
USER root
RUN chown -R nextjs:nodejs /app
USER nextjs
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PORT=3000 ENV PORT=3000
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -65,4 +64,3 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -1,18 +0,0 @@
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

View File

@@ -462,4 +462,3 @@ Proprietary - KLZ Cables
**Status**: ✅ **READY FOR DEPLOYMENT** **Status**: ✅ **READY FOR DEPLOYMENT**
**Version**: 1.0.0 **Version**: 1.0.0
**Last Updated**: December 27, 2025 **Last Updated**: December 27, 2025
Trigger rebuilding for x86 architecture.

View File

@@ -1,17 +0,0 @@
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;

View File

@@ -1,57 +0,0 @@
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
}

View File

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

View File

@@ -1,4 +0,0 @@
import config from '@payload-config';
import { GRAPHQL_POST } from '@payloadcms/next/routes';
export const POST = GRAPHQL_POST(config);

View File

@@ -1,151 +0,0 @@
/* =================================================================
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;
}

View File

@@ -1,31 +0,0 @@
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;

View File

@@ -1,10 +1,10 @@
import { notFound, redirect } from 'next/navigation'; import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { Container, Badge, Heading } from '@/components/ui'; import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPageBySlug } from '@/lib/pages'; import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs'; import { mdxComponents } from '@/components/blog/MDXComponents';
import PayloadRichText from '@/components/PayloadRichText';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink'; import TrackedLink from '@/components/analytics/TrackedLink';
@@ -15,34 +15,41 @@ interface PageProps {
}>; }>;
} }
export async function generateStaticParams() {
const locales = ['en', 'de'];
const params = [];
for (const locale of locales) {
const pages = await getAllPages(locale);
for (const page of pages) {
params.push({ locale, slug: page.slug });
}
}
return params;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params; const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale); const pageData = await getPageBySlug(slug, locale);
if (!pageData) return {}; if (!pageData) return {};
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
// Determine correct localized slug based on current locale
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
return { return {
title: pageData.frontmatter.title, title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`, canonical: `${SITE_URL}/${locale}/${slug}`,
languages: { languages: {
de: `${SITE_URL}/de/${deSlug}`, de: `${SITE_URL}/de/${slug}`,
en: `${SITE_URL}/en/${enSlug}`, en: `${SITE_URL}/en/${slug}`,
'x-default': `${SITE_URL}/en/${enSlug}`, 'x-default': `${SITE_URL}/en/${slug}`,
}, },
}, },
openGraph: { openGraph: {
title: `${pageData.frontmatter.title} | KLZ Cables`, title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`, url: `${SITE_URL}/${locale}/${slug}`,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -62,23 +69,6 @@ export default async function StandardPage({ params }: PageProps) {
notFound(); notFound();
} }
// Redirect if accessed via a different locale's slug
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
if (correctSlug && correctSlug !== slug) {
redirect(`/${locale}/${correctSlug}`);
}
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
if (pageData.frontmatter.layout === 'fullBleed') {
return (
<div className="flex flex-col min-h-screen">
<PayloadRichText data={pageData.content} className="" />
</div>
);
}
// Default article layout with hero, content, and support CTA
return ( return (
<div className="flex flex-col min-h-screen bg-white"> <div className="flex flex-col min-h-screen bg-white">
{/* Hero Section */} {/* Hero Section */}
@@ -87,7 +77,7 @@ export default async function StandardPage({ params }: PageProps) {
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" /> <div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
</div> </div>
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl"> <div className="max-w-4xl animate-slide-up">
<Badge variant="accent" className="mb-4 md:mb-6"> <Badge variant="accent" className="mb-4 md:mb-6">
{t('badge')} {t('badge')}
</Badge> </Badge>
@@ -103,7 +93,7 @@ export default async function StandardPage({ params }: PageProps) {
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Excerpt/Lead paragraph if available */} {/* Excerpt/Lead paragraph if available */}
{pageData.frontmatter.excerpt && ( {pageData.frontmatter.excerpt && (
<div className="mb-16"> <div className="mb-16 animate-slight-fade-in-from-bottom">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic"> <p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{pageData.frontmatter.excerpt} {pageData.frontmatter.excerpt}
</p> </p>
@@ -111,8 +101,8 @@ export default async function StandardPage({ params }: PageProps) {
)} )}
{/* Main content with shared blog components */} {/* Main content with shared blog components */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary"> <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">
<PayloadRichText data={pageData.content} /> <MDXRemote source={pageData.content} components={mdxComponents} />
</div> </div>
{/* Support Section */} {/* Support Section */}
@@ -122,7 +112,7 @@ export default async function StandardPage({ params }: PageProps) {
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3> <h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p> <p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
<TrackedLink <TrackedLink
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`} href={`/${locale}/contact`}
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link" className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
eventProperties={{ eventProperties={{
location: 'generic_page_support_cta', location: 'generic_page_support_cta',

View File

@@ -1,5 +1,5 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getProductBySlug } from '@/lib/products'; import { getProductBySlug } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate'; import { OGImageTemplate } from '@/components/OGImageTemplate';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';

View File

@@ -8,20 +8,6 @@ export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png'; export const contentType = 'image/png';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
async function fetchImageAsBase64(url: string) {
try {
const res = await fetch(url);
if (!res.ok) return undefined;
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const contentType = res.headers.get('content-type') || 'image/jpeg';
return `data:${contentType};base64,${buffer.toString('base64')}`;
} catch (error) {
console.error('Failed to fetch OG image:', url, error);
return undefined;
}
}
export default async function Image({ export default async function Image({
params, params,
}: { }: {
@@ -46,19 +32,12 @@ export default async function Image({
: `${SITE_URL}${post.frontmatter.featuredImage}` : `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined; : undefined;
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
// fetching remote URLs directly inside ImageResponse correctly in various environments.
let base64Image: string | undefined = undefined;
if (featuredImage) {
base64Image = await fetchImageAsBase64(featuredImage);
}
return new ImageResponse( return new ImageResponse(
<OGImageTemplate <OGImageTemplate
title={post.frontmatter.title} title={post.frontmatter.title}
description={post.frontmatter.excerpt} description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'} label={post.frontmatter.category || 'Blog'}
image={base64Image || featuredImage} image={featuredImage}
/>, />,
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,

View File

@@ -1,25 +1,18 @@
import { notFound, redirect } from 'next/navigation'; import { notFound } from 'next/navigation';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { import { MDXRemote } from 'next-mdx-remote/rsc';
getPostBySlug, import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
getAdjacentPosts,
getReadingTime,
extractLexicalHeadings,
} from '@/lib/blog';
import { Metadata } from 'next'; import { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import PostNavigation from '@/components/blog/PostNavigation'; import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA'; import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents'; import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui'; import { Heading } from '@/components/ui';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker'; import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
// Payload CMS Imports
import PayloadRichText from '@/components/PayloadRichText';
interface BlogPostProps { interface BlogPostProps {
params: Promise<{ params: Promise<{
locale: string; locale: string;
@@ -38,7 +31,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
title: post.frontmatter.title, title: post.frontmatter.title,
description: description, description: description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`, canonical: `${SITE_URL}/${locale}/blog/${slug}`,
}, },
openGraph: { openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`, title: `${post.frontmatter.title} | KLZ Cables`,
@@ -46,7 +39,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
type: 'article', type: 'article',
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'], authors: ['KLZ Cables'],
url: `${SITE_URL}/${locale}/blog/${post.slug}`, url: `${SITE_URL}/${locale}/blog/${slug}`,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -60,25 +53,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const post = await getPostBySlug(slug, locale); const post = await getPostBySlug(slug, locale);
const { prev, next } = await getAdjacentPosts(slug, locale);
if (!post) { if (!post) {
notFound(); notFound();
} }
// If the user accessed this post using a slug from a different locale const headings = getHeadings(post.content);
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
if (post.slug && post.slug !== slug) {
redirect(`/${locale}/blog/${post.slug}`);
}
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
// Convert Lexical content into a plain string to estimate reading time roughly
// Extract headings for TOC
const headings = extractLexicalHeadings(post.content?.root || post.content);
// Convert Lexical content into a plain string to estimate reading time roughly
const rawTextContent = JSON.stringify(post.content);
return ( return (
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary"> <article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
@@ -86,27 +67,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
title={post.frontmatter.title} title={post.frontmatter.title}
slug={slug} slug={slug}
category={post.frontmatter.category} category={post.frontmatter.category}
readingTime={getReadingTime(rawTextContent)} readingTime={getReadingTime(post.content)}
/> />
{/* Featured Image Header */} {/* Featured Image Header */}
{post.frontmatter.featuredImage ? ( {post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group"> <div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"> <div
<Image className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
src={post.frontmatter.featuredImage.split('?')[0]} style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
alt={post.frontmatter.title} />
fill <div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
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 */} {/* Title overlay on image */}
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24"> <div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
@@ -114,33 +84,27 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="max-w-4xl"> <div className="max-w-4xl">
{post.frontmatter.category && ( {post.frontmatter.category && (
<div className="overflow-hidden mb-6"> <div className="overflow-hidden mb-6">
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm"> <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">
{post.frontmatter.category} {post.frontmatter.category}
</span> </span>
</div> </div>
)} )}
<Heading level={1} className="text-white mb-8 drop-shadow-2xl"> <Heading
level={1}
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
>
{post.frontmatter.title} {post.frontmatter.title}
</Heading> </Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium"> <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} suppressHydrationWarning> <time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
})} })}
</time> </time>
<span className="w-1 h-1 bg-white/30 rounded-full" /> <span className="w-1 h-1 bg-white/30 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(post.content)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -159,25 +123,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
<Heading level={1} className="mb-8"> <Heading level={1} className="mb-8">
{post.frontmatter.title} {post.frontmatter.title}
</Heading> </Heading>
<div className="flex items-center gap-6 text-text-primary/80 font-medium"> <div className="flex items-center gap-6 text-text-secondary font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning> <time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
})} })}
</time> </time>
<span className="w-1 h-1 bg-neutral-400 rounded-full" /> <span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(post.content)} 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>
</div> </div>
</header> </header>
@@ -190,16 +145,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="sticky-narrative-content"> <div className="sticky-narrative-content">
{/* Excerpt/Lead paragraph if available */} {/* Excerpt/Lead paragraph if available */}
{post.frontmatter.excerpt && ( {post.frontmatter.excerpt && (
<div className="mb-16"> <div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic"> <p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{post.frontmatter.excerpt} {post.frontmatter.excerpt}
</p> </p>
</div> </div>
)} )}
{/* Main content with enhanced styling rendering Payload Lexical */} {/* 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"> <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]">
<PayloadRichText data={post.content} /> <MDXRemote source={post.content} components={mdxComponents} />
</div> </div>
{/* Power CTA */} {/* Power CTA */}
@@ -209,13 +164,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
{/* Post Navigation */} {/* Post Navigation */}
<div className="mt-16"> <div className="mt-16">
<PostNavigation <PostNavigation prev={prev} next={next} locale={locale} />
prev={prev}
next={next}
isPrevRandom={isPrevRandom}
isNextRandom={isNextRandom}
locale={locale}
/>
</div> </div>
{/* Back to blog link */} {/* Back to blog link */}
@@ -242,9 +191,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div> </div>
</div> </div>
{/* Right Column: Sticky Sidebar - TOC */} {/* Right Column: Sticky Sidebar */}
<aside className="sticky-narrative-sidebar hidden lg:block"> <aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12 lg:sticky lg:top-32"> <div className="space-y-12">
<TableOfContents headings={headings} locale={locale} /> <TableOfContents headings={headings} locale={locale} />
</div> </div>
</aside> </aside>
@@ -284,8 +233,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
'@id': `${SITE_URL}/${locale}/blog/${slug}`, '@id': `${SITE_URL}/${locale}/blog/${slug}`,
}, },
articleSection: post.frontmatter.category, articleSection: post.frontmatter.category,
wordCount: rawTextContent.split(/\s+/).length, wordCount: post.content.split(/\s+/).length,
timeRequired: `PT${getReadingTime(rawTextContent)}M`, timeRequired: `PT${getReadingTime(post.content)}M`,
} as any } as any
} }
/> />

View File

@@ -6,7 +6,6 @@ import Reveal from '@/components/Reveal';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
interface BlogIndexProps { interface BlogIndexProps {
params: Promise<{ params: Promise<{
@@ -14,7 +13,7 @@ interface BlogIndexProps {
}>; }>;
} }
export async function generateMetadata({ params }: BlogIndexProps): Promise<Metadata> { export async function generateMetadata({ params }: BlogIndexProps) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' }); const t = await getTranslations({ locale, namespace: 'Blog.meta' });
return { return {
@@ -63,38 +62,28 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
{featuredPost && featuredPost.frontmatter.featuredImage && ( {featuredPost && featuredPost.frontmatter.featuredImage && (
<> <>
<Image <Image
src={featuredPost.frontmatter.featuredImage.split('?')[0]} src={featuredPost.frontmatter.featuredImage}
alt={featuredPost.frontmatter.title} alt={featuredPost.frontmatter.title}
fill fill
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60" className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
style={{
objectPosition: `${featuredPost.frontmatter.focalX ?? 50}% ${featuredPost.frontmatter.focalY ?? 50}%`,
}}
sizes="100vw" sizes="100vw"
priority priority
/> />
<div className="absolute inset-0 bg-neutral-dark/20" /> <div className="absolute inset-0 image-overlay-gradient" />
</> </>
)} )}
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl"> <div className="max-w-4xl animate-slide-up">
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6"> <Badge variant="saturated" className="mb-4 md:mb-6">
<Badge variant="saturated">{t('featuredPost')}</Badge> {t('featuredPost')}
{featuredPost && </Badge>
(new Date(featuredPost.frontmatter.date) > new Date() ||
featuredPost.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
)}
</div>
{featuredPost && ( {featuredPost && (
<> <>
<Heading level={1} className="text-white mb-4 md:mb-8"> <Heading level={1} className="text-white mb-4 md:mb-8">
{featuredPost.frontmatter.title} {featuredPost.frontmatter.title}
</Heading> </Heading>
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl"> <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">
{featuredPost.frontmatter.excerpt} {featuredPost.frontmatter.excerpt}
</p> </p>
<Button <Button
@@ -153,76 +142,55 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
</Reveal> </Reveal>
{/* Grid for remaining posts */} {/* Grid for remaining posts */}
<div className="grid grid-cols-1 gap-12"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
{remainingPosts.map((post, idx) => ( {remainingPosts.map((post, idx) => (
<Reveal key={post.slug} delay={idx * 50}> <Reveal key={post.slug} delay={idx * 100}>
<Link <Link href={`/${locale}/blog/${post.slug}`} className="group block">
href={`/${locale}/blog/${post.slug}`}
className="group block focus:outline-none"
>
<Card <Card
tag="article" tag="article"
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]" className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
> >
{post.frontmatter.featuredImage && ( {post.frontmatter.featuredImage && (
<> <div className="relative h-48 md:h-72 overflow-hidden">
<Image <Image
src={post.frontmatter.featuredImage.split('?')[0]} src={post.frontmatter.featuredImage}
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
style={{ sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, 100vw"
loading="lazy"
/> />
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" /> <div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
)}
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
<div className="flex flex-wrap items-center gap-4 mb-4">
{post.frontmatter.category && ( {post.frontmatter.category && (
<Badge variant="accent" className="shadow-md"> <Badge
variant="accent"
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
>
{post.frontmatter.category} {post.frontmatter.category}
</Badge> </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>
)}
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase"> <div className="p-5 md:p-10 flex flex-col flex-1">
<time dateTime={post.frontmatter.date} suppressHydrationWarning> <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 || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
})} })}
</time>
</div> </div>
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
{post.frontmatter.title} {post.frontmatter.title}
</h3> </h3>
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
{post.frontmatter.excerpt && ( {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"> </p>
{post.frontmatter.excerpt} <div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
</p> <span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
)}
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
{t('readMore')} {t('readMore')}
</span> </span>
<div className="w-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"> <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">
<svg <svg
className="w-5 h-5 transition-transform group-hover:translate-x-1" className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -243,47 +211,21 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
))} ))}
</div> </div>
{/* Pagination */} {/* Pagination Placeholder */}
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4"> <div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
<Button <Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
href="#"
variant="outline"
size="sm"
className="md:h-11 md:px-6 md:text-base pointer-events-none opacity-50"
aria-disabled="true"
aria-keyshortcuts="ArrowLeft"
tabIndex={-1}
>
{t('prev')} {t('prev')}
</Button> </Button>
<Button <Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
href={`/${locale}/blog?page=1`}
variant="primary"
size="sm"
className="md:h-11 md:px-6 md:text-base"
aria-current="page"
>
1 1
</Button> </Button>
<Button <Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
href={`/${locale}/blog?page=2`}
variant="outline"
size="sm"
className="md:h-11 md:px-6 md:text-base"
>
2 2
</Button> </Button>
<Button <Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
href={`/${locale}/blog?page=2`}
variant="outline"
size="sm"
className="md:h-11 md:px-6 md:text-base"
aria-keyshortcuts="ArrowRight"
>
{t('next')} {t('next')}
</Button> </Button>
</div> </div>
<BlogPaginationKeyboardObserver currentPage={1} totalPages={2} locale={locale} />
</Container> </Container>
</Section> </Section>
</div> </div>

View File

@@ -5,10 +5,9 @@ import { Container, Heading, Section } from '@/components/ui';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react'; import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap'; import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
interface ContactPageProps { interface ContactPageProps {
params: Promise<{ params: Promise<{
@@ -25,9 +24,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
title, title,
description, description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`, canonical: `${SITE_URL}/${locale}/contact`,
languages: { languages: {
de: `${SITE_URL}/de/kontakt`, de: `${SITE_URL}/de/contact`,
en: `${SITE_URL}/en/contact`, en: `${SITE_URL}/en/contact`,
'x-default': `${SITE_URL}/en/contact`, 'x-default': `${SITE_URL}/en/contact`,
}, },
@@ -35,7 +34,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
openGraph: { openGraph: {
title: `${title} | KLZ Cables`, title: `${title} | KLZ Cables`,
description, description,
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`, url: `${SITE_URL}/${locale}/contact`,
siteName: 'KLZ Cables', siteName: 'KLZ Cables',
locale: `${locale.toUpperCase()}_DE`, locale: `${locale.toUpperCase()}_DE`,
type: 'website', type: 'website',
@@ -60,21 +59,6 @@ export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Contact' }); const t = await getTranslations({ locale, namespace: 'Contact' });
// Get translated slug to redirect if user used incorrect static slug
const { headers } = await import('next/headers');
const headersList = await headers();
const urlPath = headersList.get('x-invoke-path') || '';
const currentSlug = urlPath.split('/').pop();
if (currentSlug) {
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
const { redirect } = await import('next/navigation');
redirect(`/${locale}/${contactSlugDe}`);
}
}
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd <JsonLd
@@ -205,10 +189,12 @@ export default async function ContactPage({ params }: ContactPageProps) {
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2"> <h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')} {t('info.email')}
</h4> </h4>
<ObfuscatedEmail <a
email="info@klz-cables.com" href="mailto:info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
/> >
info@klz-cables.com
</a>
</div> </div>
</div> </div>
</address> </address>

View File

@@ -16,18 +16,12 @@ export default function Error({
const t = useTranslations('Error'); const t = useTranslations('Error');
useEffect(() => { useEffect(() => {
// Treat "Failed to find Server Action" as a deployment sync issue and reload
if (error?.message?.includes('Failed to find Server Action')) {
window.location.reload();
return;
}
const services = getAppServices(); const services = getAppServices();
services.errors.captureException(error); services.errors.captureException(error);
services.logger.error('Application error caught by boundary', { services.logger.error('Application error caught by boundary', {
message: error?.message || 'Unknown error', message: error.message,
stack: error?.stack, stack: error.stack,
digest: error?.digest, digest: error.digest
}); });
}, [error]); }, [error]);
@@ -42,14 +36,19 @@ export default function Error({
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated"> <Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
500 500
</Heading> </Heading>
<Scribble 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> </div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4"> <Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
{t('title')} {t('title')}
</Heading> </Heading>
<p className="text-white/60 mb-10 max-w-md text-lg">{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"> <div className="flex flex-col sm:flex-row gap-4">
<Button onClick={() => reset()} variant="saturated" size="lg"> <Button onClick={() => reset()} variant="saturated" size="lg">

View File

@@ -3,13 +3,18 @@ import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import SkipLink from '@/components/SkipLink'; import SkipLink from '@/components/SkipLink';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice'; 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 AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next'; import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper'; import { config } from '@/lib/config';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
@@ -25,19 +30,14 @@ export async function generateMetadata(props: {
const params = await props.params; const params = await props.params;
const { locale } = params; const { locale } = params;
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
return { return {
title: { metadataBase: new URL(SITE_URL),
template: '%s | KLZ Cables',
default: 'KLZ Cables | Ihr Partner für Kabel & Leitungen',
},
metadataBase: new URL(baseUrl),
manifest: '/manifest.webmanifest', manifest: '/manifest.webmanifest',
alternates: { alternates: {
canonical: `${baseUrl}/${locale}`, canonical: locale === 'en' ? '/' : `/${locale}`,
languages: { languages: {
de: `${baseUrl}/de`, de: '/de',
en: `${baseUrl}/en`, en: '/en',
}, },
}, },
icons: { icons: {
@@ -59,7 +59,6 @@ export const viewport: Viewport = {
themeColor: '#001a4d', themeColor: '#001a4d',
}; };
import AutoBrochureModal from '@/components/AutoBrochureModal';
export default async function Layout(props: { export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
@@ -73,32 +72,14 @@ export default async function Layout(props: {
setRequestLocale(safeLocale); setRequestLocale(safeLocale);
let messages: Record<string, any> = {}; let messages = {};
try { try {
messages = await getMessages(); messages = await getMessages();
} catch { } catch (error) {
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
messages = {}; messages = {};
} }
// Pick only the namespaces required by client components to reduce the hydration payload size
const clientKeys = [
'Footer',
'Navigation',
'Contact',
'Products',
'Team',
'Home',
'Error',
'StandardPage',
'Brochure',
];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {
if (messages[key]) {
clientMessages[key] = messages[key];
}
}
const { getServerAppServices } = await import('@/lib/services/create-services.server'); const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices(); const serverServices = getServerAppServices();
@@ -106,10 +87,7 @@ export default async function Layout(props: {
const { headers } = await import('next/headers'); const { headers } = await import('next/headers');
const requestHeaders = await headers(); const requestHeaders = await headers();
// Disable analytics in CI to prevent console noise/score penalties if ('setServerContext' in serverServices.analytics) {
if (process.env.NEXT_PUBLIC_CI === 'true') {
// Skip setting server context for analytics in CI
} else if ('setServerContext' in serverServices.analytics) {
(serverServices.analytics as any).setServerContext({ (serverServices.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined, userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined, language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
@@ -129,39 +107,38 @@ export default async function Layout(props: {
} }
// Read directly from process.env — bypasses all abstraction to guarantee correctness // 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'; const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return ( return (
<html <html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
lang={safeLocale}
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
data-scroll-behavior="smooth"
>
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="preconnect" href="https://img.infra.mintel.me" /> <link rel="preconnect" href="https://img.infra.mintel.me" />
</head> </head>
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden"> <body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}> <NextIntlClientProvider messages={messages} locale={safeLocale}>
<SkipLink /> <RecordModeProvider isEnabled={recordModeEnabled}>
<JsonLd /> <RecordModeVisuals>
<Header /> <SkipLink />
<main <JsonLd />
id="main-content" <Header />
className="flex-grow animate-fade-in overflow-visible" <main
tabIndex={-1} id="main-content"
> className="flex-grow animate-fade-in overflow-visible"
{children} tabIndex={-1}
</main> >
<Footer /> {children}
</main>
<Footer />
</RecordModeVisuals>
<CMSConnectivityNotice /> <CMSConnectivityNotice />
<AnalyticsShell /> <AnalyticsShell />
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} /> <ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider>
<AutoBrochureModal />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -1,137 +1,56 @@
import { getTranslations } from 'next-intl/server'; 'use client';
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui'; import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { getPayload } from 'payload'; import { useEffect } from 'react';
import configPromise from '@payload-config'; import { useAnalytics } from '@/components/analytics/useAnalytics';
import { headers } from 'next/headers'; import { AnalyticsEvents } from '@/components/analytics/analytics-events';
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
export default async function NotFound() { export default function NotFound() {
const t = await getTranslations('Error.notFound'); const t = useTranslations('Error.notFound');
const { trackEvent } = useAnalytics();
// Try to determine the requested path useEffect(() => {
const headersList = await headers(); trackEvent(AnalyticsEvents.ERROR, {
const urlPath = headersList.get('x-invoke-path') || ''; type: '404_not_found',
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
let suggestedUrl = null; });
let suggestedLang = null; }, [trackEvent]);
// 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 {
// Ignore Payload errors in 404
}
}
}
return ( return (
<> <Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
<ClientNotFoundTracker path={urlPath} /> {/* Industrial Background Element */}
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden"> <div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
{/* Industrial Background Element */} <span className="text-[20rem] font-bold select-none">404</span>
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center"> </div>
<span className="text-[20rem] font-bold select-none">404</span>
</div>
<div className="relative mb-8"> <div className="relative mb-8">
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2"> <Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404 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> </Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p> <Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
</Heading>
{suggestedUrl && ( <p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
<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"> <div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg"> <Button href="/" variant="accent" size="lg">
{t('cta')} {t('cta')}
</Button> </Button>
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg"> <Button href="/contact" variant="outline" size="lg">
Contact Support Contact Support
</Button> </Button>
</div> </div>
{/* Decorative Industrial Line */} {/* Decorative Industrial Line */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" /> <div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
</Container> </Container>
</>
); );
} }

View File

@@ -8,6 +8,7 @@ export const contentType = 'image/png';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) { export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
console.log('🖼️ OG Image Handler Called');
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' }); const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();

View File

@@ -1,12 +1,11 @@
import Hero from '@/components/home/Hero'; import Hero from '@/components/home/Hero';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import ProductCategories from '@/components/home/ProductCategories';
import WhatWeDo from '@/components/home/WhatWeDo';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts')); const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
const Experience = dynamic(() => import('@/components/home/Experience')); const Experience = dynamic(() => import('@/components/home/Experience'));
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs')); const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
@@ -27,13 +26,6 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
id="breadcrumb-home" id="breadcrumb-home"
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])} data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
/> />
{/*
The instruction refers to changing a class within the Hero component's paragraph.
Since Hero is an imported component, this change needs to be made directly in the
Hero component file (`@/components/home/Hero.tsx`) itself, not in this page file.
This file (`app/[locale]/page.tsx`) only renders the Hero component.
Therefore, no change is applied here.
*/}
<Hero /> <Hero />
<Reveal> <Reveal>
<ProductCategories /> <ProductCategories />

View File

@@ -1,21 +1,22 @@
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import ProductSidebar from '@/components/ProductSidebar'; import ProductSidebar from '@/components/ProductSidebar';
import ExcelDownload from '@/components/ExcelDownload'; import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts'; import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload'; import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Card, Container, Heading, Section } from '@/components/ui'; import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets'; import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/products'; import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs'; import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getProductOGImageMetadata } from '@/lib/metadata'; import { getProductOGImageMetadata } from '@/lib/metadata';
import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { notFound, redirect } from 'next/navigation'; import { notFound } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker'; import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
import PayloadRichText from '@/components/PayloadRichText';
interface ProductPageProps { interface ProductPageProps {
params: Promise<{ params: Promise<{
@@ -52,82 +53,135 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle, title: categoryTitle,
description: categoryDesc, description: categoryDesc,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
languages: { languages: {
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`, 'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
}, },
}, },
openGraph: {
title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc,
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
},
twitter: {
card: 'summary_large_image',
title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc,
},
}; };
} }
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
const getLocalizedPath = async (lang: string) => {
const parts = await Promise.all([
mapFileSlugToTranslated('products', lang),
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
]);
return parts.join('/');
};
const product = await getProductBySlug(productSlug, locale); const product = await getProductBySlug(productSlug, locale);
if (!product) return {}; if (!product) return {};
const currentLocalePath = await getLocalizedPath(locale);
return { return {
title: product.frontmatter.title, title: product.frontmatter.title,
description: product.frontmatter.description, description: product.frontmatter.description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
languages: { languages: {
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`, 'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
}, },
}, },
openGraph: { openGraph: {
title: product.frontmatter.title, title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description, description: product.frontmatter.description,
type: 'website', type: 'website',
url: `${SITE_URL}/${locale}/${currentLocalePath}`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale), images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title: product.frontmatter.title, title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description, description: product.frontmatter.description,
}, },
}; };
} }
const components = {
ProductTechnicalData,
ProductTabs,
p: (props: any) => (
<p
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
/>
),
h2: (props: any) => (
<div className="relative mb-16">
<h2
{...props}
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
/>
<div className="w-20 h-1.5 bg-accent rounded-full" />
</div>
),
h3: (props: any) => (
<h3
{...props}
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
/>
),
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
section: (props: any) => <div {...props} className="block" />,
li: (props: any) => (
<li className="flex items-start gap-4 group mb-4 last:mb-0">
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
<span
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
/>
</li>
),
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
table: (props: any) => (
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
</div>
),
th: (props: any) => (
<th
{...props}
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
/>
),
td: (props: any) => (
<td
{...props}
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
/>
),
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
blockquote: (props: any) => (
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
<div
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
{...props}
/>
</div>
),
};
export default async function ProductPage({ params }: ProductPageProps) { export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const productSlug = slug[slug.length - 1]; const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products'); const t = await getTranslations('Products');
const productsSlug = await mapFileSlugToTranslated('products', locale);
// Check if it's a category page
const categories = [ const categories = [
'low-voltage-cables', 'low-voltage-cables',
'medium-voltage-cables', 'medium-voltage-cables',
'high-voltage-cables', 'high-voltage-cables',
'solar-cables', 'solar-cables',
]; ];
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
const translatedSlugsForLocale = await Promise.all(
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
);
// If the requested slugs don't exactly match the translated slugs for the current locale
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
}
const fileSlug = fileSlugs[fileSlugs.length - 1];
if (categories.includes(fileSlug)) { if (categories.includes(fileSlug)) {
const allProducts = await getAllProducts(locale); const allProducts = await getAllProducts(locale);
@@ -138,27 +192,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`)
: fileSlug; : fileSlug;
const filteredProducts = allProducts.filter((p) => { // Filter products for this category
const firstCat = p.frontmatter.categories[0] || ''; const filteredProducts = allProducts.filter((p) =>
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-'); p.frontmatter.categories.some(
let pFileSlug = 'low-voltage-cables'; (cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables') ),
pFileSlug = 'high-voltage-cables'; );
else if (
normalizedCat === 'mittelspannungskabel' ||
normalizedCat === 'medium-voltage-cables'
)
pFileSlug = 'medium-voltage-cables';
else if (
normalizedCat === 'solarkabel' ||
normalizedCat === 'solar-cables' ||
normalizedCat === 'solar'
)
pFileSlug = 'solar-cables';
return pFileSlug === fileSlug;
});
// Get translated product slugs
const productsWithTranslatedSlugs = await Promise.all( const productsWithTranslatedSlugs = await Promise.all(
filteredProducts.map(async (p) => ({ filteredProducts.map(async (p) => ({
...p, ...p,
@@ -173,10 +214,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest"> <nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link <Link
href={`/${locale}/${productsSlug}`} href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors" className="hover:text-accent transition-colors"
> >
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'} {t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link> </Link>
<span className="mx-3 opacity-30">/</span> <span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span> <span className="text-white/90">{categoryTitle}</span>
@@ -195,7 +236,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{productsWithTranslatedSlugs.map((product) => ( {productsWithTranslatedSlugs.map((product) => (
<Link <Link
key={product.slug} key={product.slug}
href={`/${locale}/${productsSlug}/${productSlug}/${product.translatedSlug}`} href={`/${locale}/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5" className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
> >
<Card tag="article" className="premium-card-reset"> <Card tag="article" className="premium-card-reset">
@@ -209,6 +250,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10" className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/> />
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" /> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</> </>
)} )}
@@ -265,19 +307,21 @@ export default async function ProductPage({ params }: ProductPageProps) {
notFound(); notFound();
} }
// Extract technical data natively from the Lexical AST for Schema.org // Extract technical data for schema
const technicalDataMatch = product.content.match(
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
);
let technicalItems = []; let technicalItems = [];
if (product.content?.root?.children) { if (technicalDataMatch) {
const productTabsBlock = product.content.root.children.find( try {
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs', const data = JSON.parse(technicalDataMatch[1]);
); technicalItems = data.technicalItems || [];
if (productTabsBlock && productTabsBlock.fields?.technicalItems) { } catch (e) {
technicalItems = productTabsBlock.fields.technicalItems; console.error('Failed to parse technical data for schema', e);
} }
} }
const datasheetPath = getDatasheetPath(productSlug, locale); const datasheetPath = getDatasheetPath(productSlug, locale);
const excelPath = getExcelDatasheetPath(productSlug, locale);
const isFallback = (product.frontmatter as any).isFallback; const isFallback = (product.frontmatter as any).isFallback;
const categorySlug = slug[0]; const categorySlug = slug[0];
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale); const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
@@ -288,65 +332,30 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`)
: categoryFileSlug; : categoryFileSlug;
// Split content into Description and Technical Data
const rootChildren = product.content?.root?.children || [];
const technicalBlocks = rootChildren.filter(
(node: any) =>
node.type === 'block' &&
(node.fields?.blockType === 'productTabs' ||
node.fields?.blockType === 'productTechnicalData'),
);
let descriptionChildren = rootChildren.filter(
(node: any) =>
!(
node.type === 'block' &&
(node.fields?.blockType === 'productTabs' ||
node.fields?.blockType === 'productTechnicalData')
),
);
// If no standalone description nodes, extract from the productTabs block's embedded content
if (descriptionChildren.length === 0) {
const tabsBlock = rootChildren.find(
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
);
if (tabsBlock?.fields?.content?.root?.children) {
descriptionChildren = tabsBlock.fields.content.root.children.filter((node: any) => {
// Filter out MDX parsing artifacts like `}>`
if (node.type === 'paragraph' && node.children?.length === 1) {
const text = node.children[0]?.text?.trim();
return text !== '}>' && text !== '{' && text !== '}';
}
return true;
});
}
}
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
const descriptionContent = {
root: {
...product.content.root,
children: descriptionChildren,
},
};
const technicalContent = {
root: {
...product.content.root,
children: technicalBlocks,
},
};
const sidebar = ( const sidebar = (
<ProductSidebar <ProductSidebar
productName={product.frontmatter.title} productName={product.frontmatter.title}
productImage={product.frontmatter.images?.[0]} productImage={product.frontmatter.images?.[0]}
datasheetPath={datasheetPath} datasheetPath={datasheetPath}
excelPath={excelPath}
/> />
); );
const productComponents = {
...components,
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
};
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
const processedContent = product.content
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
.replace(/<p[^>]*>(.*?)<\/p>/g, '\n$1\n')
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
.replace(/<li[^>]*>(.*?)<\/li>/g, '\n- $1\n')
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
.replace(/<section[^>]*>/g, '')
.replace(/<\/section>/g, '');
return ( return (
<div className="flex flex-col min-h-screen bg-white relative"> <div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */} {/* Product Hero */}
@@ -356,31 +365,29 @@ export default async function ProductPage({ params }: ProductPageProps) {
categories={product.frontmatter.categories} categories={product.frontmatter.categories}
sku={product.frontmatter.sku} sku={product.frontmatter.sku}
/> />
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark"> <section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
{/* Background Decorative Elements */} {/* Background Decorative Elements */}
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" /> <div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" /> <div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]"> <nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
<Link <Link
href={`/${locale}/${productsSlug}`} href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors shrink-0" className="hover:text-accent transition-colors"
> >
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'} {t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link> </Link>
<span className="mx-2 md:mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<Link <Link
href={`/${locale}/${productsSlug}/${categorySlug}`} href={`/${locale}/${productSlug}`}
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate" className="hover:text-accent transition-colors"
> >
{categoryTitle} {categoryTitle}
</Link> </Link>
<span className="mx-2 md:mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<span className="text-white/90 truncate max-w-[140px] md:max-w-none"> <span className="text-white/90">{product.frontmatter.title}</span>
{product.frontmatter.title}
</span>
</nav> </nav>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
@@ -391,7 +398,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{t('englishVersion')} {t('englishVersion')}
</div> </div>
)} )}
<div className="flex flex-wrap gap-2 mb-4 md:mb-8"> <div className="flex flex-wrap gap-3 mb-8">
{product.frontmatter.categories.map((cat, idx) => ( {product.frontmatter.categories.map((cat, idx) => (
<Badge <Badge
key={idx} key={idx}
@@ -402,10 +409,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
</Badge> </Badge>
))} ))}
</div> </div>
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase"> <Heading level={1} className="text-white mb-8 uppercase">
{product.frontmatter.title} {product.frontmatter.title}
</Heading> </Heading>
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium"> <p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
{product.frontmatter.description} {product.frontmatter.description}
</p> </p>
</div> </div>
@@ -419,16 +426,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
{/* Large Product Image Section */} {/* Large Product Image Section */}
{product.frontmatter.images && product.frontmatter.images.length > 0 && ( {product.frontmatter.images && product.frontmatter.images.length > 0 && (
<div <div
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up" className="relative -mt-32 mb-32 animate-slide-up"
style={{ animationDelay: '200ms' }} style={{ animationDelay: '200ms' }}
> >
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24"> <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-[4/3] md:aspect-[21/9]"> <div className="relative w-full aspect-[21/9]">
<Image <Image
src={product.frontmatter.images[0]} src={product.frontmatter.images[0]}
alt={product.frontmatter.title} alt={product.frontmatter.title}
fill fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-contain transition-transform duration-1000 hover:scale-105" className="object-contain transition-transform duration-1000 hover:scale-105"
priority priority
/> />
@@ -447,7 +453,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
src={img} src={img}
alt="" alt=""
fill fill
sizes="128px"
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
/> />
</div> </div>
@@ -458,98 +463,68 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20"> <div className="relative">
{/* Description Area Next to Sidebar */} <div className="w-full">
<div className="lg:col-span-8"> {/* Main Content Area */}
<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"> <div className="max-w-none">
{descriptionChildren.length > 0 ? ( <MDXRemote source={processedContent} components={productComponents} />
<PayloadRichText data={descriptionContent} /> </div>
) : product.frontmatter.description ? (
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
{product.frontmatter.description}
</p>
) : null}
{product.application?.root?.children?.length > 0 && ( {/* Datasheet Download Section - Only for Medium Voltage for now */}
<div className="mt-12"> {categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
<PayloadRichText data={product.application} /> <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" />
</div> </div>
)} <DatasheetDownload datasheetPath={datasheetPath} />
</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> </div>
<div className="flex flex-row flex-wrap items-center gap-4 max-w-2xl"> )}
<DatasheetDownload
datasheetPath={datasheetPath}
className="mt-0 w-full sm:w-auto"
/>
{excelPath && (
<ExcelDownload excelPath={excelPath} className="mt-0 w-full sm:w-auto" />
)}
</div>
</div>
)}
{/* Structured Data (Hidden) */} {/* Structured Data */}
<JsonLd <JsonLd
id={`jsonld-${product.slug}`} id={`jsonld-${product.slug}`}
data={ data={
{ {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Product', '@type': 'Product',
name: product.frontmatter.title, name: product.frontmatter.title,
description: product.frontmatter.description, description: product.frontmatter.description,
sku: product.frontmatter.sku || product.slug.toUpperCase(), sku: product.frontmatter.sku || product.slug.toUpperCase(),
image: product.frontmatter.images?.[0] image: product.frontmatter.images?.[0]
? `${SITE_URL}${product.frontmatter.images[0]}` ? `${SITE_URL}${product.frontmatter.images[0]}`
: undefined, : undefined,
brand: { brand: {
'@type': 'Brand', '@type': 'Brand',
name: 'KLZ Cables', name: 'KLZ Cables',
}, },
offers: { offers: {
'@type': 'Offer', '@type': 'Offer',
availability: 'https://schema.org/InStock', availability: 'https://schema.org/InStock',
priceCurrency: 'EUR', priceCurrency: 'EUR',
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
itemCondition: 'https://schema.org/NewCondition', itemCondition: 'https://schema.org/NewCondition',
}, },
additionalProperty: technicalItems.map((item: any) => ({ additionalProperty: technicalItems.map((item: any) => ({
'@type': 'PropertyValue', '@type': 'PropertyValue',
name: item.label, name: item.label,
value: item.value, value: item.value,
})), })),
category: product.frontmatter.categories.join(', '), category: product.frontmatter.categories.join(', '),
mainEntityOfPage: { mainEntityOfPage: {
'@type': 'WebPage', '@type': 'WebPage',
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`, '@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
}, },
} as any } as any
} }
/> />
</div>
</div> </div>
{/* Related Products Section */} {/* Related Products Section */}
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5"> <div className="mt-16 pt-16 border-t border-neutral-dark/5">
<RelatedProducts <RelatedProducts
currentSlug={productSlug} currentSlug={productSlug}
categories={product.frontmatter.categories} categories={product.frontmatter.categories}

View File

@@ -12,11 +12,7 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const t = await getTranslations({ locale, namespace: 'Products' }); const t = await getTranslations({ locale, namespace: 'Products' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();
const title = t.has('meta.title') const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
? t('meta.title')
: t.has('breadcrumb')
? t('breadcrumb')
: 'Products';
const description = t('meta.description') || t('subtitle'); const description = t('meta.description') || t('subtitle');
return new ImageResponse( return new ImageResponse(

View File

@@ -1,4 +1,5 @@
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui'; import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -16,11 +17,7 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> { export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' }); const t = await getTranslations({ locale, namespace: 'Products' });
const title = t.has('meta.title') const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
? t('meta.title')
: t.has('breadcrumb')
? t('breadcrumb')
: 'Products';
const description = t('meta.description') || t('subtitle'); const description = t('meta.description') || t('subtitle');
return { return {
title, title,
@@ -58,7 +55,6 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale); const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
const productsSlug = await mapFileSlugToTranslated('products', locale); const productsSlug = await mapFileSlugToTranslated('products', locale);
const contactSlug = await mapFileSlugToTranslated('contact', locale);
const categories = [ const categories = [
{ {
@@ -94,7 +90,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
{/* Hero Section */} {/* Hero Section */}
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark"> <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">
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<Badge <Badge
@@ -105,7 +101,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</Badge> </Badge>
<Heading level={1} className="text-white mb-4 md:mb-8"> <Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', { {t.rich('title', {
green: (chunks) => <span className="text-accent italic">{chunks}</span>, 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>
),
})} })}
</Heading> </Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none"> <p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
@@ -214,7 +218,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" /> <div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12"> <div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left"> <div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight"> <h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
{t('cta.title')} {t('cta.title')}
</h2> </h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed"> <p className="text-base md:text-xl text-white/70 leading-relaxed">
@@ -222,13 +226,13 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</p> </p>
</div> </div>
<Button <Button
href={`/${locale}/${contactSlug}`} href={`/${locale}/contact`}
variant="accent" variant="accent"
size="lg" size="lg"
className="group whitespace-nowrap w-full md:w-auto md:h-16 px-6 md:px-10 text-sm md:text-xl" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
> >
{t('cta.button')} {t('cta.button')}
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2"> <span className="ml-4 transition-transform group-hover:translate-x-2">
&rarr; &rarr;
</span> </span>
</Button> </Button>

View File

@@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge } from '@/components/ui'; import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import Image from 'next/image'; import Image from 'next/image';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery'; import Gallery from '@/components/team/Gallery';
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="accent" className="mb-4 md:mb-8"> <Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')} {t('michael.role')}
</Badge> </Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl"> <Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
<span className="text-white">{t('michael.name')}</span> <span className="text-white">{t('michael.name')}</span>
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" /> <div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90"> <p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')} {t('michael.quote')}
</p> </p>
</div> </div>
@@ -156,7 +156,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('michael.name')} alt={t('michael.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
@@ -226,7 +225,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('klaus.name')} alt={t('klaus.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
@@ -237,12 +235,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="saturated" className="mb-4 md:mb-8"> <Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')} {t('klaus.role')}
</Badge> </Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl"> <Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
{t('klaus.name')} {t('klaus.name')}
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" /> <div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary"> <p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')} {t('klaus.quote')}
</p> </p>
</div> </div>

View File

@@ -1,123 +0,0 @@
'use server';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function requestBrochureAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'requestBrochureAction' });
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
services.analytics.track('brochure-request-attempt');
const email = formData.get('email') as string;
const locale = (formData.get('locale') as string) || 'en';
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in brochure request', { email });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
if (!email) {
logger.warn('Missing email in brochure request');
return { success: false, error: 'Missing email address' };
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { success: false, error: 'Invalid email address' };
}
// 1. Save to CMS
try {
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.split('@')[0],
email,
message: `Brochure download request (${locale})`,
type: 'brochure_download' as any,
},
overrideAccess: true,
});
logger.info('Successfully saved brochure request to Payload CMS', { email });
} catch (error) {
logger.error('Failed to store brochure request in Payload CMS', { error });
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
}
// 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Send Brochure via Email
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
try {
const { sendEmail } = await import('@/lib/mail/mailer');
const { render } = await import('@mintel/mail');
const React = await import('react');
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
const html = await render(
React.createElement(BrochureDeliveryEmail, {
_email: email,
brochureUrl,
locale: locale as 'en' | 'de',
}),
);
const emailResult = await sendEmail({
to: email,
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
html,
});
if (emailResult.success) {
logger.info('Brochure email sent successfully', { email });
} else {
logger.error('Failed to send brochure email', { error: emailResult.error, email });
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
action: 'requestBrochureAction_email',
email,
});
return { success: false, error: 'Failed to send email. Please try again later.' };
}
} catch (error) {
logger.error('Exception while sending brochure email', { error });
return { success: false, error: 'Failed to send email. Please try again later.' };
}
// 4. Track success
services.analytics.track('brochure-request-success', {
locale,
delivery_method: 'email',
});
return { success: true };
}

View File

@@ -1,5 +1,7 @@
'use server'; 'use server';
import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from '@directus/sdk';
import { sendEmail } from '@/lib/mail/mailer'; import { sendEmail } from '@/lib/mail/mailer';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react'; import React from 'react';
@@ -25,14 +27,6 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt // Track attempt
services.analytics.track('contact-form-attempt'); services.analytics.track('contact-form-attempt');
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
const name = formData.get('name') as string; const name = formData.get('name') as string;
const email = formData.get('email') as string; const email = formData.get('email') as string;
const message = formData.get('message') as string; const message = formData.get('message') as string;
@@ -47,31 +41,31 @@ export async function sendContactFormAction(formData: FormData) {
return { success: false, error: 'Missing required fields' }; return { success: false, error: 'Missing required fields' };
} }
// 1. Save to CMS // 1. Save to Directus
try { try {
const { getPayload } = await import('payload'); await ensureAuthenticated();
const configPromise = (await import('@payload-config')).default; if (productName) {
const payload = await getPayload({ config: configPromise }); await client.request(
createItem('product_requests', {
await payload.create({ product_name: productName,
collection: 'form-submissions', email,
data: { message,
name, }),
email, );
message, logger.info('Product request stored in Directus');
type: productName ? 'product_quote' : 'contact', } else {
productName: productName || undefined, await client.request(
}, createItem('contact_submissions', {
overrideAccess: true, name,
}); email,
message,
logger.info('Successfully saved form submission to Payload CMS', { }),
type: productName ? 'product_quote' : 'contact', );
email, logger.info('Contact submission stored in Directus');
}); }
} catch (error) { } catch (error) {
logger.error('Failed to store submission in Payload CMS', { error }); logger.error('Failed to store submission in Directus', { error });
services.errors.captureException(error, { action: 'payload_store_submission' }); services.errors.captureException(error, { action: 'directus_store_submission' });
} }
// 2. Send Emails // 2. Send Emails
@@ -81,7 +75,6 @@ export async function sendContactFormAction(formData: FormData) {
? `Product Inquiry: ${productName}` ? `Product Inquiry: ${productName}`
: 'New Contact Form Submission'; : 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry'; const confirmationSubject = 'Thank you for your inquiry';
const isTestSubmission = email === 'testing@mintel.me';
try { try {
// 2a. Send notification to Mintel/Client // 2a. Send notification to Mintel/Client
@@ -94,30 +87,16 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
if (!isTestSubmission) { const notificationResult = await sendEmail({
const notificationResult = await sendEmail({ replyTo: email,
replyTo: email, subject: notificationSubject,
subject: notificationSubject, html: notificationHtml,
html: notificationHtml, });
});
if (notificationResult.success) { if (notificationResult.success) {
logger.info('Notification email sent successfully', { logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId, messageId: notificationResult.messageId,
}); });
} else {
logger.error('Notification email FAILED', {
error: notificationResult.error,
subject: notificationSubject,
email,
});
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`),
{ action: 'sendContactFormAction_notification', email },
);
}
} else {
logger.info('Skipping notification email for test submission', { email });
} }
// 2b. Send confirmation to Customer (branded as KLZ Cables) // 2b. Send confirmation to Customer (branded as KLZ Cables)
@@ -129,30 +108,16 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
if (!isTestSubmission) { const confirmationResult = await sendEmail({
const confirmationResult = await sendEmail({ to: email,
to: email, subject: confirmationSubject,
subject: confirmationSubject, html: confirmationHtml,
html: confirmationHtml, });
});
if (confirmationResult.success) { if (confirmationResult.success) {
logger.info('Confirmation email sent successfully', { logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId, messageId: confirmationResult.messageId,
}); });
} else {
logger.error('Confirmation email FAILED', {
error: confirmationResult.error,
subject: confirmationSubject,
to: email,
});
services.errors.captureException(
new Error(`Confirmation email failed: ${confirmationResult.error}`),
{ action: 'sendContactFormAction_confirmation', email },
);
}
} else {
logger.info('Skipping confirmation email for test submission', { email });
} }
// Notify via Gotify (Internal) // Notify via Gotify (Internal)

View File

@@ -1,49 +1,9 @@
import { checkHealth } from '@/lib/directus';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/**
* Deep CMS Health Check
* Validates that Payload CMS can actually query the database.
* Used by post-deploy smoke tests to catch migration/schema issues.
*/
export async function GET() { export async function GET() {
const checks: Record<string, string> = {}; const health = await checkHealth();
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
try {
const payload = await getPayload({ config: configPromise });
checks.init = 'ok';
// Ensure migrations are applied on startup (reliable for standalone builds)
try {
await payload.db.migrate();
} catch (e: any) {
console.error('Migration failed:', e.message);
// We continue to check the collections even if migration fails
}
// 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 },
);
}
} }

View File

@@ -1,64 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToStream } from '@react-pdf/renderer';
import React from 'react';
import { PDFPage } from '@/lib/pdf-page';
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
try {
const { slug } = await params;
// Get Payload App
const payload = await getPayload({ config: configPromise });
// Fetch the page
const pages = await payload.find({
collection: 'pages',
where: {
slug: { equals: slug },
_status: { equals: 'published' },
},
limit: 1,
});
if (pages.totalDocs === 0) {
return new NextResponse('Page not found', { status: 404 });
}
const page = pages.docs[0];
// Determine locale from searchParams or default to 'de'
const searchParams = req.nextUrl.searchParams;
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
// Render the React-PDF document into a stream
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
const body = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
cancel() {
(stream as any).destroy?.();
},
});
const filename = `${slug}.pdf`;
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
// Cache control if needed, skip for now.
},
});
} catch (error) {
console.error('Error generating PDF:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@@ -17,11 +17,6 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'sentry-relay' }); const logger = services.logger.child({ component: 'sentry-relay' });
try { try {
// Prevent 403 Forbidden console noise in local dev
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
const envelope = await request.text(); const envelope = await request.text();
// Sentry envelopes can contain multiple parts separated by newlines // Sentry envelopes can contain multiple parts separated by newlines
@@ -40,8 +35,7 @@ export async function POST(request: NextRequest) {
const dsnUrl = new URL(realDsn); const dsnUrl = new URL(realDsn);
const projectId = dsnUrl.pathname.replace('/', ''); const projectId = dsnUrl.pathname.replace('/', '');
const sentryKey = dsnUrl.username; const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/?sentry_key=${sentryKey}`;
logger.debug('Relaying Sentry envelope', { logger.debug('Relaying Sentry envelope', {
projectId, projectId,

View File

@@ -1,55 +1,37 @@
import { config } from '@/lib/config'; import { config } from '@/lib/config';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { getAllProductsMetadata } from '@/lib/products'; import { getAllProductsMetadata } from '@/lib/mdx';
import { getAllPostsMetadata } from '@/lib/blog'; import { getAllPostsMetadata } from '@/lib/blog';
import { getAllPagesMetadata } from '@/lib/pages'; import { getAllPagesMetadata } from '@/lib/pages';
import { mapFileSlugToTranslated } from '@/lib/slugs';
export const dynamic = 'force-dynamic'; export const revalidate = 3600; // Revalidate every hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = config.baseUrl || 'https://klz-cables.com'; const baseUrl = config.baseUrl || 'https://klz-cables.com';
const locales = ['de', 'en']; const locales = ['de', 'en'];
const routes = [
'',
'/blog',
'/contact',
'/team',
'/products',
'/products/low-voltage-cables',
'/products/medium-voltage-cables',
'/products/high-voltage-cables',
'/products/solar-cables',
];
const sitemapEntries: MetadataRoute.Sitemap = []; const sitemapEntries: MetadataRoute.Sitemap = [];
for (const locale of locales) { for (const locale of locales) {
// Helper to generate localized URL Segment
const getLocalizedRoute = async (pageKey: string) => {
if (pageKey === '') return '';
const translated = await mapFileSlugToTranslated(pageKey, locale);
return `/${translated}`;
};
// Static routes // Static routes
const staticPages = ['', 'blog', 'contact', 'team', 'products']; for (const route of routes) {
for (const page of staticPages) {
const localizedRoute = await getLocalizedRoute(page);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}${localizedRoute}`, url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: page === '' ? 'daily' : 'weekly', changeFrequency: route === '' ? 'daily' : 'weekly',
priority: page === '' ? 1 : 0.8, priority: route === '' ? 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,
}); });
} }
@@ -58,28 +40,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const product of productsMetadata) { for (const product of productsMetadata) {
if (!product.frontmatter || !product.slug) continue; if (!product.frontmatter || !product.slug) continue;
const firstCat = product.frontmatter.categories[0] || ''; const category =
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-'); product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
let categoryFileSlug = 'low-voltage-cables';
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
categoryFileSlug = 'high-voltage-cables';
else if (
normalizedCat === 'mittelspannungskabel' ||
normalizedCat === 'medium-voltage-cables'
)
categoryFileSlug = 'medium-voltage-cables';
else if (
normalizedCat === 'solarkabel' ||
normalizedCat === 'solar-cables' ||
normalizedCat === 'solar'
)
categoryFileSlug = 'solar-cables';
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`, url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.7, priority: 0.7,
@@ -87,15 +51,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
} }
// Blog posts // Blog posts
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
const postsMetadata = await getAllPostsMetadata(locale); const postsMetadata = await getAllPostsMetadata(locale);
for (const post of postsMetadata) { for (const post of postsMetadata) {
if (!post.frontmatter || !post.slug) continue; if (!post.frontmatter || !post.slug) continue;
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`, url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date), lastModified: new Date(post.frontmatter.date),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.6, priority: 0.6,
@@ -107,10 +68,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const page of pagesMetadata) { for (const page of pagesMetadata) {
if (!page.slug) continue; if (!page.slug) continue;
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedSlug}`, url: `${baseUrl}/${locale}/${page.slug}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.5, priority: 0.5,

View File

@@ -19,11 +19,6 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'umami-smart-proxy' }); const logger = services.logger.child({ component: 'umami-smart-proxy' });
try { try {
// Prevent 400 Bad Request console noise in local dev
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
const body = await request.json(); const body = await request.json();
const { type, payload } = body; const { type, payload } = body;
@@ -61,12 +56,10 @@ export async function POST(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
if (!process.env.CI) { logger.error('Umami API responded with error', {
logger.error('Umami API responded with error', { status: response.status,
status: response.status, error: errorText.slice(0, 100),
error: errorText.slice(0, 100), });
});
}
return new NextResponse(errorText, { status: response.status }); return new NextResponse(errorText, { status: response.status });
} }
@@ -76,18 +69,16 @@ export async function POST(request: NextRequest) {
const errorStack = error instanceof Error ? error.stack : undefined; const errorStack = error instanceof Error ? error.stack : undefined;
// Console error to ensure it appears in logs even if logger fails // Console error to ensure it appears in logs even if logger fails
if (!process.env.CI) { console.error('CRITICAL PROXY ERROR:', {
console.error('CRITICAL PROXY ERROR:', { message: errorMessage,
message: errorMessage, stack: errorStack,
stack: errorStack, endpoint: config.analytics.umami.apiEndpoint,
endpoint: config.analytics.umami.apiEndpoint, });
});
logger.error('Failed to proxy analytics request', { logger.error('Failed to proxy analytics request', {
error: errorMessage, error: errorMessage,
stack: errorStack, stack: errorStack,
}); });
}
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -1,39 +0,0 @@
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();

View File

@@ -1,28 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
export default function AutoBrochureModal() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Check if user has already seen or interacted with the modal
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
if (!hasSeenModal) {
// Auto-open after 5 seconds to not interrupt immediate page load
const timer = setTimeout(() => {
setIsOpen(true);
// Mark as seen so it doesn't bother them again on next page load
localStorage.setItem('klz_brochure_modal_seen', 'true');
}, 5000);
return () => clearTimeout(timer);
}
}, []);
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
}

View File

@@ -1,88 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/components/ui/utils';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
interface Props {
className?: string;
compact?: boolean;
}
/**
* BrochureCTA — Shows a button that opens a modal asking for an email address.
* The full-catalog PDF is ONLY revealed after email submission.
* No direct download link is exposed anywhere.
*/
export default function BrochureCTA({ className, compact = false }: Props) {
const t = useTranslations('Brochure');
const [open, setOpen] = useState(false);
return (
<>
<div className={cn(className)}>
<button
type="button"
onClick={() => setOpen(true)}
className={cn(
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
)}
>
{/* Green top accent */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
{/* Icon */}
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
<svg
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</span>
{/* Labels */}
<span className="flex-1 min-w-0">
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
PDF Katalog
</span>
<span
className={cn(
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
compact ? 'text-base' : 'text-lg md:text-xl',
)}
>
{t('ctaTitle')}
</span>
</span>
{/* Arrow */}
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</span>
</button>
</div>
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
</>
);
}

View File

@@ -1,256 +0,0 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations, useLocale } from 'next-intl';
import { cn } from '@/components/ui/utils';
import { requestBrochureAction } from '@/app/actions/brochure';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface BrochureModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
const t = useTranslations('Brochure');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Close on escape + lock scroll + focus trap
useEffect(() => {
if (!isOpen) return;
// Auto-focus input when opened
const firstInput = document.getElementById('brochure-email');
if (firstInput) {
setTimeout(() => firstInput.focus(), 50);
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>;
if (focusable.length > 0) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Strict overflow lock on mobile as well
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formRef.current) return;
setState('submitting');
setErrorMsg('');
try {
const formData = new FormData(formRef.current);
formData.set('locale', locale);
const result = await requestBrochureAction(formData);
if (result.success) {
setState('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setState('error');
setErrorMsg(result.error || 'Something went wrong');
}
} catch {
setState('error');
setErrorMsg('Network error');
}
};
const handleClose = () => {
setState('idle');
setErrorMsg('');
onClose();
};
if (!isOpen) return null;
const modal = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={handleClose}
aria-hidden="true"
/>
{/* Modal Panel */}
<div
ref={modalRef}
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
>
{/* Accent bar at top */}
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
{/* Close Button */}
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
aria-label={t('close')}
>
<svg className="h-4 w-4" 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 className="p-8 pt-7">
{/* Icon + Header */}
<div className="mb-7">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
<svg
className="h-6 w-6 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
{t('title')}
</h2>
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
</div>
{state === 'success' ? (
<div>
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
<svg
className="h-5 w-5 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p className="text-sm font-bold text-[#82ed20]">
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
</p>
<p className="text-xs text-white/50 mt-0.5">
{locale === 'de'
? 'Bitte prüfen Sie Ihren Posteingang.'
: 'Please check your inbox.'}
</p>
</div>
</div>
<button
type="button"
onClick={handleClose}
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
>
{t('close')}
</button>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<div className="mb-5">
<label
htmlFor="brochure-email"
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
>
{t('emailLabel')}
</label>
<input
id="brochure-email"
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
disabled={state === 'submitting'}
/>
</div>
{state === 'error' && errorMsg && (
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className={cn(
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
state === 'submitting'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
)}
>
{state === 'submitting' ? t('submitting') : t('submit')}
</button>
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
{t('privacyNote')}
</p>
</form>
)}
</div>
</div>
</div>
);
return createPortal(modal, document.body);
}

View File

@@ -15,12 +15,10 @@ export default function CMSConnectivityNotice() {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug'); const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = config.isDevelopment; const isLocal = config.isDevelopment;
const isTesting = config.isTesting; const isTesting = config.isTesting;
const target = process.env.NEXT_PUBLIC_TARGET || '';
const isBranch = target === 'branch';
// Only proceed with check if it's developer context (Local, Testing, or Branch preview) // Only proceed with check if it's developer context (Local or Testing)
// Staging and Production should NEVER see this unless forced with ?cms_debug // Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isBranch && !isDebug) return; if (!isLocal && !isTesting && !isDebug) return;
try { try {
const response = await fetch('/api/health/cms'); const response = await fetch('/api/health/cms');
@@ -60,8 +58,8 @@ export default function CMSConnectivityNotice() {
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4> <h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
<p className="text-xs opacity-90 leading-relaxed mb-3"> <p className="text-xs opacity-90 leading-relaxed mb-3">
{errorMsg === 'relation "products" does not exist' {errorMsg === 'relation "products" does not exist'
? 'The database schema is missing. Please run migrations for this environment.' ? 'The database schema is missing. Please sync your local data to this environment.'
: 'A content service is unavailable. Check the deployment logs for details.'} : errorMsg || 'The application cannot connect to the Directus CMS.'}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button

View File

@@ -138,20 +138,7 @@ export default function ContactForm() {
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10"> <Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')} {t('form.title')}
</Heading> </Heading>
<form <form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
id="contact-form"
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"
>
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="space-y-1 md:space-y-2"> <div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label> <Label htmlFor="contact-name">{t('form.name')}</Label>
<Input <Input
@@ -161,6 +148,7 @@ export default function ContactForm() {
autoComplete="name" autoComplete="name"
enterKeyHint="next" enterKeyHint="next"
onFocus={() => handleFocus('contact-name')} onFocus={() => handleFocus('contact-name')}
aria-label={t('form.name')}
required required
/> />
</div> </div>
@@ -175,6 +163,7 @@ export default function ContactForm() {
enterKeyHint="next" enterKeyHint="next"
placeholder={t('form.emailPlaceholder')} placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('contact-email')} onFocus={() => handleFocus('contact-email')}
aria-label={t('form.email')}
required required
/> />
</div> </div>
@@ -187,6 +176,7 @@ export default function ContactForm() {
enterKeyHint="send" enterKeyHint="send"
placeholder={t('form.messagePlaceholder')} placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('contact-message')} onFocus={() => handleFocus('contact-message')}
aria-label={t('form.message')}
required required
/> />
</div> </div>

View File

@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" /> <div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */} {/* Inner Content */}
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10"> <div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
{/* Icon Container */} {/* Icon Container */}
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500"> <div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg <svg
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3" className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div> </div>
{/* Text Content */} {/* Text Content */}
<div className="flex-1 min-w-0"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent"> <span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
PDF Datasheet PDF Datasheet
</span> </span>
</div> </div>
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300"> <h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
{t('downloadDatasheet')} {t('downloadDatasheet')}
</h3> </h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed"> <p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div> </div>
{/* Arrow Icon */} {/* Arrow Icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg <svg
className="h-5 w-5" className="h-6 w-6"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -1,94 +0,0 @@
'use client';
import { cn } from '@/components/ui/utils';
import { useTranslations } from 'next-intl';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface ExcelDownloadProps {
excelPath: string;
className?: string;
}
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
const t = useTranslations('Products');
const { trackEvent } = useAnalytics();
return (
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
<a
href={excelPath}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: excelPath.split('/').pop(),
file_path: excelPath,
file_type: 'excel',
location: 'product_page',
})
}
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
>
{/* Animated Background Gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Spreadsheet/Table Icon */}
<svg
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
/>
</svg>
</div>
{/* Text Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
Excel Datasheet
</span>
</div>
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
{t('downloadExcel')}
</h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
{t('downloadExcelDesc')}
</p>
</div>
{/* Arrow Icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</a>
</div>
);
}

View File

@@ -1,18 +0,0 @@
'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 />;
}

View File

@@ -6,7 +6,6 @@ import { useTranslations, useLocale } from 'next-intl';
import { Container } from './ui'; import { Container } from './ui';
import { useAnalytics } from './analytics/useAnalytics'; import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events'; import { AnalyticsEvents } from './analytics/analytics-events';
import FooterBrochureForm from './FooterBrochureForm';
export default function Footer() { export default function Footer() {
const t = useTranslations('Footer'); const t = useTranslations('Footer');
@@ -16,14 +15,13 @@ export default function Footer() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto"> <footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" /> <div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<Container> <Container>
<h2 className="sr-only">Footer Navigation</h2> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
<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 */}
{/* Brand Column full width on mobile */} <div className="lg:col-span-4 space-y-8">
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="inline-block group" className="inline-block group"
@@ -39,7 +37,6 @@ export default function Footer() {
alt="KLZ Vertriebs GmbH" alt="KLZ Vertriebs GmbH"
width={150} width={150}
height={40} height={40}
style={{ width: 'auto' }}
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110" className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
/> />
</Link> </Link>
@@ -68,9 +65,9 @@ export default function Footer() {
</div> </div>
</div> </div>
{/* Legal Column */} {/* Links Columns */}
<div className="col-span-1 lg:col-span-2"> <div className="lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('legal')} {t('legal')}
</h3> </h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0"> <ul className="space-y-4 text-white/70 list-none m-0 p-0">
@@ -122,9 +119,8 @@ export default function Footer() {
</ul> </ul>
</div> </div>
{/* Company Column */} <div className="lg:col-span-2">
<div className="col-span-1 lg:col-span-2"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
{t('company')} {t('company')}
</h3> </h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0"> <ul className="space-y-4 text-white/70 list-none m-0 p-0">
@@ -175,12 +171,12 @@ export default function Footer() {
</li> </li>
<li> <li>
<Link <Link
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`} href={`/${locale}/contact`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block" className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('contact'), label: navT('contact'),
href: locale === 'de' ? '/kontakt' : '/contact', href: '/contact',
location: 'footer_company', location: 'footer_company',
}) })
} }
@@ -191,9 +187,9 @@ export default function Footer() {
</ul> </ul>
</div> </div>
{/* Recent Posts Column full width on mobile */} {/* Recent Posts Column */}
<div className="col-span-2 md:col-span-2 lg:col-span-4"> <div className="lg:col-span-4">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('recentPosts')} {t('recentPosts')}
</h3> </h3>
<ul className="space-y-6 list-none m-0 p-0"> <ul className="space-y-6 list-none m-0 p-0">
@@ -244,15 +240,12 @@ export default function Footer() {
</div> </div>
</div> </div>
<div className="mb-12 md:mb-16"> <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">
<FooterBrochureForm />
</div>
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p> <p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8"> <div className="flex gap-8">
<Link <Link
href="/en" href="/en"
locale="en"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
@@ -267,6 +260,7 @@ export default function Footer() {
</Link> </Link>
<Link <Link
href="/de" href="/de"
locale="de"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {

View File

@@ -1,134 +0,0 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { requestBrochureAction } from '@/app/actions/brochure';
import { cn } from '@/components/ui/utils';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface Props {
className?: string;
}
export default function FooterBrochureForm({ className }: Props) {
const t = useTranslations('Brochure');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [err, setErr] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!formRef.current) return;
setPhase('loading');
const fd = new FormData(formRef.current);
fd.set('locale', locale);
try {
const res = await requestBrochureAction(fd);
if (res.success) {
setPhase('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'footer_inline',
});
} else {
setErr(res.error || 'Error');
setPhase('error');
}
} catch {
setErr('Network error');
setPhase('error');
}
}
if (phase === 'success') {
return (
<div
className={cn(
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
className,
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h4 className="text-white font-bold mb-1">
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
</h4>
<p className="text-white/60 text-sm">
{locale === 'de'
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
: 'We have just sent the catalog to your email.'}
</p>
</div>
</div>
);
}
return (
<div
className={cn(
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
className,
)}
>
<div className="flex-1 max-w-xl">
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
{t('ctaTitle')}
</h4>
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
</div>
<form
ref={formRef}
onSubmit={handleSubmit}
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
>
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="relative w-full sm:w-64">
<input
name="email"
type="email"
required
placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'}
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
/>
</div>
<button
type="submit"
disabled={phase === 'loading'}
className={cn(
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
phase === 'loading'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
)}
>
{phase === 'loading' ? t('submitting') : t('submit')}
</button>
</form>
{phase === 'error' && err && (
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from './ui'; import { Button } from './ui';
@@ -17,6 +18,7 @@ export default function Header() {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname // Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en'; const currentLocale = pathname.split('/')[1] || 'en';
@@ -33,10 +35,10 @@ export default function Header() {
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, []);
// Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap // Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => { useEffect(() => {
if (isMobileMenuOpen) { if (isMobileMenuOpen) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Focus trap logic // Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll( const focusableElements = mobileMenuRef.current?.querySelectorAll(
@@ -81,54 +83,15 @@ export default function Header() {
}; };
} }
} else { } else {
document.documentElement.style.overflow = ''; document.body.style.overflow = 'unset';
document.body.style.overflow = '';
} }
}, [isMobileMenuOpen]); }, [isMobileMenuOpen]);
// Function to get path for a different locale with segment translation // Function to get path for a different locale
const getPathForLocale = (newLocale: string) => { const getPathForLocale = (newLocale: string) => {
const segments = pathname.split('/'); const segments = pathname.split('/');
const originLocale = segments[1] || 'en';
// Translation map for localized URL segments
const segmentMap: Record<string, Record<string, string>> = {
de: {
produkte: 'products',
kontakt: 'contact',
impressum: 'legal-notice',
datenschutz: 'privacy-policy',
agbs: 'terms',
niederspannungskabel: 'low-voltage-cables',
mittelspannungskabel: 'medium-voltage-cables',
hochspannungskabel: 'high-voltage-cables',
solarkabel: 'solar-cables',
},
en: {
products: 'produkte',
contact: 'kontakt',
'legal-notice': 'impressum',
'privacy-policy': 'datenschutz',
terms: 'agbs',
'low-voltage-cables': 'niederspannungskabel',
'medium-voltage-cables': 'mittelspannungskabel',
'high-voltage-cables': 'hochspannungskabel',
'solar-cables': 'solarkabel',
},
};
// Replace the locale segment
segments[1] = newLocale; segments[1] = newLocale;
return segments.join('/');
// Translate other segments if they exist in our map
const translatedSegments = segments.map((segment, index) => {
if (index <= 1) return segment; // Skip empty and locale segments
const mapping = segmentMap[originLocale as keyof typeof segmentMap];
return mapping && mapping[segment] ? mapping[segment] : segment;
});
return translatedSegments.join('/');
}; };
const menuItems = [ const menuItems = [
@@ -139,12 +102,10 @@ export default function Header() {
]; ];
const headerClass = cn( const headerClass = cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu fill-mode-both', 'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
{ {
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none': 'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
isHomePage && !isScrolled && !isMobileMenuOpen, 'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
!isHomePage || isScrolled || isMobileMenuOpen,
}, },
); );
@@ -153,9 +114,19 @@ export default function Header() {
return ( return (
<> <>
<header className={headerClass} style={{ animationDuration: '800ms' }}> <motion.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"> <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"> <motion.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 }}
>
<Link <Link
href={`/${currentLocale}`} href={`/${currentLocale}`}
onClick={() => onClick={() =>
@@ -170,74 +141,65 @@ export default function Header() {
alt={t('home')} alt={t('home')}
width={120} width={120}
height={120} height={120}
style={{ width: 'auto' }}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110" className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority priority
fetchPriority="high"
loading="eager"
decoding="sync"
/> />
</Link> </Link>
</div> </motion.div>
<div className="flex items-center gap-4 md:gap-12"> <motion.div
<nav className="hidden lg:flex items-center space-x-10"> className="flex items-center gap-4 md:gap-12"
{menuItems.map((item, idx) => ( initial="hidden"
<div animate="visible"
key={item.href} variants={{
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both" visible: {
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }} transition: {
> staggerChildren: 0.08,
{(() => { delayChildren: 0.3,
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`; },
const isActive = },
item.href === '/' }}
? pathname === `/${currentLocale}` || pathname === '/' >
: pathname.startsWith(fullHref); <motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
return ( {menuItems.map((item, _idx) => (
<Link <motion.div key={item.href} variants={navLinkVariants}>
href={fullHref} <Link
aria-current={isActive ? 'page' : undefined} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => { onClick={() => {
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label, label: item.label,
href: item.href, href: item.href,
location: 'header_nav', location: 'header_nav',
}); });
}} }}
className={cn( className={cn(
textColorClass, textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5', 'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
isActive && 'text-accent', )}
)} >
> {item.label}
{item.label} <span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
<span </Link>
className={cn( </motion.div>
'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> </motion.nav>
<div <motion.div
className={cn( className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
'hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both', variants={headerRightVariants}
textColorClass,
)}
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
> >
<div <motion.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" className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
style={{ animationDuration: '500ms', animationDelay: '600ms' }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
> >
<div> <motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link <Link
href={getPathForLocale('en')} href={getPathForLocale('en')}
onClick={() => onClick={() =>
@@ -248,13 +210,22 @@ export default function Header() {
location: 'header', location: 'header',
}) })
} }
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`} className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
> >
EN EN
</Link> </Link>
</div> </motion.div>
<div className="w-px h-4 bg-current opacity-30" /> <motion.div
<div> className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link <Link
href={getPathForLocale('de')} href={getPathForLocale('de')}
onClick={() => onClick={() =>
@@ -265,22 +236,23 @@ export default function Header() {
location: 'header', location: 'header',
}) })
} }
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`} className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
> >
DE DE
</Link> </Link>
</div> </motion.div>
</div> </motion.div>
<div <motion.div
className="animate-in fade-in zoom-in-95 fill-mode-both" initial={{ scale: 0.9, opacity: 0 }}
style={{ animationDuration: '600ms', animationDelay: '700ms' }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
> >
<Button <Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`} href={`/${currentLocale}/contact`}
variant="white" variant="white"
size="md" size="md"
className="px-8 shadow-xl hover:scale-105 transition-transform" className="px-8 shadow-xl"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'), label: t('contact'),
@@ -290,19 +262,27 @@ export default function Header() {
> >
{t('contact')} {t('contact')}
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<button <motion.button
className={cn( className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300', 'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass, textColorClass,
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
)} )}
aria-label={t('toggleMenu')} aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen} aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu" 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={() => { onClick={() => {
const newState = !isMobileMenuOpen; const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState); setIsMobileMenuOpen(newState);
@@ -312,163 +292,213 @@ export default function Header() {
}); });
}} }}
> >
<svg <motion.svg
className="w-7 h-7 transition-transform duration-300" className="w-7 h-7"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
> >
{isMobileMenuOpen ? ( {isMobileMenuOpen ? (
<path <motion.path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/> />
) : ( ) : (
<path <motion.path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16" d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/> />
)} )}
</svg> </motion.svg>
</button> </motion.button>
</div> </motion.div>
</div> </div>
</header>
{/* Mobile Menu Overlay */} {/* Mobile Menu Overlay */}
<div <div
className={cn( 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', 'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen isMobileMenuOpen
? 'opacity-100 translate-y-0' ? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none', : 'opacity-0 -translate-y-full pointer-events-none',
)} )}
id="mobile-menu" id="mobile-menu"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t('menu')} aria-label={t('menu')}
ref={mobileMenuRef} ref={mobileMenuRef}
inert={isMobileMenuOpen ? undefined : true} >
> <motion.nav
{/* Close Button inside overlay */} className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
<div className="flex justify-end p-6 pt-8"> initial="closed"
<button animate={isMobileMenuOpen ? 'open' : 'closed'}
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300" variants={{
aria-label={t('toggleMenu')} open: {
onClick={() => { transition: {
setIsMobileMenuOpen(false); staggerChildren: 0.1,
trackEvent(AnalyticsEvents.BUTTON_CLICK, { delayChildren: 0.2,
type: 'mobile_menu', },
action: 'close', },
});
}} }}
> >
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {menuItems.map((item, idx) => (
<path <motion.div
strokeLinecap="round" key={item.href}
strokeLinejoin="round" variants={{
strokeWidth={2} closed: { opacity: 0, y: 50, scale: 0.9 },
d="M6 18L18 6M6 6l12 12" open: {
/> opacity: 1,
</svg> y: 0,
</button> scale: 1,
</div> transition: {
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"> duration: 0.6,
{menuItems.map((item, idx) => ( ease: 'easeOut',
<div delay: idx * 0.08,
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 <Link
href={getPathForLocale('en')} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`} 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"
> >
EN {item.label}
</Link> </Link>
</div> </motion.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"> <motion.div
<Button className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`} initial={{ opacity: 0, y: 30 }}
variant="accent" animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
size="lg" transition={{ duration: 0.5, delay: 0.8 }}
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform" >
<motion.div
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }}
> >
{t('contact')} <motion.div
</Button> initial={{ opacity: 0 }}
</div> animate={{ opacity: 1 }}
</div> 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>
</motion.div>
<motion.div
className="w-px h-6 bg-white/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4, delay: 1.05 }}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
{/* Bottom Branding */} <motion.div
<div initial={{ scale: 0.9, opacity: 0, y: 20 }}
className={cn( animate={{ scale: 1, opacity: 1, y: 0 }}
'p-12 flex justify-center transition-all duration-700', transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75', >
)} <Button
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }} href={`/${currentLocale}/contact`}
> variant="accent"
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized /> size="lg"
</div> className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
</nav> >
</div> {t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Bottom Branding */}
<motion.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 }}
>
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div>
</motion.nav>
</div>
</motion.header>
</> </>
); );
} }
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;

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { m, LazyMotion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {
@@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const previousFocusRef = useRef<HTMLElement | null>(null); const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
return () => setMounted(false); return () => setMounted(false);
}, []); }, []);
@@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (photoParam !== null) { if (photoParam !== null) {
const index = parseInt(photoParam, 10); const index = parseInt(photoParam, 10);
if (!isNaN(index) && index >= 0 && index < images.length) { if (!isNaN(index) && index >= 0 && index < images.length) {
setCurrentIndex(index); setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
} }
} }
}, [searchParams, images.length]); }, [searchParams, images.length]);
@@ -125,17 +125,13 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
}; };
// Lock scroll // Lock scroll
const originalBodyStyle = window.getComputedStyle(document.body).overflow; const originalStyle = window.getComputedStyle(document.body).overflow;
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow; document.body.style.overflow = 'hidden';
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
document.documentElement.style.overflow = originalHtmlStyle; document.body.style.overflow = originalStyle;
document.body.style.overflow = originalBodyStyle;
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen, prevImage, nextImage, handleClose]); }, [isOpen, prevImage, nextImage, handleClose]);
@@ -143,120 +139,118 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null; if (!mounted) return null;
return createPortal( return createPortal(
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}> <AnimatePresence>
<AnimatePresence> {isOpen && (
{isOpen && ( <div
<div className="fixed inset-0 z-[99999] flex items-center justify-center"
className="fixed inset-0 z-[99999] flex items-center justify-center" role="dialog"
role="dialog" aria-modal="true"
aria-modal="true" >
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
> >
<m.div <div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
initial={{ opacity: 0 }} <span className="text-3xl font-extralight leading-none mb-1">×</span>
animate={{ opacity: 1 }} </div>
exit={{ opacity: 0 }} </motion.button>
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<m.button <motion.button
initial={{ opacity: 0, scale: 0.5 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5 }} exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.1, duration: 0.4 }} transition={{ delay: 0.2, duration: 0.4 }}
ref={closeButtonRef} onClick={prevImage}
onClick={handleClose} className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10" aria-label="Previous image"
aria-label="Close lightbox" >
> <span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span> </span>
</motion.button>
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</motion.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div> </div>
</m.button>
<m.button <motion.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.2, duration: 0.4 }} transition={{ delay: 0.3, duration: 0.4 }}
onClick={prevImage} className="mt-8 flex items-center gap-4"
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10" >
aria-label="Previous image" <div className="h-px w-12 bg-white/20" />
> <div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500"> {currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</span>
</m.button>
<m.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</m.button>
<m.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</m.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div> </div>
<div className="h-px w-12 bg-white/20" />
<m.div </motion.div>
initial={{ opacity: 0, y: 10 }} </div>
animate={{ opacity: 1, y: 0 }} </motion.div>
exit={{ opacity: 0, y: 10 }} </div>
transition={{ delay: 0.3, duration: 0.4 }} )}
className="mt-8 flex items-center gap-4" </AnimatePresence>,
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div>
<div className="h-px w-12 bg-white/20" />
</m.div>
</div>
</m.div>
</div>
)}
</AnimatePresence>
</LazyMotion>,
document.body, document.body,
); );
} }

View File

@@ -1,39 +0,0 @@
'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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
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>
);
}

View File

@@ -1,42 +0,0 @@
'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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
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>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import RequestQuoteForm from '@/components/RequestQuoteForm'; import RequestQuoteForm from '@/components/RequestQuoteForm';
import DatasheetDownload from '@/components/DatasheetDownload'; import DatasheetDownload from '@/components/DatasheetDownload';
import ExcelDownload from '@/components/ExcelDownload';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { cn } from '@/components/ui/utils'; import { cn } from '@/components/ui/utils';
@@ -12,7 +11,6 @@ interface ProductSidebarProps {
productName: string; productName: string;
productImage?: string; productImage?: string;
datasheetPath?: string | null; datasheetPath?: string | null;
excelPath?: string | null;
className?: string; className?: string;
} }
@@ -20,7 +18,6 @@ export default function ProductSidebar({
productName, productName,
productImage, productImage,
datasheetPath, datasheetPath,
excelPath,
className, className,
}: ProductSidebarProps) { }: ProductSidebarProps) {
const t = useTranslations('Products'); const t = useTranslations('Products');
@@ -38,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 aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105"> <div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
<Image <Image
src={productImage.split('?')[0]} src={productImage}
alt={productName} alt={productName}
fill fill
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]" className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
@@ -73,9 +70,6 @@ export default function ProductSidebar({
{/* Datasheet Download */} {/* Datasheet Download */}
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />} {datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
{/* Excel Download right below datasheet */}
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
</aside> </aside>
); );
} }

View File

@@ -2,7 +2,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface KeyValueItem { interface KeyValueItem {
label: string; label: string;
@@ -39,47 +38,29 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
}; };
return ( return (
<div className="space-y-8 md:space-y-16"> <div className="space-y-16">
{technicalItems.length > 0 && ( {technicalItems.length > 0 && (
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5"> <div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3"> <h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" /> <div className="w-2 h-8 bg-accent rounded-full" />
General Data General Data
</h3> </h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8"> <dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
{technicalItems.map((item, idx) => { {technicalItems.map((item, idx) => (
const formatted = formatTechnicalValue(item.value); <div key={idx} className="flex flex-col group">
return ( <dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
<div key={idx} className="flex flex-col group"> {item.label}
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors"> </dt>
{item.label} <dd className="text-lg font-semibold text-text-primary">
</dt> {item.value}{' '}
<dd className="text-lg font-semibold text-text-primary"> {item.unit && (
{formatted.isList ? ( <span className="text-sm font-normal text-text-secondary ml-1">
<div className="flex flex-wrap gap-2 mt-1"> {item.unit}
{formatted.parts.map((p, pIdx) => ( </span>
<span )}
key={pIdx} </dd>
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors" </div>
> ))}
{p}
</span>
))}
</div>
) : (
<>
{item.value}{' '}
{item.unit && (
<span className="text-sm font-normal text-text-secondary ml-1">
{item.unit}
</span>
)}
</>
)}
</dd>
</div>
);
})}
</dl> </dl>
</div> </div>
)} )}
@@ -91,18 +72,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
return ( return (
<div <div
key={idx} key={idx}
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden" className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
> >
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3"> <h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" /> <div className="w-2 h-8 bg-accent rounded-full" />
{table.voltageLabel !== 'Voltage unknown' && {table.voltageLabel !== 'Voltage unknown' &&
table.voltageLabel !== 'Spannung unbekannt' table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel ? table.voltageLabel
: 'Technical Specifications'} : 'Technical Specifications'}
</h3> </h3>
{table.metaItems.length > 0 && ( {table.metaItems.length > 0 && (
<dl className="grid grid-cols-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"> <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">
{table.metaItems.map((item, mIdx) => ( {table.metaItems.map((item, mIdx) => (
<div key={mIdx}> <div key={mIdx}>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1"> <dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
@@ -117,12 +98,11 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
)} )}
<div className="relative"> <div className="relative">
{/* Scroll hint gradient on right edge for mobile */}
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
<div <div
id={`voltage-table-${idx}`} id={`voltage-table-${idx}`}
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]' className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
}`} !isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
}`}
> >
<table className="min-w-full border-separate border-spacing-0"> <table className="min-w-full border-separate border-spacing-0">
<thead> <thead>
@@ -150,14 +130,12 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap"> <td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
{row.configuration} {row.configuration}
</td> </td>
{row.cells.map((cell: any, cellIdx: number) => ( {row.cells.map((cell, cellIdx) => (
<td <td
key={cellIdx} key={cellIdx}
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap" className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
> >
{typeof cell === 'object' && cell !== null && 'value' in cell {cell}
? cell.value
: cell}
</td> </td>
))} ))}
</tr> </tr>

View File

@@ -1,4 +1,4 @@
import { getAllProducts } from '@/lib/products'; import { getAllProducts } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import Image from 'next/image'; import Image from 'next/image';
import { RelatedProductLink } from './RelatedProductLink'; import { RelatedProductLink } from './RelatedProductLink';
@@ -29,32 +29,6 @@ export default async function RelatedProducts({
if (related.length === 0) return null; if (related.length === 0) return null;
// Pre-calculate translated slugs for related products
const relatedWithTranslatedSlugs = await Promise.all(
related.map(async (product) => {
// Find the category slug for the link
const categorySlugs = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const catFileSlug =
categorySlugs.find((slug) => {
return product.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
);
}) || 'low-voltage-cables';
const catSlug = await mapFileSlugToTranslated(catFileSlug, locale);
return {
...product,
catSlug,
};
}),
);
return ( return (
<div className=""> <div className="">
<div className="flex items-end justify-between mb-12"> <div className="flex items-end justify-between mb-12">
@@ -67,11 +41,29 @@ export default async function RelatedProducts({
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{relatedWithTranslatedSlugs.map((product) => { {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';
return ( return (
<RelatedProductLink <RelatedProductLink
key={product.slug} key={product.slug}
href={`/${locale}/${productsSlug}/${product.catSlug}/${product.slug}`} href={`/${locale}/${productsSlug}/${catSlug}/${product.slug}`}
productSlug={product.slug} productSlug={product.slug}
productTitle={product.frontmatter.title} productTitle={product.frontmatter.title}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5" className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
@@ -80,7 +72,7 @@ export default async function RelatedProducts({
{product.frontmatter.images?.[0] ? ( {product.frontmatter.images?.[0] ? (
<> <>
<Image <Image
src={product.frontmatter.images[0].split('?')[0]} src={product.frontmatter.images[0]}
alt={product.frontmatter.title} alt={product.frontmatter.title}
fill fill
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10" className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"

View File

@@ -78,9 +78,6 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
} }
}; };
const emailId = React.useId();
const requestId = React.useId();
if (status === 'success') { if (status === 'success') {
return ( return (
<div <div
@@ -164,46 +161,32 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
} }
return ( return (
<form id="quote-request-form" onSubmit={handleSubmit} className="space-y-3 !mt-0"> <form onSubmit={handleSubmit} className="space-y-3 !mt-0">
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="space-y-2 !mt-0"> <div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
<label htmlFor={emailId} className="sr-only">
{t('email')}
</label>
<Input <Input
type="email" type="email"
id={emailId} id="quote-email"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onFocus={() => handleFocus('quote-email')} onFocus={() => handleFocus('quote-email')}
placeholder={t('email')} placeholder={t('email')}
aria-label={t('email')}
className="h-9 text-xs !mt-0" className="h-9 text-xs !mt-0"
/> />
</div> </div>
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
<label htmlFor={requestId} className="sr-only">
{t('message')}
</label>
<Textarea <Textarea
id={requestId} id="quote-request"
required required
rows={3} rows={3}
value={request} value={request}
onChange={(e) => setRequest(e.target.value)} onChange={(e) => setRequest(e.target.value)}
onFocus={() => handleFocus('quote-request')} onFocus={() => handleFocus('quote-request')}
placeholder={t('message')} placeholder={t('message')}
aria-label={t('message')}
className="text-xs !mt-0" className="text-xs !mt-0"
/> />
</div> </div>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { motion, Variants } from 'framer-motion';
import { cn } from '@/components/ui'; import { cn } from '@/components/ui';
interface ScribbleProps { interface ScribbleProps {
@@ -10,6 +11,18 @@ interface ScribbleProps {
} }
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) { export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
const pathVariants: Variants = {
hidden: { pathLength: 0, opacity: 0 },
visible: {
pathLength: 1,
opacity: 1,
transition: {
duration: 1.8,
ease: 'easeInOut',
},
},
};
if (variant === 'circle') { if (variant === 'circle') {
return ( return (
<svg <svg
@@ -18,10 +31,11 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
viewBox="0 0 800 350" viewBox="0 0 800 350"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<path <motion.path
className="animate-draw-stroke" variants={pathVariants}
pathLength="1" initial="hidden"
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }} whileInView="visible"
viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)" transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter" strokeLinejoin="miter"
fillOpacity="0" fillOpacity="0"
@@ -43,10 +57,11 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
viewBox="-400 -55 730 60" viewBox="-400 -55 730 60"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<path <motion.path
className="animate-draw-stroke" variants={pathVariants}
pathLength="1" initial="hidden"
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }} 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" d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color} stroke={color}
strokeWidth="20" strokeWidth="20"

View File

@@ -8,7 +8,7 @@ export default function SkipLink() {
return ( return (
<a <a
href="#main-content" href="#main-content"
className="fixed -top-full left-4 z-[100] px-6 py-3 bg-white text-primary-dark font-bold rounded-lg shadow-xl outline-none ring-2 ring-accent transition-all duration-300 focus:top-4" 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"
> >
{t('skipToContent')} {t('skipToContent')}
</a> </a>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Suspense, useEffect, useState } from 'react'; import { Suspense } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), { const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false, ssr: false,
@@ -9,35 +9,12 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), { const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false, ssr: false,
}); });
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
ssr: false,
});
export default function AnalyticsShell() { export default function AnalyticsShell() {
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
// Disable analytics in CI to prevent console noise/score penalties
if (process.env.NEXT_PUBLIC_CI === 'true') {
return;
}
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
} else {
const timer = setTimeout(() => setShouldLoad(true), 2500);
return () => clearTimeout(timer);
}
}, []);
if (!shouldLoad) return null;
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<DynamicAnalyticsProvider /> <DynamicAnalyticsProvider />
<DynamicScrollDepthTracker /> <DynamicScrollDepthTracker />
<DynamicWebVitalsTracker />
</Suspense> </Suspense>
); );
} }

View File

@@ -1,26 +0,0 @@
'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;
}

View File

@@ -136,14 +136,18 @@ function AddToCartButton({ product, quantity = 1 }) {
product_category: product.category, product_category: product.category,
price: product.price, price: product.price,
quantity: quantity, quantity: quantity,
cart_total: 150.0, // Current cart total cart_total: 150.00, // Current cart total
}); });
// Actual add to cart logic // Actual add to cart logic
// addToCart(product, quantity); // addToCart(product, quantity);
}; };
return <button onClick={handleAddToCart}>Add to Cart</button>; return (
<button onClick={handleAddToCart}>
Add to Cart
</button>
);
} }
``` ```
@@ -167,7 +171,7 @@ function CheckoutComplete({ order }) {
transaction_tax: order.tax, transaction_tax: order.tax,
transaction_shipping: order.shipping, transaction_shipping: order.shipping,
product_count: order.items.length, product_count: order.items.length,
products: order.items.map((item) => ({ products: order.items.map(item => ({
product_id: item.product.id, product_id: item.product.id,
product_name: item.product.name, product_name: item.product.name,
quantity: item.quantity, quantity: item.quantity,
@@ -196,19 +200,25 @@ function WishlistButton({ product }) {
const newState = !isInWishlist; const newState = !isInWishlist;
trackEvent( trackEvent(
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE, newState
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
{ {
product_id: product.id, product_id: product.id,
product_name: product.name, product_name: product.name,
product_category: product.category, product_category: product.category,
}, }
); );
setIsInWishlist(newState); setIsInWishlist(newState);
// Update wishlist in backend // Update wishlist in backend
}; };
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>; return (
<button onClick={toggleWishlist}>
{isInWishlist ? '❤️' : '🤍'}
</button>
);
} }
``` ```
@@ -258,7 +268,7 @@ function ContactForm() {
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
[e.target.name]: e.target.value, [e.target.name]: e.target.value,
})); }));
@@ -300,7 +310,9 @@ function NewsletterSignup() {
return ( return (
<div> <div>
<input placeholder="Enter email" /> <input placeholder="Enter email" />
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button> <button onClick={() => handleSubscribe('user@example.com')}>
Subscribe
</button>
</div> </div>
); );
} }
@@ -384,12 +396,10 @@ function LoginForm() {
}; };
return ( return (
<form <form onSubmit={(e) => {
onSubmit={(e) => { e.preventDefault();
e.preventDefault(); handleLogin('user@example.com', 'password');
handleLogin('user@example.com', 'password'); }}>
}}
>
{/* Form fields */} {/* Form fields */}
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
@@ -408,7 +418,11 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function SignupForm() { function SignupForm() {
const { trackEvent } = useAnalytics(); 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, { trackEvent(AnalyticsEvents.USER_SIGNUP, {
user_email: userData.email, user_email: userData.email,
user_name: userData.name, user_name: userData.name,
@@ -422,16 +436,14 @@ function SignupForm() {
}; };
return ( return (
<form <form onSubmit={(e) => {
onSubmit={(e) => { e.preventDefault();
e.preventDefault(); handleSignup({
handleSignup({ email: 'user@example.com',
email: 'user@example.com', name: 'John Doe',
name: 'John Doe', company: 'ACME Corp',
company: 'ACME Corp', });
}); }}>
}}
>
{/* Form fields */} {/* Form fields */}
<button type="submit">Sign Up</button> <button type="submit">Sign Up</button>
</form> </form>
@@ -619,7 +631,11 @@ function VideoPlayer({ videoId, videoTitle }) {
}; };
return ( return (
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}> <video
onPlay={handlePlay}
onPause={handlePause}
onEnded={handleComplete}
>
<source src="/video.mp4" type="video/mp4" /> <source src="/video.mp4" type="video/mp4" />
</video> </video>
); );
@@ -649,7 +665,11 @@ function DownloadButton({ fileName, fileType, fileSize }) {
// window.location.href = `/downloads/${fileName}`; // window.location.href = `/downloads/${fileName}`;
}; };
return <button onClick={handleDownload}>Download {fileName}</button>; return (
<button onClick={handleDownload}>
Download {fileName}
</button>
);
} }
``` ```
@@ -943,9 +963,15 @@ function CableProductPage({ cable }) {
return ( return (
<div> <div>
<h1>{cable.name}</h1> <h1>{cable.name}</h1>
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button> <button onClick={handleTechnicalSpecDownload}>
<button onClick={handleRequestQuote}>Request Quote</button> Download Technical Specs
<button onClick={handleBrochureDownload}>Download Brochure</button> </button>
<button onClick={handleRequestQuote}>
Request Quote
</button>
<button onClick={handleBrochureDownload}>
Download Brochure
</button>
</div> </div>
); );
} }
@@ -984,8 +1010,12 @@ function WindFarmProjectPage({ project }) {
return ( return (
<div> <div>
<h1>{project.name}</h1> <h1>{project.name}</h1>
<button onClick={handleProjectInquiry}>Request Project Consultation</button> <button onClick={handleProjectInquiry}>
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button> Request Project Consultation
</button>
<button onClick={handleCableCalculation}>
Calculate Cable Requirements
</button>
</div> </div>
); );
} }
@@ -1036,7 +1066,7 @@ test('tracks button click', () => {
// [Umami] Tracked pageview: /products/123 // [Umami] Tracked pageview: /products/123
// To test without sending data to Umami: // To test without sending data to Umami:
// 1. Remove UMAMI_WEBSITE_ID from .env // 1. Remove NEXT_PUBLIC_UMAMI_WEBSITE_ID from .env
// 2. Or set it to an empty string // 2. Or set it to an empty string
// 3. Check console logs to verify events are being tracked // 3. Check console logs to verify events are being tracked
``` ```
@@ -1139,9 +1169,7 @@ function WebVitalsTracker() {
} }
}); });
observer.observe({ observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'],
});
} }
}, []); }, []);
@@ -1166,7 +1194,6 @@ This examples file demonstrates how to implement comprehensive analytics trackin
-**Business-specific events** (KLZ Cables, wind farms) -**Business-specific events** (KLZ Cables, wind farms)
Remember to: Remember to:
1. Use the `useAnalytics` hook for client-side tracking 1. Use the `useAnalytics` hook for client-side tracking
2. Import events from `AnalyticsEvents` for consistency 2. Import events from `AnalyticsEvents` for consistency
3. Include relevant context in your events 3. Include relevant context in your events

View File

@@ -2,7 +2,7 @@
## Setup Checklist ## Setup Checklist
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file - [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
- [ ] Verify `UmamiScript` is in your layout - [ ] Verify `UmamiScript` is in your layout
- [ ] Verify `AnalyticsProvider` is in your layout - [ ] Verify `AnalyticsProvider` is in your layout
- [ ] Test in development mode - [ ] Test in development mode
@@ -12,7 +12,7 @@
```bash ```bash
# Required # Required
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3 NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
# Optional (defaults to https://analytics.infra.mintel.me/script.js) # Optional (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
@@ -86,16 +86,16 @@ function ProductCard({ product }) {
## Common Events ## Common Events
| Event | When to Use | Example Properties | | Event | When to Use | Example Properties |
| --------------------- | ------------------- | ------------------------------------------------- | |-------|-------------|-------------------|
| `pageview` | Page loads | `{ url: '/products/123' }` | | `pageview` | Page loads | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` | | `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` | | `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` | | `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` | | `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` | | `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` | | `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` | | `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
## Testing ## Testing
@@ -112,7 +112,7 @@ In development, you'll see console logs:
```bash ```bash
# .env.local # .env.local
# UMAMI_WEBSITE_ID= # NEXT_PUBLIC_UMAMI_WEBSITE_ID=
``` ```
## Troubleshooting ## Troubleshooting
@@ -120,9 +120,8 @@ In development, you'll see console logs:
### Analytics Not Working? ### Analytics Not Working?
1. **Check environment variables:** 1. **Check environment variables:**
```bash ```bash
echo $UMAMI_WEBSITE_ID echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
``` ```
2. **Verify script is loading:** 2. **Verify script is loading:**
@@ -137,12 +136,12 @@ In development, you'll see console logs:
### Common Issues ### Common Issues
| Issue | Solution | | Issue | Solution |
| ------------------- | ----------------------------------- | |-------|----------|
| No data in Umami | Check website ID and script URL | | No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used | | Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS | | Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct | | Wrong data | Verify event properties are correct |
## Performance Tips ## Performance Tips

View File

@@ -20,7 +20,7 @@ Add these to your `.env` file:
```bash ```bash
# Required: Your Umami website ID # Required: Your Umami website ID
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3 NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js) # Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
@@ -32,7 +32,7 @@ The `docker-compose.yml` already includes the environment variables:
```yaml ```yaml
environment: environment:
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} - NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js} - NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
``` ```
@@ -75,7 +75,11 @@ function ProductCard({ product }) {
}); });
}; };
return <button onClick={handleAddToCart}>Add to Cart</button>; return (
<button onClick={handleAddToCart}>
Add to Cart
</button>
);
} }
``` ```
@@ -273,7 +277,11 @@ function ErrorBoundary({ children }) {
}); });
}; };
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>; return (
<ErrorBoundary onError={handleError}>
{children}
</ErrorBoundary>
);
} }
``` ```
@@ -281,20 +289,20 @@ function ErrorBoundary({ children }) {
### Common Events ### Common Events
| Event Name | Description | Example Properties | | Event Name | Description | Example Properties |
| --------------------- | --------------------- | ------------------------------------------------------------ | |------------|-------------|-------------------|
| `pageview` | Page view | `{ url: '/products/123' }` | | `pageview` | Page view | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` | | `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` | | `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` | | `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_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_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` | | `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` | | `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` | | `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` | | `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` | | `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` | | `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
### Custom Events ### Custom Events
@@ -377,9 +385,8 @@ The analytics system includes development mode logging:
### Analytics Not Working ### Analytics Not Working
1. **Check environment variables:** 1. **Check environment variables:**
```bash ```bash
echo $UMAMI_WEBSITE_ID echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
``` ```
2. **Verify the script is loading:** 2. **Verify the script is loading:**
@@ -398,11 +405,11 @@ In development mode, you'll see console logs for all tracked events. This helps
### Disabling Analytics ### Disabling Analytics
To disable analytics (e.g., for local development), simply remove the `UMAMI_WEBSITE_ID` environment variable: To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
```bash ```bash
# .env.local (not committed to git) # .env.local (not committed to git)
# UMAMI_WEBSITE_ID= # NEXT_PUBLIC_UMAMI_WEBSITE_ID=
``` ```
## Performance ## Performance
@@ -431,7 +438,6 @@ The analytics implementation is optimized for performance:
## Support ## Support
For issues or questions about the analytics implementation, check: For issues or questions about the analytics implementation, check:
1. This README for usage examples 1. This README for usage examples
2. The component source code for implementation details 2. The component source code for implementation details
3. The Umami documentation for platform-specific questions 3. The Umami documentation for platform-specific questions

View File

@@ -16,7 +16,6 @@ The project already had a solid foundation:
## What Was Enhanced ## What Was Enhanced
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`) ### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
- ✅ Added TypeScript props interface for customization - ✅ Added TypeScript props interface for customization
- ✅ Added JSDoc documentation with usage examples - ✅ Added JSDoc documentation with usage examples
- ✅ Added error handling for script loading failures - ✅ Added error handling for script loading failures
@@ -24,13 +23,11 @@ The project already had a solid foundation:
- ✅ Improved type safety and comments - ✅ Improved type safety and comments
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`) ### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
- ✅ Added comprehensive JSDoc documentation - ✅ Added comprehensive JSDoc documentation
- ✅ Added development mode logging - ✅ Added development mode logging
- ✅ Improved code comments - ✅ Improved code comments
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`) ### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
- ✅ Type-safe `useAnalytics` hook for easy event tracking - ✅ Type-safe `useAnalytics` hook for easy event tracking
-`trackEvent()` method for custom events -`trackEvent()` method for custom events
-`trackPageview()` method for manual pageview tracking -`trackPageview()` method for manual pageview tracking
@@ -38,14 +35,12 @@ The project already had a solid foundation:
- ✅ Development mode logging - ✅ Development mode logging
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`) ### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
- ✅ Centralized event constants for consistency - ✅ Centralized event constants for consistency
- ✅ Type-safe event names - ✅ Type-safe event names
- ✅ Helper functions for common event properties - ✅ Helper functions for common event properties
- ✅ 30+ predefined events for various use cases - ✅ 30+ predefined events for various use cases
### 5. **Comprehensive Documentation** ### 5. **Comprehensive Documentation**
-**README.md** - Full documentation with setup, usage, and best practices -**README.md** - Full documentation with setup, usage, and best practices
-**EXAMPLES.md** - 20+ practical examples for different scenarios -**EXAMPLES.md** - 20+ practical examples for different scenarios
-**QUICK_REFERENCE.md** - Quick start guide and troubleshooting -**QUICK_REFERENCE.md** - Quick start guide and troubleshooting
@@ -68,14 +63,12 @@ components/analytics/
## Key Features ## Key Features
### 🚀 Modern Implementation ### 🚀 Modern Implementation
- Uses Next.js `Script` component (not old-school `<script>` tags) - Uses Next.js `Script` component (not old-school `<script>` tags)
- TypeScript for type safety - TypeScript for type safety
- React hooks for clean API - React hooks for clean API
- Environment variable configuration - Environment variable configuration
### 📊 Comprehensive Tracking ### 📊 Comprehensive Tracking
- Automatic pageview tracking on route changes - Automatic pageview tracking on route changes
- Custom event tracking with properties - Custom event tracking with properties
- E-commerce events (products, cart, purchases) - E-commerce events (products, cart, purchases)
@@ -84,7 +77,6 @@ components/analytics/
- Error and performance tracking - Error and performance tracking
### 🎯 Developer Experience ### 🎯 Developer Experience
- Type-safe event tracking - Type-safe event tracking
- Centralized event definitions - Centralized event definitions
- Development mode logging - Development mode logging
@@ -92,7 +84,6 @@ components/analytics/
- 20+ practical examples - 20+ practical examples
### 🔒 Privacy & Performance ### 🔒 Privacy & Performance
- No PII tracking by default - No PII tracking by default
- Script loads after page is interactive - Script loads after page is interactive
- Minimal performance impact - Minimal performance impact
@@ -104,7 +95,7 @@ The project is already configured in `docker-compose.yml`:
```yaml ```yaml
environment: environment:
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} - NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js} - NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
``` ```
@@ -113,7 +104,7 @@ environment:
Add to your `.env` file: Add to your `.env` file:
```bash ```bash
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3 NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
``` ```
## Usage Examples ## Usage Examples
@@ -197,7 +188,7 @@ In development, you'll see console logs:
```bash ```bash
# .env.local # .env.local
# UMAMI_WEBSITE_ID= # NEXT_PUBLIC_UMAMI_WEBSITE_ID=
``` ```
## Troubleshooting ## Troubleshooting
@@ -205,9 +196,8 @@ In development, you'll see console logs:
### Analytics Not Working? ### Analytics Not Working?
1. **Check environment variables:** 1. **Check environment variables:**
```bash ```bash
echo $UMAMI_WEBSITE_ID echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
``` ```
2. **Verify script is loading:** 2. **Verify script is loading:**
@@ -222,12 +212,12 @@ In development, you'll see console logs:
### Common Issues ### Common Issues
| Issue | Solution | | Issue | Solution |
| ------------------- | ----------------------------------- | |-------|----------|
| No data in Umami | Check website ID and script URL | | No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used | | Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS | | Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct | | Wrong data | Verify event properties are correct |
## Performance Tips ## Performance Tips
@@ -249,13 +239,13 @@ In development, you'll see console logs:
1. ✅ **Setup complete** - All files are in place 1. ✅ **Setup complete** - All files are in place
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE 2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs 3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
4. 📝 **Add to .env** - Set `UMAMI_WEBSITE_ID` 4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
5. 🧪 **Test in development** - Verify events are tracked 5. 🧪 **Test in development** - Verify events are tracked
6. 🚀 **Deploy** - Analytics will work in production 6. 🚀 **Deploy** - Analytics will work in production
## Quick Start Checklist ## Quick Start Checklist
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file - [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx` - [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx` - [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
- [ ] Test in development mode (check console logs) - [ ] Test in development mode (check console logs)

View File

@@ -28,15 +28,11 @@ export default function TrackedLink({
}: TrackedLinkProps) { }: TrackedLinkProps) {
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const handleClick = () => { const handleClick = (e: React.MouseEvent) => {
try { trackEvent(eventName, {
trackEvent(eventName, { href,
href, ...eventProperties,
...eventProperties, });
});
} catch {
// Analytics tracking should not block navigation, so we catch and ignore errors.
}
if (onClick) onClick(); if (onClick) onClick();
}; };

View File

@@ -1,54 +0,0 @@
'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;
}

View File

@@ -34,7 +34,7 @@ export default function AnimatedImage({
} }
}); });
}, },
{ threshold: 0.1 }, { threshold: 0.1 }
); );
if (containerRef.current) { if (containerRef.current) {
@@ -49,12 +49,10 @@ export default function AnimatedImage({
ref={containerRef} ref={containerRef}
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`} className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
> >
<div <div className={`
className={`
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000 absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'} ${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
`} `} />
/>
<Image <Image
src={src} src={src}
@@ -72,6 +70,14 @@ export default function AnimatedImage({
{/* Subtle reflection overlay */} {/* Subtle reflection overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
{alt && (
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/60 to-transparent translate-y-full group-hover:translate-y-0 transition-transform duration-500">
<p className="text-sm text-white font-medium italic">
{alt}
</p>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,42 +0,0 @@
'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;
}

View File

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

View File

@@ -1,22 +1,14 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PostData } from '@/lib/blog'; import { PostMdx } from '@/lib/blog';
interface PostNavigationProps { interface PostNavigationProps {
prev: PostData | null; prev: PostMdx | null;
next: PostData | null; next: PostMdx | null;
isPrevRandom?: boolean;
isNextRandom?: boolean;
locale: string; locale: string;
} }
export default function PostNavigation({ export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
prev,
next,
isPrevRandom,
isNextRandom,
locale,
}: PostNavigationProps) {
if (!prev && !next) return null; if (!prev && !next) return null;
return ( return (
@@ -30,11 +22,8 @@ export default function PostNavigation({
{/* Background Image */} {/* Background Image */}
{prev.frontmatter.featuredImage ? ( {prev.frontmatter.featuredImage ? (
<div <div
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${prev.frontmatter.focalX ?? 50}% ${prev.frontmatter.focalY ?? 50}%`,
}}
/> />
) : ( ) : (
<div className="absolute inset-0 bg-neutral-100" /> <div className="absolute inset-0 bg-neutral-100" />
@@ -45,14 +34,8 @@ export default function PostNavigation({
{/* Content */} {/* Content */}
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10"> <div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90"> <span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
{isPrevRandom {locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
? locale === 'de'
? 'Weiterer Artikel'
: 'More Article'
: locale === 'de'
? 'Vorheriger Beitrag'
: 'Previous Post'}
</span> </span>
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4"> <h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
{prev.frontmatter.title} {prev.frontmatter.title}
@@ -60,14 +43,9 @@ export default function PostNavigation({
</div> </div>
{/* Arrow Icon */} {/* Arrow Icon */}
<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"> <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">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
</div> </div>
</Link> </Link>
@@ -84,11 +62,8 @@ export default function PostNavigation({
{/* Background Image */} {/* Background Image */}
{next.frontmatter.featuredImage ? ( {next.frontmatter.featuredImage ? (
<div <div
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${next.frontmatter.focalX ?? 50}% ${next.frontmatter.focalY ?? 50}%`,
}}
/> />
) : ( ) : (
<div className="absolute inset-0 bg-neutral-100" /> <div className="absolute inset-0 bg-neutral-100" />
@@ -99,14 +74,8 @@ export default function PostNavigation({
{/* Content */} {/* Content */}
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10"> <div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90"> <span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
{isNextRandom {locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
? locale === 'de'
? 'Weiterer Artikel'
: 'More Article'
: locale === 'de'
? 'Nächster Beitrag'
: 'Next Post'}
</span> </span>
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4"> <h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
{next.frontmatter.title} {next.frontmatter.title}

View File

@@ -28,7 +28,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span> <span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
</h3> </h3>
<p className="text-xl text-white/90 mb-10 leading-relaxed max-w-2xl"> <p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
{isDe {isDe
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.' ? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'} : 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'}
@@ -45,7 +45,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
? 'Zertifizierte Qualität nach EU-Standards' ? 'Zertifizierte Qualität nach EU-Standards'
: 'Certified quality according to EU standards', : 'Certified quality according to EU standards',
].map((item, i) => ( ].map((item, i) => (
<div key={i} className="flex items-center gap-4 text-white/90"> <div key={i} className="flex items-center gap-4 text-white/80">
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0"> <div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<svg <svg
className="w-3 h-3 text-accent" className="w-3 h-3 text-accent"
@@ -88,7 +88,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
/> />
</svg> </svg>
</Link> </Link>
<p className="text-white/80 text-sm font-medium"> <p className="text-white/50 text-sm font-medium">
{isDe {isDe
? 'Kostenlose Erstberatung für Ihr Vorhaben.' ? 'Kostenlose Erstberatung für Ihr Vorhaben.'
: 'Free initial consultation for your project.'} : 'Free initial consultation for your project.'}

View File

@@ -8,17 +8,15 @@ interface SplitHeadingProps {
id?: string; id?: string;
} }
export default function SplitHeading({ export default function SplitHeading({ children, className = '', id }: SplitHeadingProps) {
children,
className = '',
id,
level: Level = 'h2',
}: SplitHeadingProps & { level?: any }) {
return ( return (
<div id={id} className={className}> <div
<Level className="text-xl md:text-2xl font-bold leading-tight text-text-primary"> id={id}
className={className}
>
<h2 className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
{children} {children}
</Level> </h2>
</div> </div>
); );
} }

View File

@@ -53,7 +53,7 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
return ( return (
<nav className="hidden lg:block w-full ml-12"> <nav className="hidden lg:block w-full ml-12">
<div className="relative pl-6 border-l border-neutral-200"> <div className="relative pl-6 border-l border-neutral-200">
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/70 mb-6"> <h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/50 mb-6">
{locale === 'de' ? 'Inhalt' : 'Table of Contents'} {locale === 'de' ? 'Inhalt' : 'Table of Contents'}
</h4> </h4>
<ul className="space-y-4"> <ul className="space-y-4">

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface TechnicalGridItem { interface TechnicalGridItem {
label: string; label: string;
@@ -26,33 +26,17 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
</h3> </h3>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((item, index) => { {items.map((item, index) => (
const formatted = formatTechnicalValue(item.value); <div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
return ( <div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden"> <span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" /> {item.label}
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70"> </span>
{item.label} <span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
</span> {item.value}
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors"> </span>
{formatted.isList ? ( </div>
<div className="flex flex-wrap gap-2 mt-2"> ))}
{formatted.parts.map((p, pIdx) => (
<span
key={pIdx}
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
>
{p}
</span>
))}
</div>
) : (
item.value
)}
</div>
</div>
);
})}
</div> </div>
</div> </div>
); );

View File

@@ -19,17 +19,12 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
})(); })();
return ( return (
<Link <Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
href={url}
target="_blank"
rel="noopener noreferrer"
className="block my-12 no-underline group"
>
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group"> <div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden"> <div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
{image ? ( {image ? (
<Image <Image
src={image.split('?')[0]} src={image}
alt={title} alt={title}
fill fill
unoptimized unoptimized
@@ -37,18 +32,8 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-primary/5"> <div className="w-full h-full flex items-center justify-center bg-primary/5">
<svg <svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-12 h-12 text-primary/20" <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" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg> </svg>
</div> </div>
)} )}
@@ -61,10 +46,10 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" /> <div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/80 bg-primary/10 px-2 py-0.5 rounded"> <span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
External Link External Link
</span> </span>
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/80"> <span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
{hostname} {hostname}
</span> </span>
</div> </div>
@@ -79,18 +64,8 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest"> <div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
<span>Read more</span> <span>Read more</span>
<svg <svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-4 h-4 transition-transform group-hover:translate-x-1" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg> </svg>
</div> </div>
</div> </div>

View File

@@ -1,145 +0,0 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Button,
} from '@react-email/components';
import * as React from 'react';
interface BrochureDeliveryEmailProps {
_email: string;
brochureUrl: string;
locale: 'en' | 'de';
}
export const BrochureDeliveryEmail = ({
_email,
brochureUrl,
locale = 'en',
}: BrochureDeliveryEmailProps) => {
const t =
locale === 'de'
? {
subject: 'Ihr KLZ Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
button: 'Katalog herunterladen',
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
}
: {
subject: 'Your KLZ Cable Catalog',
greeting: 'Thank you for your interest in KLZ Cables.',
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
button: 'Download Catalog',
footer: 'This email was sent from klz-cables.com.',
};
return (
<Html>
<Head />
<Preview>{t.subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={headerSection}>
<Heading style={h1}>{t.subject}</Heading>
</Section>
<Section style={section}>
<Text style={text}>
<strong>{t.greeting}</strong>
</Text>
<Text style={text}>{t.body}</Text>
<Section style={buttonContainer}>
<Button style={button} href={brochureUrl}>
{t.button}
</Button>
</Section>
<Hr style={hr} />
</Section>
<Text style={footer}>{t.footer}</Text>
</Container>
</Body>
</Html>
);
};
export default BrochureDeliveryEmail;
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '0 0 48px',
marginBottom: '64px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #e6ebf1',
};
const headerSection = {
backgroundColor: '#000d26',
padding: '32px 48px',
borderBottom: '4px solid #4da612',
};
const h1 = {
color: '#ffffff',
fontSize: '24px',
fontWeight: 'bold',
margin: '0',
};
const section = {
padding: '32px 48px 0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'left' as const,
};
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '32px',
marginBottom: '32px',
};
const button = {
backgroundColor: '#4da612',
borderRadius: '4px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '16px 32px',
};
const hr = {
borderColor: '#e6ebf1',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center' as const,
marginTop: '20px',
};

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { Section, Container, Button, Heading } from '../../components/ui'; import { Section, Container, Button, Heading } from '../../components/ui';
export default function CTA({ data }: { data?: any }) { export default function CTA() {
const t = useTranslations('Home.cta'); const t = useTranslations('Home.cta');
const locale = useLocale(); const locale = useLocale();
@@ -14,16 +14,16 @@ export default function CTA({ data }: { data?: any }) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="flex flex-col lg:flex-row items-center justify-between gap-16"> <div className="flex flex-col lg:flex-row items-center justify-between gap-16">
<div className="max-w-3xl text-center lg:text-left"> <div className="max-w-3xl text-center lg:text-left">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-6"> <Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
<span className="text-white">{data?.title || t('title')}</span> <span className="text-white">{t('title')}</span>
</Heading> </Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed"> <p className="text-lg md:text-xl text-white/70 leading-relaxed">
{data?.description || t('description')} {t('description')}
</p> </p>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12"> <Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12">
{data?.buttonLabel || t('button')} {t('button')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span> <span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button> </Button>
</div> </div>

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading } from '../../components/ui';
export default function Experience({ data }: { data?: any }) { export default function Experience() {
const t = useTranslations('Home.experience'); const t = useTranslations('Home.experience');
return ( return (
@@ -11,11 +11,10 @@ export default function Experience({ data }: { data?: any }) {
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<Image <Image
src="/uploads/2024/12/1694273920124-copy-2.webp" src="/uploads/2024/12/1694273920124-copy-2.webp"
alt={data?.subtitle || t('subtitle')} alt={t('subtitle')}
fill fill
className="object-cover object-center scale-105 animate-slow-zoom" className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" /> <div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
@@ -23,31 +22,31 @@ export default function Experience({ data }: { data?: any }) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-3xl"> <div className="max-w-3xl">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white"> <Heading level={2} subtitle={t('subtitle')} className="text-white">
<span className="text-white">{data?.title || t('title')}</span> <span className="text-white">{t('title')}</span>
</Heading> </Heading>
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium"> <div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl"> <p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
{data?.paragraph1 || t('p1')} {t('p1')}
</p> </p>
<p className="pl-9">{data?.paragraph2 || t('p2')}</p> <p className="pl-9">{t('p2')}</p>
</div> </div>
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12"> <dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="animate-fade-in"> <div className="animate-fade-in">
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4"> <dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge1 || t('certifiedQuality')} {t('certifiedQuality')}
</dt> </dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60"> <dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{data?.badge1Text || t('vdeApproved')} {t('vdeApproved')}
</dd> </dd>
</div> </div>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}> <div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4"> <dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge2 || t('fullSpectrum')} {t('fullSpectrum')}
</dt> </dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60"> <dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{data?.badge2Text || t('solutionsRange')} {t('solutionsRange')}
</dd> </dd>
</div> </div>
</dl> </dl>

View File

@@ -3,11 +3,10 @@ import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading } from '../../components/ui';
import dynamic from 'next/dynamic'; import Lightbox from '../Lightbox';
const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false });
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
export default function GallerySection({ data }: { data?: any }) { export default function GallerySection() {
const t = useTranslations('Home.gallery'); const t = useTranslations('Home.gallery');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const images = [ const images = [
@@ -26,8 +25,8 @@ export default function GallerySection({ data }: { data?: any }) {
return ( return (
<Section className="bg-white text-white py-32"> <Section className="bg-white text-white py-32">
<Container> <Container>
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center"> <Heading level={2} subtitle={t('subtitle')} align="center">
{data?.title || t('title')} {t('title')}
</Heading> </Heading>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">

View File

@@ -1,13 +1,15 @@
'use client'; 'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics'; import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events'; import { AnalyticsEvents } from '../analytics/analytics-events';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero({ data }: { data?: any }) { export default function Hero() {
const t = useTranslations('Home.hero'); const t = useTranslations('Home.hero');
const locale = useLocale(); const locale = useLocale();
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
@@ -15,84 +17,182 @@ export default function Hero({ data }: { data?: any }) {
return ( return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0"> <Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none"> <Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<div className="max-w-5xl mx-auto md:mx-0"> <motion.div
<div> className="max-w-5xl mx-auto md:mx-0"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={headingVariants}>
<Heading <Heading
level={1} 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" 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]"
> >
{data?.title ? ( {t.rich('title', {
<span green: (chunks) => (
dangerouslySetInnerHTML={{ <span className="relative inline-block">
__html: data.title <motion.span
.replace(/<green>/g, '<span class="text-accent italic">') className="relative z-10 text-accent italic"
.replace(/<\/green>/g, '</span>'), variants={accentVariants}
}} >
/> {chunks}
) : ( </motion.span>
t.rich('title', { <motion.div
green: (chunks) => <span className="text-accent italic">{chunks}</span>, variants={scribbleVariants}
}) className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
)} >
<Scribble variant="circle" />
</motion.div>
</span>
),
})}
</Heading> </Heading>
</div> </motion.div>
<div> <motion.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12"> <p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')} {t('subtitle')}
</p> </p>
</div> </motion.div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"> <motion.div
<div> className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
>
<motion.div variants={buttonVariants}>
<Button <Button
href="/contact" href="/contact"
variant="accent" variant="accent"
size="lg" 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" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'), label: t('cta'),
location: 'home_hero_primary', location: 'home_hero_primary',
}) })
} }
> >
{data?.ctaLabel || t('cta')} {t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2"> <span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
&rarr;
</span>
</Button> </Button>
</div> </motion.div>
<div> <motion.div variants={buttonVariants}>
<Button <Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`} href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white" variant="white"
size="lg" 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" 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={() => onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'), label: t('exploreProducts'),
location: 'home_hero_secondary', location: 'home_hero_secondary',
}) })
} }
> >
{data?.secondaryCtaLabel || t('exploreProducts')} {t('exploreProducts')}
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
</div> </motion.div>
</Container> </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"> <motion.div
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
>
<HeroIllustration /> <HeroIllustration />
</div> </motion.div>
<div <motion.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" className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
style={{ animationDelay: '2000ms' }} 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"> <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" /> <motion.div
className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div> </div>
</div> </motion.div>
</Section> </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;

View File

@@ -125,9 +125,8 @@ export default function HeroIllustration() {
}, []); }, []);
const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100'; const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100';
// Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance const scale = isMobile ? 1.44 : 1;
const scale = isMobile ? 1.6 : 1; const opacity = isMobile ? 0.6 : 0.85;
const opacity = isMobile ? 0.9 : 0.85;
return ( return (
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full"> <div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
@@ -155,15 +154,15 @@ export default function HeroIllustration() {
<stop offset="70%" stopColor="white" stopOpacity="0.4" /> <stop offset="70%" stopColor="white" stopOpacity="0.4" />
<stop offset="100%" stopColor="white" stopOpacity="0" /> <stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient> </linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" /> <feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
</feMerge> </feMerge>
</filter> </filter>
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%"> <filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="blur" /> <feGaussianBlur stdDeviation="1" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
@@ -216,10 +215,10 @@ export default function HeroIllustration() {
</g> </g>
{/* ANIMATED ENERGY FLOW */} {/* ANIMATED ENERGY FLOW */}
<g filter="url(#glow)"> <g>
{POWER_LINES.map((line, i) => { {POWER_LINES.map((line, i) => {
// Only animate a subset of lines to reduce main-thread work // Only animate a small subset of lines to reduce main-thread work significantly
if (i % 2 !== 0) return null; if (i % 5 !== 0) return null;
const from = gridToScreen(line.from.col, line.from.row); const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.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)); const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { Section, Container, Button, Heading } from '../../components/ui'; import { Section, Container, Button, Heading } from '../../components/ui';
export default function MeetTheTeam({ data }: { data?: any }) { export default function MeetTheTeam() {
const t = useTranslations('Home.meetTheTeam'); const t = useTranslations('Home.meetTheTeam');
const teamT = useTranslations('Team'); const teamT = useTranslations('Team');
const locale = useLocale(); const locale = useLocale();
@@ -13,11 +13,10 @@ export default function MeetTheTeam({ data }: { data?: any }) {
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<Image <Image
src="/uploads/2024/12/DSC08036-Large.webp" src="/uploads/2024/12/DSC08036-Large.webp"
alt={data?.subtitle || t('subtitle')} alt={t('subtitle')}
fill fill
className="object-cover scale-105 animate-slow-zoom" className="object-cover scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" /> <div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
@@ -25,20 +24,20 @@ export default function MeetTheTeam({ data }: { data?: any }) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-3xl text-white animate-slide-up"> <div className="max-w-3xl text-white animate-slide-up">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8"> <Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
<span className="text-white">{data?.title || t('title')}</span> <span className="text-white">{t('title')}</span>
</Heading> </Heading>
<div className="relative mb-12"> <div className="relative mb-12">
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" /> <div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8"> <p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
"{data?.description || t('description')}" "{t('description')}"
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-8 items-center"> <div className="flex flex-wrap gap-8 items-center">
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group"> <Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
{data?.ctaLabel || t('cta')} {t('cta')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span> <span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button> </Button>
@@ -62,7 +61,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
</div> </div>
</div> </div>
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest"> <span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
{data?.networkLabel || t('andNetwork')} {t('andNetwork')}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,7 @@ import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { Section } from '../../components/ui'; import { Section } from '../../components/ui';
export default function ProductCategories({ data }: { data?: any }) { export default function ProductCategories() {
const t = useTranslations('Products'); const t = useTranslations('Products');
const locale = useLocale(); const locale = useLocale();
@@ -43,15 +43,7 @@ export default function ProductCategories({ data }: { data?: any }) {
return ( return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px"> <Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
{(data?.title || t.has('title')) && ( <h2 className="sr-only">{t('title')}</h2>
<h2 className="sr-only">
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title }} />
) : (
t.rich('title', { green: (chunks: any) => <span>{chunks}</span> })
)}
</h2>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{categories.map((category, idx) => ( {categories.map((category, idx) => (
<Link <Link

View File

@@ -3,108 +3,99 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { getAllPosts } from '@/lib/blog'; import { getAllPosts } from '@/lib/blog';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading, Card, Badge } from '../../components/ui';
interface RecentPostsProps { interface RecentPostsProps {
locale: string; locale: string;
data?: any;
} }
export default async function RecentPosts({ locale, data }: RecentPostsProps) { export default async function RecentPosts({ locale }: RecentPostsProps) {
const t = await getTranslations('Blog'); const t = await getTranslations('Blog');
const posts = await getAllPosts(locale); const posts = await getAllPosts(locale);
const recentPosts = posts.slice(0, 4); const recentPosts = posts.slice(0, 3);
if (recentPosts.length === 0) return null; if (recentPosts.length === 0) return null;
const title = data?.title || t('allArticles');
const subtitle = data?.subtitle || t('latestNews');
return ( return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0"> <Section className="bg-neutral py-16 md:py-24">
<Container className="py-12 md:py-16"> <Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6"> <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={subtitle} className="mb-0 text-primary"> <Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
{title} {t('allArticles')}
</Heading> </Heading>
<Link <Link
href={`/${locale}/blog`} href={`/${locale}/blog`}
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target" className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
> >
{title} {t('allArticles')}
<span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span> <span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link> </Link>
</div> </div>
</Container>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none"> <ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
{recentPosts.map((post, idx) => ( {recentPosts.map((post) => (
<li key={`${post.slug}-${idx}`} className="block"> <li key={post.slug}>
<Link <Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
href={`/${locale}/blog/${post.slug}`} <Card
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" tag="article"
> className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
{post.frontmatter.featuredImage && ( >
<> {post.frontmatter.featuredImage && (
<Image <div className="relative h-64 overflow-hidden">
src={post.frontmatter.featuredImage.split('?')[0]} <Image
alt={post.frontmatter.title} src={post.frontmatter.featuredImage}
fill alt={post.frontmatter.title}
className="object-cover transition-transform duration-1000 group-hover:scale-110" fill
style={{ className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`, sizes="(max-width: 768px) 100vw, 33vw"
}} loading="lazy"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw" />
loading="lazy" <div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
/> {post.frontmatter.category && (
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" /> <Badge variant="accent" className="absolute top-4 left-4 shadow-md">
</> {post.frontmatter.category}
)} </Badge>
<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(
['en', 'de'].includes(locale) ? locale : 'de',
{
year: 'numeric',
month: 'short',
day: 'numeric',
},
)} )}
</time> </div>
{(new Date(post.frontmatter.date) > new Date() || )}
post.frontmatter.public === false) && ( <div className="p-6 md:p-8 flex flex-col flex-grow">
<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"> <div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
Draft Preview <span className="w-6 md:w-8 h-px bg-neutral-medium" />
</span> <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> </div>
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md"> </Card>
{post.frontmatter.title} </Link>
</h3> </li>
</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"> </ul>
{t('readMore')}{' '} </Container>
<span className="ml-2 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</div>
</div>
</Link>
</li>
))}
</ul>
</Section> </Section>
); );
} }

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export default function VideoSection({ data }: { data?: any }) { export default function VideoSection() {
const t = useTranslations('Home.video'); const t = useTranslations('Home.video');
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
@@ -39,19 +40,17 @@ export default function VideoSection({ data }: { data?: any }) {
<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="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"> <div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? ( {t.rich('title', {
<span future: (chunks) => (
dangerouslySetInnerHTML={{ <span className="relative inline-block mx-2">
__html: data.title <span className="relative z-10 italic text-accent">{chunks}</span>
.replace(/<future>/g, '<span class="italic text-accent">') <Scribble
.replace(/<\/future>/g, '</span>'), variant="underline"
}} className="w-full h-4 -bottom-2 left-0 text-accent/40"
/> />
) : ( </span>
t.rich('title', { ),
future: (chunks) => <span className="italic text-accent">{chunks}</span>, })}
})
)}
</h2> </h2>
</div> </div>
</div> </div>

View File

@@ -2,35 +2,30 @@ import React from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading } from '../../components/ui';
export default function WhatWeDo({ data }: { data?: any }) { export default function WhatWeDo() {
const t = useTranslations('Home.whatWeDo'); const t = useTranslations('Home.whatWeDo');
const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({
title: t(`items.${idx}.title`),
description: t(`items.${idx}.description`)
}));
return ( return (
<Section className="bg-white"> <Section className="bg-white">
<Container> <Container>
<div className="sticky-narrative-container"> <div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar"> <div className="sticky-narrative-sidebar">
<div className="lg:sticky lg:top-32"> <div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={data?.expertiseLabel || t('expertise')} className="text-primary-dark"> <Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
{data?.title || t('title')} {t('title')}
</Heading> </Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed"> <p className="text-base md:text-lg text-text-secondary leading-relaxed">
{data?.subtitle || t('subtitle')} {t('subtitle')}
</p> </p>
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10"> <div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
<p className="text-saturated font-bold text-base md:text-base italic"> <p className="text-saturated font-bold text-base md:text-base italic">
"{data?.quote || t('quote')}" "{t('quote')}"
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20"> <div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20">
{items.map((item: any, idx: number) => ( {[0, 1, 2, 3].map((idx) => (
<div key={idx} className="group"> <div key={idx} className="group">
<div className="flex items-center gap-4 mb-4 md:mb-6"> <div className="flex items-center gap-4 mb-4 md:mb-6">
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform"> <span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
@@ -38,8 +33,8 @@ export default function WhatWeDo({ data }: { data?: any }) {
</span> </span>
<div className="h-px flex-grow bg-neutral-medium" /> <div className="h-px flex-grow bg-neutral-medium" />
</div> </div>
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{item.title}</h3> <h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{item.description}</p> <p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
</div> </div>
))} ))}
</div> </div>

View File

@@ -2,27 +2,24 @@ import React from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading } from '../../components/ui';
export default function WhyChooseUs({ data }: { data?: any }) { export default function WhyChooseUs() {
const t = useTranslations('Home.whyChooseUs'); const t = useTranslations('Home.whyChooseUs');
const features = data?.features?.length ? data.features.map((f: any) => f.feature) : [0, 1, 2, 3].map(i => t(`features.${i}`));
const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({ title: t(`items.${idx}.title`), description: t(`items.${idx}.description`) }));
return ( return (
<Section className="bg-neutral-light"> <Section className="bg-neutral-light">
<Container> <Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
<div className="lg:col-span-4 order-1 lg:order-2"> <div className="lg:col-span-4 order-1 lg:order-2">
<div className="sticky top-32"> <div className="sticky top-32">
<Heading level={2} subtitle={data?.tagline || t('whyKlz')} className="text-primary-dark"> <Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
{data?.title || t('title')} {t('title')}
</Heading> </Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed"> <p className="text-base md:text-lg text-text-secondary leading-relaxed">
{data?.subtitle || t('subtitle')} {t('subtitle')}
</p> </p>
<ul className="mt-12 space-y-6 list-none p-0"> <ul className="mt-12 space-y-6 list-none p-0">
{features.map((featureText: string, i: number) => ( {[0, 1, 2, 3].map((i) => (
<li key={i} className="flex items-center gap-4"> <li key={i} className="flex items-center gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center"> <div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
<svg <svg
@@ -41,7 +38,7 @@ export default function WhyChooseUs({ data }: { data?: any }) {
</svg> </svg>
</div> </div>
<span className="font-bold text-primary-dark text-base md:text-base"> <span className="font-bold text-primary-dark text-base md:text-base">
{featureText} {t(`features.${i}`)}
</span> </span>
</li> </li>
))} ))}
@@ -49,7 +46,7 @@ export default function WhyChooseUs({ data }: { data?: any }) {
</div> </div>
</div> </div>
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0"> <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">
{items.map((item: any, idx: number) => ( {[0, 1, 2, 3].map((idx) => (
<li <li
key={idx} key={idx}
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group" className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
@@ -60,10 +57,10 @@ export default function WhyChooseUs({ data }: { data?: any }) {
</span> </span>
</div> </div>
<h3 className="text-xl font-bold mb-4 text-primary-dark"> <h3 className="text-xl font-bold mb-4 text-primary-dark">
{item.title} {t(`items.${idx}.title`)}
</h3> </h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed"> <p className="text-text-secondary text-base md:text-base leading-relaxed">
{item.description} {t(`items.${idx}.description`)}
</p> </p>
</li> </li>
))} ))}

View File

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

View File

@@ -0,0 +1,90 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion, 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 (
<motion.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 && (
<motion.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>
</motion.div>
);
}

View File

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

View File

@@ -0,0 +1,583 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import { Reorder, AnimatePresence } 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 (
<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>
);
}

View File

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

View File

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

View File

@@ -3,8 +3,7 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import dynamic from 'next/dynamic'; import Lightbox from '@/components/Lightbox';
const Lightbox = dynamic(() => import('@/components/Lightbox'), { ssr: false });
import { Section, Container, Heading } from '@/components/ui'; import { Section, Container, Heading } from '@/components/ui';
export default function Gallery() { export default function Gallery() {

View File

@@ -26,7 +26,7 @@ export function Button({
...props ...props
}: ButtonProps) { }: ButtonProps) {
const baseStyles = const baseStyles =
'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'; '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';
const variants = { const variants = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl', primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
@@ -45,8 +45,8 @@ export function Button({
const sizes = { const sizes = {
sm: 'h-9 px-4 text-sm md:text-base', sm: 'h-9 px-4 text-sm md:text-base',
md: 'h-11 px-6 text-base md:text-lg', md: 'h-11 px-6 text-base md:text-lg',
lg: 'h-14 px-5 md:px-8 text-base md:text-lg', lg: 'h-14 px-8 text-base md:text-lg',
xl: 'h-16 px-6 md:px-10 text-lg md:text-xl', xl: 'h-16 px-10 text-lg md:text-xl',
}; };
const styles = cn(baseStyles, variants[variant], sizes[size], className); const styles = cn(baseStyles, variants[variant], sizes[size], className);

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