Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 678c803408 | |||
| 21288a4a45 | |||
| b514125e0d | |||
| 55a084e762 | |||
| 2b09cfc5d9 | |||
| 927ce977f2 | |||
| 85bc03b9d2 | |||
| c4bc10ef76 | |||
| e95f7c6dd2 | |||
| 17a91e48e6 | |||
| 4d0a94d288 | |||
| 3568c13941 | |||
| d538d7b9ec | |||
| 8c08b552cf | |||
| 1dd74a3861 | |||
| 8d77ca45f7 | |||
| c646815a3a | |||
| 23bf327670 | |||
| c77f99ef37 | |||
| bffcc98820 | |||
| 7519e17280 | |||
| 5bd7421764 | |||
| d7aba218d9 | |||
| e20d7f42c0 | |||
| 16d06d3275 | |||
| 7542f42568 | |||
| 474fa4f3df | |||
| f1d49416d1 | |||
| e3e0a7670c | |||
| 8a87318b12 | |||
| 93cb12d7d9 | |||
| 44f0c430a9 | |||
| 1478909a73 | |||
| 837abd4921 | |||
| 75c6d363c0 | |||
| a2b7f28b9f | |||
| 52ecd1b052 | |||
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 | |||
| 02bd1dcd7f | |||
| 4b0433394f | |||
| d9bddae20e | |||
| e7c482dabf | |||
| 8974d89b33 | |||
| f99ca4d35d | |||
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c | |||
| 1d074ba6d2 | |||
| 0e972983bc | |||
| c979582193 | |||
| e47ba31763 | |||
| 28072908f7 | |||
| 7e6b4a3ed7 | |||
| d7e5a57344 | |||
| c859d5e677 | |||
| e036dea089 | |||
| 39088ca868 | |||
| 18f9104623 | |||
| 76f745cc87 | |||
| 848d58010f | |||
| c0f5799667 | |||
| 0e089f9471 | |||
| 52b17423dd | |||
| bfd3c8164b | |||
| b091175b89 | |||
| 1baf03a84e | |||
| 483dfabe10 | |||
| 65f8b2c485 | |||
| 90cdd7e713 | |||
| 40fa2a7721 | |||
| a136e7b4a7 | |||
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 | |||
| 319b2b3e0c | |||
| d7f5504149 | |||
| 0f705b474b | |||
| 67046b9301 | |||
| 0b6211cf5f | |||
| c7f2c3fdfe | |||
| f30c93ffce | |||
| 3e6bbe9a93 | |||
| c6cbb02dfa | |||
| bec1916ccc | |||
| ab17e9e758 | |||
| f257e5428f | |||
| 797411ccc3 | |||
| 94a609e438 | |||
| 409ac3fea7 | |||
| b3876666c8 | |||
| bd1a61e9cd | |||
| f2ce9ec262 | |||
| eddfa3a1f1 | |||
| 1e77914314 | |||
| 52dfbb3870 | |||
| 72e85b99ee | |||
| c7807610f6 | |||
| 81f0dd88a6 | |||
| 458e467a14 | |||
| 060118202f | |||
| 64af78a984 | |||
| f6d7584613 | |||
| 2192f37fee | |||
| 8a9cd7ef3e | |||
| 406cf22050 | |||
| 5e82d6edc9 | |||
| 85375eefb0 | |||
| fe829b0c4c | |||
| 9ed08004af | |||
| fa6f27114b | |||
| a60e8af26b | |||
| c111efae1a | |||
| a12759d507 | |||
| eefabfa3ff | |||
| 86d28796a7 | |||
| bb9424d482 | |||
| b1515155b7 | |||
| 65d54ae789 | |||
| dc21d480ab | |||
| 51043da882 | |||
| 4a31cddf11 | |||
| 1b999510db | |||
| 0d852db651 | |||
| f3ff9cd364 | |||
| f15957847c | |||
| 55fc63fed5 | |||
| dac719efd2 | |||
| ec3f9d5c8e | |||
| 7ad5b5696d | |||
| 9bcf946752 | |||
| 1fefb794c1 | |||
| 1c1aebb804 | |||
| 30d8645f74 | |||
| 365cd50402 | |||
| a9f03b24c8 | |||
| 79a2a5121e | |||
| b2f26208ad | |||
| 6c739e2726 | |||
| 0ec830f5c6 | |||
| 713908ef95 | |||
| c3f41a24d5 | |||
| 013fbc5d66 | |||
| fd65b19f1d | |||
| 340c145863 | |||
| 2da182ec47 | |||
| 33a0877a6d | |||
| fdd1d5afb7 | |||
| bf996934af | |||
| 3e724f74fa | |||
| cfd5cbda55 | |||
| 0032da1562 | |||
| 7965e9c01a | |||
| f5df62c297 | |||
| 87ef5798d2 | |||
| 90e992636c | |||
| 44dbfdb3a8 | |||
| f60288a06c | |||
| 5906fc3375 | |||
| f36c6731e8 | |||
| 65ce8adc5d | |||
| 1d7c52fbca | |||
| 16f0e9b4e5 | |||
| 8dc41d52ed | |||
| 169b25ea12 | |||
| 205880b41a | |||
| 84555d11ed | |||
| 1dce82b74e | |||
| 3be4939ff5 | |||
| e054bb3490 | |||
| 75234095b7 | |||
| 4bdd4efdc3 | |||
| 47ca58a85a | |||
| d5d39a218a | |||
| ae7a45a911 | |||
| cb51c37207 | |||
| 8872d2424a | |||
| eb388610de | |||
| 6451a9e28e | |||
| 7ec826dae3 | |||
| 453a603392 | |||
| 5cfcc16dc2 | |||
| 5b43349205 | |||
| 96b296da12 | |||
| d5eb20a341 | |||
| 333111f03b | |||
| 698141f70b | |||
| e179e8162c | |||
| 259d712105 | |||
| 0178e828d6 |
22
.env
22
.env
@@ -1,16 +1,12 @@
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
# WooCommerce & WordPress
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
WOOCOMMERCE_URL=https://klz-cables.com
|
|
||||||
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
|
||||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
|
||||||
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
|
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -21,16 +17,22 @@ MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
|||||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
# Directus
|
# Directus
|
||||||
DIRECTUS_URL=https://cms.klz-cables.com
|
DIRECTUS_URL=http://klz-cms:8055
|
||||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
|
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
DIRECTUS_DB_NAME=directus
|
DIRECTUS_DB_NAME=directus
|
||||||
DIRECTUS_DB_USER=directus
|
DIRECTUS_DB_USER=klz_db_user
|
||||||
|
DIRECTUS_DB_PASSWORD=klz_db_pass
|
||||||
# Local Development
|
# Local Development
|
||||||
PROJECT_NAME=klz-cables
|
PROJECT_NAME=klz-cables
|
||||||
|
GATEKEEPER_BYPASS_ENABLED=true
|
||||||
TRAEFIK_HOST=klz.localhost
|
TRAEFIK_HOST=klz.localhost
|
||||||
DIRECTUS_HOST=cms.klz.localhost
|
DIRECTUS_HOST=cms.klz.localhost
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
COOKIE_DOMAIN=localhost
|
COOKIE_DOMAIN=localhost
|
||||||
|
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||||
|
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||||
12
.env.example
12
.env.example
@@ -10,18 +10,19 @@
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
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
|
||||||
NEXT_PUBLIC_TARGET=development
|
|
||||||
# TARGET is used server-side
|
|
||||||
TARGET=development
|
TARGET=development
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable analytics
|
# Optional: Leave empty to disable analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
@@ -56,6 +57,9 @@ SENTRY_DSN=
|
|||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
TRAEFIK_HOST=klz-cables.com
|
TRAEFIK_HOST=klz-cables.com
|
||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
||||||
|
# Next.js will proxy requests from /_img to this URL.
|
||||||
|
IMGPROXY_URL=https://img.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Varnish Configuration
|
# Varnish Configuration
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
|
|||||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||||
MAIL_RECIPIENTS=info@klz-cables.com
|
MAIL_RECIPIENTS=info@klz-cables.com
|
||||||
|
|
||||||
# Strapi
|
|
||||||
STRAPI_DATABASE_NAME=strapi
|
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
|
||||||
STRAPI_DATABASE_PASSWORD=
|
|
||||||
APP_KEYS=
|
|
||||||
API_TOKEN_SALT=
|
|
||||||
ADMIN_JWT_SECRET=
|
|
||||||
TRANSFER_TOKEN_SALT=
|
|
||||||
JWT_SECRET=
|
|
||||||
|
|
||||||
# Varnish Cache Size (optional)
|
# Varnish Cache Size (optional)
|
||||||
VARNISH_CACHE_SIZE=256m
|
VARNISH_CACHE_SIZE=256m
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
.next/
|
|
||||||
node_modules/
|
|
||||||
reference/
|
|
||||||
public/
|
|
||||||
dist/
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"argsIgnorePattern": "^_"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
"prefer-const": "warn",
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"@next/next/no-img-element": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
name: CI - Lint, Typecheck & Test
|
name: CI - Lint, Typecheck & Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -13,20 +10,47 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
|
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🔍 Lint
|
- name: 🧪 QA Checks
|
||||||
run: npm run lint
|
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
|
||||||
|
|
||||||
- name: 🏗️ Typecheck
|
- name: 🏗️ Build
|
||||||
run: npm run typecheck
|
run: pnpm build
|
||||||
|
|
||||||
- name: 🧪 Test
|
- name: ♿ Accessibility Check
|
||||||
run: npm run test
|
run: pnpm check:a11y
|
||||||
|
|||||||
@@ -1,43 +1,39 @@
|
|||||||
name: Build & Deploy KLZ Cables
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- '**'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
skip_long_checks:
|
skip_checks:
|
||||||
description: 'Skip tests? (true/false)'
|
description: 'Skip tests? (true/false)'
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
|
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:
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 1: Prepare & Determine Environment
|
# JOB 1: Prepare Environment
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
prepare:
|
prepare:
|
||||||
name: 🔍 Prepare Environment
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
target: ${{ steps.determine.outputs.target }}
|
target: ${{ steps.determine.outputs.target }}
|
||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
env_file: ${{ steps.determine.outputs.env_file }}
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||||
directus_host: ${{ steps.determine.outputs.directus_host }}
|
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
is_prod: ${{ steps.determine.outputs.is_prod }}
|
|
||||||
gotify_title: ${{ steps.determine.outputs.gotify_title }}
|
|
||||||
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
|
||||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -53,89 +49,103 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: 🔍 Environment ermitteln
|
||||||
- name: 🔍 Environment & Version ermitteln
|
|
||||||
id: determine
|
id: determine
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.ref_name }}"
|
REF="${{ github.ref_name }}"
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
IMAGE_TAG="sha-${SHORT_SHA}"
|
DOMAIN="klz-cables.com"
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
PRJ="klz"
|
||||||
|
|
||||||
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||||
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
|
TARGET="testing"
|
||||||
TARGET="skip"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
GOTIFY_TITLE="ℹ️ Skip Deploy (Chore)"
|
ENV_FILE=".env.testing"
|
||||||
GOTIFY_PRIORITY=2
|
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||||
else
|
|
||||||
TARGET="testing"
|
|
||||||
IMAGE_TAG="main-${SHORT_SHA}"
|
|
||||||
ENV_FILE=".env.testing"
|
|
||||||
TRAEFIK_HOST="testing.klz-cables.com"
|
|
||||||
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
|
|
||||||
DIRECTUS_URL="https://cms.testing.klz-cables.com"
|
|
||||||
DIRECTUS_HOST="cms.testing.klz-cables.com"
|
|
||||||
PROJECT_NAME="klz-cables-testing"
|
|
||||||
IS_PROD="false"
|
|
||||||
GOTIFY_TITLE="🧪 Testing-Deploy"
|
|
||||||
GOTIFY_PRIORITY=4
|
|
||||||
fi
|
|
||||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$TAG"
|
IMAGE_TAG="$REF"
|
||||||
ENV_FILE=".env.prod"
|
ENV_FILE=".env.prod"
|
||||||
TRAEFIK_HOST="klz-cables.com, www.klz-cables.com"
|
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||||
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
|
|
||||||
DIRECTUS_URL="https://cms.klz-cables.com"
|
|
||||||
DIRECTUS_HOST="cms.klz-cables.com"
|
|
||||||
PROJECT_NAME="klz-cables-prod"
|
|
||||||
IS_PROD="true"
|
|
||||||
GOTIFY_TITLE="🚀 Production-Release"
|
|
||||||
GOTIFY_PRIORITY=6
|
|
||||||
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
|
|
||||||
TARGET="staging"
|
|
||||||
IMAGE_TAG="$TAG"
|
|
||||||
ENV_FILE=".env.staging"
|
|
||||||
TRAEFIK_HOST="staging.klz-cables.com"
|
|
||||||
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
|
|
||||||
DIRECTUS_URL="https://cms.staging.klz-cables.com"
|
|
||||||
DIRECTUS_HOST="cms.staging.klz-cables.com"
|
|
||||||
PROJECT_NAME="klz-cables-staging"
|
|
||||||
IS_PROD="false"
|
|
||||||
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
|
||||||
GOTIFY_PRIORITY=5
|
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="staging"
|
||||||
GOTIFY_TITLE="❓ Unbekannter Tag"
|
IMAGE_TAG="$REF"
|
||||||
GOTIFY_PRIORITY=3
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="branch"
|
||||||
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Standardize Traefik Rule
|
||||||
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
||||||
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
|
else
|
||||||
|
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||||
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "target=$TARGET"
|
echo "target=$TARGET"
|
||||||
echo "image_tag=$IMAGE_TAG"
|
echo "image_tag=$IMAGE_TAG"
|
||||||
echo "env_file=$ENV_FILE"
|
echo "env_file=$ENV_FILE"
|
||||||
echo "traefik_host=$TRAEFIK_HOST"
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
echo "directus_url=$DIRECTUS_URL"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
echo "directus_host=$DIRECTUS_HOST"
|
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||||
echo "project_name=$PROJECT_NAME"
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
echo "is_prod=$IS_PROD"
|
echo "project_name=klz-cablescom"
|
||||||
echo "gotify_title=$GOTIFY_TITLE"
|
else
|
||||||
echo "gotify_priority=$GOTIFY_PRIORITY"
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
fi
|
||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
echo "commit_msg=$COMMIT_MSG"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
|
# 1. Discovery (Works without token for public repositories)
|
||||||
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
|
||||||
|
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
if [[ -n "$POLL_TOKEN" ]]; then
|
||||||
|
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||||
|
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||||
|
chmod +x wait-for-upstream.sh
|
||||||
|
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||||
|
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 2: Quality Assurance (Lint & Test)
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
qa:
|
qa:
|
||||||
name: 🧪 Quality Assurance
|
name: 🧪 QA
|
||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -144,248 +154,320 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
version: 10
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
- name: 🧪 Run Checks in Parallel
|
|
||||||
if: github.event.inputs.skip_long_checks != 'true'
|
|
||||||
run: |
|
run: |
|
||||||
npm run lint &
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
LINT_PID=$!
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
npm run typecheck &
|
- name: Install dependencies
|
||||||
TYPE_PID=$!
|
run: pnpm install --frozen-lockfile
|
||||||
npm run test &
|
- name: 🧪 QA Checks
|
||||||
TEST_PID=$!
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
|
run: |
|
||||||
# Wait for all and fail if any fail
|
pnpm lint
|
||||||
wait $LINT_PID || exit 1
|
pnpm typecheck
|
||||||
wait $TYPE_PID || exit 1
|
pnpm test
|
||||||
wait $TEST_PID || exit 1
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push Docker Image
|
# JOB 3: Build & Push
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
build-app:
|
build:
|
||||||
name: 🏗️ Build App
|
name: 🏗️ Build
|
||||||
needs: prepare
|
needs: [prepare, qa]
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
- name: 🏗️ Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
- name: 🏗️ App bauen & pushen
|
with:
|
||||||
env:
|
context: .
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
push: true
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
platforms: linux/arm64
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
build-args: |
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||||
run: |
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
docker buildx build \
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
--pull \
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
--platform linux/arm64 \
|
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2,mode=max
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
secrets: |
|
||||||
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||||
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:$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 \
|
|
||||||
--push .
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy via SSH
|
# JOB 4: Deploy
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
name: 🚀 Deploy
|
name: 🚀 Deploy
|
||||||
needs: [prepare, build-app, qa]
|
needs: [prepare, build, qa]
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
|
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
# Secrets mapping (Directus)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
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 }}
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }}
|
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 }}
|
||||||
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST }}
|
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' }}
|
||||||
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT }}
|
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 }}
|
||||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME }}
|
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||||
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM }}
|
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' }}
|
||||||
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS }}
|
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 }}
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
|
||||||
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
# Secrets mapping (Mail)
|
||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY }}
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET }}
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL }}
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
|
||||||
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD }}
|
# Monitoring
|
||||||
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
|
# Gatekeeper
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
- name: 📝 Generate Environment
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: 🚀 Deploy to ${{ env.TARGET }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
# Middleware Selection Logic
|
||||||
|
# Regular app routes get auth on non-production
|
||||||
|
# Unprotected routes (/stats, /errors) never get auth
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
|
COMPOSE_PROFILES=""
|
||||||
|
else
|
||||||
|
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||||
|
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||||
|
COMPOSE_PROFILES="gatekeeper"
|
||||||
|
fi
|
||||||
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
|
# Gatekeeper Origin
|
||||||
|
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Generated by CI - $TARGET"
|
||||||
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
|
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||||
|
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||||
|
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||||
|
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||||
|
echo "MAIL_HOST=$MAIL_HOST"
|
||||||
|
echo "MAIL_PORT=$MAIL_PORT"
|
||||||
|
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||||
|
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||||
|
echo "MAIL_FROM=$MAIL_FROM"
|
||||||
|
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||||
|
echo ""
|
||||||
|
echo "# Directus"
|
||||||
|
echo "DIRECTUS_URL=$DIRECTUS_URL"
|
||||||
|
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
|
||||||
|
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
|
||||||
|
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
|
||||||
|
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
|
||||||
|
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
|
||||||
|
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
|
||||||
|
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
|
||||||
|
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
|
||||||
|
echo "DIRECTUS_DB_CLIENT=pg"
|
||||||
|
echo "DIRECTUS_DB_HOST=directus-db"
|
||||||
|
echo "DIRECTUS_DB_PORT=5432"
|
||||||
|
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
|
||||||
|
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
|
||||||
|
echo ""
|
||||||
|
echo "# Gatekeeper"
|
||||||
|
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||||
|
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||||
|
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
echo "# Analytics"
|
||||||
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
|
echo ""
|
||||||
|
echo "TARGET=$TARGET"
|
||||||
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
|
echo "TRAEFIK_TLS=true"
|
||||||
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
|
echo "ENV_FILE=$ENV_FILE"
|
||||||
|
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||||
|
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||||
|
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||||
|
} > .env.deploy
|
||||||
|
|
||||||
|
echo "--- Generated .env.deploy ---"
|
||||||
|
cat .env.deploy
|
||||||
|
echo "----------------------------"
|
||||||
|
|
||||||
|
- name: 🚀 SSH Deploy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
# Transfer and Restart
|
||||||
# Generated by CI - $TARGET - $(date -u)
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
NODE_ENV=production
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
|
||||||
NEXT_PUBLIC_TARGET=$TARGET
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
scp -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
|
||||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||||
MAIL_HOST=$MAIL_HOST
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||||
MAIL_PORT=$MAIL_PORT
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
|
||||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
# Apply Directus Schema Snapshot if available
|
||||||
MAIL_FROM=$MAIL_FROM
|
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"
|
||||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
|
||||||
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
# Directus
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
DIRECTUS_URL=$DIRECTUS_URL
|
if: always()
|
||||||
DIRECTUS_HOST=$DIRECTUS_HOST
|
run: docker builder prune -f --filter "until=1h"
|
||||||
DIRECTUS_KEY=$DIRECTUS_KEY
|
|
||||||
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
|
||||||
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
|
||||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
|
||||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
|
||||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
|
||||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
|
||||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
|
||||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
|
||||||
|
|
||||||
TARGET=$TARGET
|
|
||||||
SENTRY_ENVIRONMENT=$TARGET
|
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
|
||||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
|
||||||
ENV_FILE=$ENV_FILE
|
|
||||||
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
|
||||||
PROJECT_NAME=$PROJECT_NAME
|
|
||||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 1. Cleanup and Create Directories on server BEFORE SCP
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
|
||||||
set -e
|
|
||||||
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
|
||||||
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads /home/deploy/sites/klz-cables.com/directus/extensions
|
|
||||||
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
|
||||||
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
|
||||||
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
|
||||||
fi
|
|
||||||
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. Transfer files
|
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
|
||||||
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
|
||||||
set -e
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
chown deploy:deploy "$ENV_FILE"
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
echo "→ Pulling image: $IMAGE_TAG"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
|
||||||
echo "→ Starting containers..."
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
|
||||||
docker system prune -f --filter "until=24h"
|
|
||||||
echo "→ Waiting 15s for warmup..."
|
|
||||||
sleep 15
|
|
||||||
echo "→ Container status:"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps
|
|
||||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps | grep -q "Up"; then
|
|
||||||
echo "❌ Fehler: Container nicht Up!"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "→ Verifying Varnish Backend Health..."
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
|
||||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
|
|
||||||
echo "❌ Fehler: Varnish Backend ist SICK!"
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Varnish Backend ist Healthy."
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 5: PageSpeed Test
|
# JOB 5: Smoke Test (OG Images)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
pagespeed:
|
smoke_test:
|
||||||
name: ⚡ PageSpeed
|
name: 🧪 Smoke Test
|
||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
if: |
|
if: needs.deploy.result == 'success'
|
||||||
always() &&
|
|
||||||
needs.prepare.outputs.target != 'skip' &&
|
|
||||||
needs.deploy.result == 'success' &&
|
|
||||||
github.event.inputs.skip_long_checks != 'true'
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
# outputs:
|
|
||||||
# report_url: ${{ steps.save.outputs.report_url }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
version: 10
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🚀 Run OG Image Check
|
||||||
|
env:
|
||||||
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
run: pnpm run check:og
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 6: Lighthouse (Performance & Accessibility)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: ⚡ Lighthouse
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
@@ -403,98 +485,48 @@ jobs:
|
|||||||
mkdir -p /etc/apt/keyrings
|
mkdir -p /etc/apt/keyrings
|
||||||
KEY_ID="82BB6851C64F6880"
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
# Multi-method Key Fetch
|
# Fetch PPA key
|
||||||
SUCCESS=false
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
echo "Fetching key $KEY_ID..."
|
|
||||||
|
|
||||||
# Method 1: gpg --recv-keys (standard)
|
# Add PPA repository
|
||||||
for server in "hkp://keyserver.ubuntu.com:80" "hkp://keyserver.ubuntu.com:11371"; do
|
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
|
||||||
if gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --keyserver "$server" --recv-keys "$KEY_ID"; then
|
|
||||||
gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --export > /etc/apt/keyrings/xtradeb.gpg
|
|
||||||
SUCCESS=true && break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Method 2: Direct wget (fallback)
|
|
||||||
if [ "$SUCCESS" = false ]; then
|
|
||||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg && SUCCESS=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$SUCCESS" = true ]; then
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
else
|
|
||||||
echo "⚠️ GPG fetch failed, using legacy apt-key as last resort..."
|
|
||||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$KEY_ID" || true
|
|
||||||
echo "deb http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --allow-downgrades chromium || apt-get install -y chromium-browser
|
apt-get install -y --allow-downgrades chromium
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force clean paths (remove existing dead links/files if they are snap wrappers)
|
# Standardize binary paths
|
||||||
rm -f /usr/bin/google-chrome /usr/bin/chromium-browser
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
- name: ⚡ Run Lighthouse CI
|
||||||
echo "✅ Binary check:"
|
|
||||||
ls -l /usr/bin/chromium* /usr/bin/google-chrome || true
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: 🧪 Run PageSpeed (Lighthouse)
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
CHROME_PATH: /usr/bin/chromium
|
||||||
run: npm run pagespeed:test
|
PAGESPEED_LIMIT: 8
|
||||||
|
run: pnpm run pagespeed:test
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 6: Notifications
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notifications
|
name: 🔔 Notify
|
||||||
needs: [prepare, qa, build-app, deploy, pagespeed]
|
needs: [prepare, deploy, 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: 📊 Deployment Summary
|
- name: 🔔 Gotify
|
||||||
run: |
|
|
||||||
echo "┌──────────────────────────────┐"
|
|
||||||
echo "│ Deployment Summary │"
|
|
||||||
echo "├──────────────────────────────┤"
|
|
||||||
echo "│ Status: ${{ needs.deploy.result }} │"
|
|
||||||
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
|
|
||||||
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
|
|
||||||
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
|
|
||||||
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
|
|
||||||
echo "└──────────────────────────────┘"
|
|
||||||
|
|
||||||
- name: 🔔 Gotify - Success
|
|
||||||
if: needs.deploy.result == 'success'
|
|
||||||
run: |
|
run: |
|
||||||
|
STATUS="${{ needs.deploy.result }}"
|
||||||
|
TITLE="klz-cables.com: $STATUS"
|
||||||
|
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||||
|
|
||||||
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=${{ needs.prepare.outputs.gotify_title }}" \
|
-F "title=$TITLE" \
|
||||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||||
-F "priority=4" || true
|
-F "priority=$PRIORITY" || true
|
||||||
|
|
||||||
- name: 🔔 Gotify - Failure
|
|
||||||
if: |
|
|
||||||
needs.prepare.result == 'failure' ||
|
|
||||||
needs.qa.result == 'failure' ||
|
|
||||||
needs.build-app.result == 'failure' ||
|
|
||||||
needs.deploy.result == 'failure' ||
|
|
||||||
needs.pagespeed.result == 'failure'
|
|
||||||
run: |
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
|
||||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
|
||||||
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
|
|
||||||
-F "priority=8" || true
|
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -2,6 +2,15 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Lighthouse CI
|
||||||
|
.lighthouseci/
|
||||||
|
lighthouserc.cjs
|
||||||
|
.lighthouserc.json
|
||||||
|
|
||||||
# Directus
|
# Directus
|
||||||
directus/uploads
|
directus/uploads
|
||||||
!directus/extensions/
|
!directus/extensions/
|
||||||
|
!directus/schema/
|
||||||
|
!directus/migrations/
|
||||||
|
|
||||||
|
.next-docker
|
||||||
32
.husky/pre-push
Executable file
32
.husky/pre-push
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Husky pre-push hook to validate tags
|
||||||
|
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
|
||||||
|
|
||||||
|
z40=0000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
while read local_ref local_sha remote_ref remote_sha
|
||||||
|
do
|
||||||
|
# Check if we are pushing a tag
|
||||||
|
case "$local_ref" in
|
||||||
|
refs/tags/*)
|
||||||
|
tag_name="${local_ref#refs/tags/}"
|
||||||
|
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: Invalid tag name '$tag_name'"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo "Consistency check failed: All tags MUST start with 'v'."
|
||||||
|
echo "Example: v1.0.10"
|
||||||
|
echo ""
|
||||||
|
echo "Please delete the invalid tag and create a new one:"
|
||||||
|
echo " git tag -d $tag_name"
|
||||||
|
echo " git tag v$tag_name"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
10
.lintstagedrc.cjs
Normal file
10
.lintstagedrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
const path = require('path'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||||
|
|
||||||
|
const buildEslintCommand = (filenames) =>
|
||||||
|
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||||
|
'*.{json,md,css,scss}': ['prettier --write'],
|
||||||
|
};
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
|
|
||||||
const buildEslintCommand = (filenames) =>
|
|
||||||
`next lint --fix --file ${filenames
|
|
||||||
.map((f) => path.relative(process.cwd(), f))
|
|
||||||
.join(' --file ')}`;
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
|
||||||
'*.{json,md,css,scss}': ['prettier --write'],
|
|
||||||
};
|
|
||||||
26
.pa11yci.json
Normal file
26
.pa11yci.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"standard": "WCAG2AA",
|
||||||
|
"runners": ["axe", "htmlcs"],
|
||||||
|
"ignore": [],
|
||||||
|
"timeout": 50000,
|
||||||
|
"wait": 1000,
|
||||||
|
"chromeLaunchConfig": {
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
},
|
||||||
|
"threshold": 25
|
||||||
|
},
|
||||||
|
"urls": [
|
||||||
|
"http://localhost:3000/en",
|
||||||
|
"http://localhost:3000/en/blog",
|
||||||
|
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
|
||||||
|
"http://localhost:3000/en/contact",
|
||||||
|
"http://localhost:3000/en/team",
|
||||||
|
"http://localhost:3000/en/products",
|
||||||
|
"http://localhost:3000/en/products/medium-voltage-cables",
|
||||||
|
"http://localhost:3000/en/products/low-voltage-cables",
|
||||||
|
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
|
||||||
|
"http://localhost:3000/en/legal-notice",
|
||||||
|
"http://localhost:3000/en/privacy-policy"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Ignore Next.js auto-generated environment file
|
||||||
|
# It often uses different quote styles than our project config
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
|
||||||
|
# Ignore other potentially generated files
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -1 +0,0 @@
|
|||||||
Sheet 1
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,237 +0,0 @@
|
|||||||
# Analytics Migration Complete ✅
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Successfully migrated analytics data from Independent Analytics (WordPress) to Umami.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. Migration Script
|
|
||||||
**Location:** `scripts/migrate-analytics-to-umami.py`
|
|
||||||
- Converts Independent Analytics CSV to Umami format
|
|
||||||
- Supports 3 output formats: JSON (API), SQL (database), API payload
|
|
||||||
- Preserves page view counts and average duration data
|
|
||||||
|
|
||||||
### 2. Deployment Script
|
|
||||||
**Location:** `scripts/deploy-analytics-to-umami.sh`
|
|
||||||
- Tailored for your server setup (`deploy@alpha.mintel.me`)
|
|
||||||
- Copies files to your Umami server
|
|
||||||
- Provides import instructions for your specific environment
|
|
||||||
|
|
||||||
### 3. Output Files
|
|
||||||
|
|
||||||
#### JSON Import File
|
|
||||||
**Location:** `data/umami-import.json`
|
|
||||||
- **Size:** 2.1 MB
|
|
||||||
- **Records:** 7,634 page view events
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
- **Use:** Import via Umami API
|
|
||||||
|
|
||||||
#### SQL Import File
|
|
||||||
**Location:** `data/umami-import.sql`
|
|
||||||
- **Size:** 1.8 MB
|
|
||||||
- **Records:** 5,250 SQL statements
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
- **Use:** Direct database import
|
|
||||||
|
|
||||||
### 4. Documentation
|
|
||||||
|
|
||||||
**Location:** `scripts/README-migration.md`
|
|
||||||
- Step-by-step migration guide
|
|
||||||
- Prerequisites and setup instructions
|
|
||||||
- Import methods (API and database)
|
|
||||||
- Troubleshooting tips
|
|
||||||
|
|
||||||
**Location:** `MIGRATION_SUMMARY.md`
|
|
||||||
- Complete migration overview
|
|
||||||
- Data summary and limitations
|
|
||||||
- Verification steps
|
|
||||||
- Next steps
|
|
||||||
|
|
||||||
**Location:** `ANALYTICS_MIGRATION_COMPLETE.md` (this file)
|
|
||||||
- Quick reference guide
|
|
||||||
- Deployment instructions
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Option 1: Automated Deployment (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the deployment script
|
|
||||||
./scripts/deploy-analytics-to-umami.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This script will:
|
|
||||||
1. Copy files to your server
|
|
||||||
2. Provide import instructions
|
|
||||||
3. Show you the exact commands to run
|
|
||||||
|
|
||||||
### Option 2: Manual Deployment
|
|
||||||
|
|
||||||
#### Step 1: Copy files to server
|
|
||||||
```bash
|
|
||||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 2: SSH into server
|
|
||||||
```bash
|
|
||||||
ssh deploy@alpha.mintel.me
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 3: Import data
|
|
||||||
|
|
||||||
**Method A: API Import (if API key is available)**
|
|
||||||
```bash
|
|
||||||
# Get your API key from Umami dashboard
|
|
||||||
# Add to .env: UMAMI_API_KEY=your-api-key
|
|
||||||
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
http://localhost:3000/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method B: Database Import (direct)**
|
|
||||||
```bash
|
|
||||||
# Import SQL file into PostgreSQL
|
|
||||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method C: Manual via Umami Dashboard**
|
|
||||||
1. Access Umami dashboard: https://analytics.infra.mintel.me
|
|
||||||
2. Go to Settings → Import
|
|
||||||
3. Upload `data/umami-import.json`
|
|
||||||
4. Select website ID: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
5. Click Import
|
|
||||||
|
|
||||||
## Your Umami Configuration
|
|
||||||
|
|
||||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
**Environment Variables** (from docker-compose.yml):
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server Details:**
|
|
||||||
- **Host:** alpha.mintel.me
|
|
||||||
- **User:** deploy
|
|
||||||
- **Path:** /home/deploy/sites/klz-cables.com
|
|
||||||
- **Umami API:** http://localhost:3000/api/import
|
|
||||||
|
|
||||||
## Data Summary
|
|
||||||
|
|
||||||
### What Was Migrated
|
|
||||||
- **Source:** Independent Analytics CSV (220 unique pages)
|
|
||||||
- **Migrated:** 7,634 simulated page view events
|
|
||||||
- **Metrics:** Page views, visitor counts, average duration
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
### What Was NOT Migrated
|
|
||||||
- Individual user sessions
|
|
||||||
- Real-time data
|
|
||||||
- Geographic data
|
|
||||||
- Referrer data
|
|
||||||
- Device/browser data
|
|
||||||
- Custom events
|
|
||||||
|
|
||||||
**Note:** The CSV contains aggregated data, not raw event data. The migration creates simulated historical data for reference only.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### After Import
|
|
||||||
1. **Check Umami dashboard:** https://analytics.infra.mintel.me
|
|
||||||
2. **Verify page view counts** match your expectations
|
|
||||||
3. **Check top pages** appear correctly
|
|
||||||
4. **Monitor for a few days** to ensure new data is being collected
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
- ✅ 7,634 events imported
|
|
||||||
- ✅ 220 unique pages
|
|
||||||
- ✅ Historical view counts preserved
|
|
||||||
- ✅ Duration data maintained
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "SSH connection failed"
|
|
||||||
**Solution:** Check your SSH key and ensure `deploy@alpha.mintel.me` has access
|
|
||||||
|
|
||||||
### Issue: "API import failed"
|
|
||||||
**Solution:**
|
|
||||||
1. Check if Umami API is running: `docker compose ps`
|
|
||||||
2. Verify API key in `.env`: `UMAMI_API_KEY=your-key`
|
|
||||||
3. Try database import instead
|
|
||||||
|
|
||||||
### Issue: "Database import failed"
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure PostgreSQL is running: `docker compose ps`
|
|
||||||
2. Check database credentials
|
|
||||||
3. Run migrations first: `docker exec -it $(docker compose ps -q postgres) psql -U umami -d umami -c "SELECT 1;"`
|
|
||||||
|
|
||||||
### Issue: "No data appears in dashboard"
|
|
||||||
**Solution:**
|
|
||||||
1. Verify import completed successfully
|
|
||||||
2. Check Umami logs: `docker compose logs app`
|
|
||||||
3. Ensure website ID matches: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### 1. Import the Data
|
|
||||||
Choose one of the import methods above and run it.
|
|
||||||
|
|
||||||
### 2. Verify the Migration
|
|
||||||
- Check Umami dashboard
|
|
||||||
- Verify page view counts
|
|
||||||
- Confirm data appears correctly
|
|
||||||
|
|
||||||
### 3. Update Your Website
|
|
||||||
Your website is already configured with:
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Monitor for a Few Days
|
|
||||||
- Ensure Umami is collecting new data
|
|
||||||
- Compare with any remaining Independent Analytics data
|
|
||||||
- Verify tracking code is working
|
|
||||||
|
|
||||||
### 5. Clean Up
|
|
||||||
- Keep the original CSV as backup: `data/pages(1).csv`
|
|
||||||
- Store migration files for future reference
|
|
||||||
- Remove old Independent Analytics plugin from WordPress
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Migration Details
|
|
||||||
|
|
||||||
**Migration Date:** 2026-01-25
|
|
||||||
**Source Plugin:** Independent Analytics v2.9.7
|
|
||||||
**Target Platform:** Umami Analytics
|
|
||||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
**Server:** alpha.mintel.me (deploy user)
|
|
||||||
**Status:** ✅ Ready for import
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Quick Command Reference:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Deploy to server
|
|
||||||
./scripts/deploy-analytics-to-umami.sh
|
|
||||||
|
|
||||||
# Or manually:
|
|
||||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
|
||||||
ssh deploy@alpha.mintel.me
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Need help?** Check `scripts/README-migration.md` for detailed instructions.
|
|
||||||
106
Dockerfile
106
Dockerfile
@@ -1,79 +1,65 @@
|
|||||||
FROM node:20-alpine AS base
|
# Stage 1: Builder
|
||||||
|
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS deps
|
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
|
||||||
RUN apk add --no-cache libc6-compat curl
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Arguments for build-time configuration
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# Build-time environment variables for Next.js
|
|
||||||
# These are baked into the client bundle during build
|
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
ARG UMAMI_WEBSITE_ID
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
|
# Environment variables for Next.js build
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# Validate environment variables during build
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
# Configure private registry and install dependencies
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
|
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||||
|
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
|
pnpm install --frozen-lockfile && \
|
||||||
|
rm .npmrc
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Copy source code
|
||||||
FROM base AS runner
|
COPY . .
|
||||||
|
|
||||||
|
# Stage 2: Development (Hot-Reloading)
|
||||||
|
FROM base AS development
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
# Stage 3: Builder (Production)
|
||||||
|
FROM base AS builder
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 3: Runner
|
||||||
|
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install curl for health checks
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
RUN apk add --no-cache curl
|
USER root
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
ENV NODE_ENV=production
|
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV PORT=3000
|
|
||||||
# set hostname to localhost
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Copy standalone output and static files
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
# server.js is created by next build from the standalone output
|
|
||||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
# Environment Variables Cleanup - Summary
|
|
||||||
|
|
||||||
## What Was Done
|
|
||||||
|
|
||||||
Cleaned up the fragile, overkill environment variable mess and replaced it with a simple, clean, robust **fully automated** system.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Dockerfile ✅
|
|
||||||
**Before**: 4 build args including runtime-only variables (SENTRY_DSN)
|
|
||||||
**After**: 3 build args - only `NEXT_PUBLIC_*` variables that need to be baked into the client bundle
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Only these build args now:
|
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. docker-compose.yml ✅
|
|
||||||
**Before**: 12+ individual environment variables listed
|
|
||||||
**After**: Single `env_file: .env` directive
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
app:
|
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
|
||||||
env_file:
|
|
||||||
- .env # All runtime vars loaded from here
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. .gitea/workflows/deploy.yml ✅
|
|
||||||
**Before**: Passing 12+ environment variables individually via SSH command (fragile!)
|
|
||||||
**After**: **Fully automated** - workflow creates `.env` file from Gitea secrets and uploads it
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Before (FRAGILE):
|
|
||||||
ssh root@alpha.mintel.me \
|
|
||||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
|
||||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
|
||||||
... (12+ variables) \
|
|
||||||
/home/deploy/deploy.sh"
|
|
||||||
|
|
||||||
# After (AUTOMATED):
|
|
||||||
# 1. Create .env from secrets
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
# ... all other vars from secrets
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. Upload to server
|
|
||||||
scp /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
|
||||||
|
|
||||||
# 3. Deploy
|
|
||||||
ssh root@alpha.mintel.me "cd /home/deploy/sites/klz-cables.com && docker-compose up -d"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. New Files Created ✅
|
|
||||||
|
|
||||||
- **`.env.production`** - Template for reference (not used in automation)
|
|
||||||
- **`docs/DEPLOYMENT.md`** - Complete deployment guide
|
|
||||||
- **`docs/SERVER_SETUP.md`** - Server setup instructions
|
|
||||||
- **`docs/ENV_MIGRATION.md`** - Migration guide from old to new system
|
|
||||||
|
|
||||||
### 5. Updated Files ✅
|
|
||||||
|
|
||||||
- **`.env.example`** - Clear documentation of all variables with build-time vs runtime notes
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Build Time (CI/CD)
|
|
||||||
```
|
|
||||||
Gitea Workflow
|
|
||||||
↓
|
|
||||||
Only passes NEXT_PUBLIC_* as --build-arg
|
|
||||||
↓
|
|
||||||
Docker Build
|
|
||||||
↓
|
|
||||||
Validates env vars
|
|
||||||
↓
|
|
||||||
Bakes NEXT_PUBLIC_* into client bundle
|
|
||||||
↓
|
|
||||||
Push to Registry
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime (Production Server) - FULLY AUTOMATED
|
|
||||||
```
|
|
||||||
Gitea Secrets
|
|
||||||
↓
|
|
||||||
Workflow creates .env file
|
|
||||||
↓
|
|
||||||
SCP uploads to server
|
|
||||||
↓
|
|
||||||
Secured (chmod 600, chown deploy:deploy)
|
|
||||||
↓
|
|
||||||
docker-compose.yml (env_file: .env)
|
|
||||||
↓
|
|
||||||
Loads .env into container
|
|
||||||
↓
|
|
||||||
Application runs with full config
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Benefits
|
|
||||||
|
|
||||||
### 1. Simplicity
|
|
||||||
- **Before**: 15+ Gitea secrets, variables in 3+ places
|
|
||||||
- **After**: All secrets in Gitea, automatically deployed
|
|
||||||
|
|
||||||
### 2. Clarity
|
|
||||||
- **Before**: Confusing duplication, unclear which vars go where
|
|
||||||
- **After**: Clear separation - build args vs runtime env file
|
|
||||||
|
|
||||||
### 3. Robustness
|
|
||||||
- **Before**: Fragile SSH command with 12+ inline variables
|
|
||||||
- **After**: Robust automated file generation and upload
|
|
||||||
|
|
||||||
### 4. Security
|
|
||||||
- **Before**: Secrets potentially exposed in CI logs
|
|
||||||
- **After**: Secrets masked in logs, .env auto-secured on server
|
|
||||||
|
|
||||||
### 5. Maintainability
|
|
||||||
- **Before**: Update in 3 places (Dockerfile, docker-compose.yml, deploy.yml)
|
|
||||||
- **After**: Update Gitea secrets only - deployment is automatic
|
|
||||||
|
|
||||||
### 6. **Zero Manual Steps** 🎉
|
|
||||||
- **Before**: Manual .env file creation on server (error-prone, can be forgotten)
|
|
||||||
- **After**: **Fully automated** - .env file created and uploaded on every deployment
|
|
||||||
|
|
||||||
## What You Need to Do
|
|
||||||
|
|
||||||
### Required Gitea Secrets
|
|
||||||
|
|
||||||
Ensure these secrets are configured in your Gitea repository:
|
|
||||||
|
|
||||||
**Build-Time (NEXT_PUBLIC_*):**
|
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
|
||||||
|
|
||||||
**Runtime:**
|
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
|
||||||
- `MAIL_HOST` - SMTP server
|
|
||||||
- `MAIL_PORT` - SMTP port (e.g., `587`)
|
|
||||||
- `MAIL_USERNAME` - SMTP username
|
|
||||||
- `MAIL_PASSWORD` - SMTP password
|
|
||||||
- `MAIL_FROM` - Sender email
|
|
||||||
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
|
|
||||||
|
|
||||||
**Infrastructure:**
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment server
|
|
||||||
|
|
||||||
**Notifications:**
|
|
||||||
- `GOTIFY_URL` - Gotify notification server URL
|
|
||||||
- `GOTIFY_TOKEN` - Gotify application token
|
|
||||||
|
|
||||||
### That's It!
|
|
||||||
|
|
||||||
**No manual steps required.** Just push to main branch and the workflow will:
|
|
||||||
1. ✅ Build Docker image with NEXT_PUBLIC_* build args
|
|
||||||
2. ✅ Create .env file from all secrets
|
|
||||||
3. ✅ Upload .env to server
|
|
||||||
4. ✅ Secure .env file (600 permissions, deploy:deploy ownership)
|
|
||||||
5. ✅ Pull latest image
|
|
||||||
6. ✅ Deploy with docker-compose
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
```
|
|
||||||
Modified:
|
|
||||||
├── Dockerfile (removed redundant build args)
|
|
||||||
├── docker-compose.yml (use env_file instead of individual vars)
|
|
||||||
├── .gitea/workflows/deploy.yml (automated .env creation & upload)
|
|
||||||
├── .env.example (clear documentation)
|
|
||||||
├── lib/services/create-services.ts (removed redundant dotenv usage)
|
|
||||||
└── scripts/migrate-*.ts (removed redundant dotenv usage)
|
|
||||||
|
|
||||||
Created:
|
|
||||||
├── .env.production (reference template)
|
|
||||||
├── docs/DEPLOYMENT.md (deployment guide)
|
|
||||||
├── docs/SERVER_SETUP.md (server setup guide)
|
|
||||||
├── docs/ENV_MIGRATION.md (migration guide)
|
|
||||||
└── ENV_CLEANUP_SUMMARY.md (this file)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Developer pushes to main branch │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Gitea Workflow Triggered │
|
|
||||||
│ │
|
|
||||||
│ 1. Build Docker image (NEXT_PUBLIC_* build args) │
|
|
||||||
│ 2. Push to registry │
|
|
||||||
│ 3. Generate .env from secrets │
|
|
||||||
│ 4. Upload .env to server via SCP │
|
|
||||||
│ 5. SSH to server and deploy │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Production Server │
|
|
||||||
│ │
|
|
||||||
│ 1. .env file secured (600, deploy:deploy) │
|
|
||||||
│ 2. Docker login to registry │
|
|
||||||
│ 3. Pull latest image │
|
|
||||||
│ 4. docker-compose down │
|
|
||||||
│ 5. docker-compose up -d (loads .env) │
|
|
||||||
│ 6. Health checks pass │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ✅ Deployment Complete - Gotify Notification Sent │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comparison: Before vs After
|
|
||||||
|
|
||||||
| Aspect | Before | After |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| **Gitea Secrets** | 15+ secrets | Same secrets, better organized |
|
|
||||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
|
||||||
| **Runtime Vars** | Passed via SSH command | Auto-generated .env file |
|
|
||||||
| **Manual Steps** | ❌ Manual .env creation | ✅ Fully automated |
|
|
||||||
| **Maintenance** | Update in 3 places | Update Gitea secrets only |
|
|
||||||
| **Security** | Secrets in CI logs | Secrets masked, .env secured |
|
|
||||||
| **Clarity** | Confusing duplication | Clear separation |
|
|
||||||
| **Robustness** | Fragile SSH command | Robust automation |
|
|
||||||
| **Error-Prone** | ❌ Can forget .env | ✅ Impossible to forget |
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Complete deployment guide
|
|
||||||
- **[SERVER_SETUP.md](docs/SERVER_SETUP.md)** - Server setup instructions (mostly automated now)
|
|
||||||
- **[ENV_MIGRATION.md](docs/ENV_MIGRATION.md)** - Migration from old to new system
|
|
||||||
- **[.env.example](.env.example)** - Environment variables reference
|
|
||||||
- **[.env.production](.env.production)** - Production template (for reference)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Deployment Fails
|
|
||||||
|
|
||||||
1. **Check Gitea secrets** - Ensure all required secrets are set
|
|
||||||
2. **Check workflow logs** - Look for specific error messages
|
|
||||||
3. **SSH to server** - Verify .env file exists and has correct permissions
|
|
||||||
4. **Check container logs** - `docker-compose logs -f app`
|
|
||||||
|
|
||||||
### .env File Issues
|
|
||||||
|
|
||||||
The workflow automatically:
|
|
||||||
- Creates .env from secrets
|
|
||||||
- Uploads to server
|
|
||||||
- Sets 600 permissions
|
|
||||||
- Sets deploy:deploy ownership
|
|
||||||
|
|
||||||
If there are issues, check the workflow logs for the "📝 Preparing environment configuration" step.
|
|
||||||
|
|
||||||
### Missing Environment Variables
|
|
||||||
|
|
||||||
If a variable is missing:
|
|
||||||
1. Add it to Gitea secrets
|
|
||||||
2. Update `.gitea/workflows/deploy.yml` to include it in the .env generation
|
|
||||||
3. Push to trigger new deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Result**: Environment variable management is now simple, clean, robust, and **fully automated**! 🎉
|
|
||||||
|
|
||||||
No more manual .env file creation. No more forgotten configuration. No more fragile SSH commands. Just push and deploy!
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Analytics Migration Summary: Independent Analytics → Umami
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Successfully migrated analytics data from Independent Analytics WordPress plugin to Umami format.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. Migration Script
|
|
||||||
- **Location:** `scripts/migrate-analytics-to-umami.py`
|
|
||||||
- **Purpose:** Converts Independent Analytics CSV data to Umami format
|
|
||||||
- **Features:**
|
|
||||||
- JSON format (for API import)
|
|
||||||
- SQL format (for direct database import)
|
|
||||||
- API payload format (for manual import)
|
|
||||||
|
|
||||||
### 2. Migration Documentation
|
|
||||||
- **Location:** `scripts/README-migration.md`
|
|
||||||
- **Purpose:** Step-by-step guide for migration
|
|
||||||
- **Contents:**
|
|
||||||
- Prerequisites
|
|
||||||
- Migration options
|
|
||||||
- Import instructions
|
|
||||||
- Troubleshooting guide
|
|
||||||
|
|
||||||
### 3. Output Files
|
|
||||||
|
|
||||||
#### JSON Import File
|
|
||||||
- **Location:** `data/umami-import.json`
|
|
||||||
- **Size:** 2.1 MB
|
|
||||||
- **Records:** 7,634 simulated page view events
|
|
||||||
- **Format:** JSON array of Umami-compatible events
|
|
||||||
- **Use Case:** Import via Umami API
|
|
||||||
|
|
||||||
#### SQL Import File
|
|
||||||
- **Location:** `data/umami-import.sql`
|
|
||||||
- **Size:** 1.8 MB
|
|
||||||
- **Records:** 5,250 SQL INSERT statements
|
|
||||||
- **Format:** PostgreSQL-compatible SQL
|
|
||||||
- **Use Case:** Direct database import
|
|
||||||
|
|
||||||
## Data Migrated
|
|
||||||
|
|
||||||
### Source Data
|
|
||||||
- **File:** `data/pages(1).csv`
|
|
||||||
- **Records:** 220 unique pages
|
|
||||||
- **Metrics:**
|
|
||||||
- Page titles
|
|
||||||
- Visitor counts
|
|
||||||
- View counts
|
|
||||||
- Average view duration
|
|
||||||
- Bounce rates
|
|
||||||
- URLs
|
|
||||||
- Page types (Page, Post, Product, Category, etc.)
|
|
||||||
|
|
||||||
### Migrated Data
|
|
||||||
- **Total Events:** 7,634 simulated page views
|
|
||||||
- **Unique Pages:** 220
|
|
||||||
- **Data Points:**
|
|
||||||
- Website ID: `klz-cables`
|
|
||||||
- Path: Page URLs
|
|
||||||
- Duration: Preserved from average view duration
|
|
||||||
- Timestamp: Current time (for historical reference)
|
|
||||||
|
|
||||||
## Migration Process
|
|
||||||
|
|
||||||
### Step 1: Run Migration Script
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import.json \
|
|
||||||
--format json \
|
|
||||||
--site-id klz-cables
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Choose Import Method
|
|
||||||
|
|
||||||
#### Option A: API Import (Recommended)
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
https://your-umami-instance.com/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Database Import
|
|
||||||
```bash
|
|
||||||
psql -U umami -d umami -f data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Verify Migration
|
|
||||||
1. Check Umami dashboard
|
|
||||||
2. Verify page view counts
|
|
||||||
3. Confirm data appears correctly
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### Data Limitations
|
|
||||||
The CSV export contains **aggregated data**, not raw event data:
|
|
||||||
- ✅ Page views (total counts)
|
|
||||||
- ✅ Visitor counts
|
|
||||||
- ✅ Average view duration
|
|
||||||
- ❌ Individual user sessions
|
|
||||||
- ❌ Real-time data
|
|
||||||
- ❌ Geographic data
|
|
||||||
- ❌ Referrer data
|
|
||||||
- ❌ Device/browser data
|
|
||||||
|
|
||||||
### What Gets Imported
|
|
||||||
The migration creates **simulated historical data**:
|
|
||||||
- Each page view becomes a separate event
|
|
||||||
- Timestamps are set to current time
|
|
||||||
- Duration is preserved from average view duration
|
|
||||||
- No session tracking (each view is independent)
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
1. **Start fresh with Umami** - Let Umami collect new data going forward
|
|
||||||
2. **Keep the original CSV** - Store as backup for future reference
|
|
||||||
3. **Update your website** - Replace Independent Analytics tracking with Umami tracking
|
|
||||||
4. **Monitor for a few days** - Verify Umami is collecting data correctly
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Check Generated Files
|
|
||||||
```bash
|
|
||||||
# Verify JSON file
|
|
||||||
ls -lh data/umami-import.json
|
|
||||||
head -20 data/umami-import.json
|
|
||||||
|
|
||||||
# Verify SQL file
|
|
||||||
ls -lh data/umami-import.sql
|
|
||||||
head -20 data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
- ✅ JSON file: ~2.1 MB, 7,634 records
|
|
||||||
- ✅ SQL file: ~1.8 MB, 5,250 statements
|
|
||||||
- ✅ Both files contain valid data for Umami import
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Set up Umami instance** (if not already done)
|
|
||||||
2. **Create a website** in Umami dashboard
|
|
||||||
3. **Get your Website ID** and API key
|
|
||||||
4. **Run the migration script** with your credentials
|
|
||||||
5. **Import the data** using your preferred method
|
|
||||||
6. **Verify the migration** in Umami dashboard
|
|
||||||
7. **Update your website** to use Umami tracking code
|
|
||||||
8. **Monitor for a few days** to ensure data collection works
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "ModuleNotFoundError"
|
|
||||||
**Solution:** Ensure Python 3 is installed: `python3 --version`
|
|
||||||
|
|
||||||
### Issue: "Permission denied"
|
|
||||||
**Solution:** Make script executable: `chmod +x scripts/migrate-analytics-to-umami.py`
|
|
||||||
|
|
||||||
### Issue: API import fails
|
|
||||||
**Solution:** Check API key, website ID, and Umami instance accessibility
|
|
||||||
|
|
||||||
### Issue: SQL import fails
|
|
||||||
**Solution:** Verify database credentials and run migrations first
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
✅ **Completed:**
|
|
||||||
- Created migration script with 3 output formats
|
|
||||||
- Generated JSON import file (2.1 MB, 7,634 events)
|
|
||||||
- Generated SQL import file (1.8 MB, 5,250 statements)
|
|
||||||
- Created comprehensive documentation
|
|
||||||
|
|
||||||
📊 **Data Migrated:**
|
|
||||||
- 220 unique pages
|
|
||||||
- 7,634 simulated page view events
|
|
||||||
- Historical view counts and durations
|
|
||||||
|
|
||||||
🎯 **Ready for Import:**
|
|
||||||
- Choose API or SQL import method
|
|
||||||
- Follow instructions in `scripts/README-migration.md`
|
|
||||||
- Verify data in Umami dashboard
|
|
||||||
|
|
||||||
**Migration Date:** 2026-01-25
|
|
||||||
**Source:** Independent Analytics v2.9.7
|
|
||||||
**Target:** Umami Analytics
|
|
||||||
**Site ID:** klz-cables
|
|
||||||
55
README.md
55
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
|||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
|
||||||
|
- Node.js 18+
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
```bash
|
|
||||||
|
````bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
@@ -42,11 +44,12 @@ npm run cms:logs
|
|||||||
|
|
||||||
# Stop the CMS
|
# Stop the CMS
|
||||||
npm run cms:stop
|
npm run cms:stop
|
||||||
```
|
````
|
||||||
|
|
||||||
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||||
|
|
||||||
### 🔄 Data & Migration
|
### 🔄 Data & Migration
|
||||||
|
|
||||||
To sync data or migrate existing content:
|
To sync data or migrate existing content:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -61,6 +64,7 @@ npm run cms:migrate
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
SITE_URL=https://klz-cables.com
|
SITE_URL=https://klz-cables.com
|
||||||
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
|||||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||||
|
|
||||||
# Umami
|
# Umami
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# GlitchTip (Sentry compatible)
|
# GlitchTip (Sentry compatible)
|
||||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||||
@@ -81,6 +85,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
## 📊 Project Overview
|
## 📊 Project Overview
|
||||||
|
|
||||||
### Migration Statistics
|
### Migration Statistics
|
||||||
|
|
||||||
- **Content Exported**: 141 items
|
- **Content Exported**: 141 items
|
||||||
- 18 pages (9 EN + 9 DE)
|
- 18 pages (9 EN + 9 DE)
|
||||||
- 59 posts (29 EN + 30 DE)
|
- 59 posts (29 EN + 30 DE)
|
||||||
@@ -91,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
- **Translation Pairs**: 16
|
- **Translation Pairs**: 16
|
||||||
|
|
||||||
### Performance Benefits
|
### Performance Benefits
|
||||||
|
|
||||||
- **Before**: Dynamic WordPress with database queries
|
- **Before**: Dynamic WordPress with database queries
|
||||||
- **After**: Static HTML with CDN delivery
|
- **After**: Static HTML with CDN delivery
|
||||||
- **Load Time**: <100ms (vs 500ms+)
|
- **Load Time**: <100ms (vs 500ms+)
|
||||||
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 14 (App Router)
|
- **Framework**: Next.js 14 (App Router)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Styling**: SCSS
|
- **Styling**: SCSS
|
||||||
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
- **CAPTCHA**: Cloudflare Turnstile
|
- **CAPTCHA**: Cloudflare Turnstile
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
@@ -163,6 +171,7 @@ scripts/
|
|||||||
## 🎯 Features
|
## 🎯 Features
|
||||||
|
|
||||||
### ✅ Implemented
|
### ✅ Implemented
|
||||||
|
|
||||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||||
- **Contact Forms**: Resend integration with validation
|
- **Contact Forms**: Resend integration with validation
|
||||||
- **GDPR Compliance**: Cookie consent banner
|
- **GDPR Compliance**: Cookie consent banner
|
||||||
@@ -175,12 +184,14 @@ scripts/
|
|||||||
- **Asset Management**: WordPress → local path mapping
|
- **Asset Management**: WordPress → local path mapping
|
||||||
|
|
||||||
### 🔄 In Progress
|
### 🔄 In Progress
|
||||||
|
|
||||||
- Analytics integration (consent-based)
|
- Analytics integration (consent-based)
|
||||||
- Turnstile CAPTCHA
|
- Turnstile CAPTCHA
|
||||||
- Build testing
|
- Build testing
|
||||||
- Deployment configuration
|
- Deployment configuration
|
||||||
|
|
||||||
### 📝 Remaining
|
### 📝 Remaining
|
||||||
|
|
||||||
- Performance optimization
|
- Performance optimization
|
||||||
- Final QA testing
|
- Final QA testing
|
||||||
- Documentation updates
|
- Documentation updates
|
||||||
@@ -188,6 +199,7 @@ scripts/
|
|||||||
## 📝 Content Management
|
## 📝 Content Management
|
||||||
|
|
||||||
### Data Export
|
### Data Export
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Export from WordPress
|
# Export from WordPress
|
||||||
npm run data:export
|
npm run data:export
|
||||||
@@ -203,6 +215,7 @@ npm run data:improve-mapping
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Content
|
### Adding New Content
|
||||||
|
|
||||||
1. Export new content from WordPress
|
1. Export new content from WordPress
|
||||||
2. Process the data
|
2. Process the data
|
||||||
3. Rebuild the site
|
3. Rebuild the site
|
||||||
@@ -210,17 +223,20 @@ npm run data:improve-mapping
|
|||||||
## 🎨 Design System
|
## 🎨 Design System
|
||||||
|
|
||||||
### Colors
|
### Colors
|
||||||
|
|
||||||
- Primary: `#0066cc` (KLZ Blue)
|
- Primary: `#0066cc` (KLZ Blue)
|
||||||
- Secondary: `#00a896` (Teal)
|
- Secondary: `#00a896` (Teal)
|
||||||
- Text: `#1a1a1a`
|
- Text: `#1a1a1a`
|
||||||
- Background: `#f8f9fa`
|
- Background: `#f8f9fa`
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- Font: Inter
|
- Font: Inter
|
||||||
- Base: 16px
|
- Base: 16px
|
||||||
- Scale: 1.25 (Major Third)
|
- Scale: 1.25 (Major Third)
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
- Max width: 1200px
|
- Max width: 1200px
|
||||||
- Responsive grid
|
- Responsive grid
|
||||||
- Mobile-first
|
- Mobile-first
|
||||||
@@ -228,6 +244,7 @@ npm run data:improve-mapping
|
|||||||
## 🔧 API Endpoints
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
### Contact Form
|
### Contact Form
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/contact
|
POST /api/contact
|
||||||
{
|
{
|
||||||
@@ -239,11 +256,13 @@ POST /api/contact
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sitemap
|
### Sitemap
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /sitemap.xml
|
GET /sitemap.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Robots
|
### Robots
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /robots.txt
|
GET /robots.txt
|
||||||
```
|
```
|
||||||
@@ -261,6 +280,7 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
|||||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
**Branch Deployments**:
|
**Branch Deployments**:
|
||||||
|
|
||||||
- `main` branch: Deploys to production using `.env.prod`
|
- `main` branch: Deploys to production using `.env.prod`
|
||||||
- `staging` branch: Deploys to staging using `.env.staging`
|
- `staging` branch: Deploys to staging using `.env.staging`
|
||||||
|
|
||||||
@@ -268,12 +288,13 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
|||||||
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
||||||
|
|
||||||
**Required Secrets** (configure in Gitea repository settings):
|
**Required Secrets** (configure in Gitea repository settings):
|
||||||
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
- `REGISTRY_USER` - Docker registry username
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
- `REGISTRY_PASS` - Docker registry password
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
- `SENTRY_DSN` - Error tracking DSN
|
||||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||||
|
|
||||||
@@ -293,6 +314,7 @@ docker image prune -f
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or use the convenience script:
|
Or use the convenience script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/deploy-webhook.sh
|
bash scripts/deploy-webhook.sh
|
||||||
```
|
```
|
||||||
@@ -304,11 +326,13 @@ Client → Traefik (TLS) → Next.js App
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Domains**:
|
**Domains**:
|
||||||
|
|
||||||
- `klz-cables.com` - Production
|
- `klz-cables.com` - Production
|
||||||
- `www.klz-cables.com` - Production (www)
|
- `www.klz-cables.com` - Production (www)
|
||||||
- `staging.klz-cables.com` - Staging
|
- `staging.klz-cables.com` - Staging
|
||||||
|
|
||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
- `app`: Next.js application (port 3000)
|
- `app`: Next.js application (port 3000)
|
||||||
- `traefik`: Reverse proxy (external)
|
- `traefik`: Reverse proxy (external)
|
||||||
|
|
||||||
@@ -317,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
## 📈 Performance
|
## 📈 Performance
|
||||||
|
|
||||||
### Build Time
|
### Build Time
|
||||||
|
|
||||||
- **Target**: < 2 minutes
|
- **Target**: < 2 minutes
|
||||||
- **Current**: ~1-2 minutes
|
- **Current**: ~1-2 minutes
|
||||||
|
|
||||||
### Page Load
|
### Page Load
|
||||||
|
|
||||||
- **Target**: < 100ms
|
- **Target**: < 100ms
|
||||||
- **Current**: Static HTML from CDN
|
- **Current**: Static HTML from CDN
|
||||||
|
|
||||||
### Bundle Size
|
### Bundle Size
|
||||||
|
|
||||||
- **Target**: < 100KB gzipped
|
- **Target**: < 100KB gzipped
|
||||||
- **Current**: Optimized with code splitting
|
- **Current**: Optimized with code splitting
|
||||||
|
|
||||||
## 🔒 Security
|
## 🔒 Security
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- Never commit `.env` file
|
- Never commit `.env` file
|
||||||
- Rotate keys regularly
|
- Rotate keys regularly
|
||||||
- Use secrets in deployment platform
|
- Use secrets in deployment platform
|
||||||
|
|
||||||
### Form Security
|
### Form Security
|
||||||
|
|
||||||
- Email validation
|
- Email validation
|
||||||
- Rate limiting (recommended)
|
- Rate limiting (recommended)
|
||||||
- Turnstile CAPTCHA (pending)
|
- Turnstile CAPTCHA (pending)
|
||||||
@@ -343,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
## 🎓 WordPress Specifics
|
## 🎓 WordPress Specifics
|
||||||
|
|
||||||
### WPBakery Shortcodes Removed
|
### WPBakery Shortcodes Removed
|
||||||
|
|
||||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||||
- `[nectar_*]` (Salient theme)
|
- `[nectar_*]` (Salient theme)
|
||||||
- `[image_with_animation]`
|
- `[image_with_animation]`
|
||||||
@@ -350,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
- `[divider]`
|
- `[divider]`
|
||||||
|
|
||||||
### HTML Sanitization
|
### HTML Sanitization
|
||||||
|
|
||||||
- Removes inline event handlers
|
- Removes inline event handlers
|
||||||
- Strips scripts
|
- Strips scripts
|
||||||
- Normalizes classes
|
- Normalizes classes
|
||||||
- Preserves structure
|
- Preserves structure
|
||||||
|
|
||||||
### Asset Mapping
|
### Asset Mapping
|
||||||
|
|
||||||
WordPress URLs → Local paths:
|
WordPress URLs → Local paths:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||||
```
|
```
|
||||||
@@ -364,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||||
- `FINAL_SUMMARY.md` - Complete overview
|
- `FINAL_SUMMARY.md` - Complete overview
|
||||||
|
|
||||||
### External
|
### External
|
||||||
|
|
||||||
- [Next.js Docs](https://nextjs.org/docs)
|
- [Next.js Docs](https://nextjs.org/docs)
|
||||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||||
- [Resend Docs](https://resend.com/docs)
|
- [Resend Docs](https://resend.com/docs)
|
||||||
@@ -379,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
**TypeScript Errors**
|
**TypeScript Errors**
|
||||||
|
|
||||||
- The TypeScript errors shown in the editor are expected
|
- The TypeScript errors shown in the editor are expected
|
||||||
- They occur because modules reference each other
|
- They occur because modules reference each other
|
||||||
- The build process resolves these correctly
|
- The build process resolves these correctly
|
||||||
- Run `npm run build` to verify
|
- Run `npm run build` to verify
|
||||||
|
|
||||||
**Build Failures**
|
**Build Failures**
|
||||||
|
|
||||||
- Check environment variables
|
- Check environment variables
|
||||||
- Verify data files exist
|
- Verify data files exist
|
||||||
- Clear `.next` cache: `rm -rf .next`
|
- Clear `.next` cache: `rm -rf .next`
|
||||||
|
|
||||||
**Missing Modules**
|
**Missing Modules**
|
||||||
|
|
||||||
- Run `npm install --legacy-peer-deps`
|
- Run `npm install --legacy-peer-deps`
|
||||||
- Check `package.json` dependencies
|
- Check `package.json` dependencies
|
||||||
|
|
||||||
@@ -404,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
✅ **i18n**: Multi-language support
|
✅ **i18n**: Multi-language support
|
||||||
✅ **SEO**: Metadata and sitemaps
|
✅ **SEO**: Metadata and sitemaps
|
||||||
✅ **Compatibility**: WPBakery content handled
|
✅ **Compatibility**: WPBakery content handled
|
||||||
✅ **Media**: All images downloaded
|
✅ **Media**: All images downloaded
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
For issues or questions:
|
For issues or questions:
|
||||||
|
|
||||||
1. Check the documentation
|
1. Check the documentation
|
||||||
2. Review the troubleshooting section
|
2. Review the troubleshooting section
|
||||||
3. Check environment variables
|
3. Check environment variables
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import { getPageBySlug } from '@/lib/pages';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
export default async function Image({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
@@ -15,17 +22,14 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
|||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={pageData.frontmatter.title}
|
||||||
title={pageData.frontmatter.title}
|
description={pageData.frontmatter.excerpt}
|
||||||
description={pageData.frontmatter.excerpt}
|
label="Information"
|
||||||
label="Information"
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@@ -29,7 +29,8 @@ export async function generateStaticParams() {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
@@ -38,18 +39,17 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
|||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/${slug}`,
|
de: `${SITE_URL}/de/${slug}`,
|
||||||
en: `/en/${slug}`,
|
en: `${SITE_URL}/en/${slug}`,
|
||||||
'x-default': `/en/${slug}`,
|
'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}/${slug}`,
|
url: `${SITE_URL}/${locale}/${slug}`,
|
||||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -59,7 +59,9 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
export default async function StandardPage({ params }: PageProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||||
{t('badge')}
|
{t('badge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -91,7 +93,7 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{pageData.frontmatter.excerpt && (
|
{pageData.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom">
|
<div className="mb-16">
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
{pageData.frontmatter.excerpt}
|
{pageData.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
@@ -99,7 +101,7 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with shared blog components */}
|
{/* Main content with shared blog components */}
|
||||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom">
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||||
<MDXRemote source={pageData.content} components={mdxComponents} />
|
<MDXRemote source={pageData.content} components={mdxComponents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,15 +111,19 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||||
<a
|
<TrackedLink
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/contact`}
|
||||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
|
eventProperties={{
|
||||||
|
location: 'generic_page_support_cta',
|
||||||
|
page_slug: slug,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('contactUs')}
|
{t('contactUs')}
|
||||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</TrackedLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { locale: string } }
|
{ params }: { params: Promise<{ locale: string }> },
|
||||||
) {
|
) {
|
||||||
const { searchParams, origin } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get('slug');
|
const slug = searchParams.get('slug');
|
||||||
const locale = params.locale || 'en';
|
const { locale } = await params;
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return new Response('Missing slug', { status: 400 });
|
return new Response('Missing slug', { status: 400 });
|
||||||
@@ -23,24 +25,29 @@ export async function GET(
|
|||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
|
||||||
// Check if it's a category page
|
// Check if it's a category page
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
if (categories.includes(slug)) {
|
if (categories.includes(slug)) {
|
||||||
const categoryKey = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = slug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : slug;
|
.replace(/-cables$/, '')
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: slug;
|
||||||
|
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||||
|
? t(`categories.${categoryKey}.description`)
|
||||||
|
: '';
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={categoryTitle}
|
|
||||||
description={categoryDesc}
|
|
||||||
label="Product Category"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,24 +58,21 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const featuredImage = product.frontmatter.images?.[0]
|
const featuredImage = product.frontmatter.images?.[0]
|
||||||
? (product.frontmatter.images[0].startsWith('http')
|
? product.frontmatter.images[0].startsWith('http')
|
||||||
? product.frontmatter.images[0]
|
? product.frontmatter.images[0]
|
||||||
: `${origin}${product.frontmatter.images[0]}`)
|
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={product.frontmatter.title}
|
||||||
title={product.frontmatter.title}
|
description={product.frontmatter.description}
|
||||||
description={product.frontmatter.description}
|
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
image={featuredImage}
|
||||||
image={featuredImage}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
|||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({
|
export default async function Image({
|
||||||
params: { locale, slug },
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string; slug: string };
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } 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 { mdxComponents } from '@/components/blog/MDXComponents';
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||||
params: { locale, slug },
|
const { locale, slug } = await params;
|
||||||
}: BlogPostProps): Promise<Metadata> {
|
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
@@ -32,12 +32,7 @@ export async function generateMetadata({
|
|||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
languages: {
|
|
||||||
de: `/de/blog/${slug}`,
|
|
||||||
en: `/en/blog/${slug}`,
|
|
||||||
'x-default': `/en/blog/${slug}`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
@@ -46,7 +41,6 @@ export async function generateMetadata({
|
|||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -56,9 +50,11 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
export default async function BlogPost({ params }: BlogPostProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -68,13 +64,26 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||||
|
<BlogEngagementTracker
|
||||||
|
title={post.frontmatter.title}
|
||||||
|
slug={slug}
|
||||||
|
category={post.frontmatter.category}
|
||||||
|
readingTime={getReadingTime(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
|
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
<Image
|
||||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
src={`${post.frontmatter.featuredImage}?gravity=obj:face`}
|
||||||
/>
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
@@ -83,18 +92,15 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<div className="overflow-hidden mb-6">
|
<div className="overflow-hidden mb-6">
|
||||||
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
|
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Heading
|
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
|
||||||
level={1}
|
|
||||||
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
|
|
||||||
>
|
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date}>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -104,6 +110,12 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{getReadingTime(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>
|
||||||
@@ -132,6 +144,12 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{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>
|
||||||
@@ -144,7 +162,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
<div className="sticky-narrative-content">
|
<div className="sticky-narrative-content">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{post.frontmatter.excerpt && (
|
{post.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
|
<div className="mb-16">
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
@@ -152,7 +170,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with enhanced styling */}
|
{/* Main content with enhanced styling */}
|
||||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||||
<MDXRemote source={post.content} components={mdxComponents} />
|
<MDXRemote source={post.content} components={mdxComponents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,7 +181,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
|
|
||||||
{/* Post Navigation */}
|
{/* Post Navigation */}
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<PostNavigation prev={prev} next={next} locale={locale} />
|
<PostNavigation prev={prev} next={next} isPrevRandom={isPrevRandom} isNextRandom={isNextRandom} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to blog link */}
|
{/* Back to blog link */}
|
||||||
|
|||||||
@@ -3,23 +3,20 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={t('title')}
|
|
||||||
description={t('description')}
|
|
||||||
label="Blog"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
return {
|
return {
|
||||||
title: t('title'),
|
title: t('title'),
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog`,
|
canonical: `${SITE_URL}/${locale}/blog`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/blog',
|
de: `${SITE_URL}/de/blog`,
|
||||||
en: '/en/blog',
|
en: `${SITE_URL}/en/blog`,
|
||||||
'x-default': '/en/blog',
|
'x-default': `${SITE_URL}/en/blog`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${t('title')} | KLZ Cables`,
|
title: `${t('title')} | KLZ Cables`,
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
url: `${SITE_URL}/${locale}/blog`,
|
url: `${SITE_URL}/${locale}/blog`,
|
||||||
images: getOGImageMetadata('blog', t('title'), locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -39,7 +40,9 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
|
|
||||||
@@ -55,23 +58,33 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
<div className="bg-neutral-light min-h-screen">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
{/* Hero Section - Immersive Magazine Feel */}
|
{/* Hero Section - Immersive Magazine Feel */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<>
|
<>
|
||||||
<img
|
<Image
|
||||||
src={featuredPost.frontmatter.featuredImage}
|
src={`${featuredPost.frontmatter.featuredImage}?gravity=obj:face`}
|
||||||
alt={featuredPost.frontmatter.title}
|
alt={featuredPost.frontmatter.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
fill
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 image-overlay-gradient" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-6">
|
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||||
{t('featuredPost')}
|
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||||
</Badge>
|
{featuredPost &&
|
||||||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
|
featuredPost.frontmatter.public === false) && (
|
||||||
|
<Badge variant="neutral" className="border border-white/30 bg-transparent text-white/80 shadow-none">
|
||||||
|
Draft Preview
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
@@ -95,7 +108,7 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</article>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Section className="bg-neutral-light py-12 md:py-28">
|
<Section className="bg-neutral-light py-12 md:py-28">
|
||||||
@@ -140,13 +153,18 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{remainingPosts.map((post, idx) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 100}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
<Card
|
||||||
|
tag="article"
|
||||||
|
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
||||||
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src={post.frontmatter.featuredImage}
|
src={`${post.frontmatter.featuredImage}?gravity=obj:face`}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
@@ -157,15 +175,22 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
<div className="p-5 md:p-10 flex flex-col flex-1">
|
||||||
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
<div className="flex items-center gap-3 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, {
|
<span>
|
||||||
year: 'numeric',
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
month: 'long',
|
year: 'numeric',
|
||||||
day: 'numeric',
|
month: 'long',
|
||||||
})}
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">Draft</span>
|
||||||
|
)}
|
||||||
</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-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">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
@@ -13,16 +16,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Contact"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,20 @@ import JsonLd from '@/components/JsonLd';
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { Container, Heading, Section } from '@/components/ui';
|
import { Container, Heading, Section } from '@/components/ui';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import ContactMap from '@/components/ContactMap';
|
||||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
|
||||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||||
params: { locale },
|
const { locale } = await params;
|
||||||
}: ContactPageProps): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
@@ -35,8 +26,9 @@ export async function generateMetadata({
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/contact`,
|
canonical: `${SITE_URL}/${locale}/contact`,
|
||||||
languages: {
|
languages: {
|
||||||
'de-DE': '/de/contact',
|
de: `${SITE_URL}/de/contact`,
|
||||||
'en-US': '/en/contact',
|
en: `${SITE_URL}/en/contact`,
|
||||||
|
'x-default': `${SITE_URL}/en/contact`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -44,7 +36,6 @@ export async function generateMetadata({
|
|||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/contact`,
|
url: `${SITE_URL}/${locale}/contact`,
|
||||||
siteName: 'KLZ Cables',
|
siteName: 'KLZ Cables',
|
||||||
images: getOGImageMetadata('contact', title, locale),
|
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
@@ -52,7 +43,6 @@ export async function generateMetadata({
|
|||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -66,7 +56,8 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage({ params }: ContactPageProps) {
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
const { locale } = params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
@@ -145,7 +136,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||||
{t('info.howToReachUs')}
|
{t('info.howToReachUs')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="space-y-4 md:space-y-8">
|
<address className="space-y-4 md:space-y-8 not-italic">
|
||||||
<div className="flex items-start gap-4 md:gap-6 group">
|
<div className="flex items-start gap-4 md:gap-6 group">
|
||||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
@@ -206,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||||
@@ -249,7 +240,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LeafletMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,165 @@
|
|||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import SkipLink from '@/components/SkipLink';
|
||||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
|
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||||
|
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||||
|
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||||
|
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { 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 { config } from '@/lib/config';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const inter = Inter({
|
||||||
metadataBase: new URL(SITE_URL),
|
subsets: ['latin'],
|
||||||
icons: {
|
display: 'swap',
|
||||||
icon: [
|
variable: '--font-inter',
|
||||||
{ url: '/favicon.ico', sizes: 'any' },
|
});
|
||||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
|
||||||
],
|
export async function generateMetadata(props: {
|
||||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
params: Promise<{ locale: string }>;
|
||||||
},
|
}): Promise<Metadata> {
|
||||||
};
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
|
||||||
|
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
|
||||||
|
return {
|
||||||
|
metadataBase: new URL(baseUrl),
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}/${locale}`,
|
||||||
|
languages: {
|
||||||
|
de: `${baseUrl}/de`,
|
||||||
|
en: `${baseUrl}/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
|
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 5,
|
||||||
userScalable: false,
|
userScalable: true,
|
||||||
viewportFit: 'cover',
|
viewportFit: 'cover',
|
||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function Layout(props: {
|
||||||
children,
|
|
||||||
params: { locale },
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Providing all messages to the client
|
const params = await props.params;
|
||||||
// side is the easiest way to get started
|
const { locale } = params;
|
||||||
const messages = await getMessages();
|
const { children } = props;
|
||||||
|
const supportedLocales = ['en', 'de'];
|
||||||
|
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||||
|
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||||
|
|
||||||
|
setRequestLocale(safeLocale);
|
||||||
|
|
||||||
|
let messages: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
messages = await getMessages();
|
||||||
|
} catch (error) {
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
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 serverServices = getServerAppServices();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
// Skip setting server context for analytics in CI
|
||||||
|
} else if ('setServerContext' in serverServices.analytics) {
|
||||||
|
(serverServices.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||||
|
// Client-side AnalyticsProvider handles all pageviews.
|
||||||
|
} catch {
|
||||||
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
|
console.warn(
|
||||||
|
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||||
|
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
|
||||||
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
||||||
|
</head>
|
||||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
<JsonLd />
|
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||||
<Header />
|
<RecordModeVisuals>
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
<SkipLink />
|
||||||
<Footer />
|
<JsonLd />
|
||||||
<CMSConnectivityNotice />
|
<Header />
|
||||||
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="flex-grow animate-fade-in overflow-visible"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</RecordModeVisuals>
|
||||||
|
|
||||||
{/* Sends pageviews for client-side navigations */}
|
<CMSConnectivityNotice />
|
||||||
<AnalyticsProvider />
|
|
||||||
|
<AnalyticsShell />
|
||||||
|
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||||
|
</RecordModeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
'use client';
|
||||||
import { useTranslations } from 'next-intl';
|
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 { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = useTranslations('Error.notFound');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
|
||||||
|
});
|
||||||
|
}, [trackEvent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
@@ -16,19 +28,17 @@ export default function NotFound() {
|
|||||||
<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>
|
</Heading>
|
||||||
<Scribble
|
<Scribble
|
||||||
variant="circle"
|
variant="circle"
|
||||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant="accent" size="lg">
|
||||||
|
|||||||
@@ -3,24 +3,25 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
console.log('🖼️ OG Image Handler Called');
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={t('title')}
|
||||||
title={t('title')}
|
description={t('description')}
|
||||||
description={t('description')}
|
label="Reliable Energy Infrastructure"
|
||||||
label="Reliable Energy Infrastructure"
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import Hero from '@/components/home/Hero';
|
import Hero from '@/components/home/Hero';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import dynamic from 'next/dynamic';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
|
||||||
import RecentPosts from '@/components/home/RecentPosts';
|
|
||||||
import Experience from '@/components/home/Experience';
|
|
||||||
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
|
||||||
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|
||||||
import GallerySection from '@/components/home/GallerySection';
|
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
|
||||||
import CTA from '@/components/home/CTA';
|
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
|
||||||
|
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
|
||||||
|
|
||||||
|
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||||
|
const Experience = dynamic(() => import('@/components/home/Experience'));
|
||||||
|
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
||||||
|
const MeetTheTeam = dynamic(() => import('@/components/home/MeetTheTeam'));
|
||||||
|
const GallerySection = dynamic(() => import('@/components/home/GallerySection'));
|
||||||
|
const VideoSection = dynamic(() => import('@/components/home/VideoSection'));
|
||||||
|
const CTA = dynamic(() => import('@/components/home/CTA'));
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -47,7 +52,7 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<VideoSection />
|
<VideoSection />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal>
|
<Reveal className="content-visibility-auto">
|
||||||
<CTA />
|
<CTA />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,36 +60,39 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
// Use translations for meta where available (namespace: Index.meta)
|
// Use translations for meta where available (namespace: Index.meta)
|
||||||
// Fallback to a sensible default if translation keys are missing.
|
// Fallback to a sensible default if translation keys are missing.
|
||||||
let t;
|
let t;
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If translations for Index.meta are not present, try generic Index namespace
|
// If translations for Index.meta are not present, try generic Index namespace
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index' });
|
t = await getTranslations({ locale, namespace: 'Index' });
|
||||||
} catch (e) {
|
} catch {
|
||||||
t = (key: string) => '';
|
t = () => '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = t('title') || 'KLZ Cables';
|
const title = t('title') || 'KLZ Cables';
|
||||||
const description = t('description') || '';
|
const description =
|
||||||
|
t('description') ||
|
||||||
|
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}`,
|
canonical: `${SITE_URL}/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de',
|
de: `${SITE_URL}/de`,
|
||||||
en: '/en',
|
en: `${SITE_URL}/en`,
|
||||||
'x-default': '/en',
|
'x-default': `${SITE_URL}/en`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import ProductSidebar from '@/components/ProductSidebar';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
import ProductTabs from '@/components/ProductTabs';
|
import ProductTabs from '@/components/ProductTabs';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
import RelatedProducts from '@/components/RelatedProducts';
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getDatasheetPath } from '@/lib/datasheets';
|
import { getDatasheetPath } from '@/lib/datasheets';
|
||||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string[];
|
slug: string[];
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -81,11 +81,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -169,7 +169,8 @@ const components = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
@@ -212,8 +213,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
<Link
|
||||||
{t('title')}
|
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
@@ -235,56 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
href={`/${locale}/products/${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"
|
||||||
>
|
>
|
||||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
<Card tag="article" className="premium-card-reset">
|
||||||
{product.frontmatter.images?.[0] && (
|
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||||
<>
|
{product.frontmatter.images?.[0] && (
|
||||||
<Image
|
<>
|
||||||
src={product.frontmatter.images[0]}
|
<Image
|
||||||
alt={product.frontmatter.title}
|
src={product.frontmatter.images[0]}
|
||||||
fill
|
alt={product.frontmatter.title}
|
||||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
fill
|
||||||
/>
|
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||||
{/* Subtle reflection/shadow effect */}
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
/>
|
||||||
</>
|
{/* 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>
|
</>
|
||||||
<div className="p-8 md:p-10">
|
)}
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
</div>
|
||||||
{product.frontmatter.categories.map((cat, i) => (
|
<div className="p-8 md:p-10">
|
||||||
<span
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
key={i}
|
{product.frontmatter.categories.map((cat, i) => (
|
||||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
<span
|
||||||
>
|
key={i}
|
||||||
{cat}
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||||
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||||
|
{t('details')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
<svg
|
||||||
|
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
</Card>
|
||||||
{product.frontmatter.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
|
||||||
{product.frontmatter.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
|
||||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
|
||||||
{t('details')}
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,6 +359,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
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 */}
|
||||||
|
<ProductEngagementTracker
|
||||||
|
productName={product.frontmatter.title}
|
||||||
|
productSlug={productSlug}
|
||||||
|
categories={product.frontmatter.categories}
|
||||||
|
sku={product.frontmatter.sku}
|
||||||
|
/>
|
||||||
<section className="relative pt-40 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" />
|
||||||
@@ -360,8 +373,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-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 href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
<Link
|
||||||
{t('title')}
|
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -3,27 +3,23 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Products"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
params: { locale },
|
const { locale } = await params;
|
||||||
}: ProductsPageProps): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/products',
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||||
en: '/en/products',
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
'x-default': '/en/products',
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/products`,
|
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
images: getOGImageMetadata('products', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -47,13 +44,17 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Get translated category slugs
|
// Get translated category slugs
|
||||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
|
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
|
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
|
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
@@ -61,28 +62,28 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${lowVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${mediumVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${highVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${params.locale}/products/${solarSlug}`,
|
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -134,7 +135,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Reveal key={idx} delay={idx * 100}>
|
<Reveal key={idx} delay={idx * 100}>
|
||||||
<Link key={idx} href={category.href} className="group block">
|
<TrackedLink
|
||||||
|
key={idx}
|
||||||
|
href={category.href}
|
||||||
|
className="group block"
|
||||||
|
eventProperties={{
|
||||||
|
category_title: category.title,
|
||||||
|
location: 'products_index',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
||||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
@@ -142,8 +151,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
||||||
|
|
||||||
@@ -195,7 +203,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</TrackedLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +226,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
href={`/${params.locale}/contact`}
|
href={`/${locale}/contact`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
@@ -13,17 +16,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Our Team"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||||
|
|
||||||
interface TeamPageProps {
|
interface TeamPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const title = t('meta.title') || t('hero.subtitle');
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
@@ -22,18 +23,17 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/team`,
|
canonical: `${SITE_URL}/${locale}/team`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/team',
|
de: `${SITE_URL}/de/team`,
|
||||||
en: '/en/team',
|
en: `${SITE_URL}/en/team`,
|
||||||
'x-default': '/en/team',
|
'x-default': `${SITE_URL}/en/team`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/team`,
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
images: getOGImageMetadata('team', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -43,7 +43,9 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
export default async function TeamPage({ params }: TeamPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,6 +93,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
alt="KLZ Team"
|
alt="KLZ Team"
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||||
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||||
@@ -111,7 +114,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||||
<section className="relative bg-white overflow-hidden">
|
<article className="relative bg-white overflow-hidden">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||||
@@ -131,15 +134,20 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||||
{t('michael.description')}
|
{t('michael.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Michael Bodemer',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('michael.linkedin')}
|
{t('michael.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||||
@@ -153,7 +161,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Legacy Section - Immersive Background */}
|
{/* Legacy Section - Immersive Background */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -209,7 +217,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||||
<section className="relative bg-white overflow-hidden">
|
<article className="relative bg-white overflow-hidden">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||||
<Image
|
<Image
|
||||||
@@ -239,19 +247,24 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||||
{t('klaus.description')}
|
{t('klaus.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Klaus Mintel',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('klaus.linkedin')}
|
{t('klaus.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Manifesto Section - Modern Grid */}
|
{/* Manifesto Section - Modern Grid */}
|
||||||
<Section className="bg-white text-primary py-16 md:py-28">
|
<Section className="bg-white text-primary py-16 md:py-28">
|
||||||
@@ -279,9 +292,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
||||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
<div
|
<li
|
||||||
key={idx}
|
key={idx}
|
||||||
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||||
>
|
>
|
||||||
@@ -296,9 +309,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t(`manifesto.items.${idx}.description`)}
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ import { getServerAppServices } from '@/lib/services/create-services.server';
|
|||||||
export async function sendContactFormAction(formData: FormData) {
|
export async function sendContactFormAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||||
|
|
||||||
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track attempt
|
||||||
|
services.analytics.track('contact-form-attempt');
|
||||||
|
|
||||||
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;
|
||||||
@@ -110,6 +127,11 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
priority: 5,
|
priority: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track('contact-form-success', {
|
||||||
|
is_product_request: !!productName,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Only allow in development
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Ensure we are in the project root by using process.cwd()
|
||||||
|
// Path: <project-root>/remotion/session.json
|
||||||
|
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||||
|
const filePath = path.join(remotionDir, 'session.json');
|
||||||
|
|
||||||
|
// Create remotion directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(remotionDir)) {
|
||||||
|
fs.mkdirSync(remotionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the JSON file
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, path: filePath });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save session:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||||
|
}
|
||||||
73
app/errors/api/relay/route.ts
Normal file
73
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||||
|
*
|
||||||
|
* This Route Handler receives Sentry envelopes from the client,
|
||||||
|
* injects the correct DSN if needed, and forwards them to the
|
||||||
|
* internal GlitchTip/Sentry instance.
|
||||||
|
*
|
||||||
|
* This hides the real DSN from the client and bypasses ad-blockers
|
||||||
|
* that target Sentry's default ingest endpoints.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envelope = await request.text();
|
||||||
|
|
||||||
|
// Sentry envelopes can contain multiple parts separated by newlines
|
||||||
|
const lines = envelope.split('\n');
|
||||||
|
if (lines.length < 1) {
|
||||||
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON.parse(lines[0]);
|
||||||
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
|
if (!realDsn) {
|
||||||
|
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dsnUrl = new URL(realDsn);
|
||||||
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||||
|
|
||||||
|
logger.debug('Relaying Sentry envelope', {
|
||||||
|
projectId,
|
||||||
|
host: dsnUrl.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(relayUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: envelope,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-sentry-envelope',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (!process.env.CI) {
|
||||||
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
if (!process.env.CI) {
|
||||||
|
logger.error('Failed to relay Sentry request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import { getAllPagesMetadata } from '@/lib/pages';
|
|||||||
export const revalidate = 3600; // Revalidate every hour
|
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 = process.env.CI
|
||||||
|
? 'http://klz.localhost'
|
||||||
|
: config.baseUrl || 'https://klz-cables.com';
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
|||||||
96
app/stats/api/send/route.ts
Normal file
96
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy for Umami Analytics.
|
||||||
|
*
|
||||||
|
* This Route Handler receives tracking events from the browser,
|
||||||
|
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||||
|
* internal Umami API endpoint.
|
||||||
|
*
|
||||||
|
* This ensures:
|
||||||
|
* 1. The Website ID is NOT leaked to the client bundle.
|
||||||
|
* 2. The Umami API endpoint is hidden behind our domain.
|
||||||
|
* 3. We have full control over the tracking data.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
// Inject the secret websiteId from server config
|
||||||
|
const websiteId = config.analytics.umami.websiteId;
|
||||||
|
if (!websiteId) {
|
||||||
|
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the enhanced payload with the secret ID
|
||||||
|
const enhancedPayload = {
|
||||||
|
...payload,
|
||||||
|
website: websiteId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||||
|
|
||||||
|
// Log the event (internal only)
|
||||||
|
logger.debug('Forwarding analytics event', {
|
||||||
|
type,
|
||||||
|
url: payload.url,
|
||||||
|
website: websiteId.slice(0, 8) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||||
|
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (!process.env.CI) {
|
||||||
|
logger.error('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|
||||||
|
// Console error to ensure it appears in logs even if logger fails
|
||||||
|
if (!process.env.CI) {
|
||||||
|
console.error('CRITICAL PROXY ERROR:', {
|
||||||
|
message: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
endpoint: config.analytics.umami.apiEndpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error('Failed to proxy analytics request', {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
details: errorMessage, // Expose error for debugging
|
||||||
|
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['@commitlint/config-conventional'],
|
extends: ['@commitlint/config-conventional'],
|
||||||
rules: {
|
rules: {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { config } from '../lib/config';
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
export default function CMSConnectivityNotice() {
|
export default function CMSConnectivityNotice() {
|
||||||
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export default function CMSConnectivityNotice() {
|
|||||||
setStatus('ok');
|
setStatus('ok');
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If it's a connection error, only show if we are really debugging
|
// If it's a connection error, only show if we are really debugging
|
||||||
if (isDebug || isLocal) {
|
if (isDebug || isLocal) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const t = useTranslations('Contact');
|
const t = useTranslations('Contact');
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
form_name: 'Contact',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -29,17 +48,29 @@ export default function ContactForm() {
|
|||||||
(e.target as HTMLFormElement).reset();
|
(e.target as HTMLFormElement).reset();
|
||||||
} else {
|
} else {
|
||||||
console.error('Contact form submission failed:', { email, error: result.error });
|
console.error('Contact form submission failed:', { email, error: result.error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Contact form submission error:', { email, error });
|
console.error('Contact form submission error:', { email, error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
<Card
|
||||||
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-10 h-10 text-primary-dark"
|
className="w-10 h-10 text-primary-dark"
|
||||||
@@ -66,7 +97,11 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
<Card
|
||||||
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-10 h-10 text-destructive-foreground"
|
className="w-10 h-10 text-destructive-foreground"
|
||||||
@@ -105,38 +140,43 @@ export default function ContactForm() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="contact-name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.namePlaceholder')}
|
onFocus={() => handleFocus('contact-name')}
|
||||||
|
aria-label={t('form.name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="email">{t('form.email')}</Label>
|
<Label htmlFor="contact-email">{t('form.email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="contact-email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.emailPlaceholder')}
|
placeholder={t('form.emailPlaceholder')}
|
||||||
|
onFocus={() => handleFocus('contact-email')}
|
||||||
|
aria-label={t('form.email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="message">{t('form.message')}</Label>
|
<Label htmlFor="contact-message">{t('form.message')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="contact-message"
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
|
onFocus={() => handleFocus('contact-message')}
|
||||||
|
aria-label={t('form.message')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||||
|
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ContactMapProps {
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||||
|
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
interface DatasheetDownloadProps {
|
interface DatasheetDownloadProps {
|
||||||
datasheetPath: string;
|
datasheetPath: string;
|
||||||
@@ -10,34 +12,43 @@ interface DatasheetDownloadProps {
|
|||||||
|
|
||||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
||||||
<a
|
<a
|
||||||
href={datasheetPath}
|
href={datasheetPath}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||||
|
file_name: datasheetPath.split('/').pop(),
|
||||||
|
file_path: datasheetPath,
|
||||||
|
location: 'product_page',
|
||||||
|
})
|
||||||
|
}
|
||||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
{/* Animated Background Gradient */}
|
{/* Animated Background Gradient */}
|
||||||
<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-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 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-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="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-8 w-8 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"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
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"
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +56,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||||
|
PDF Datasheet
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||||
{t('downloadDatasheet')}
|
{t('downloadDatasheet')}
|
||||||
@@ -57,8 +70,19 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,40 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
const navT = useTranslations('Navigation');
|
const navT = useTranslations('Navigation');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden">
|
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="lg:col-span-4 space-y-8">
|
||||||
<Link href={`/${locale}`} className="inline-block group">
|
<Link
|
||||||
<Image
|
href={`/${locale}`}
|
||||||
src="/logo-white.svg"
|
className="inline-block group"
|
||||||
alt={t('products')}
|
onClick={() =>
|
||||||
width={150}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
height={40}
|
target: 'home_logo',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/logo-white.svg"
|
||||||
|
alt="KLZ Vertriebs GmbH"
|
||||||
|
width={150}
|
||||||
|
height={40}
|
||||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
||||||
{t('tagline')}
|
{t('tagline')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<a href="https://www.linkedin.com/company/klz-vertriebs-gmbh/" target="_blank" rel="noopener noreferrer" className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10">
|
<a
|
||||||
|
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
type: 'social',
|
||||||
|
target: 'linkedin',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10"
|
||||||
|
>
|
||||||
<span className="sr-only">LinkedIn</span>
|
<span className="sr-only">LinkedIn</span>
|
||||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,52 +67,172 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Links Columns */}
|
{/* Links Columns */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('legal')}</h4>
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
|
{t('legal')}
|
||||||
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li><Link href={`/${locale}/${t('legalNoticeSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('legalNotice')}</Link></li>
|
<li>
|
||||||
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('privacyPolicy')}</Link></li>
|
<Link
|
||||||
<li><Link href={`/${locale}/${t('termsSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('terms')}</Link></li>
|
href={`/${locale}/${t('legalNoticeSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('legalNotice'),
|
||||||
|
href: t('legalNoticeSlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('legalNotice')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${t('privacyPolicySlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('privacyPolicy'),
|
||||||
|
href: t('privacyPolicySlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('privacyPolicy')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${t('termsSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: t('terms'),
|
||||||
|
href: t('termsSlug'),
|
||||||
|
location: 'footer_legal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('terms')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('company')}</h4>
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
|
{t('company')}
|
||||||
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li><Link href={`/${locale}/team`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('team')}</Link></li>
|
<li>
|
||||||
<li><Link href={`/${locale}/products`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('products')}</Link></li>
|
<Link
|
||||||
<li><Link href={`/${locale}/blog`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('blog')}</Link></li>
|
href={`/${locale}/team`}
|
||||||
<li><Link href={`/${locale}/contact`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('contact')}</Link></li>
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('team'),
|
||||||
|
href: '/team',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('team')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('products'),
|
||||||
|
href: locale === 'de' ? '/produkte' : '/products',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('products')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('blog'),
|
||||||
|
href: '/blog',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('blog')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: navT('contact'),
|
||||||
|
href: '/contact',
|
||||||
|
location: 'footer_company',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navT('contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column */}
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('recentPosts')}</h4>
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
|
{t('recentPosts')}
|
||||||
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: locale === 'de'
|
title:
|
||||||
? "Windparkbau im Fokus: drei typische Kabelherausforderungen"
|
locale === 'de'
|
||||||
: "Focus on wind farm construction: three typical cable challenges",
|
? 'Windparkbau im Fokus: drei typische Kabelherausforderungen'
|
||||||
slug: locale === 'de'
|
: 'Focus on wind farm construction: three typical cable challenges',
|
||||||
? "windparkbau-im-fokus-drei-typische-kabelherausforderungen"
|
slug:
|
||||||
: "focus-on-wind-farm-construction-three-typical-cable-challenges"
|
locale === 'de'
|
||||||
|
? 'windparkbau-im-fokus-drei-typische-kabelherausforderungen'
|
||||||
|
: 'focus-on-wind-farm-construction-three-typical-cable-challenges',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: locale === 'de'
|
title:
|
||||||
? "Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist"
|
locale === 'de'
|
||||||
: "Why the N2XS(F)2Y is the ideal cable for your energy project",
|
? 'Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist'
|
||||||
slug: locale === 'de'
|
: 'Why the N2XS(F)2Y is the ideal cable for your energy project',
|
||||||
? "n2xsf2y-mittelspannungskabel-energieprojekt"
|
slug:
|
||||||
: "why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project"
|
locale === 'de'
|
||||||
}
|
? 'n2xsf2y-mittelspannungskabel-energieprojekt'
|
||||||
|
: 'why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||||
|
},
|
||||||
].map((post, i) => (
|
].map((post, i) => (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block text-white/80"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
location: 'footer_recent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||||
{post.title}
|
{post.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-white/40 uppercase tracking-widest">{t('readArticle')} →</span>
|
<span className="text-xs text-white/70 uppercase tracking-widest">
|
||||||
|
{t('readArticle')} →
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -95,11 +240,39 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
<div className="pt-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">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link href="/en" locale="en" className="hover:text-white transition-colors">English</Link>
|
<Link
|
||||||
<Link href="/de" locale="de" className="hover:text-white transition-colors">Deutsch</Link>
|
href="/en"
|
||||||
|
locale="en"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: locale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/de"
|
||||||
|
locale="de"
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: locale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -2,22 +2,25 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
const currentLocale = pathname.split('/')[1] || 'en';
|
const currentLocale = pathname.split('/')[1] || 'en';
|
||||||
|
|
||||||
// Check if homepage
|
// Check if homepage
|
||||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||||
|
|
||||||
@@ -30,20 +33,57 @@ export default function Header() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Prevent scroll when mobile menu is open and handle focus trap
|
||||||
useEffect(() => {
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
// Prevent scroll when mobile menu is open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileMenuOpen) {
|
if (isMobileMenuOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
// Focus trap logic
|
||||||
|
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (focusableElements && focusableElements.length > 0) {
|
||||||
|
const firstElement = focusableElements[0] as HTMLElement;
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||||
|
|
||||||
|
const handleTabKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleTabKey);
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
|
||||||
|
// Focus the first element when menu opens
|
||||||
|
setTimeout(() => firstElement.focus(), 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleTabKey);
|
||||||
|
document.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
}
|
}
|
||||||
}, [isMobileMenuOpen]);
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
// Function to get path for a different locale
|
// Function to get path for a different locale
|
||||||
const getPathForLocale = (newLocale: string) => {
|
const getPathForLocale = (newLocale: string) => {
|
||||||
const segments = pathname.split('/');
|
const segments = pathname.split('/');
|
||||||
@@ -54,37 +94,38 @@ export default function Header() {
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ label: t('home'), href: '/' },
|
{ label: t('home'), href: '/' },
|
||||||
{ label: t('team'), href: '/team' },
|
{ label: t('team'), href: '/team' },
|
||||||
{ label: t('products'), href: '/products' },
|
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
|
||||||
{ label: t('blog'), href: '/blog' },
|
{ label: t('blog'), href: '/blog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const headerClass = cn(
|
const headerClass = cn(
|
||||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu",
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu animate-in fade-in slide-in-from-top-12 fill-mode-both',
|
||||||
{
|
{
|
||||||
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none': isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen,
|
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const textColorClass = "text-white";
|
const textColorClass = 'text-white';
|
||||||
const logoSrc = "/logo-white.svg";
|
const logoSrc = '/logo-white.svg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.header
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||||
className={headerClass}
|
|
||||||
initial={{ y: -100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
||||||
>
|
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<motion.div
|
<div
|
||||||
className="flex-shrink-0 group touch-target"
|
className="flex-shrink-0 group touch-target animate-in fade-in zoom-in-90 fill-mode-both"
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
style={{ animationDuration: '600ms', animationDelay: '100ms' }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
|
||||||
>
|
>
|
||||||
<Link href={`/${currentLocale}`}>
|
<Link
|
||||||
|
href={`/${currentLocale}`}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
target: 'home_logo',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt={t('home')}
|
alt={t('home')}
|
||||||
@@ -92,304 +133,235 @@ export default function Header() {
|
|||||||
height={120}
|
height={120}
|
||||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||||
priority
|
priority
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
className="flex items-center gap-4 md:gap-12"
|
className="flex items-center gap-4 md:gap-12"
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
variants={{
|
|
||||||
visible: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.08,
|
|
||||||
delayChildren: 0.3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<motion.nav
|
<nav className="hidden lg:flex items-center space-x-10">
|
||||||
className="hidden lg:flex items-center space-x-10"
|
|
||||||
variants={navVariants}
|
|
||||||
>
|
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<motion.div
|
<div
|
||||||
key={item.href}
|
key={item.href}
|
||||||
variants={navLinkVariants}
|
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||||
|
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'header_nav',
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
textColorClass,
|
textColorClass,
|
||||||
"hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5"
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</motion.nav>
|
</nav>
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
className={cn("hidden lg:flex items-center space-x-8", textColorClass)}
|
className={cn('hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both', textColorClass)}
|
||||||
variants={headerRightVariants}
|
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<div
|
||||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
|
||||||
initial={{ opacity: 0, x: 20 }}
|
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.6 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<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={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div
|
<div className="w-px h-4 bg-current opacity-20" />
|
||||||
className="w-px h-4 bg-current opacity-20"
|
<div>
|
||||||
initial={{ scaleY: 0 }}
|
|
||||||
animate={{ scaleY: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.75 }}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('de')}
|
href={getPathForLocale('de')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/contact`}
|
||||||
variant="white"
|
variant="white"
|
||||||
size="md"
|
size="md"
|
||||||
className="px-8 shadow-xl"
|
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('contact'),
|
||||||
|
location: 'header_cta',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('contact')}
|
{t('contact')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<motion.button
|
<button
|
||||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)}
|
className={cn(
|
||||||
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50 transition-all duration-300',
|
||||||
|
textColorClass,
|
||||||
|
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100'
|
||||||
|
)}
|
||||||
aria-label={t('toggleMenu')}
|
aria-label={t('toggleMenu')}
|
||||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
aria-expanded={isMobileMenuOpen}
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
aria-controls="mobile-menu"
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 300, damping: 20, delay: 0.5 }}
|
onClick={() => {
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
const newState = !isMobileMenuOpen;
|
||||||
|
setIsMobileMenuOpen(newState);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: newState ? 'open' : 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<motion.svg
|
<svg
|
||||||
className="w-7 h-7"
|
className="w-7 h-7 transition-transform duration-300"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.6 }}
|
|
||||||
>
|
>
|
||||||
{isMobileMenuOpen ? (
|
{isMobileMenuOpen ? (
|
||||||
<motion.path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.svg>
|
</svg>
|
||||||
</motion.button>
|
</button>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div className={cn(
|
<div
|
||||||
"fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col",
|
className={cn(
|
||||||
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
)}>
|
isMobileMenuOpen
|
||||||
<motion.div
|
? 'opacity-100 translate-y-0'
|
||||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
initial="closed"
|
)}
|
||||||
animate={isMobileMenuOpen ? "open" : "closed"}
|
id="mobile-menu"
|
||||||
variants={{
|
role="dialog"
|
||||||
open: {
|
aria-modal="true"
|
||||||
transition: {
|
aria-label={t('menu')}
|
||||||
staggerChildren: 0.1,
|
ref={mobileMenuRef}
|
||||||
delayChildren: 0.2
|
>
|
||||||
}
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<motion.div
|
<div
|
||||||
key={item.href}
|
key={item.href}
|
||||||
variants={{
|
className={cn('transition-all duration-500 transform', isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')}
|
||||||
closed: { opacity: 0, y: 50, scale: 0.9 },
|
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||||
open: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: "easeOut",
|
|
||||||
delay: idx * 0.08
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
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"
|
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
className={cn('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')}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||||
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.8 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||||
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
<div>
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.9 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 1.0 }}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('en')}
|
href={getPathForLocale('en')}
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div
|
<div className="w-px h-6 bg-white/20" />
|
||||||
className="w-px h-6 bg-white/20"
|
<div>
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 1.05 }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 1.1 }}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={getPathForLocale('de')}
|
href={getPathForLocale('de')}
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div className="w-full max-w-xs">
|
||||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 20, delay: 1.2 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/contact`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
>
|
>
|
||||||
{t('contact')}
|
{t('contact')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
{/* Bottom Branding */}
|
||||||
<motion.div
|
<div
|
||||||
className="p-12 flex justify-center opacity-20"
|
className={cn('p-12 flex justify-center transition-all duration-700', isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75')}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||||
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 />
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</nav>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.header>
|
</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;
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
// Fix for default marker icon in Leaflet with Next.js
|
// Fix for default marker icon in Leaflet with Next.js
|
||||||
const DefaultIcon = L.icon({
|
if (typeof window !== 'undefined') {
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
const DefaultIcon = L.icon({
|
||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
iconSize: [25, 41],
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
iconAnchor: [12, 41],
|
iconSize: [25, 41],
|
||||||
});
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
}
|
||||||
|
|
||||||
interface LeafletMapProps {
|
interface LeafletMapProps {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -21,25 +21,46 @@ interface LeafletMapProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
||||||
const position: [number, number] = [lat, lng];
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<MapContainer
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
center={position}
|
|
||||||
zoom={15}
|
// Initialize map
|
||||||
scrollWheelZoom={false}
|
const map = L.map(mapRef.current, {
|
||||||
className="h-full w-full z-0"
|
center: [lat, lng],
|
||||||
>
|
zoom: 15,
|
||||||
<TileLayer
|
scrollWheelZoom: false,
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
});
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
// Add tiles
|
||||||
<Marker position={position}>
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
<Popup>
|
attribution:
|
||||||
<div className="text-primary font-bold">KLZ Cables</div>
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
<div className="text-sm whitespace-pre-line">{address}</div>
|
}).addTo(map);
|
||||||
</Popup>
|
|
||||||
</Marker>
|
// Add marker
|
||||||
</MapContainer>
|
const marker = L.marker([lat, lng]).addTo(map);
|
||||||
);
|
|
||||||
|
// Create popup content
|
||||||
|
const popupContent = `
|
||||||
|
<div class="text-primary font-bold">KLZ Cables</div>
|
||||||
|
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [lat, lng, address]);
|
||||||
|
|
||||||
|
return <div ref={mapRef} className="h-full w-full z-0" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
@@ -19,21 +19,26 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
return () => setMounted(false);
|
return () => setMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateUrl = useCallback((index: number | null) => {
|
const updateUrl = useCallback(
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
(index: number | null) => {
|
||||||
if (index !== null) {
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('photo', index.toString());
|
if (index !== null) {
|
||||||
} else {
|
params.set('photo', index.toString());
|
||||||
params.delete('photo');
|
} else {
|
||||||
}
|
params.delete('photo');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
}
|
||||||
}, [pathname, router, searchParams]);
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[pathname, router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const prevImage = useCallback(() => {
|
const prevImage = useCallback(() => {
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
@@ -56,11 +61,16 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (photoParam !== null) {
|
if (photoParam !== null) {
|
||||||
const index = parseInt(photoParam, 10);
|
const index = parseInt(photoParam, 10);
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, images.length]);
|
}, [searchParams, images.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
updateUrl(null);
|
||||||
|
onClose();
|
||||||
|
}, [updateUrl, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updateUrl(currentIndex);
|
updateUrl(currentIndex);
|
||||||
@@ -68,137 +78,181 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
}, [isOpen, currentIndex, updateUrl]);
|
}, [isOpen, currentIndex, updateUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
|
if (previousFocusRef.current) {
|
||||||
|
previousFocusRef.current.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture previous focus
|
||||||
|
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
|
// Focus close button on open
|
||||||
|
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') handleClose();
|
if (e.key === 'Escape') handleClose();
|
||||||
if (e.key === 'ArrowLeft') prevImage();
|
if (e.key === 'ArrowLeft') prevImage();
|
||||||
if (e.key === 'ArrowRight') nextImage();
|
if (e.key === 'ArrowRight') nextImage();
|
||||||
|
|
||||||
|
// Focus Trap
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const focusableElements = document.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
const modalElements = Array.from(focusableElements).filter((el) =>
|
||||||
|
document.querySelector('[role="dialog"]')?.contains(el),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modalElements.length === 0) return;
|
||||||
|
|
||||||
|
const firstElement = modalElements[0] as HTMLElement;
|
||||||
|
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
lastElement.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
firstElement.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lock scroll
|
// Lock scroll
|
||||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = originalStyle;
|
document.body.style.overflow = originalStyle;
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpen, prevImage, nextImage]);
|
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
updateUrl(null);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||||
{isOpen && (
|
<AnimatePresence>
|
||||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
|
{isOpen && (
|
||||||
<motion.div
|
<div
|
||||||
initial={{ opacity: 0 }}
|
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||||
animate={{ opacity: 1 }}
|
role="dialog"
|
||||||
exit={{ opacity: 0 }}
|
aria-modal="true"
|
||||||
transition={{ duration: 0.5 }}
|
>
|
||||||
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
<m.div
|
||||||
onClick={handleClose}
|
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
|
<m.button
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.5 }}
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
transition={{ delay: 0.1, duration: 0.4 }}
|
transition={{ delay: 0.1, duration: 0.4 }}
|
||||||
onClick={handleClose}
|
ref={closeButtonRef}
|
||||||
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"
|
onClick={handleClose}
|
||||||
aria-label="Close lightbox"
|
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"
|
||||||
<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>
|
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
||||||
</div>
|
<span className="text-3xl font-extralight leading-none mb-1">×</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={prevImage}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">‹</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>
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<m.button
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: 0.3, duration: 0.4 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
className="mt-8 flex items-center gap-4"
|
transition={{ delay: 0.2, duration: 0.4 }}
|
||||||
>
|
onClick={prevImage}
|
||||||
<div className="h-px w-12 bg-white/20" />
|
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"
|
||||||
<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">
|
aria-label="Previous image"
|
||||||
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
>
|
||||||
|
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||||
|
‹
|
||||||
|
</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" />
|
|
||||||
</motion.div>
|
<m.div
|
||||||
</div>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
</motion.div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</div>
|
exit={{ opacity: 0, y: 10 }}
|
||||||
)}
|
transition={{ delay: 0.3, duration: 0.4 }}
|
||||||
</AnimatePresence>,
|
className="mt-8 flex items-center gap-4"
|
||||||
document.body
|
>
|
||||||
|
<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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function OGImageTemplate({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -182,4 +183,3 @@ export function OGImageTemplate({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,25 +14,30 @@ interface ProductSidebarProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
|
export default function ProductSidebar({
|
||||||
|
productName,
|
||||||
|
productImage,
|
||||||
|
datasheetPath,
|
||||||
|
className,
|
||||||
|
}: ProductSidebarProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-4 animate-slight-fade-in-from-bottom", className)}>
|
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
|
||||||
{/* Request Quote Form Card */}
|
{/* Request Quote Form Card */}
|
||||||
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||||
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||||
{/* Background Accent - Saturated Blue Glow */}
|
{/* Background Accent - Saturated Blue Glow */}
|
||||||
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
|
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
|
||||||
|
|
||||||
{/* Product Thumbnail with Reflection */}
|
{/* Product Thumbnail with Reflection */}
|
||||||
{productImage && (
|
{productImage && (
|
||||||
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||||
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||||
<Image
|
<Image
|
||||||
src={productImage}
|
src={productImage}
|
||||||
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)]"
|
||||||
/>
|
/>
|
||||||
{/* Subtle Reflection Overlay */}
|
{/* Subtle Reflection Overlay */}
|
||||||
@@ -46,9 +51,9 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
|||||||
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
||||||
{t('requestQuote')}
|
{t('requestQuote')}
|
||||||
</h3>
|
</h3>
|
||||||
<Scribble
|
<Scribble
|
||||||
variant="underline"
|
variant="underline"
|
||||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||||
color="var(--color-accent)"
|
color="var(--color-accent)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,16 +62,14 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-neutral-light/50">
|
<div className="p-6 bg-neutral-light/50">
|
||||||
<RequestQuoteForm productName={productName} />
|
<RequestQuoteForm productName={productName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datasheet Download */}
|
{/* Datasheet Download */}
|
||||||
{datasheetPath && (
|
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
</aside>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
const { technicalItems = [], voltageTables = [] } = data;
|
const { technicalItems = [], voltageTables = [] } = data;
|
||||||
|
|
||||||
const toggleTable = (idx: number) => {
|
const toggleTable = (idx: number) => {
|
||||||
setExpandedTables(prev => ({
|
setExpandedTables((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[idx]: !prev[idx]
|
[idx]: !prev[idx],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,9 +48,16 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex flex-col group">
|
<div key={idx} className="flex flex-col group">
|
||||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">{item.label}</dt>
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||||
|
{item.label}
|
||||||
|
</dt>
|
||||||
<dd className="text-lg font-semibold text-text-primary">
|
<dd className="text-lg font-semibold text-text-primary">
|
||||||
{item.value} {item.unit && <span className="text-sm font-normal text-text-secondary ml-1">{item.unit}</span>}
|
{item.value}{' '}
|
||||||
|
{item.unit && (
|
||||||
|
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -61,29 +68,38 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
{voltageTables.map((table, idx) => {
|
{voltageTables.map((table, idx) => {
|
||||||
const isExpanded = expandedTables[idx];
|
const isExpanded = expandedTables[idx];
|
||||||
const hasManyRows = table.rows.length > 10;
|
const hasManyRows = table.rows.length > 10;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white p-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 !== 'Spannung unbekannt'
|
{table.voltageLabel !== 'Voltage unknown' &&
|
||||||
? table.voltageLabel
|
table.voltageLabel !== 'Spannung unbekannt'
|
||||||
|
? table.voltageLabel
|
||||||
: 'Technical Specifications'}
|
: 'Technical Specifications'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{table.metaItems.length > 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
<dl className="grid grid-cols-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">{item.label}</dt>
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
|
{item.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-bold text-primary">
|
||||||
|
{item.value} {item.unit}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
|
id={`voltage-table-${idx}`}
|
||||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
className={`overflow-x-auto -mx-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]'
|
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
}`}
|
}`}
|
||||||
@@ -91,11 +107,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<table className="min-w-full border-separate border-spacing-0">
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10"
|
||||||
|
>
|
||||||
Config.
|
Config.
|
||||||
</th>
|
</th>
|
||||||
{table.columns.map((col, cIdx) => (
|
{table.columns.map((col, cIdx) => (
|
||||||
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
|
<th
|
||||||
|
key={cIdx}
|
||||||
|
scope="col"
|
||||||
|
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10"
|
||||||
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -108,7 +131,10 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
{row.configuration}
|
{row.configuration}
|
||||||
</td>
|
</td>
|
||||||
{row.cells.map((cell, cellIdx) => (
|
{row.cells.map((cell, cellIdx) => (
|
||||||
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
|
<td
|
||||||
|
key={cellIdx}
|
||||||
|
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
|
||||||
|
>
|
||||||
{cell}
|
{cell}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -127,6 +153,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTable(idx)}
|
onClick={() => toggleTable(idx)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={`voltage-table-${idx}`}
|
||||||
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
||||||
>
|
>
|
||||||
{isExpanded ? t('showLess') : t('showMore')}
|
{isExpanded ? t('showLess') : t('showMore')}
|
||||||
|
|||||||
39
components/RelatedProductLink.tsx
Normal file
39
components/RelatedProductLink.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface RelatedProductLinkProps {
|
||||||
|
href: string;
|
||||||
|
productSlug: string;
|
||||||
|
productTitle: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedProductLink({
|
||||||
|
href,
|
||||||
|
productSlug,
|
||||||
|
productTitle,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: RelatedProductLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: productSlug,
|
||||||
|
product_name: productTitle,
|
||||||
|
location: 'related_products',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProducts } from '@/lib/mdx';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import { RelatedProductLink } from './RelatedProductLink';
|
||||||
|
|
||||||
interface RelatedProductsProps {
|
interface RelatedProductsProps {
|
||||||
currentSlug: string;
|
currentSlug: string;
|
||||||
@@ -10,15 +9,19 @@ interface RelatedProductsProps {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
export default async function RelatedProducts({
|
||||||
const allProducts = await getAllProducts(locale);
|
currentSlug,
|
||||||
|
categories,
|
||||||
|
locale,
|
||||||
|
}: RelatedProductsProps) {
|
||||||
|
const products = await getAllProducts(locale);
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Filter products: same category, not current product
|
// Filter products: same category, not current product
|
||||||
const related = allProducts
|
const related = products
|
||||||
.filter(p =>
|
.filter(
|
||||||
p.slug !== currentSlug &&
|
(p) =>
|
||||||
p.frontmatter.categories.some(cat => categories.includes(cat))
|
p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)),
|
||||||
)
|
)
|
||||||
.slice(0, 3); // Limit to 3 for better spacing
|
.slice(0, 3); // Limit to 3 for better spacing
|
||||||
|
|
||||||
@@ -36,23 +39,31 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{related.map(async (product) => {
|
{related.map((product) => {
|
||||||
// Find the category slug for the link
|
// Find the category slug for the link
|
||||||
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categorySlugs = [
|
||||||
const catSlug = categorySlugs.find(slug => {
|
'low-voltage-cables',
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
'medium-voltage-cables',
|
||||||
const title = t(`categories.${key}.title`);
|
'high-voltage-cables',
|
||||||
return product.frontmatter.categories.some(cat =>
|
'solar-cables',
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
|
];
|
||||||
);
|
const catSlug =
|
||||||
}) || 'low-voltage-cables';
|
categorySlugs.find((slug) => {
|
||||||
|
const key = slug
|
||||||
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
|
.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 (
|
||||||
<Link
|
<RelatedProductLink
|
||||||
key={product.slug}
|
key={product.slug}
|
||||||
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
href={`/${locale}/products/${catSlug}/${product.slug}`}
|
||||||
|
productSlug={product.slug}
|
||||||
|
productTitle={product.frontmatter.title}
|
||||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||||
>
|
>
|
||||||
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
||||||
@@ -74,8 +85,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
|
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
|
||||||
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -87,12 +101,23 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
||||||
{t('details')}
|
{t('details')}
|
||||||
</span>
|
</span>
|
||||||
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</RelatedProductLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Input, Textarea, Button } from '@/components/ui';
|
import { Input, Textarea, Button } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
interface RequestQuoteFormProps {
|
interface RequestQuoteFormProps {
|
||||||
productName: string;
|
productName: string;
|
||||||
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [request, setRequest] = useState('');
|
const [request, setRequest] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
form_name: 'Product Quote Inquiry',
|
||||||
|
product_name: productName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
product_name: productName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,17 +60,31 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setRequest('');
|
setRequest('');
|
||||||
} else {
|
} else {
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
product_name: productName,
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Form submission error:', error);
|
console.error('Form submission error:', error);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'quote_request_form',
|
||||||
|
product_name: productName,
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
<div
|
||||||
|
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||||
<svg
|
<svg
|
||||||
@@ -87,7 +122,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
<div
|
||||||
|
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||||
<svg
|
<svg
|
||||||
@@ -127,23 +166,27 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
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')}
|
||||||
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">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="request"
|
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')}
|
||||||
placeholder={t('message')}
|
placeholder={t('message')}
|
||||||
|
aria-label={t('message')}
|
||||||
className="text-xs !mt-0"
|
className="text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, Variants } from 'framer-motion';
|
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface ScribbleProps {
|
interface ScribbleProps {
|
||||||
@@ -11,38 +10,25 @@ interface ScribbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
||||||
const pathVariants: Variants = {
|
|
||||||
hidden: { pathLength: 0, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
pathLength: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 1.8,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn("absolute pointer-events-none", className)}
|
className={cn('absolute pointer-events-none', className)}
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
viewBox="0 0 800 350"
|
viewBox="0 0 800 350"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<motion.path
|
<path
|
||||||
variants={pathVariants}
|
className="animate-draw-stroke"
|
||||||
initial="hidden"
|
pathLength="1"
|
||||||
whileInView="visible"
|
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||||
viewport={{ once: true }}
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
strokeLinejoin="miter"
|
||||||
strokeLinejoin="miter"
|
fillOpacity="0"
|
||||||
fillOpacity="0"
|
strokeMiterlimit="4"
|
||||||
strokeMiterlimit="4"
|
stroke={color}
|
||||||
stroke={color}
|
strokeOpacity="1"
|
||||||
strokeOpacity="1"
|
strokeWidth="20"
|
||||||
strokeWidth="20"
|
|
||||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -51,20 +37,19 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
|
|
||||||
if (variant === 'underline') {
|
if (variant === 'underline') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn("absolute pointer-events-none", className)}
|
className={cn('absolute pointer-events-none', className)}
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
viewBox="-400 -55 730 60"
|
viewBox="-400 -55 730 60"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<motion.path
|
<path
|
||||||
variants={pathVariants}
|
className="animate-draw-stroke"
|
||||||
initial="hidden"
|
pathLength="1"
|
||||||
whileInView="visible"
|
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||||
viewport={{ once: true }}
|
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
stroke={color}
|
||||||
stroke={color}
|
strokeWidth="20"
|
||||||
strokeWidth="20"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
16
components/SkipLink.tsx
Normal file
16
components/SkipLink.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export default function SkipLink() {
|
||||||
|
const t = useTranslations('Navigation');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="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')}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,25 +3,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AnalyticsProvider Component
|
* AnalyticsProvider Component
|
||||||
*
|
*
|
||||||
* Automatically tracks pageviews on client-side route changes.
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
* This component should be placed inside your layout to handle navigation events.
|
* This component handles navigation events for the Umami analytics service.
|
||||||
*
|
*
|
||||||
* @example
|
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||||
* ```tsx
|
* so it's no longer needed as a prop here.
|
||||||
* // In your layout.tsx
|
|
||||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
|
||||||
* <UmamiScript />
|
|
||||||
* <Header />
|
|
||||||
* <main>{children}</main>
|
|
||||||
* <Footer />
|
|
||||||
* <AnalyticsProvider />
|
|
||||||
* </NextIntlClientProvider>
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export default function AnalyticsProvider() {
|
export default function AnalyticsProvider() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -29,31 +19,17 @@ export default function AnalyticsProvider() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pathname) return;
|
if (!pathname) return;
|
||||||
|
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
// Track pageview with the full URL
|
// Track pageview with the full URL
|
||||||
|
// The service will relay this to our internal proxy which injects the Website ID
|
||||||
services.analytics.trackPageview(url);
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
// Services like logger are already sub-initialized in getAppServices()
|
||||||
console.log('[Umami] Tracked pageview:', url);
|
// so we don't need to log here manually.
|
||||||
}
|
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
return null;
|
||||||
if (!websiteId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Script
|
|
||||||
id="umami-analytics"
|
|
||||||
src="/stats/script.js"
|
|
||||||
data-website-id={websiteId}
|
|
||||||
data-host-url="/stats"
|
|
||||||
strategy="afterInteractive"
|
|
||||||
data-domains="klz-cables.com"
|
|
||||||
defer
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
components/analytics/AnalyticsShell.tsx
Normal file
39
components/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AnalyticsShell() {
|
||||||
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
||||||
|
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||||
|
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
||||||
|
} else {
|
||||||
|
const timer = setTimeout(() => setShouldLoad(true), 2500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!shouldLoad) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DynamicAnalyticsProvider />
|
||||||
|
<DynamicScrollDepthTracker />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/analytics/BlogEngagementTracker.tsx
Normal file
53
components/analytics/BlogEngagementTracker.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface BlogEngagementTrackerProps {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string;
|
||||||
|
readingTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogEngagementTracker
|
||||||
|
* Tracks reading time and article completion.
|
||||||
|
*/
|
||||||
|
export default function BlogEngagementTracker({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
readingTime,
|
||||||
|
}: BlogEngagementTrackerProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Article start
|
||||||
|
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
estimated_reading_time: readingTime,
|
||||||
|
location: 'blog_post_pdp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
|
||||||
|
// We only consider it a "read" if they stay a reasonable amount of time
|
||||||
|
// or if they scroll (covered by ScrollDepthTracker)
|
||||||
|
trackEvent('blog_dwell_time', {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
seconds: dwellTime,
|
||||||
|
reading_time_completion: Math.min(100, Math.round((dwellTime / (readingTime * 60)) * 100)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [title, slug, category, readingTime, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -136,18 +136,14 @@ function AddToCartButton({ product, quantity = 1 }) {
|
|||||||
product_category: product.category,
|
product_category: product.category,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
cart_total: 150.00, // Current cart total
|
cart_total: 150.0, // Current cart total
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actual add to cart logic
|
// Actual add to cart logic
|
||||||
// addToCart(product, quantity);
|
// addToCart(product, quantity);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||||
<button onClick={handleAddToCart}>
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,7 +167,7 @@ function CheckoutComplete({ order }) {
|
|||||||
transaction_tax: order.tax,
|
transaction_tax: order.tax,
|
||||||
transaction_shipping: order.shipping,
|
transaction_shipping: order.shipping,
|
||||||
product_count: order.items.length,
|
product_count: order.items.length,
|
||||||
products: order.items.map(item => ({
|
products: order.items.map((item) => ({
|
||||||
product_id: item.product.id,
|
product_id: item.product.id,
|
||||||
product_name: item.product.name,
|
product_name: item.product.name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -198,27 +194,21 @@ function WishlistButton({ product }) {
|
|||||||
|
|
||||||
const toggleWishlist = () => {
|
const toggleWishlist = () => {
|
||||||
const newState = !isInWishlist;
|
const newState = !isInWishlist;
|
||||||
|
|
||||||
trackEvent(
|
trackEvent(
|
||||||
newState
|
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
||||||
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
|
|
||||||
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
|
|
||||||
{
|
{
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
product_category: product.category,
|
product_category: product.category,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setIsInWishlist(newState);
|
setIsInWishlist(newState);
|
||||||
// Update wishlist in backend
|
// Update wishlist in backend
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>;
|
||||||
<button onClick={toggleWishlist}>
|
|
||||||
{isInWishlist ? '❤️' : '🤍'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -268,7 +258,7 @@ function ContactForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
}));
|
}));
|
||||||
@@ -310,9 +300,7 @@ function NewsletterSignup() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input placeholder="Enter email" />
|
<input placeholder="Enter email" />
|
||||||
<button onClick={() => handleSubscribe('user@example.com')}>
|
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button>
|
||||||
Subscribe
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -396,10 +384,12 @@ function LoginForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
e.preventDefault();
|
onSubmit={(e) => {
|
||||||
handleLogin('user@example.com', 'password');
|
e.preventDefault();
|
||||||
}}>
|
handleLogin('user@example.com', 'password');
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -418,11 +408,7 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
|||||||
function SignupForm() {
|
function SignupForm() {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const handleSignup = (userData: {
|
const handleSignup = (userData: { email: string; name: string; company?: string }) => {
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
company?: string;
|
|
||||||
}) => {
|
|
||||||
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
trackEvent(AnalyticsEvents.USER_SIGNUP, {
|
||||||
user_email: userData.email,
|
user_email: userData.email,
|
||||||
user_name: userData.name,
|
user_name: userData.name,
|
||||||
@@ -436,14 +422,16 @@ function SignupForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
e.preventDefault();
|
onSubmit={(e) => {
|
||||||
handleSignup({
|
e.preventDefault();
|
||||||
email: 'user@example.com',
|
handleSignup({
|
||||||
name: 'John Doe',
|
email: 'user@example.com',
|
||||||
company: 'ACME Corp',
|
name: 'John Doe',
|
||||||
});
|
company: 'ACME Corp',
|
||||||
}}>
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<button type="submit">Sign Up</button>
|
<button type="submit">Sign Up</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -483,7 +471,7 @@ function SearchBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
@@ -549,7 +537,7 @@ function ProductFilters() {
|
|||||||
<option value="cables">Cables</option>
|
<option value="cables">Cables</option>
|
||||||
<option value="connectors">Connectors</option>
|
<option value="connectors">Connectors</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button onClick={handleClearFilters}>Clear Filters</button>
|
<button onClick={handleClearFilters}>Clear Filters</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -631,11 +619,7 @@ function VideoPlayer({ videoId, videoTitle }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}>
|
||||||
onPlay={handlePlay}
|
|
||||||
onPause={handlePause}
|
|
||||||
onEnded={handleComplete}
|
|
||||||
>
|
|
||||||
<source src="/video.mp4" type="video/mp4" />
|
<source src="/video.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
);
|
);
|
||||||
@@ -665,11 +649,7 @@ function DownloadButton({ fileName, fileType, fileSize }) {
|
|||||||
// window.location.href = `/downloads/${fileName}`;
|
// window.location.href = `/downloads/${fileName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleDownload}>Download {fileName}</button>;
|
||||||
<button onClick={handleDownload}>
|
|
||||||
Download {fileName}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -700,7 +680,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps> {
|
|||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
error_message: error.message,
|
error_message: error.message,
|
||||||
error_stack: error.stack,
|
error_stack: error.stack,
|
||||||
@@ -742,14 +722,14 @@ function ApiClient() {
|
|||||||
const fetchData = async (endpoint: string) => {
|
const fetchData = async (endpoint: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint);
|
const response = await fetch(endpoint);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
trackEvent(AnalyticsEvents.API_ERROR, {
|
trackEvent(AnalyticsEvents.API_ERROR, {
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
status_code: response.status,
|
status_code: response.status,
|
||||||
error_message: response.statusText,
|
error_message: response.statusText,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +745,7 @@ function ApiClient() {
|
|||||||
error_message: error.message,
|
error_message: error.message,
|
||||||
error_type: error.name,
|
error_type: error.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -963,15 +943,9 @@ function CableProductPage({ cable }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{cable.name}</h1>
|
<h1>{cable.name}</h1>
|
||||||
<button onClick={handleTechnicalSpecDownload}>
|
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button>
|
||||||
Download Technical Specs
|
<button onClick={handleRequestQuote}>Request Quote</button>
|
||||||
</button>
|
<button onClick={handleBrochureDownload}>Download Brochure</button>
|
||||||
<button onClick={handleRequestQuote}>
|
|
||||||
Request Quote
|
|
||||||
</button>
|
|
||||||
<button onClick={handleBrochureDownload}>
|
|
||||||
Download Brochure
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1010,12 +984,8 @@ function WindFarmProjectPage({ project }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{project.name}</h1>
|
<h1>{project.name}</h1>
|
||||||
<button onClick={handleProjectInquiry}>
|
<button onClick={handleProjectInquiry}>Request Project Consultation</button>
|
||||||
Request Project Consultation
|
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button>
|
||||||
</button>
|
|
||||||
<button onClick={handleCableCalculation}>
|
|
||||||
Calculate Cable Requirements
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1066,7 +1036,7 @@ test('tracks button click', () => {
|
|||||||
// [Umami] Tracked pageview: /products/123
|
// [Umami] Tracked pageview: /products/123
|
||||||
|
|
||||||
// To test without sending data to Umami:
|
// To test without sending data to Umami:
|
||||||
// 1. Remove NEXT_PUBLIC_UMAMI_WEBSITE_ID from .env
|
// 1. Remove UMAMI_WEBSITE_ID from .env
|
||||||
// 2. Or set it to an empty string
|
// 2. Or set it to an empty string
|
||||||
// 3. Check console logs to verify events are being tracked
|
// 3. Check console logs to verify events are being tracked
|
||||||
```
|
```
|
||||||
@@ -1169,7 +1139,9 @@ function WebVitalsTracker() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
|
observer.observe({
|
||||||
|
entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1194,6 +1166,7 @@ This examples file demonstrates how to implement comprehensive analytics trackin
|
|||||||
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
- ✅ **Business-specific events** (KLZ Cables, wind farms)
|
||||||
|
|
||||||
Remember to:
|
Remember to:
|
||||||
|
|
||||||
1. Use the `useAnalytics` hook for client-side tracking
|
1. Use the `useAnalytics` hook for client-side tracking
|
||||||
2. Import events from `AnalyticsEvents` for consistency
|
2. Import events from `AnalyticsEvents` for consistency
|
||||||
3. Include relevant context in your events
|
3. Include relevant context in your events
|
||||||
|
|||||||
50
components/analytics/ProductEngagementTracker.tsx
Normal file
50
components/analytics/ProductEngagementTracker.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface ProductEngagementTrackerProps {
|
||||||
|
productName: string;
|
||||||
|
productSlug: string;
|
||||||
|
categories: string[];
|
||||||
|
sku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductEngagementTracker
|
||||||
|
* Deep analytics for product pages.
|
||||||
|
* Tracks specific view events with full metadata for sales analysis.
|
||||||
|
*/
|
||||||
|
export default function ProductEngagementTracker({
|
||||||
|
productName,
|
||||||
|
productSlug,
|
||||||
|
categories,
|
||||||
|
sku,
|
||||||
|
}: ProductEngagementTrackerProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Standardized product view event for "High-Fidelity" sales insights
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: productSlug,
|
||||||
|
product_name: productName,
|
||||||
|
product_sku: sku,
|
||||||
|
product_categories: categories.join(', '),
|
||||||
|
location: 'pdp_standard',
|
||||||
|
});
|
||||||
|
|
||||||
|
// We can also track "Engagement Start" to measure dwell time later
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
trackEvent('pdp_dwell_time', {
|
||||||
|
product_id: productSlug,
|
||||||
|
seconds: dwellTime,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [productName, productSlug, categories, sku, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Setup Checklist
|
## Setup Checklist
|
||||||
|
|
||||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||||
- [ ] Verify `UmamiScript` is in your layout
|
- [ ] Verify `UmamiScript` is in your layout
|
||||||
- [ ] Verify `AnalyticsProvider` is in your layout
|
- [ ] Verify `AnalyticsProvider` is in your layout
|
||||||
- [ ] Test in development mode
|
- [ ] Test in development mode
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required
|
# Required
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -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
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -120,8 +120,9 @@ In development, you'll see console logs:
|
|||||||
### Analytics Not Working?
|
### Analytics Not Working?
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify script is loading:**
|
2. **Verify script is loading:**
|
||||||
@@ -136,12 +137,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
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Add these to your `.env` file:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required: Your Umami website ID
|
# Required: Your Umami website ID
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -32,7 +32,7 @@ The `docker-compose.yml` already includes the environment variables:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,11 +75,7 @@ function ProductCard({ product }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <button onClick={handleAddToCart}>Add to Cart</button>;
|
||||||
<button onClick={handleAddToCart}>
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,7 +92,7 @@ function CustomNavigation() {
|
|||||||
const navigateToCustomPage = () => {
|
const navigateToCustomPage = () => {
|
||||||
// Track a custom pageview
|
// Track a custom pageview
|
||||||
trackPageview('/custom-path?param=value');
|
trackPageview('/custom-path?param=value');
|
||||||
|
|
||||||
// Then perform navigation
|
// Then perform navigation
|
||||||
window.location.href = '/custom-path?param=value';
|
window.location.href = '/custom-path?param=value';
|
||||||
};
|
};
|
||||||
@@ -277,11 +273,7 @@ function ErrorBoundary({ children }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>;
|
||||||
<ErrorBoundary onError={handleError}>
|
|
||||||
{children}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -289,20 +281,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
|
||||||
|
|
||||||
@@ -385,8 +377,9 @@ The analytics system includes development mode logging:
|
|||||||
### Analytics Not Working
|
### Analytics Not Working
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify the script is loading:**
|
2. **Verify the script is loading:**
|
||||||
@@ -405,11 +398,11 @@ In development mode, you'll see console logs for all tracked events. This helps
|
|||||||
|
|
||||||
### Disabling Analytics
|
### Disabling Analytics
|
||||||
|
|
||||||
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
|
To disable analytics (e.g., for local development), simply remove the `UMAMI_WEBSITE_ID` environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local (not committed to git)
|
# .env.local (not committed to git)
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
@@ -438,6 +431,7 @@ The analytics implementation is optimized for performance:
|
|||||||
## Support
|
## Support
|
||||||
|
|
||||||
For issues or questions about the analytics implementation, check:
|
For issues or questions about the analytics implementation, check:
|
||||||
|
|
||||||
1. This README for usage examples
|
1. This README for usage examples
|
||||||
2. The component source code for implementation details
|
2. The component source code for implementation details
|
||||||
3. The Umami documentation for platform-specific questions
|
3. The Umami documentation for platform-specific questions
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ The project already had a solid foundation:
|
|||||||
## What Was Enhanced
|
## What Was Enhanced
|
||||||
|
|
||||||
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
||||||
|
|
||||||
- ✅ Added TypeScript props interface for customization
|
- ✅ Added TypeScript props interface for customization
|
||||||
- ✅ Added JSDoc documentation with usage examples
|
- ✅ Added JSDoc documentation with usage examples
|
||||||
- ✅ Added error handling for script loading failures
|
- ✅ Added error handling for script loading failures
|
||||||
@@ -23,11 +24,13 @@ The project already had a solid foundation:
|
|||||||
- ✅ Improved type safety and comments
|
- ✅ Improved type safety and comments
|
||||||
|
|
||||||
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
||||||
|
|
||||||
- ✅ Added comprehensive JSDoc documentation
|
- ✅ Added comprehensive JSDoc documentation
|
||||||
- ✅ Added development mode logging
|
- ✅ Added development mode logging
|
||||||
- ✅ Improved code comments
|
- ✅ Improved code comments
|
||||||
|
|
||||||
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
||||||
|
|
||||||
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
||||||
- ✅ `trackEvent()` method for custom events
|
- ✅ `trackEvent()` method for custom events
|
||||||
- ✅ `trackPageview()` method for manual pageview tracking
|
- ✅ `trackPageview()` method for manual pageview tracking
|
||||||
@@ -35,12 +38,14 @@ The project already had a solid foundation:
|
|||||||
- ✅ Development mode logging
|
- ✅ Development mode logging
|
||||||
|
|
||||||
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
||||||
|
|
||||||
- ✅ Centralized event constants for consistency
|
- ✅ Centralized event constants for consistency
|
||||||
- ✅ Type-safe event names
|
- ✅ Type-safe event names
|
||||||
- ✅ Helper functions for common event properties
|
- ✅ Helper functions for common event properties
|
||||||
- ✅ 30+ predefined events for various use cases
|
- ✅ 30+ predefined events for various use cases
|
||||||
|
|
||||||
### 5. **Comprehensive Documentation**
|
### 5. **Comprehensive Documentation**
|
||||||
|
|
||||||
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
||||||
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
||||||
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
||||||
@@ -63,12 +68,14 @@ components/analytics/
|
|||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### 🚀 Modern Implementation
|
### 🚀 Modern Implementation
|
||||||
|
|
||||||
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
||||||
- TypeScript for type safety
|
- TypeScript for type safety
|
||||||
- React hooks for clean API
|
- React hooks for clean API
|
||||||
- Environment variable configuration
|
- Environment variable configuration
|
||||||
|
|
||||||
### 📊 Comprehensive Tracking
|
### 📊 Comprehensive Tracking
|
||||||
|
|
||||||
- Automatic pageview tracking on route changes
|
- Automatic pageview tracking on route changes
|
||||||
- Custom event tracking with properties
|
- Custom event tracking with properties
|
||||||
- E-commerce events (products, cart, purchases)
|
- E-commerce events (products, cart, purchases)
|
||||||
@@ -77,6 +84,7 @@ components/analytics/
|
|||||||
- Error and performance tracking
|
- Error and performance tracking
|
||||||
|
|
||||||
### 🎯 Developer Experience
|
### 🎯 Developer Experience
|
||||||
|
|
||||||
- Type-safe event tracking
|
- Type-safe event tracking
|
||||||
- Centralized event definitions
|
- Centralized event definitions
|
||||||
- Development mode logging
|
- Development mode logging
|
||||||
@@ -84,6 +92,7 @@ components/analytics/
|
|||||||
- 20+ practical examples
|
- 20+ practical examples
|
||||||
|
|
||||||
### 🔒 Privacy & Performance
|
### 🔒 Privacy & Performance
|
||||||
|
|
||||||
- No PII tracking by default
|
- No PII tracking by default
|
||||||
- Script loads after page is interactive
|
- Script loads after page is interactive
|
||||||
- Minimal performance impact
|
- Minimal performance impact
|
||||||
@@ -95,7 +104,7 @@ The project is already configured in `docker-compose.yml`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,7 +113,7 @@ environment:
|
|||||||
Add to your `.env` file:
|
Add to your `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
@@ -188,7 +197,7 @@ In development, you'll see console logs:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env.local
|
# .env.local
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
# UMAMI_WEBSITE_ID=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -196,8 +205,9 @@ In development, you'll see console logs:
|
|||||||
### Analytics Not Working?
|
### Analytics Not Working?
|
||||||
|
|
||||||
1. **Check environment variables:**
|
1. **Check environment variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
echo $UMAMI_WEBSITE_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Verify script is loading:**
|
2. **Verify script is loading:**
|
||||||
@@ -212,12 +222,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
|
||||||
|
|
||||||
@@ -239,13 +249,13 @@ In development, you'll see console logs:
|
|||||||
1. ✅ **Setup complete** - All files are in place
|
1. ✅ **Setup complete** - All files are in place
|
||||||
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
||||||
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
||||||
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
4. 📝 **Add to .env** - Set `UMAMI_WEBSITE_ID`
|
||||||
5. 🧪 **Test in development** - Verify events are tracked
|
5. 🧪 **Test in development** - Verify events are tracked
|
||||||
6. 🚀 **Deploy** - Analytics will work in production
|
6. 🚀 **Deploy** - Analytics will work in production
|
||||||
|
|
||||||
## Quick Start Checklist
|
## Quick Start Checklist
|
||||||
|
|
||||||
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
|
||||||
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
||||||
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
||||||
- [ ] Test in development mode (check console logs)
|
- [ ] Test in development mode (check console logs)
|
||||||
|
|||||||
62
components/analytics/ScrollDepthTracker.tsx
Normal file
62
components/analytics/ScrollDepthTracker.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollDepthTracker
|
||||||
|
* Tracks user scroll progress across pages.
|
||||||
|
* Fires events at 25%, 50%, 75%, and 100% depth.
|
||||||
|
*/
|
||||||
|
export default function ScrollDepthTracker() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const trackedDepths = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Reset tracking when path changes
|
||||||
|
useEffect(() => {
|
||||||
|
trackedDepths.current.clear();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
// Calculate how far the user has scrolled in percentage
|
||||||
|
// documentHeight - windowHeight is the total scrollable distance
|
||||||
|
const totalScrollable = documentHeight - windowHeight;
|
||||||
|
if (totalScrollable <= 0) return; // Not scrollable
|
||||||
|
|
||||||
|
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
|
||||||
|
|
||||||
|
// We only care about specific milestones
|
||||||
|
const milestones = [25, 50, 75, 100];
|
||||||
|
|
||||||
|
milestones.forEach((milestone) => {
|
||||||
|
if (scrollPercentage >= milestone && !trackedDepths.current.has(milestone)) {
|
||||||
|
trackedDepths.current.add(milestone);
|
||||||
|
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
|
||||||
|
depth: milestone,
|
||||||
|
path: pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use passive listener for better performance
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Initial check (in case page is short or already scrolled)
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [pathname, trackEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
34
components/analytics/TrackedButton.tsx
Normal file
34
components/analytics/TrackedButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button, ButtonProps } from '../ui/Button';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface TrackedButtonProps extends ButtonProps {
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around the project's Button component that tracks click events.
|
||||||
|
* Safe to use in server components.
|
||||||
|
*/
|
||||||
|
export default function TrackedButton({
|
||||||
|
eventName = AnalyticsEvents.BUTTON_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: TrackedButtonProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
...eventProperties,
|
||||||
|
label: typeof props.children === 'string' ? props.children : eventProperties.label,
|
||||||
|
});
|
||||||
|
if (onClick) onClick(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Button {...props} onClick={handleClick} />;
|
||||||
|
}
|
||||||
44
components/analytics/TrackedLink.tsx
Normal file
44
components/analytics/TrackedLink.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
interface TrackedLinkProps {
|
||||||
|
href: string;
|
||||||
|
eventName?: string;
|
||||||
|
eventProperties?: Record<string, any>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around next/link that tracks the click event.
|
||||||
|
* Useful for adding tracking to server components.
|
||||||
|
*/
|
||||||
|
export default function TrackedLink({
|
||||||
|
href,
|
||||||
|
eventName = AnalyticsEvents.LINK_CLICK,
|
||||||
|
eventProperties = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: TrackedLinkProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
trackEvent(eventName, {
|
||||||
|
href,
|
||||||
|
...eventProperties,
|
||||||
|
});
|
||||||
|
if (onClick) onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} onClick={handleClick}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Analytics Events Utility
|
* Analytics Events Utility
|
||||||
*
|
*
|
||||||
* Centralized definitions for common analytics events and their properties.
|
* Centralized definitions for common analytics events and their properties.
|
||||||
* This helps maintain consistency across the application and makes it easier
|
* This helps maintain consistency across the application and makes it easier
|
||||||
* to track meaningful events.
|
* to track meaningful events.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
*
|
*
|
||||||
* function ProductPage() {
|
* function ProductPage() {
|
||||||
* const { trackEvent } = useAnalytics();
|
* const { trackEvent } = useAnalytics();
|
||||||
*
|
*
|
||||||
* const handleAddToCart = (productId: string, productName: string) => {
|
* const handleAddToCart = (productId: string, productName: string) => {
|
||||||
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||||
* product_id: productId,
|
* product_id: productId,
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
* page: 'product-detail'
|
* page: 'product-detail'
|
||||||
* });
|
* });
|
||||||
* };
|
* };
|
||||||
*
|
*
|
||||||
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
|
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
@@ -31,6 +31,7 @@ export const AnalyticsEvents = {
|
|||||||
PAGE_VIEW: 'pageview',
|
PAGE_VIEW: 'pageview',
|
||||||
PAGE_SCROLL: 'page_scroll',
|
PAGE_SCROLL: 'page_scroll',
|
||||||
PAGE_EXIT: 'page_exit',
|
PAGE_EXIT: 'page_exit',
|
||||||
|
SCROLL_DEPTH: 'scroll_depth',
|
||||||
|
|
||||||
// User Interaction Events
|
// User Interaction Events
|
||||||
BUTTON_CLICK: 'button_click',
|
BUTTON_CLICK: 'button_click',
|
||||||
@@ -38,6 +39,7 @@ export const AnalyticsEvents = {
|
|||||||
FORM_SUBMIT: 'form_submit',
|
FORM_SUBMIT: 'form_submit',
|
||||||
FORM_START: 'form_start',
|
FORM_START: 'form_start',
|
||||||
FORM_ERROR: 'form_error',
|
FORM_ERROR: 'form_error',
|
||||||
|
FORM_FIELD_FOCUS: 'form_field_focus',
|
||||||
|
|
||||||
// E-commerce Events
|
// E-commerce Events
|
||||||
PRODUCT_VIEW: 'product_view',
|
PRODUCT_VIEW: 'product_view',
|
||||||
@@ -46,6 +48,7 @@ export const AnalyticsEvents = {
|
|||||||
PRODUCT_PURCHASE: 'product_purchase',
|
PRODUCT_PURCHASE: 'product_purchase',
|
||||||
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
||||||
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
||||||
|
PRODUCT_TAB_SWITCH: 'product_tab_switch',
|
||||||
|
|
||||||
// Search & Filter Events
|
// Search & Filter Events
|
||||||
SEARCH: 'search',
|
SEARCH: 'search',
|
||||||
@@ -71,6 +74,7 @@ export const AnalyticsEvents = {
|
|||||||
TOGGLE_SWITCH: 'toggle_switch',
|
TOGGLE_SWITCH: 'toggle_switch',
|
||||||
ACCORDION_TOGGLE: 'accordion_toggle',
|
ACCORDION_TOGGLE: 'accordion_toggle',
|
||||||
TAB_SWITCH: 'tab_switch',
|
TAB_SWITCH: 'tab_switch',
|
||||||
|
TOC_CLICK: 'toc_click',
|
||||||
|
|
||||||
// Error & Performance Events
|
// Error & Performance Events
|
||||||
ERROR: 'error',
|
ERROR: 'error',
|
||||||
|
|||||||
@@ -5,43 +5,47 @@ import { PostMdx } from '@/lib/blog';
|
|||||||
interface PostNavigationProps {
|
interface PostNavigationProps {
|
||||||
prev: PostMdx | null;
|
prev: PostMdx | null;
|
||||||
next: PostMdx | null;
|
next: PostMdx | null;
|
||||||
|
isPrevRandom?: boolean;
|
||||||
|
isNextRandom?: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
|
export default function PostNavigation({ prev, next, isPrevRandom, isNextRandom, locale }: PostNavigationProps) {
|
||||||
if (!prev && !next) return null;
|
if (!prev && !next) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 w-full mt-16">
|
<div className="grid grid-cols-1 md:grid-cols-2 w-full mt-16">
|
||||||
{/* Previous Post (Older) */}
|
{/* Previous Post (Older) */}
|
||||||
{prev ? (
|
{prev ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/blog/${prev.slug}`}
|
href={`/${locale}/blog/${prev.slug}`}
|
||||||
className="group relative h-64 md:h-80 overflow-hidden block"
|
className="group relative h-64 md:h-80 overflow-hidden block"
|
||||||
>
|
>
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
{prev.frontmatter.featuredImage ? (
|
{prev.frontmatter.featuredImage ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||||
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
|
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-neutral-100" />
|
<div className="absolute inset-0 bg-neutral-100" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
||||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
||||||
{locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
|
{isPrevRandom
|
||||||
|
? (locale === 'de' ? 'Weiterer Artikel' : 'More Article')
|
||||||
|
: (locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post')}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
{prev.frontmatter.title}
|
{prev.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:-translate-x-2 transition-all duration-300">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/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">
|
||||||
@@ -55,33 +59,35 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
|
|
||||||
{/* Next Post (Newer) */}
|
{/* Next Post (Newer) */}
|
||||||
{next ? (
|
{next ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/blog/${next.slug}`}
|
href={`/${locale}/blog/${next.slug}`}
|
||||||
className="group relative h-64 md:h-80 overflow-hidden block"
|
className="group relative h-64 md:h-80 overflow-hidden block"
|
||||||
>
|
>
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
{next.frontmatter.featuredImage ? (
|
{next.frontmatter.featuredImage ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||||
style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
|
style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-neutral-100" />
|
<div className="absolute inset-0 bg-neutral-100" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
||||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
||||||
{locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
|
{isNextRandom
|
||||||
|
? (locale === 'de' ? 'Weiterer Artikel' : 'More Article')
|
||||||
|
: (locale === 'de' ? 'Nächster Beitrag' : 'Next Post')}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
{next.frontmatter.title}
|
{next.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:translate-x-2 transition-all duration-300">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:translate-x-2 transition-all duration-300">
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -14,57 +14,84 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
|||||||
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative accent */}
|
{/* Decorative accent */}
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
|
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
|
||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
|
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
|
||||||
{isDe ? 'Lösungen' : 'Solutions'}
|
{isDe ? 'Lösungen' : 'Solutions'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
|
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
|
||||||
{isDe ? 'Bereit für die' : 'Ready for the'}
|
{isDe ? 'Bereit für die' : 'Ready for the'}
|
||||||
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
|
<p className="text-xl text-white/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.'}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||||
{[
|
{[
|
||||||
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
||||||
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
||||||
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
|
isDe
|
||||||
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
|
? 'Expertenberatung für Großprojekte'
|
||||||
|
: 'Expert consulting for large-scale projects',
|
||||||
|
isDe
|
||||||
|
? 'Zertifizierte Qualität nach EU-Standards'
|
||||||
|
: 'Certified quality according to EU standards',
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-4 text-white/80">
|
<div key={i} className="flex items-center gap-4 text-white/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 className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
className="w-3 h-3 text-accent"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{item}</span>
|
<span className="text-sm font-medium">{item}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/contact`}
|
||||||
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
||||||
>
|
>
|
||||||
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
||||||
<svg className="w-5 h-5 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-5 h-5 transition-transform group-hover/btn:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/50 text-sm font-medium">
|
<p className="text-white/50 text-sm font-medium">
|
||||||
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
|
{isDe
|
||||||
|
? 'Kostenlose Erstberatung für Ihr Vorhaben.'
|
||||||
|
: 'Free initial consultation for your project.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
|
||||||
interface TocItem {
|
interface TocItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,11 +18,12 @@ interface TableOfContentsProps {
|
|||||||
|
|
||||||
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observerOptions = {
|
const observerOptions = {
|
||||||
rootMargin: '-10% 0% -70% 0%',
|
rootMargin: '-10% 0% -70% 0%',
|
||||||
threshold: 0
|
threshold: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -66,15 +69,20 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
|||||||
<a
|
<a
|
||||||
href={`#${heading.id}`}
|
href={`#${heading.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug",
|
'text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug',
|
||||||
activeId === heading.id
|
activeId === heading.id
|
||||||
? "text-primary font-bold translate-x-1"
|
? 'text-primary font-bold translate-x-1'
|
||||||
: "text-text-secondary font-medium hover:translate-x-1"
|
: 'text-text-secondary font-medium hover:translate-x-1',
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const element = document.getElementById(heading.id);
|
const element = document.getElementById(heading.id);
|
||||||
if (element) {
|
if (element) {
|
||||||
|
trackEvent(AnalyticsEvents.TOC_CLICK, {
|
||||||
|
heading_id: heading.id,
|
||||||
|
heading_text: heading.text,
|
||||||
|
location: 'blog_sidebar',
|
||||||
|
});
|
||||||
const yOffset = -100;
|
const yOffset = -100;
|
||||||
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||||
|
|||||||
@@ -19,53 +19,78 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
|
<Link
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block my-12 no-underline group"
|
||||||
|
>
|
||||||
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
||||||
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
||||||
{image ? (
|
{image ? (
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
unoptimized
|
unoptimized
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
||||||
<svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
className="w-12 h-12 text-primary/20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Industrial overlay */}
|
{/* Industrial overlay */}
|
||||||
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 flex flex-col justify-center relative">
|
<div className="p-8 flex flex-col justify-center relative">
|
||||||
{/* Industrial accent corner */}
|
{/* Industrial accent corner */}
|
||||||
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/80 bg-primary/10 px-2 py-0.5 rounded">
|
||||||
External Link
|
External Link
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/80">
|
||||||
{hostname}
|
{hostname}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
<h3 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-text-secondary text-base line-clamp-2 leading-relaxed mb-4">
|
<p className="text-text-secondary text-base line-clamp-2 leading-relaxed mb-4">
|
||||||
{summary}
|
{summary}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
||||||
<span>Read more</span>
|
<span>Read more</span>
|
||||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ export default function Experience() {
|
|||||||
return (
|
return (
|
||||||
<Section className="relative py-32 md:py-48 overflow-hidden text-white">
|
<Section className="relative py-32 md:py-48 overflow-hidden text-white">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||||
alt={t('subtitle')}
|
alt={t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
/>
|
/>
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
||||||
@@ -29,21 +29,27 @@ export default function Experience() {
|
|||||||
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
||||||
{t('p1')}
|
{t('p1')}
|
||||||
</p>
|
</p>
|
||||||
<p className="pl-9">
|
<p className="pl-9">{t('p2')}</p>
|
||||||
{t('p2')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('certifiedQuality')}</div>
|
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('vdeApproved')}</div>
|
{t('certifiedQuality')}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
|
{t('vdeApproved')}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('fullSpectrum')}</div>
|
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('solutionsRange')}</div>
|
{t('fullSpectrum')}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
|
{t('solutionsRange')}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Section, Container, Heading } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
import Lightbox from '../Lightbox';
|
import dynamic from 'next/dynamic';
|
||||||
|
const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false });
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
export default function GallerySection() {
|
export default function GallerySection() {
|
||||||
@@ -19,19 +19,9 @@ export default function GallerySection() {
|
|||||||
'/uploads/2024/12/DSC07768-Large.webp',
|
'/uploads/2024/12/DSC07768-Large.webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const photoParam = searchParams.get('photo');
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const lightboxOpen = photoParam !== null;
|
||||||
|
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||||
useEffect(() => {
|
|
||||||
const photoParam = searchParams.get('photo');
|
|
||||||
if (photoParam !== null) {
|
|
||||||
const index = parseInt(photoParam, 10);
|
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
|
||||||
setLightboxIndex(index);
|
|
||||||
setLightboxOpen(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, images.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-white py-32">
|
<Section className="bg-white text-white py-32">
|
||||||
@@ -39,14 +29,18 @@ export default function GallerySection() {
|
|||||||
<Heading level={2} subtitle={t('subtitle')} align="center">
|
<Heading level={2} subtitle={t('subtitle')} align="center">
|
||||||
{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">
|
||||||
{images.map((src, idx) => (
|
{images.map((src, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t('alt')} ${idx + 1}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLightboxIndex(idx);
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
setLightboxOpen(true);
|
params.set('photo', idx.toString());
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
// Since we're using derive-from-url, the component will re-render with the new value
|
||||||
}}
|
}}
|
||||||
className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700 cursor-pointer"
|
className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700 cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -55,7 +49,8 @@ export default function GallerySection() {
|
|||||||
alt={`${t('alt')} ${idx + 1}`}
|
alt={`${t('alt')} ${idx + 1}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
unoptimized
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
||||||
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||||
@@ -68,7 +63,11 @@ export default function GallerySection() {
|
|||||||
isOpen={lightboxOpen}
|
isOpen={lightboxOpen}
|
||||||
images={images}
|
images={images}
|
||||||
initialIndex={lightboxIndex}
|
initialIndex={lightboxIndex}
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('photo');
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,167 +2,96 @@
|
|||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { motion } from 'framer-motion';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { useTranslations } from 'next-intl';
|
import dynamic from 'next/dynamic';
|
||||||
import HeroIllustration from './HeroIllustration';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<motion.div
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
className="max-w-5xl mx-auto md:mx-0"
|
<div>
|
||||||
initial="hidden"
|
<Heading
|
||||||
animate="visible"
|
level={1}
|
||||||
variants={containerVariants}
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-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]"
|
||||||
>
|
>
|
||||||
<motion.div variants={headingVariants}>
|
|
||||||
<Heading level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]">
|
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="relative inline-block">
|
||||||
<motion.span
|
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
|
||||||
className="relative z-10 text-accent italic"
|
<div
|
||||||
variants={accentVariants}
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||||
>
|
style={{ animationDelay: '500ms' }}
|
||||||
{chunks}
|
|
||||||
</motion.span>
|
|
||||||
<motion.div
|
|
||||||
variants={scribbleVariants}
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
|
||||||
>
|
>
|
||||||
<Scribble variant="circle" />
|
<Scribble variant="circle" />
|
||||||
</motion.div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div variants={subtitleVariants}>
|
<div>
|
||||||
<p className="text-lg md:text-xl text-white/90 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">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div
|
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
<div>
|
||||||
variants={buttonContainerVariants}
|
<Button
|
||||||
>
|
href="/contact"
|
||||||
<motion.div variants={buttonVariants}>
|
variant="accent"
|
||||||
<Button href="/contact" variant="accent" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg">
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
<motion.div variants={buttonVariants}>
|
<div>
|
||||||
<Button href="/products" variant="white" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none">
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{t('exploreProducts')}
|
{t('exploreProducts')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<motion.div
|
<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">
|
||||||
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 />
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
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"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
style={{ animationDelay: '2000ms' }}
|
||||||
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">
|
||||||
<motion.div
|
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||||
className="w-1 h-2 bg-white rounded-full"
|
|
||||||
animate={{ y: [0, -10, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 1 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.12,
|
|
||||||
delayChildren: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const headingVariants = {
|
|
||||||
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const accentVariants = {
|
|
||||||
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const scribbleVariants = {
|
|
||||||
hidden: { opacity: 0, scale: 0, rotate: 180 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: { duration: 1, type: "spring", stiffness: 300, damping: 20 }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const subtitleVariants = {
|
|
||||||
hidden: { opacity: 0, y: 40, scale: 0.95 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const buttonContainerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.15,
|
|
||||||
delayChildren: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const buttonVariants = {
|
|
||||||
hidden: { opacity: 0, y: 30, scale: 0.9 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: { type: "spring", stiffness: 400, damping: 20 }
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,43 +11,53 @@ export default function MeetTheTeam() {
|
|||||||
return (
|
return (
|
||||||
<Section className="relative py-32 md:py-48 overflow-hidden">
|
<Section className="relative py-32 md:py-48 overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||||
alt={t('subtitle')}
|
alt={t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
/>
|
/>
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl text-white animate-slide-up">
|
<div className="max-w-3xl text-white animate-slide-up">
|
||||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
||||||
<span className="text-white">{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">
|
||||||
"{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">
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex -space-x-4">
|
<div className="flex -space-x-4">
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
||||||
<Image src="/uploads/2024/12/DSC07768-Large.webp" alt={teamT('michael.name')} fill className="object-cover" />
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||||
|
alt={teamT('michael.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
|
||||||
<Image src="/uploads/2024/12/DSC07963-Large.webp" alt={teamT('klaus.name')} fill className="object-cover" />
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||||
|
alt={teamT('klaus.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
||||||
|
|||||||
@@ -8,63 +8,78 @@ export default function ProductCategories() {
|
|||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const productsBase = locale === 'de' ? 'produkte' : 'products';
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
title: t('categories.lowVoltage.title'),
|
title: t('categories.lowVoltage.title'),
|
||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${locale}/products/low-voltage-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'niederspannungskabel' : 'low-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${locale}/products/medium-voltage-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'mittelspannungskabel' : 'medium-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${locale}/products/high-voltage-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'hochspannungskabel' : 'high-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${locale}/products/solar-cables`
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||||
|
<h2 className="sr-only">{t('title')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Link key={idx} href={category.href} className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0">
|
<Link
|
||||||
<Image
|
key={idx}
|
||||||
src={category.img}
|
href={category.href}
|
||||||
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={category.img}
|
||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 24vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
|
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
|
||||||
<Image src={category.icon} alt="" width={40} height={40} className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert" unoptimized />
|
<Image
|
||||||
|
src={category.icon}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">{category.title}</h3>
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">
|
||||||
|
{category.title}
|
||||||
|
</h3>
|
||||||
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
|
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
|
||||||
{category.desc}
|
{category.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
{t('exploreCategory')} <span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
{t('exploreCategory')}{' '}
|
||||||
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
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';
|
||||||
@@ -22,54 +23,78 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
|||||||
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
||||||
{t('allArticles')}
|
{t('allArticles')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Link href={`/${locale}/blog`} className="group flex items-center text-primary font-bold text-base md:text-lg touch-target">
|
<Link
|
||||||
{t('allArticles')}
|
href={`/${locale}/blog`}
|
||||||
|
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
|
||||||
|
>
|
||||||
|
{t('allArticles')}
|
||||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
||||||
{recentPosts.map((post) => (
|
{recentPosts.map((post) => (
|
||||||
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
|
<li key={post.slug}>
|
||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
||||||
{post.frontmatter.featuredImage && (
|
<Card
|
||||||
<div className="relative h-64 overflow-hidden">
|
tag="article"
|
||||||
<img
|
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
||||||
src={post.frontmatter.featuredImage}
|
>
|
||||||
alt={post.frontmatter.title}
|
{post.frontmatter.featuredImage && (
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
<div className="relative h-64 overflow-hidden">
|
||||||
/>
|
<Image
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
src={post.frontmatter.featuredImage}
|
||||||
{post.frontmatter.category && (
|
alt={post.frontmatter.title}
|
||||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
fill
|
||||||
{post.frontmatter.category}
|
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
</Badge>
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
)}
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
{post.frontmatter.category && (
|
||||||
|
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||||
|
{post.frontmatter.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||||
|
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||||
|
<time dateTime={post.frontmatter.date}>
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||||
|
{post.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||||
|
{t('readMore')}
|
||||||
|
<svg
|
||||||
|
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
</Link>
|
||||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
</li>
|
||||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})}
|
|
||||||
</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">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection() {
|
export default function VideoSection() {
|
||||||
const t = useTranslations('Home.video');
|
const t = useTranslations('Home.video');
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sectionRef.current) {
|
||||||
|
observer.observe(sectionRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[70vh] overflow-hidden bg-primary">
|
<section ref={sectionRef} className="relative h-[70vh] overflow-hidden bg-primary">
|
||||||
<video
|
{isVisible && (
|
||||||
className="w-full h-full object-cover opacity-60"
|
<video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
|
||||||
autoPlay
|
<source
|
||||||
muted
|
src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm"
|
||||||
loop
|
type="video/webm"
|
||||||
playsInline
|
/>
|
||||||
>
|
</video>
|
||||||
<source src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm" type="video/mp4" />
|
)}
|
||||||
</video>
|
<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">
|
<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">
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
future: (chunks) => (
|
future: (chunks) => (
|
||||||
<span className="relative inline-block mx-2">
|
<span className="relative inline-block mx-2">
|
||||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
<span className="relative z-10 italic text-accent">{chunks}</span>
|
||||||
<Scribble variant="underline" className="w-full h-4 -bottom-2 left-0 text-accent/40" />
|
<Scribble
|
||||||
|
variant="underline"
|
||||||
|
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
})}
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,32 +17,54 @@ export default function WhyChooseUs() {
|
|||||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-12 space-y-6">
|
<ul className="mt-12 space-y-6 list-none p-0">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<li key={i} className="flex items-center gap-4">
|
||||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
||||||
<svg className="w-4 h-4 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
className="w-4 h-4 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
|
<span className="font-bold text-primary-dark text-base md:text-base">
|
||||||
</div>
|
{t(`features.${i}`)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
|
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
|
||||||
{[0, 1, 2, 3].map((idx) => (
|
{[0, 1, 2, 3].map((idx) => (
|
||||||
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
|
||||||
|
>
|
||||||
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
||||||
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
|
<span className="text-white font-bold text-lg group-hover:text-primary-dark">
|
||||||
|
0{idx + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
|
<h3 className="text-xl font-bold mb-4 text-primary-dark">
|
||||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
{t(`items.${idx}.title`)}
|
||||||
</div>
|
</h3>
|
||||||
|
<p className="text-text-secondary text-base md:text-base leading-relaxed">
|
||||||
|
{t(`items.${idx}.description`)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
118
components/record-mode/PickingHelper.tsx
Normal file
118
components/record-mode/PickingHelper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
components/record-mode/PlaybackCursor.tsx
Normal file
92
components/record-mode/PlaybackCursor.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function PlaybackCursor() {
|
||||||
|
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
|
||||||
|
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Track scroll so cursor stays locked to the correct element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll(); // Init
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||||
|
<m.div
|
||||||
|
className="fixed z-[10000] pointer-events-none"
|
||||||
|
animate={{
|
||||||
|
x: cursorPosition.x,
|
||||||
|
y: cursorPosition.y,
|
||||||
|
scale: isClicking ? 0.8 : 1,
|
||||||
|
rotateX: isClicking ? 15 : 0,
|
||||||
|
rotateY: isClicking ? -15 : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
|
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
|
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
}}
|
||||||
|
style={{ perspective: '1000px' }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isClicking && (
|
||||||
|
<m.div
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 2.5, opacity: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Outer Pulse Ring */}
|
||||||
|
<div
|
||||||
|
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual Cursor */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Soft Glow */}
|
||||||
|
<div
|
||||||
|
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pointer Arrow */}
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||||
|
fill={isClicking ? '#82ed20' : 'white'}
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
392
components/record-mode/RecordModeContext.tsx
Normal file
392
components/record-mode/RecordModeContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
605
components/record-mode/RecordModeOverlay.tsx
Normal file
605
components/record-mode/RecordModeOverlay.tsx
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
import { Reorder, AnimatePresence, LazyMotion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
MousePointer2,
|
||||||
|
Scroll,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Settings2,
|
||||||
|
GripVertical,
|
||||||
|
Clock,
|
||||||
|
Maximize2,
|
||||||
|
Box,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { RecordEvent } from '@/types/record-mode';
|
||||||
|
import { PlaybackCursor } from './PlaybackCursor';
|
||||||
|
|
||||||
|
export function RecordModeOverlay() {
|
||||||
|
const {
|
||||||
|
isActive,
|
||||||
|
setIsActive,
|
||||||
|
events,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
removeEvent,
|
||||||
|
isPlaying,
|
||||||
|
playEvents,
|
||||||
|
saveSession,
|
||||||
|
clearEvents,
|
||||||
|
reorderEvents,
|
||||||
|
setHoveredEventId,
|
||||||
|
setEvents, // Added setEvents here
|
||||||
|
} = useRecordMode();
|
||||||
|
|
||||||
|
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||||
|
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||||
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || !isActive) return;
|
||||||
|
|
||||||
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||||
|
const { selector, rect, tagName } = e.data;
|
||||||
|
|
||||||
|
if (pickingMode === 'mouse') {
|
||||||
|
addEvent({
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: lastInteractionType,
|
||||||
|
selector,
|
||||||
|
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||||
|
motionBlur: false,
|
||||||
|
realClick: false,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
} else if (pickingMode === 'scroll') {
|
||||||
|
addEvent({
|
||||||
|
type: 'scroll',
|
||||||
|
selector,
|
||||||
|
duration: 1500,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Scroll to ${tagName}`,
|
||||||
|
motionBlur: false,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setPickingMode(null);
|
||||||
|
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||||
|
setPickingMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
if (pickingMode) {
|
||||||
|
// Find the iframe and signal start picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Signal stop picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
}, [isActive, pickingMode, addEvent, mounted]);
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingEventId) {
|
||||||
|
updateEvent(editingEventId, editForm);
|
||||||
|
setEditingEventId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showEvents, setShowEvents] = useState(true);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
// Failsafe: Never render host toggle in embedded mode
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window.self !== window.top ||
|
||||||
|
window.name === 'record-mode-iframe' ||
|
||||||
|
window.location.search.includes('embedded=true'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(true)}
|
||||||
|
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||||
|
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||||
|
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||||
|
{/* Identity Tag */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||||
|
Event Builder
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||||
|
Manual Mode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Action Tools */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode('mouse');
|
||||||
|
setLastInteractionType('click');
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={16} />
|
||||||
|
<span>Mouse</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPickingMode('scroll')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={16} />
|
||||||
|
<span>Scroll</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
addEvent({
|
||||||
|
type: 'wait',
|
||||||
|
duration: 2000,
|
||||||
|
zoom: 1,
|
||||||
|
description: 'Wait for 2s',
|
||||||
|
motionBlur: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Sequence Controls */}
|
||||||
|
<div className="flex items-center gap-1 p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={playEvents}
|
||||||
|
disabled={isPlaying || events.length === 0}
|
||||||
|
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||||
|
title="Preview Sequence"
|
||||||
|
>
|
||||||
|
<Play size={18} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEvents(!showEvents)}
|
||||||
|
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
{events.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||||
|
{events.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const session = {
|
||||||
|
events,
|
||||||
|
name: 'Recording',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/save-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(session),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Visual feedback could be improved, but alert is fine for dev tool
|
||||||
|
alert('Session saved to remotion/session.json');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Failed to save: ${err.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error saving session');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||||
|
title="Save to Project (Dev)"
|
||||||
|
>
|
||||||
|
<Save size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const data = JSON.stringify(
|
||||||
|
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'remotion-session.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||||
|
title="Download JSON"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(false)}
|
||||||
|
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||||
|
title="Exit Studio"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Event Timeline Popover */}
|
||||||
|
{showEvents && (
|
||||||
|
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||||
|
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||||
|
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||||
|
{events.length} Actions Recorded
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearEvents}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={events}
|
||||||
|
onReorder={setEvents}
|
||||||
|
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||||
|
>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||||
|
<Plus size={40} strokeWidth={1} />
|
||||||
|
<p className="text-xs mt-4">Timeline is empty</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={event.id}
|
||||||
|
value={event}
|
||||||
|
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||||
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||||
|
onMouseLeave={() => setHoveredEventId(null)}
|
||||||
|
>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{event.type === 'mouse'
|
||||||
|
? `Mouse (${event.interactionType})`
|
||||||
|
: event.type}
|
||||||
|
</span>
|
||||||
|
{event.clickOrigin &&
|
||||||
|
event.clickOrigin !== 'center' &&
|
||||||
|
event.interactionType === 'click' && (
|
||||||
|
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
|
{event.clickOrigin}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||||
|
{event.duration}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||||
|
{event.selector || 'system:wait'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEventId(event.id);
|
||||||
|
setEditForm(event);
|
||||||
|
}}
|
||||||
|
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEvent(event.id)}
|
||||||
|
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Reorder.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||||
|
|
||||||
|
{/* Picking Tooltip */}
|
||||||
|
{pickingMode && (
|
||||||
|
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||||
|
<span className="font-black uppercase tracking-widest text-xs">
|
||||||
|
Assigning {pickingMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-primary-dark/20" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
ESC to Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaybackCursor />
|
||||||
|
|
||||||
|
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingEventId && (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||||
|
Event Options
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingEventId(null)}
|
||||||
|
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||||
|
{/* Type Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Interaction Type
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'click',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Click</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'hover',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precise Click Origin */}
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Click Origin
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
{[
|
||||||
|
{ id: 'top-left', label: 'TL' },
|
||||||
|
{ id: 'top-right', label: 'TR' },
|
||||||
|
{ id: 'center', label: 'CTR' },
|
||||||
|
{ id: 'bottom-left', label: 'BL' },
|
||||||
|
{ id: 'bottom-right', label: 'BR' },
|
||||||
|
].map((origin) => (
|
||||||
|
<button
|
||||||
|
key={origin.id}
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||||
|
}
|
||||||
|
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||||
|
>
|
||||||
|
{origin.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||||
|
<span>Timeline Allocation</span>
|
||||||
|
<span className="text-accent">{editForm.duration}ms</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="5000"
|
||||||
|
step="100"
|
||||||
|
value={editForm.duration || 1000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom & Effects */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Maximize2 size={18} className="text-white/40" />
|
||||||
|
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
Zoom Shift
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
value={editForm.zoom || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
|
||||||
|
}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Box size={18} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Motion Blur
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
|
||||||
|
}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Trigger Navigation
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] opacity-60">
|
||||||
|
Allows URL transitions in Studio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
Commit Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
components/record-mode/RecordModeVisuals.tsx
Normal file
259
components/record-mode/RecordModeVisuals.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
'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(() => {
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Recursion Guard: If we are already in an embedded iframe,
|
||||||
|
// strictly return just the children to prevent Inception.
|
||||||
|
// Note: This causes a hydration mismatch remount ONLY when actually embedded (e.g. inside Directus).
|
||||||
|
// Standard users and Lighthouse bots will NOT suffer a remount.
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
components/record-mode/ToolCoordinator.tsx
Normal file
75
components/record-mode/ToolCoordinator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Lightbox from '@/components/Lightbox';
|
import dynamic from 'next/dynamic';
|
||||||
|
const Lightbox = dynamic(() => import('@/components/Lightbox'), { ssr: false });
|
||||||
import { Section, Container, Heading } from '@/components/ui';
|
import { Section, Container, Heading } from '@/components/ui';
|
||||||
|
|
||||||
export default function Gallery() {
|
export default function Gallery() {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from './utils';
|
import { cn } from './utils';
|
||||||
|
|
||||||
export function Card({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
interface CardProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
tag?: 'div' | 'article' | 'section' | 'aside' | 'header' | 'footer' | 'nav' | 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ className, children, tag: Tag = 'div', ...props }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('premium-card overflow-hidden', className)} {...props}>
|
<Tag className={cn('premium-card overflow-hidden', className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
config/lighthouserc.json
Normal file
57
config/lighthouserc.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"numberOfRuns": 3,
|
||||||
|
"settings": {
|
||||||
|
"preset": "desktop",
|
||||||
|
"onlyCategories": [
|
||||||
|
"performance",
|
||||||
|
"accessibility",
|
||||||
|
"best-practices",
|
||||||
|
"seo"
|
||||||
|
],
|
||||||
|
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"minScore": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories:accessibility": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"minScore": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories:best-practices": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"minScore": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories:seo": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"minScore": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"first-contentful-paint": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"maxNumericValue": 2000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactive": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"maxNumericValue": 3500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ date: '2025-03-31T12:00:34'
|
|||||||
featuredImage: /uploads/2025/02/image_fx_-6.webp
|
featuredImage: /uploads/2025/02/image_fx_-6.webp
|
||||||
locale: de
|
locale: de
|
||||||
category: Kabel Technologie
|
category: Kabel Technologie
|
||||||
|
excerpt: Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer Energie sind.
|
||||||
---
|
---
|
||||||
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
|
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
|
||||||
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.
|
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
title: 'Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch'
|
||||||
|
date: '2026-02-20T14:50:00'
|
||||||
|
featuredImage: /uploads/2026/01/1767353529807.jpg
|
||||||
|
locale: de
|
||||||
|
category: Kabel Technologie
|
||||||
|
excerpt: 'KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Johannes Gleich übernimmt die Rolle des Senior Key Account Managers. Erfahren Sie mehr über unseren neuen Experten für Infrastruktur und Energieversorger.'
|
||||||
|
public: false
|
||||||
|
---
|
||||||
|
# Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch
|
||||||
|
|
||||||
|
KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Seit Januar 2026 übernimmt Johannes Gleich die Rolle des Senior Key Account Managers. Mit ihm gewinnen wir nicht nur zusätzliche Vertriebskraft, sondern auch jahrzehntelange Erfahrung und ein wertvolles Branchennetzwerk.
|
||||||
|
|
||||||
|
### **1. Ein bekanntes Gesicht für eine effektive Zusammenarbeit**
|
||||||
|
|
||||||
|
Johannes ist für KLZ kein Neuling: Bereits während seiner über zehnjährigen Tätigkeit bei der LAPP Gruppe hat unser Team die Zusammenarbeit mit ihm kennengelernt und sehr geschätzt. Diese bestehende Vertrautheit und das gegenseitige Vertrauen erleichtern den Einstieg enorm und versprechen eine produktive Kooperation von Tag eins an.
|
||||||
|
|
||||||
|
### **2. Beruflicher Hintergrund: Erfahrung trifft technische Tiefe**
|
||||||
|
|
||||||
|
Mit rund 50 Jahren verbindet Johannes fundierte Berufserfahrung mit frischer Motivation. Seine Basis ist eine technische Ausbildung im Bereich Elektrotechnik. Dieses Fundament ermöglicht es ihm, unsere Produkte nicht nur zu vertreiben, sondern sie in ihrer gesamten technischen Tiefe zu erklären und einzuordnen.
|
||||||
|
|
||||||
|
**Sein Werdegang im Überblick:**
|
||||||
|
|
||||||
|
<TechnicalGrid
|
||||||
|
title="Karrierestationen"
|
||||||
|
items={[
|
||||||
|
{ label: "Seit Jan. 2026", value: "Senior Key Account Manager bei KLZ Vertriebs GmbH (Remote)" },
|
||||||
|
{ label: "2015 – 2026", value: "Projektmanager Infrastrukturbereich Stadtwerke & Energieversorger bei der LAPP Gruppe (Stuttgart)" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
In den vergangenen elf Jahren hat er sich als Experte für die Anforderungen großer Infrastrukturanbieter etabliert. Er kennt die Herausforderungen der Branche – technisch, wirtschaftlich und strategisch – aus erster Hand.
|
||||||
|
|
||||||
|
### **3. Expertise: Ausschreibungen, Normen und Markttrends**
|
||||||
|
|
||||||
|
Was Johannes besonders wertvoll für unser Team macht, ist sein spezialisiertes Fachwissen:
|
||||||
|
|
||||||
|
<TechnicalGrid
|
||||||
|
title="Kernkompetenzen"
|
||||||
|
items={[
|
||||||
|
{ label: "Tender-Management", value: "Seine umfassende Erfahrung macht ihn zu einem sicheren Partner bei komplexen Ausschreibungen." },
|
||||||
|
{ label: "Normen & Fertigung", value: "Er verfügt über tiefgehende Kenntnisse im Bereich Kabelnormen und der Kabelfertigung." },
|
||||||
|
{ label: "Marktkenntnis", value: "Trends, Preisentwicklungen und Beschaffungsstrategien im deutschen Kabelmarkt sind ihm bestens vertraut." },
|
||||||
|
{ label: "Logistik", value: "Fundierte Kenntnisse in der Lieferkette runden sein Profil ab." }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### **4. Ein verlässlicher Partner auf Augenhöhe**
|
||||||
|
|
||||||
|
Johannes genießt bei Kunden eine hohe Wertschätzung als echter „Kümmerer“. Er übernimmt Verantwortung und zeichnet sich durch eine ausgleichende, aber in der Sache klare Verhandlungsführung aus. Seine Fähigkeit, komplexe Anforderungen strukturiert umzusetzen, hat sich bereits in früheren gemeinsamen Projekten mit KLZ bewährt.
|
||||||
|
|
||||||
|
### **5. Neue Rolle und Ziele bei KLZ Cables**
|
||||||
|
|
||||||
|
In seiner neuen Position wird Johannes den Vertrieb strategisch verstärken und die Geschäftsführung operativ entlasten.
|
||||||
|
|
||||||
|
**Seine Kernaufgaben umfassen:**
|
||||||
|
|
||||||
|
- **Gezielte Betreuung:** Fokus auf Stadtwerke, Netzbetreiber und Energieversorger.
|
||||||
|
- **Markterschließung:** Aufbau von Kontakten in den Bereichen Renewables und Tiefbau.
|
||||||
|
- **Strategische Planung:** Umsetzung von Vertriebsaktivitäten ohne administrative Grenzen, um maximale Dynamik zu entfalten.
|
||||||
|
|
||||||
|
### **6. Ausblick**
|
||||||
|
|
||||||
|
Wir freuen uns besonders, dass Johannes bei KLZ den Raum findet, sein gesamtes Wissen optimal für unsere Kunden einzusetzen. Mit seiner Kombination aus technischem Know-how, Markterfahrung und menschlicher Integrität ist er genau am richtigen Ort, um das Wachstum von KLZ Cables nachhaltig zu fördern.
|
||||||
|
|
||||||
|
Herzlich willkommen im Team, Johannes! Wir freuen uns auf die gemeinsamen Projekte.
|
||||||
@@ -24,15 +24,9 @@ image="https://www.zdf.de/assets/bundestag-berlin-118~1280x720?cb=1741856505967"
|
|||||||
### Warum Kabelhersteller jetzt durchstarten sollten
|
### Warum Kabelhersteller jetzt durchstarten sollten
|
||||||
Es wird viel über Subventionen, Fördergelder und deren Verwendung gesprochen. Doch die eigentliche Herausforderung bleibt: Die notwendige Infrastruktur muss geschaffen werden – und das gelingt nur mit leistungsfähigen Kabeln.
|
Es wird viel über Subventionen, Fördergelder und deren Verwendung gesprochen. Doch die eigentliche Herausforderung bleibt: Die notwendige Infrastruktur muss geschaffen werden – und das gelingt nur mit leistungsfähigen Kabeln.
|
||||||
Die folgenden Trends sind für uns besonders relevant:
|
Die folgenden Trends sind für uns besonders relevant:
|
||||||
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br />
|
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br /></strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
|
||||||
|
- <strong>Dezentralisierung der Energieversorgung:<br /></strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt – und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
|
||||||
</strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
|
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br /></strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
|
||||||
- <strong>Dezentralisierung der Energieversorgung:<br />
|
|
||||||
|
|
||||||
</strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt – und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
|
|
||||||
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br />
|
|
||||||
|
|
||||||
</strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
|
|
||||||
Dies betrifft insbesondere Kabelsysteme, die für hohe Leistung und Stabilität ausgelegt sind – so wie die, die wir bei **KLZ** liefern.
|
Dies betrifft insbesondere Kabelsysteme, die für hohe Leistung und Stabilität ausgelegt sind – so wie die, die wir bei **KLZ** liefern.
|
||||||
### **Die Rolle von KLZ in dieser gigantischen Investitionsoffensive**
|
### **Die Rolle von KLZ in dieser gigantischen Investitionsoffensive**
|
||||||
Mit diesen milliardenschweren Investitionen wird der Bedarf an Erdkabeln, insbesondere Mittelspannungskabeln, geradezu explodieren. Die Frage ist nicht, **ob** Kabel gebraucht werden – sondern **wann und in welchen Mengen**. Und genau da kommen wir ins Spiel.
|
Mit diesen milliardenschweren Investitionen wird der Bedarf an Erdkabeln, insbesondere Mittelspannungskabeln, geradezu explodieren. Die Frage ist nicht, **ob** Kabel gebraucht werden – sondern **wann und in welchen Mengen**. Und genau da kommen wir ins Spiel.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user