Compare commits
295 Commits
feature/st
...
v1.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e3f7344daf | |||
| 21a7b0ade2 | |||
| d027fbeac2 | |||
| 8a751998eb | |||
| 48c3e1d013 | |||
| 3df4b44b8d | |||
| 07e0f237b9 | |||
| 57a3944301 | |||
| 5fe0a8d83e | |||
| 8062d33f35 | |||
| ebe67afd73 | |||
| b74f6b6f9e | |||
| 24eea9a2fe | |||
| c70288bba7 | |||
| d438dbdc9d | |||
| e0c4aaf298 | |||
| f44487eeac | |||
| a82b95a28f | |||
| ab688a3dab | |||
| a0ce37708e | |||
| 0379d1f05d | |||
| 50347d049d | |||
| 9678181927 | |||
| 3ffaafefe5 | |||
| e5bf8c861c | |||
| 651e14d665 | |||
| 580cd6789c | |||
| db4cf354ff | |||
| e8957e0672 | |||
| 7ef0bca9f6 | |||
| 198944649a | |||
| 6aa741ab0a | |||
| f69952a5da | |||
| 81af9bf3dd | |||
| f1b617e967 | |||
| d6be9beebf | |||
| 0a797260e3 | |||
| 2a4cc76292 | |||
| f87eb27f41 | |||
| acd86099e5 | |||
| 5ab9791c72 | |||
| 8152ccd5df | |||
| 8eeb571c2d | |||
| b1854d5255 | |||
| 7f4f970a38 | |||
| e5908c757c | |||
| 70efb0c593 | |||
| 479a36f1d0 | |||
| 372a0c5cfa | |||
| 42b06e1ef8 | |||
| b25fdd877a | |||
| dd23310ac4 | |||
| f6f28a4529 | |||
| fc3635db86 | |||
| fc000353a9 | |||
| 73c32c6d31 | |||
| 381e0b121f | |||
| 829c074c7f | |||
| 9189e813b2 | |||
| 249313cc37 | |||
| 8232971419 | |||
| f41260e1db | |||
| 950ef9d463 | |||
| fcb3169d04 | |||
| 9e87720494 | |||
| 6a403f47a0 | |||
| 2d8df53e36 | |||
| 6f49dbc56c | |||
| ad2a477636 | |||
| 77a1067820 | |||
| ea3076b4ec | |||
| 17fe0d7107 | |||
| 38b512973b | |||
| 4f73838c21 | |||
| 2f8ce42409 | |||
| cf7af73b72 | |||
| d526bfe56f | |||
| 4cb7d438a0 | |||
| 03e597442b | |||
| 5f9ee7d976 | |||
| c1304403a1 | |||
| 5036c5fe28 | |||
| 50a524c515 | |||
| 57886a01d6 | |||
| c89bd8e80f | |||
| 9c54322654 | |||
| 8a80eb7b9a | |||
| c1773a7072 | |||
| 33ed13d255 | |||
| 0f5811edb9 |
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
!.next/cache
|
||||||
.git
|
.git
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|||||||
29
.env
29
.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,11 +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=directus
|
DIRECTUS_DB_PASSWORD=klz_db_pass
|
||||||
|
# Local Development
|
||||||
|
PROJECT_NAME=klz-cables
|
||||||
|
GATEKEEPER_BYPASS_ENABLED=true
|
||||||
|
TRAEFIK_HOST=klz.localhost
|
||||||
|
DIRECTUS_HOST=cms.klz.localhost
|
||||||
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
COOKIE_DOMAIN=localhost
|
||||||
|
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||||
|
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||||
42
.env.example
42
.env.example
@@ -10,13 +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)
|
||||||
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
|
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)
|
||||||
@@ -39,24 +45,26 @@ MAIL_RECIPIENTS=info@klz-cables.com
|
|||||||
# Logging
|
# Logging
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
SENTRY_DSN=
|
||||||
|
# For Directus Error Tracking
|
||||||
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Varnish Cache (Docker only)
|
# Deployment Configuration (CI/CD only)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
VARNISH_CACHE_SIZE=256m
|
# These are typically set by the CI/CD workflow
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
TRAEFIK_HOST=klz-cables.com
|
||||||
|
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
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Strapi CMS
|
# Varnish Configuration
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
STRAPI_DATABASE_NAME=strapi
|
VARNISH_CACHE_SIZE=256M
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
|
||||||
STRAPI_DATABASE_PASSWORD=strapi
|
|
||||||
STRAPI_URL=http://localhost:1337
|
|
||||||
APP_KEYS=toBeModified1,toBeModified2
|
|
||||||
API_TOKEN_SALT=tobemodified
|
|
||||||
ADMIN_JWT_SECRET=tobemodified
|
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
|
||||||
JWT_SECRET=tobemodified
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IMPORTANT NOTES
|
# IMPORTANT NOTES
|
||||||
@@ -74,7 +82,11 @@ JWT_SECRET=tobemodified
|
|||||||
# ──────────────────
|
# ──────────────────
|
||||||
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
||||||
# 2. Runtime: All vars are loaded from .env file on the server
|
# 2. Runtime: All vars are loaded from .env file on the server
|
||||||
# 3. The .env file should exist at: /home/deploy/sites/klz-cables.com/.env
|
# 3. Branch Deployments:
|
||||||
|
# - main branch uses .env.prod
|
||||||
|
# - staging branch uses .env.staging
|
||||||
|
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
|
||||||
|
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
|
||||||
#
|
#
|
||||||
# Security:
|
# Security:
|
||||||
# ─────────
|
# ─────────
|
||||||
|
|||||||
@@ -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,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
56
.gitea/workflows/ci.yml
Normal file
56
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: CI - Lint, Typecheck & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-assurance:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
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
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
|
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
|
- name: 🧪 QA Checks
|
||||||
|
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
|
||||||
|
|
||||||
|
- name: 🏗️ Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: ♿ Accessibility Check
|
||||||
|
run: pnpm check:a11y
|
||||||
@@ -1,196 +1,532 @@
|
|||||||
name: Build & Deploy KLZ Cables
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_checks:
|
||||||
|
description: 'Skip tests? (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 1: Prepare Environment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
prepare:
|
||||||
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
outputs:
|
||||||
|
target: ${{ steps.determine.outputs.target }}
|
||||||
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
|
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||||
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
# LOGGING: Workflow Start - Full Transparency
|
shell: bash
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
- name: 📋 Log Workflow Start
|
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
|
echo "Purging old build layers and dangling images..."
|
||||||
echo " • Commit: ${{ github.sha }}"
|
docker image prune -f
|
||||||
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
docker builder prune -f --filter "until=6h"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🔍 Environment ermitteln
|
||||||
# LOGGING: Registry Login Phase
|
id: determine
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
shell: bash
|
||||||
- name: 🔐 Login to private registry
|
|
||||||
run: |
|
run: |
|
||||||
echo "🔐 Authenticating with registry.infra.mintel.me..."
|
REF="${{ github.ref_name }}"
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
DOMAIN="klz-cables.com"
|
||||||
|
PRJ="klz"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||||
# LOGGING: Build Phase
|
TARGET="testing"
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
- name: 🏗️ Build Docker image
|
ENV_FILE=".env.testing"
|
||||||
run: |
|
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||||
echo "🏗️ Building Docker image (linux/arm64)..."
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
docker buildx build \
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
--pull \
|
TARGET="production"
|
||||||
--platform linux/arm64 \
|
IMAGE_TAG="$REF"
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
ENV_FILE=".env.prod"
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
|
else
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
TARGET="staging"
|
||||||
--push .
|
IMAGE_TAG="$REF"
|
||||||
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
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
|
||||||
# LOGGING: Deployment Phase
|
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?"":" || ")}')
|
||||||
- name: 🚀 Deploy to production server
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
|
else
|
||||||
|
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||||
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "target=$TARGET"
|
||||||
|
echo "image_tag=$IMAGE_TAG"
|
||||||
|
echo "env_file=$ENV_FILE"
|
||||||
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
|
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
echo "project_name=klz-cablescom"
|
||||||
|
else
|
||||||
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
fi
|
||||||
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
|
# 1. Discovery (Works without token for public repositories)
|
||||||
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
|
||||||
|
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
if [[ -n "$POLL_TOKEN" ]]; then
|
||||||
|
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||||
|
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||||
|
chmod +x wait-for-upstream.sh
|
||||||
|
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||||
|
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
qa:
|
||||||
|
name: 🧪 QA
|
||||||
|
needs: prepare
|
||||||
|
if: 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: |
|
run: |
|
||||||
echo "🚀 Deploying to alpha.mintel.me..."
|
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: 🧪 QA Checks
|
||||||
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm lint
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 3: Build & Push
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
build:
|
||||||
|
name: 🏗️ Build
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: 🔐 Registry Login
|
||||||
|
run: 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
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/arm64
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
|
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
|
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||||
|
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2
|
||||||
|
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2,mode=max
|
||||||
|
secrets: |
|
||||||
|
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 4: Deploy
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
deploy:
|
||||||
|
name: 🚀 Deploy
|
||||||
|
needs: [prepare, build, qa]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
|
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
|
||||||
|
# Secrets mapping (Directus)
|
||||||
|
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||||
|
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD) || secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
|
||||||
|
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||||
|
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||||
|
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||||
|
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
|
||||||
|
|
||||||
|
# Secrets mapping (Mail)
|
||||||
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
|
||||||
|
# Gatekeeper
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 📝 Generate Environment
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
|
# 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"
|
||||||
|
|
||||||
# Setup SSH
|
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 "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_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
|
||||||
|
|
||||||
# Create .env file content
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
|
||||||
# ============================================================================
|
|
||||||
# KLZ Cables - Production Environment Configuration
|
|
||||||
# ============================================================================
|
|
||||||
# Auto-generated by CI/CD workflow
|
|
||||||
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Application
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
|
|
||||||
# Analytics (Umami)
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
|
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
|
||||||
|
|
||||||
# Email Configuration (Mailgun)
|
|
||||||
MAIL_HOST=${{ secrets.MAIL_HOST }}
|
|
||||||
MAIL_PORT=${{ secrets.MAIL_PORT }}
|
|
||||||
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
|
|
||||||
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
|
|
||||||
MAIL_FROM=${{ secrets.MAIL_FROM }}
|
|
||||||
MAIL_RECIPIENTS=${{ secrets.MAIL_RECIPIENTS }}
|
|
||||||
|
|
||||||
# Directus
|
|
||||||
DIRECTUS_URL=https://cms.klz-cables.com
|
|
||||||
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY }}
|
|
||||||
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET }}
|
|
||||||
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL }}
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
|
|
||||||
DIRECTUS_DB_NAME=directus
|
|
||||||
DIRECTUS_DB_USER=directus
|
|
||||||
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD }}
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Upload .env and docker-compose.yml
|
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
|
|
||||||
set -e
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
|
|
||||||
chmod 600 .env
|
|
||||||
chown deploy:deploy .env
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
|
|
||||||
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
echo "🚀 Starting containers..."
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
echo "⏳ Giving the app a few seconds to warm up..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo "🔍 Checking container status..."
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
if ! docker-compose ps | grep -q "Up"; then
|
|
||||||
echo "❌ Container failed to start"
|
|
||||||
docker-compose logs --tail=100
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Deployment complete!"
|
# Transfer and Restart
|
||||||
EOF
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||||
|
|
||||||
rm -f /tmp/klz-cables.env
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
scp -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
|
||||||
|
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
|
|
||||||
|
# Apply Directus Schema Snapshot if available
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
|
||||||
|
|
||||||
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
# LOGGING: Workflow Summary
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
- name: 📊 Workflow Summary
|
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: docker builder prune -f --filter "until=1h"
|
||||||
echo "📊 Status: ${{ job.status }}"
|
|
||||||
echo "🎯 Target: alpha.mintel.me"
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# NOTIFICATION: Gotify
|
# JOB 5: Smoke Test (OG Images)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
- name: 🔔 Gotify Notification (Success)
|
smoke_test:
|
||||||
if: success()
|
name: 🧪 Smoke Test
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
if: needs.deploy.result == 'success'
|
||||||
|
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: |
|
run: |
|
||||||
echo "Sending success notification to Gotify..."
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
- name: Setup pnpm cache
|
||||||
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
uses: actions/cache@v4
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
Commit: ${{ github.sha }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
Actor: ${{ github.actor }}
|
restore-keys: |
|
||||||
Run ID: ${{ github.run_id }}" \
|
${{ runner.os }}-pnpm-store-
|
||||||
-F "priority=5")
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
with:
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
run: |
|
||||||
echo "Response Body: $BODY"
|
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
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
- name: Install dependencies
|
||||||
echo "Failed to send Gotify notification"
|
run: pnpm install --frozen-lockfile
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
- name: 🚀 Run OG Image Check
|
||||||
fi
|
env:
|
||||||
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
run: pnpm run check:og
|
||||||
|
|
||||||
- name: 🔔 Gotify Notification (Failure)
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
if: failure()
|
# 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: |
|
run: |
|
||||||
echo "Sending failure notification to Gotify..."
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
- name: Setup pnpm cache
|
||||||
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
uses: actions/cache@v4
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
|
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)
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
echo "🎯 Debian detected - installing native chromium"
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
Commit: ${{ github.sha }}
|
# Fetch PPA key
|
||||||
Actor: ${{ github.actor }}
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
Run ID: ${{ github.run_id }}
|
|
||||||
|
|
||||||
Please check the logs for details." \
|
# Add PPA repository
|
||||||
-F "priority=8")
|
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
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
apt-get update
|
||||||
echo "Response Body: $BODY"
|
apt-get install -y --allow-downgrades chromium
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
|
||||||
echo "Failed to send Gotify notification"
|
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Standardize binary paths
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
- name: ⚡ Run Lighthouse CI
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
CHROME_PATH: /usr/bin/chromium
|
||||||
|
PAGESPEED_LIMIT: 8
|
||||||
|
run: pnpm run pagespeed:test
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 7: Notifications
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
notifications:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
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 }}" \
|
||||||
|
-F "title=$TITLE" \
|
||||||
|
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,2 +1,14 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Lighthouse CI
|
||||||
|
.lighthouseci/
|
||||||
|
lighthouserc.cjs
|
||||||
|
.lighthouserc.json
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
directus/uploads
|
||||||
|
!directus/extensions/
|
||||||
|
!directus/schema/
|
||||||
|
!directus/migrations/
|
||||||
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
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'],
|
||||||
|
};
|
||||||
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
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -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.
|
|
||||||
110
Dockerfile
110
Dockerfile
@@ -1,75 +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* ./
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
RUN npm ci
|
ARG NEXT_PUBLIC_TARGET
|
||||||
|
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_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
|
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
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
FROM base AS builder
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
# 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
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Stage 2: Development (Hot-Reloading)
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
FROM base AS development
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
ENV NODE_ENV=development
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
# Build-time environment variables for Next.js
|
# Build application
|
||||||
# These are baked into the client bundle during build
|
# Stage 3: Builder (Production)
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
FROM base AS builder
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
RUN pnpm build
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
# Stage 3: Runner
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
|
|
||||||
# Validate environment variables during build
|
|
||||||
RUN npx tsx scripts/validate-env.ts
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
|
||||||
FROM base 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
|
|
||||||
76
README.md
76
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
|
||||||
@@ -133,7 +141,7 @@ app/
|
|||||||
├── api/
|
├── api/
|
||||||
│ └── contact/route.ts # Contact API
|
│ └── contact/route.ts # Contact API
|
||||||
├── sitemap.ts # Sitemap generator
|
├── sitemap.ts # Sitemap generator
|
||||||
└── robots.ts # Robots.txt generator
|
├── robots.ts # Robots.txt generator
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
├── data.ts # Data access
|
├── data.ts # Data access
|
||||||
@@ -144,7 +152,7 @@ components/
|
|||||||
├── LocaleSwitcher.tsx # Language switcher
|
├── LocaleSwitcher.tsx # Language switcher
|
||||||
├── ContactForm.tsx # Contact form
|
├── ContactForm.tsx # Contact form
|
||||||
├── CookieConsent.tsx # GDPR banner
|
├── CookieConsent.tsx # GDPR banner
|
||||||
└── SEO.tsx # SEO utilities
|
├── SEO.tsx # SEO utilities
|
||||||
|
|
||||||
data/
|
data/
|
||||||
├── raw/ # WordPress export
|
├── raw/ # WordPress export
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
@@ -252,21 +271,32 @@ GET /robots.txt
|
|||||||
|
|
||||||
### Automatic Deployment (Current Setup)
|
### Automatic Deployment (Current Setup)
|
||||||
|
|
||||||
The project uses **Gitea Actions** for CI/CD. Every push to `main` triggers:
|
The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
|
||||||
|
|
||||||
1. **Build**: Docker image built for `linux/arm64`
|
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
|
||||||
2. **Push**: Image pushed to `registry.infra.mintel.me`
|
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
|
||||||
3. **Deploy**: SSH to production server, pull and restart containers
|
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
|
||||||
|
|
||||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
**Branch Deployments**:
|
||||||
|
|
||||||
|
- `main` branch: Deploys to production using `.env.prod`
|
||||||
|
- `staging` branch: Deploys to staging using `.env.staging`
|
||||||
|
|
||||||
|
**Environment Overrides**:
|
||||||
|
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_UMAMI_WEBSITE_ID` - Analytics ID
|
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||||
|
- `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
|
||||||
|
|
||||||
### Manual Deployment
|
### Manual Deployment
|
||||||
|
|
||||||
@@ -284,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
|
||||||
```
|
```
|
||||||
@@ -295,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)
|
||||||
|
|
||||||
@@ -308,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)
|
||||||
@@ -334,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]`
|
||||||
@@ -341,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/...
|
||||||
```
|
```
|
||||||
@@ -355,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)
|
||||||
@@ -370,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
|
||||||
|
|
||||||
@@ -395,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
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPageBySlug } from '@/lib/pages';
|
import { getPageBySlug } from '@/lib/pages';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
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) {
|
||||||
return new ImageResponse(
|
return new Response('Page not found', { status: 404 });
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +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 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() {
|
||||||
@@ -28,27 +29,27 @@ 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 {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${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: `https://klz-cables.com/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${slug}`,
|
||||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -58,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 +78,9 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
</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 animate-slide-up">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||||
|
{t('badge')}
|
||||||
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-0">
|
<Heading level={1} className="text-white mb-0">
|
||||||
{pageData.frontmatter.title}
|
{pageData.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -106,14 +111,23 @@ 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 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">
|
<TrackedLink
|
||||||
{t('contactUs')}
|
href={`/${locale}/contact`}
|
||||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">→</span>
|
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"
|
||||||
</a>
|
eventProperties={{
|
||||||
|
location: 'generic_page_support_cta',
|
||||||
|
page_slug: slug,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('contactUs')}
|
||||||
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</TrackedLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,72 +3,76 @@ import { getProductBySlug } from '@/lib/mdx';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
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 } = 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fonts = await getOgFonts();
|
||||||
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"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await getProductBySlug(slug, locale);
|
const product = await getProductBySlug(slug, locale);
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return new ImageResponse(
|
return new Response('Product not found', { status: 404 });
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { origin } = new URL(request.url);
|
|
||||||
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}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,47 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPostBySlug } from '@/lib/blog';
|
import { getPostBySlug } from '@/lib/blog';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
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({ 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 post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return new ImageResponse(
|
return new Response('Post not found', { status: 404 });
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
|
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||||
|
// For local images during dev, relative paths in <img> might not work in Satori
|
||||||
|
// but if we are in nodejs runtime, we could potentially read from disk.
|
||||||
|
// For now, let's just make sure it's absolute.
|
||||||
const featuredImage = post.frontmatter.featuredImage
|
const featuredImage = post.frontmatter.featuredImage
|
||||||
? (post.frontmatter.featuredImage.startsWith('http')
|
? post.frontmatter.featuredImage.startsWith('http')
|
||||||
? post.frontmatter.featuredImage
|
? post.frontmatter.featuredImage
|
||||||
: `https://klz-cables.com${post.frontmatter.featuredImage}`)
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={post.frontmatter.title}
|
||||||
title={post.frontmatter.title}
|
description={post.frontmatter.excerpt}
|
||||||
description={post.frontmatter.excerpt}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
image={featuredImage}
|
||||||
image={featuredImage}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
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({ params: { locale, slug } }: BlogPostProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
|
|
||||||
const description = post.frontmatter.excerpt || '';
|
const description = post.frontmatter.excerpt || '';
|
||||||
@@ -30,12 +32,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
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`,
|
||||||
@@ -43,8 +40,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `https://klz-cables.com/${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',
|
||||||
@@ -54,7 +50,9 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
@@ -66,16 +64,32 @@ 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)}
|
||||||
|
/>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && (
|
||||||
|
<div className="bg-orange-500 text-white text-center py-2 px-4 font-bold text-sm tracking-wider uppercase relative z-50">
|
||||||
|
Preview (Not visible in production)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 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 */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
@@ -87,7 +101,10 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
<Heading
|
||||||
|
level={1}
|
||||||
|
className="text-white mb-8 drop-shadow-2xl 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 animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
||||||
@@ -95,7 +112,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
@@ -123,7 +140,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
@@ -168,8 +185,18 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
href={`/${locale}/blog`}
|
href={`/${locale}/blog`}
|
||||||
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 transition-transform group-hover:-translate-x-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
className="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="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -188,57 +215,63 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{/* Structured Data */}
|
{/* Structured Data */}
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`jsonld-${slug}`}
|
id={`jsonld-${slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'BlogPosting',
|
'@context': 'https://schema.org',
|
||||||
headline: post.frontmatter.title,
|
'@type': 'BlogPosting',
|
||||||
datePublished: post.frontmatter.date,
|
headline: post.frontmatter.title,
|
||||||
dateModified: post.frontmatter.date,
|
datePublished: post.frontmatter.date,
|
||||||
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
|
dateModified: post.frontmatter.date,
|
||||||
author: {
|
image: post.frontmatter.featuredImage
|
||||||
'@type': 'Organization',
|
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
name: 'KLZ Cables',
|
: undefined,
|
||||||
url: 'https://klz-cables.com',
|
author: {
|
||||||
logo: 'https://klz-cables.com/logo-blue.svg'
|
'@type': 'Organization',
|
||||||
},
|
name: 'KLZ Cables',
|
||||||
publisher: {
|
url: SITE_URL,
|
||||||
'@type': 'Organization',
|
logo: `${SITE_URL}/logo-blue.svg`,
|
||||||
name: 'KLZ Cables',
|
|
||||||
logo: {
|
|
||||||
'@type': 'ImageObject',
|
|
||||||
url: 'https://klz-cables.com/logo-blue.svg',
|
|
||||||
},
|
},
|
||||||
},
|
publisher: {
|
||||||
description: post.frontmatter.excerpt,
|
'@type': 'Organization',
|
||||||
mainEntityOfPage: {
|
name: 'KLZ Cables',
|
||||||
'@type': 'WebPage',
|
logo: {
|
||||||
'@id': `https://klz-cables.com/${locale}/blog/${slug}`,
|
'@type': 'ImageObject',
|
||||||
},
|
url: `${SITE_URL}/logo-blue.svg`,
|
||||||
articleSection: post.frontmatter.category,
|
},
|
||||||
wordCount: post.content.split(/\s+/).length,
|
},
|
||||||
timeRequired: `PT${getReadingTime(post.content)}M`
|
description: post.frontmatter.excerpt,
|
||||||
} as any}
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
|
},
|
||||||
|
articleSection: post.frontmatter.category,
|
||||||
|
wordCount: post.content.split(/\s+/).length,
|
||||||
|
timeRequired: `PT${getReadingTime(post.content)}M`,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`breadcrumb-${slug}`}
|
id={`breadcrumb-${slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'BreadcrumbList',
|
'@context': 'https://schema.org',
|
||||||
itemListElement: [
|
'@type': 'BreadcrumbList',
|
||||||
{
|
itemListElement: [
|
||||||
'@type': 'ListItem',
|
{
|
||||||
position: 1,
|
'@type': 'ListItem',
|
||||||
name: 'Blog',
|
position: 1,
|
||||||
item: `https://klz-cables.com/${locale}/blog`,
|
name: 'Blog',
|
||||||
},
|
item: `${SITE_URL}/${locale}/blog`,
|
||||||
{
|
},
|
||||||
'@type': 'ListItem',
|
{
|
||||||
position: 2,
|
'@type': 'ListItem',
|
||||||
name: post.frontmatter.title,
|
position: 2,
|
||||||
item: `https://klz-cables.com/${locale}/blog/${slug}`,
|
name: post.frontmatter.title,
|
||||||
},
|
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
],
|
},
|
||||||
} as any}
|
],
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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';
|
||||||
|
|
||||||
|
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 title = t('title');
|
const fonts = await getOgFonts();
|
||||||
const description = t('description');
|
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Blog"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +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';
|
||||||
|
|
||||||
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: `https://klz-cables.com/${locale}/blog`,
|
url: `${SITE_URL}/${locale}/blog`,
|
||||||
images: getOGImageMetadata('blog', t('title'), locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -38,13 +40,15 @@ 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);
|
||||||
|
|
||||||
// Sort posts by date descending
|
// Sort posts by date descending
|
||||||
const sortedPosts = [...posts].sort((a, b) =>
|
const sortedPosts = [...posts].sort(
|
||||||
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const featuredPost = sortedPosts[0];
|
const featuredPost = sortedPosts[0];
|
||||||
@@ -54,21 +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}
|
||||||
|
fill
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom 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 animate-slide-up">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||||
|
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||||
|
{featuredPost &&
|
||||||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
|
featuredPost.frontmatter.public === false) && (
|
||||||
|
<Badge variant="accent" className="bg-orange-500 text-white border-none">
|
||||||
|
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">
|
||||||
@@ -77,15 +93,22 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||||
{featuredPost.frontmatter.excerpt}
|
{featuredPost.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<Button href={`/${locale}/blog/${featuredPost.slug}`} variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
<Button
|
||||||
|
href={`/${locale}/blog/${featuredPost.slug}`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
>
|
||||||
{t('readFullArticle')}
|
{t('readFullArticle')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
@@ -97,10 +120,30 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||||
{/* Category filters could go here */}
|
{/* Category filters could go here */}
|
||||||
<Badge variant="primary" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.all')}</Badge>
|
<Badge
|
||||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.industry')}</Badge>
|
variant="primary"
|
||||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.technical')}</Badge>
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.sustainability')}</Badge>
|
>
|
||||||
|
{t('categories.all')}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
|
>
|
||||||
|
{t('categories.industry')}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
|
>
|
||||||
|
{t('categories.technical')}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
|
>
|
||||||
|
{t('categories.sustainability')}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -110,20 +153,37 @@ 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 && (
|
||||||
<Badge variant="accent" className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg">
|
<Badge
|
||||||
|
variant="accent"
|
||||||
|
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
||||||
|
>
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<Badge
|
||||||
|
variant="accent"
|
||||||
|
className="absolute top-3 right-3 md:top-6 md:right-6 shadow-lg bg-orange-500 text-white border-none"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</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">
|
||||||
@@ -131,7 +191,7 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</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">
|
||||||
@@ -145,8 +205,18 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5 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 md:w-5 md:h-5 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>
|
||||||
@@ -156,13 +226,21 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Placeholder */}
|
{/* Pagination Placeholder */}
|
||||||
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>{t('prev')}</Button>
|
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
|
||||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">1</Button>
|
{t('prev')}
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">2</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">{t('next')}</Button>
|
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||||
|
2
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||||
|
{t('next')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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';
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Contact"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +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({ params: { locale } }: ContactPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
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');
|
||||||
@@ -31,18 +24,18 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `https://klz-cables.com/${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: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${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',
|
||||||
},
|
},
|
||||||
@@ -50,7 +43,6 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
|||||||
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,
|
||||||
@@ -64,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">
|
||||||
@@ -78,7 +71,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
'@type': 'ListItem',
|
'@type': 'ListItem',
|
||||||
position: 1,
|
position: 1,
|
||||||
name: t('title'),
|
name: t('title'),
|
||||||
item: `https://klz-cables.com/${locale}/contact`,
|
item: `${SITE_URL}/${locale}/contact`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
@@ -89,9 +82,9 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'LocalBusiness',
|
'@type': 'LocalBusiness',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
image: 'https://klz-cables.com/logo.png',
|
image: `${SITE_URL}/logo.png`,
|
||||||
'@id': 'https://klz-cables.com',
|
'@id': SITE_URL,
|
||||||
url: 'https://klz-cables.com',
|
url: SITE_URL,
|
||||||
address: {
|
address: {
|
||||||
'@type': 'PostalAddress',
|
'@type': 'PostalAddress',
|
||||||
streetAddress: 'Raiffeisenstraße 22',
|
streetAddress: 'Raiffeisenstraße 22',
|
||||||
@@ -107,20 +100,12 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
openingHoursSpecification: [
|
openingHoursSpecification: [
|
||||||
{
|
{
|
||||||
'@type': 'OpeningHoursSpecification',
|
'@type': 'OpeningHoursSpecification',
|
||||||
dayOfWeek: [
|
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||||
'Monday',
|
|
||||||
'Tuesday',
|
|
||||||
'Wednesday',
|
|
||||||
'Thursday',
|
|
||||||
'Friday'
|
|
||||||
],
|
|
||||||
opens: '08:00',
|
opens: '08:00',
|
||||||
closes: '17:00'
|
closes: '17:00',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||||
'https://www.linkedin.com/company/klz-cables'
|
|
||||||
]
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -151,39 +136,74 @@ 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 className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
className="w-5 h-5 md:w-7 md:h-7"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.office')}</h4>
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
|
{t('info.office')}
|
||||||
|
</h4>
|
||||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
|
||||||
{t('info.address')}
|
{t('info.address')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<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 className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
className="w-5 h-5 md:w-7 md:h-7"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.email')}</h4>
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
<a href="mailto:info@klz-cables.com" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">info@klz-cables.com</a>
|
{t('info.email')}
|
||||||
|
</h4>
|
||||||
|
<a
|
||||||
|
href="mailto:info@klz-cables.com"
|
||||||
|
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||||
|
>
|
||||||
|
info@klz-cables.com
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<Heading level={4} className="mb-4 md:mb-6">{t('hours.title')}</Heading>
|
<Heading level={4} className="mb-4 md:mb-6">
|
||||||
|
{t('hours.title')}
|
||||||
|
</Heading>
|
||||||
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
||||||
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
||||||
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
||||||
@@ -199,24 +219,28 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
|
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<Suspense fallback={<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Map Section */}
|
{/* Map Section */}
|
||||||
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
||||||
<Suspense fallback={<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
<Suspense
|
||||||
<div className="text-primary font-medium">Loading Map...</div>
|
fallback={
|
||||||
</div>}>
|
<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
||||||
<LeafletMap
|
<div className="text-primary font-medium">Loading Map...</div>
|
||||||
address={t('info.address')}
|
</div>
|
||||||
lat={48.8144}
|
}
|
||||||
lng={9.4144}
|
>
|
||||||
/>
|
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +1,153 @@
|
|||||||
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 { 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'],
|
||||||
};
|
display: 'swap',
|
||||||
|
variable: '--font-inter',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
|
alternates: {
|
||||||
|
canonical: locale === 'en' ? '/' : `/${locale}`,
|
||||||
|
languages: {
|
||||||
|
de: '/de',
|
||||||
|
en: '/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) {
|
||||||
|
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||||
|
messages = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||||
|
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home'];
|
||||||
|
const 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();
|
||||||
|
|
||||||
|
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">
|
<SkipLink />
|
||||||
{children}
|
<JsonLd />
|
||||||
</main>
|
<Header />
|
||||||
<Footer />
|
<main
|
||||||
|
id="main-content"
|
||||||
{/* Sends pageviews for client-side navigations */}
|
className="flex-grow animate-fade-in overflow-visible"
|
||||||
<AnalyticsProvider />
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</RecordModeVisuals>
|
||||||
|
|
||||||
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
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"
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,104 @@
|
|||||||
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
|
||||||
id="breadcrumb-home"
|
id="breadcrumb-home"
|
||||||
data={getBreadcrumbSchema([
|
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||||
{ name: 'Home', item: `/${locale}` },
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Reveal><ProductCategories /></Reveal>
|
<Reveal>
|
||||||
<Reveal><WhatWeDo /></Reveal>
|
<ProductCategories />
|
||||||
<Reveal><RecentPosts locale={locale} /></Reveal>
|
</Reveal>
|
||||||
<Reveal><Experience /></Reveal>
|
<Reveal>
|
||||||
<Reveal><WhyChooseUs /></Reveal>
|
<WhatWeDo />
|
||||||
<Reveal><MeetTheTeam /></Reveal>
|
</Reveal>
|
||||||
<Reveal><GallerySection /></Reveal>
|
<Reveal>
|
||||||
<Reveal><VideoSection /></Reveal>
|
<RecentPosts locale={locale} />
|
||||||
<Reveal><CTA /></Reveal>
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<Experience />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<WhyChooseUs />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<MeetTheTeam />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<GallerySection />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<VideoSection />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal className="content-visibility-auto">
|
||||||
|
<CTA />
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }): Promise<Metadata> {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): 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: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${locale}`,
|
url: `${SITE_URL}/${locale}`,
|
||||||
images: getOGImageMetadata('', title, locale),
|
images: getOGImageMetadata('', title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
|
|||||||
@@ -1,57 +1,69 @@
|
|||||||
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 { Badge, Container, Heading, Section } from '@/components/ui';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
|
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');
|
||||||
|
|
||||||
// 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',
|
||||||
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = fileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
.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`)
|
||||||
|
: fileSlug;
|
||||||
|
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||||
|
? t(`categories.${categoryKey}.description`)
|
||||||
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
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: {
|
||||||
title: `${categoryTitle} | KLZ Cables`,
|
title: `${categoryTitle} | KLZ Cables`,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
url: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -69,18 +81,18 @@ 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: {
|
||||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -94,20 +106,36 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
const components = {
|
const components = {
|
||||||
ProductTechnicalData,
|
ProductTechnicalData,
|
||||||
ProductTabs,
|
ProductTabs,
|
||||||
p: (props: any) => <p {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium" />,
|
p: (props: any) => (
|
||||||
|
<p
|
||||||
|
{...props}
|
||||||
|
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
|
||||||
|
/>
|
||||||
|
),
|
||||||
h2: (props: any) => (
|
h2: (props: any) => (
|
||||||
<div className="relative mb-16">
|
<div className="relative mb-16">
|
||||||
<h2 {...props} className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6" />
|
<h2
|
||||||
|
{...props}
|
||||||
|
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
|
||||||
|
/>
|
||||||
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
h3: (props: any) => <h3 {...props} className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase" />,
|
h3: (props: any) => (
|
||||||
|
<h3
|
||||||
|
{...props}
|
||||||
|
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
|
||||||
|
/>
|
||||||
|
),
|
||||||
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
||||||
section: (props: any) => <div {...props} className="block" />,
|
section: (props: any) => <div {...props} className="block" />,
|
||||||
li: (props: any) => (
|
li: (props: any) => (
|
||||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
||||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
||||||
<span {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium" />
|
<span
|
||||||
|
{...props}
|
||||||
|
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
||||||
@@ -116,45 +144,67 @@ const components = {
|
|||||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60" />,
|
th: (props: any) => (
|
||||||
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium" />,
|
<th
|
||||||
|
{...props}
|
||||||
|
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
td: (props: any) => (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
||||||
|
/>
|
||||||
|
),
|
||||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
||||||
blockquote: (props: any) => (
|
blockquote: (props: any) => (
|
||||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||||
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight" {...props} />
|
<div
|
||||||
|
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
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');
|
||||||
|
|
||||||
// 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',
|
||||||
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = fileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: fileSlug;
|
||||||
|
|
||||||
// Filter products for this category
|
// Filter products for this category
|
||||||
const filteredProducts = allProducts.filter(p =>
|
const filteredProducts = allProducts.filter((p) =>
|
||||||
p.frontmatter.categories.some(cat =>
|
p.frontmatter.categories.some(
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === fileSlug ||
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||||
cat === categoryTitle
|
),
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get translated product slugs
|
// Get translated product slugs
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
...p,
|
...p,
|
||||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale)
|
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +213,12 @@ 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">{t('title')}</Link>
|
<Link
|
||||||
|
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||||
|
</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>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -184,43 +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 key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{cat}
|
{product.frontmatter.categories.map((cat, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
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>
|
||||||
@@ -237,7 +308,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract technical data for schema
|
// Extract technical data for schema
|
||||||
const technicalDataMatch = product.content.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
|
const technicalDataMatch = product.content.match(
|
||||||
|
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
||||||
|
);
|
||||||
let technicalItems = [];
|
let technicalItems = [];
|
||||||
if (technicalDataMatch) {
|
if (technicalDataMatch) {
|
||||||
try {
|
try {
|
||||||
@@ -252,11 +325,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
const isFallback = (product.frontmatter as any).isFallback;
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
const categorySlug = slug[0];
|
const categorySlug = slug[0];
|
||||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||||
const categoryKey = categoryFileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = categoryFileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : categoryFileSlug;
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: categoryFileSlug;
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<ProductSidebar
|
<ProductSidebar
|
||||||
productName={product.frontmatter.title}
|
productName={product.frontmatter.title}
|
||||||
productImage={product.frontmatter.images?.[0]}
|
productImage={product.frontmatter.images?.[0]}
|
||||||
datasheetPath={datasheetPath}
|
datasheetPath={datasheetPath}
|
||||||
@@ -282,21 +359,37 @@ 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" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex 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">{t('title')}</Link>
|
<Link
|
||||||
|
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||||
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<Link href={`/${locale}/products/${categorySlug}`} className="hover:text-accent transition-colors">{categoryTitle}</Link>
|
<Link
|
||||||
|
href={`/${locale}/products/${categorySlug}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{categoryTitle}
|
||||||
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{isFallback && (
|
{isFallback && (
|
||||||
@@ -307,7 +400,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-3 mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge key={idx} variant="accent" className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]">
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant="accent"
|
||||||
|
className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]"
|
||||||
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -328,11 +425,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative">
|
<Container className="relative">
|
||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div className="relative -mt-32 mb-32 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
<div
|
||||||
|
className="relative -mt-32 mb-32 animate-slide-up"
|
||||||
|
style={{ animationDelay: '200ms' }}
|
||||||
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||||
@@ -341,12 +441,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Subtle reflection/shadow effect */}
|
{/* Subtle reflection/shadow effect */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.frontmatter.images.length > 1 && (
|
{product.frontmatter.images.length > 1 && (
|
||||||
<div className="flex justify-center gap-8 mt-20">
|
<div className="flex justify-center gap-8 mt-20">
|
||||||
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
||||||
<div key={idx} className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4">
|
<div
|
||||||
<Image src={img} alt="" fill className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" />
|
key={idx}
|
||||||
|
className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -359,51 +467,68 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="max-w-none">
|
<div className="max-w-none">
|
||||||
<MDXRemote source={processedContent} components={productComponents} />
|
<MDXRemote source={processedContent} components={productComponents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
||||||
|
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
||||||
|
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||||
|
{t('downloadDatasheet')}
|
||||||
|
</h2>
|
||||||
|
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Structured Data */}
|
{/* Structured Data */}
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`jsonld-${product.slug}`}
|
id={`jsonld-${product.slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'Product',
|
'@context': 'https://schema.org',
|
||||||
name: product.frontmatter.title,
|
'@type': 'Product',
|
||||||
description: product.frontmatter.description,
|
name: product.frontmatter.title,
|
||||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
description: product.frontmatter.description,
|
||||||
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
|
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||||
brand: {
|
image: product.frontmatter.images?.[0]
|
||||||
'@type': 'Brand',
|
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||||
name: 'KLZ Cables',
|
: undefined,
|
||||||
},
|
brand: {
|
||||||
offers: {
|
'@type': 'Brand',
|
||||||
'@type': 'Offer',
|
name: 'KLZ Cables',
|
||||||
availability: 'https://schema.org/InStock',
|
},
|
||||||
priceCurrency: 'EUR',
|
offers: {
|
||||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
'@type': 'Offer',
|
||||||
itemCondition: 'https://schema.org/NewCondition',
|
availability: 'https://schema.org/InStock',
|
||||||
},
|
priceCurrency: 'EUR',
|
||||||
additionalProperty: technicalItems.map((item: any) => ({
|
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
'@type': 'PropertyValue',
|
itemCondition: 'https://schema.org/NewCondition',
|
||||||
name: item.label,
|
},
|
||||||
value: item.value,
|
additionalProperty: technicalItems.map((item: any) => ({
|
||||||
})),
|
'@type': 'PropertyValue',
|
||||||
category: product.frontmatter.categories.join(', '),
|
name: item.label,
|
||||||
mainEntityOfPage: {
|
value: item.value,
|
||||||
'@type': 'WebPage',
|
})),
|
||||||
'@id': `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
category: product.frontmatter.categories.join(', '),
|
||||||
},
|
mainEntityOfPage: {
|
||||||
} as any}
|
'@type': 'WebPage',
|
||||||
|
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,83 +1,25 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getProductBySlug } from '@/lib/mdx';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { 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 }> }) {
|
||||||
const t = await getTranslations('Products');
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
// If no slug, it's the main products page
|
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||||
if (!slug || slug.length === 0) {
|
const description = t('meta.description') || t('subtitle');
|
||||||
const title = t('meta.title') || t('title');
|
|
||||||
const description = t('meta.description') || t('subtitle');
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Products"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const productSlug = slug[slug.length - 1];
|
|
||||||
|
|
||||||
// Check if it's a category page
|
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
|
||||||
if (categories.includes(productSlug)) {
|
|
||||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
|
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<OGImageTemplate
|
|
||||||
title={categoryTitle}
|
|
||||||
description={categoryDesc}
|
|
||||||
label="Product Category"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return new ImageResponse(
|
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const featuredImage = product.frontmatter.images?.[0]
|
|
||||||
? (product.frontmatter.images[0].startsWith('http')
|
|
||||||
? product.frontmatter.images[0]
|
|
||||||
: `https://klz-cables.com${product.frontmatter.images[0]}`)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={product.frontmatter.title}
|
|
||||||
description={product.frontmatter.description}
|
|
||||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
|
||||||
image={featuredImage}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +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 TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: ProductsPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('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: `https://klz-cables.com/${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',
|
||||||
@@ -44,13 +44,17 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = [
|
||||||
{
|
{
|
||||||
@@ -58,29 +62,29 @@ 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}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,7 +93,10 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5">
|
<Badge
|
||||||
|
variant="saturated"
|
||||||
|
className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5"
|
||||||
|
>
|
||||||
{t('heroSubtitle')}
|
{t('heroSubtitle')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
@@ -97,16 +104,24 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="relative inline-block">
|
||||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||||
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
|
<Scribble
|
||||||
|
variant="circle"
|
||||||
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-4 md:gap-6">
|
<div className="flex flex-wrap gap-4 md:gap-6">
|
||||||
<Button href="#categories" variant="accent" size="lg" className="group w-full md:w-auto">
|
<Button
|
||||||
|
href="#categories"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto"
|
||||||
|
>
|
||||||
{t('viewProducts')}
|
{t('viewProducts')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,25 +135,41 @@ 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
|
||||||
src={category.img}
|
src={category.img}
|
||||||
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" />
|
||||||
|
|
||||||
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
||||||
<Image src={category.icon} alt="" width={24} height={24} className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
|
<Image
|
||||||
|
src={category.icon}
|
||||||
|
alt=""
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
||||||
<Badge variant="accent" className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs">
|
<Badge
|
||||||
|
variant="accent"
|
||||||
|
className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs"
|
||||||
|
>
|
||||||
{t('categoryLabel')}
|
{t('categoryLabel')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
||||||
@@ -155,20 +186,30 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
{t('viewProducts')}
|
{t('viewProducts')}
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5 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 md:w-5 md:h-5 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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</TrackedLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Technical Support CTA */}
|
{/* Technical Support CTA */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<Section className="bg-white py-12 md:py-28">
|
<Section className="bg-white py-12 md:py-28">
|
||||||
@@ -177,14 +218,23 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{t('cta.title')}</h2>
|
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
|
{t('cta.title')}
|
||||||
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
{t('cta.description')}
|
{t('cta.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button href={`/${params.locale}/contact`} variant="accent" size="lg" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
<Button
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
>
|
||||||
{t('cta.button')}
|
{t('cta.button')}
|
||||||
<span className="ml-4 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-4 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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';
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Our Team"
|
|
||||||
image="https://klz-cables.com/uploads/2024/12/DSC07655-Large.webp"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
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: `https://klz-cables.com/${locale}/team`,
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
images: getOGImageMetadata('team', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -43,16 +43,16 @@ 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 (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="breadcrumb-team"
|
id="breadcrumb-team"
|
||||||
data={getBreadcrumbSchema([
|
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||||
{ name: t('hero.subtitle'), item: `/team` },
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="person-michael"
|
id="person-michael"
|
||||||
@@ -65,10 +65,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||||
'https://www.linkedin.com/in/michael-bodemer-33b493122/'
|
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||||
],
|
|
||||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -82,10 +80,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||||
'https://www.linkedin.com/in/klaus-mintel-b80a8b193/'
|
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||||
],
|
|
||||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -97,13 +93,16 @@ 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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10 text-center text-white max-w-5xl">
|
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">{t('hero.badge')}</Badge>
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||||
|
{t('hero.badge')}
|
||||||
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t('hero.subtitle')}
|
{t('hero.subtitle')}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -115,12 +114,14 @@ 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" />
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-8">{t('michael.role')}</Badge>
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
|
{t('michael.role')}
|
||||||
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -133,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">
|
||||||
@@ -155,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>
|
||||||
@@ -173,26 +179,36 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||||
<div className="lg:col-span-6">
|
<div className="lg:col-span-6">
|
||||||
<Heading level={2} subtitle={t('legacy.subtitle')} className="text-white mb-6 md:mb-10">
|
<Heading
|
||||||
|
level={2}
|
||||||
|
subtitle={t('legacy.subtitle')}
|
||||||
|
className="text-white mb-6 md:mb-10"
|
||||||
|
>
|
||||||
<span className="text-white">{t('legacy.title')}</span>
|
<span className="text-white">{t('legacy.title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
||||||
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
||||||
{t('legacy.p1')}
|
{t('legacy.p1')}
|
||||||
</p>
|
</p>
|
||||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">
|
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
|
||||||
{t('legacy.p2')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
||||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.expertise')}</div>
|
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.expertiseDesc')}</div>
|
{t('legacy.expertise')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||||
|
{t('legacy.expertiseDesc')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.network')}</div>
|
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.networkDesc')}</div>
|
{t('legacy.network')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||||
|
{t('legacy.networkDesc')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,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
|
||||||
@@ -216,7 +232,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
||||||
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8">{t('klaus.role')}</Badge>
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
|
{t('klaus.role')}
|
||||||
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -229,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">
|
||||||
@@ -255,28 +278,40 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
||||||
{t('manifesto.tagline')}
|
{t('manifesto.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Mobile-only progress indicator */}
|
{/* Mobile-only progress indicator */}
|
||||||
<div className="flex lg:hidden mt-8 gap-2">
|
<div className="flex lg:hidden mt-8 gap-2">
|
||||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
<div key={i} className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 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">
|
<li
|
||||||
|
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"
|
||||||
|
>
|
||||||
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
||||||
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
|
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
||||||
|
0{idx + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{t(`manifesto.items.${idx}.title`)}</h3>
|
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
||||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{t(`manifesto.items.${idx}.description`)}</p>
|
{t(`manifesto.items.${idx}.title`)}
|
||||||
</div>
|
</h3>
|
||||||
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,72 +1,153 @@
|
|||||||
"use server";
|
'use server';
|
||||||
|
|
||||||
import client, { ensureAuthenticated } from "@/lib/directus";
|
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||||
import { createItem } from "@directus/sdk";
|
import { createItem } from '@directus/sdk';
|
||||||
import { sendEmail } from "@/lib/mail/mailer";
|
import { sendEmail } from '@/lib/mail/mailer';
|
||||||
import ContactEmail from "@/components/emails/ContactEmail";
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
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' });
|
||||||
const name = formData.get("name") as string;
|
|
||||||
const email = formData.get("email") as string;
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
const message = formData.get("message") as string;
|
const { headers } = await import('next/headers');
|
||||||
const productName = formData.get("productName") as string | null;
|
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 email = formData.get('email') as string;
|
||||||
|
const message = formData.get('message') as string;
|
||||||
|
const productName = formData.get('productName') as string | null;
|
||||||
|
|
||||||
if (!name || !email || !message) {
|
if (!name || !email || !message) {
|
||||||
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
|
logger.warn('Missing required fields in contact form', {
|
||||||
return { success: false, error: "Missing required fields" };
|
name: !!name,
|
||||||
|
email: !!email,
|
||||||
|
message: !!message,
|
||||||
|
});
|
||||||
|
return { success: false, error: 'Missing required fields' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Save to Directus
|
// 1. Save to Directus
|
||||||
try {
|
try {
|
||||||
await ensureAuthenticated();
|
await ensureAuthenticated();
|
||||||
if (productName) {
|
if (productName) {
|
||||||
await client.request(createItem('product_requests', {
|
await client.request(
|
||||||
product_name: productName,
|
createItem('product_requests', {
|
||||||
email,
|
product_name: productName,
|
||||||
message
|
email,
|
||||||
}));
|
message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
logger.info('Product request stored in Directus');
|
logger.info('Product request stored in Directus');
|
||||||
} else {
|
} else {
|
||||||
await client.request(createItem('contact_submissions', {
|
await client.request(
|
||||||
name,
|
createItem('contact_submissions', {
|
||||||
email,
|
name,
|
||||||
message
|
email,
|
||||||
}));
|
message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
logger.info('Contact submission stored in Directus');
|
logger.info('Contact submission stored in Directus');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to store submission in Directus', { error });
|
logger.error('Failed to store submission in Directus', { error });
|
||||||
// We continue anyway to try sending the email, but maybe we should report this
|
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Send Email
|
// 2. Send Emails
|
||||||
logger.info('Sending contact form email', { email, productName });
|
logger.info('Sending branded emails', { email, productName });
|
||||||
|
|
||||||
const subject = productName
|
const notificationSubject = productName
|
||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: "New Contact Form Submission";
|
: 'New Contact Form Submission';
|
||||||
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
|
||||||
const result = await sendEmail({
|
try {
|
||||||
subject,
|
// 2a. Send notification to Mintel/Client
|
||||||
template: React.createElement(ContactEmail, {
|
const notificationHtml = await render(
|
||||||
name,
|
React.createElement(ContactFormNotification, {
|
||||||
email,
|
name,
|
||||||
message,
|
email,
|
||||||
productName: productName || undefined,
|
message,
|
||||||
subject,
|
productName: productName || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
if (result.success) {
|
const notificationResult = await sendEmail({
|
||||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
replyTo: email,
|
||||||
} else {
|
subject: notificationSubject,
|
||||||
logger.error('Failed to send contact form email', { error: result.error });
|
html: notificationHtml,
|
||||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
});
|
||||||
|
|
||||||
|
if (notificationResult.success) {
|
||||||
|
logger.info('Notification email sent successfully', {
|
||||||
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
|
const confirmationHtml = await render(
|
||||||
|
React.createElement(ConfirmationMessage, {
|
||||||
|
name,
|
||||||
|
clientName: 'KLZ Cables',
|
||||||
|
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmationResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmationResult.success) {
|
||||||
|
logger.info('Confirmation email sent successfully', {
|
||||||
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify via Gotify (Internal)
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: `📩 ${notificationSubject}`,
|
||||||
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track('contact-form-success', {
|
||||||
|
is_product_request: !!productName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to send branded emails', {
|
||||||
|
error: errorMsg,
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||||
|
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '🚨 Contact Form Error',
|
||||||
|
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
9
app/api/health/cms/route.ts
Normal file
9
app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { checkHealth } from '@/lib/directus';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const health = await checkHealth();
|
||||||
|
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
69
app/errors/api/relay/route.ts
Normal file
69
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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();
|
||||||
|
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) {
|
||||||
|
logger.error('Failed to relay Sentry request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
sizes: 'any',
|
sizes: 'any',
|
||||||
type: 'image/x-icon',
|
type: 'image/x-icon',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: '/logo.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: '/apple-touch-icon.png',
|
src: '/apple-touch-icon.png',
|
||||||
sizes: '180x180',
|
sizes: '180x180',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
@@ -11,8 +13,8 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
{
|
{
|
||||||
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||||
allow: '/',
|
allow: '/',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
sitemap: 'https://klz-cables.com/sitemap.xml',
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPostsMetadata } from '@/lib/blog';
|
||||||
import { getAllPages } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
|
|
||||||
|
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 = 'https://klz-cables.com';
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -33,12 +36,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
const products = await getAllProducts(locale);
|
const productsMetadata = await getAllProductsMetadata(locale);
|
||||||
for (const product of products) {
|
for (const product of productsMetadata) {
|
||||||
// We need to find the category for the product to build the URL
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
// In this project, products are under /products/[category]/[slug]
|
|
||||||
// The category is in product.frontmatter.categories
|
const category =
|
||||||
const category = product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
@@ -48,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blog posts
|
// Blog posts
|
||||||
const posts = await getAllPosts(locale);
|
const postsMetadata = await getAllPostsMetadata(locale);
|
||||||
for (const post of posts) {
|
for (const post of postsMetadata) {
|
||||||
|
if (!post.frontmatter || !post.slug) continue;
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||||
lastModified: new Date(post.frontmatter.date),
|
lastModified: new Date(post.frontmatter.date),
|
||||||
@@ -59,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
const pages = await getAllPages(locale);
|
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||||
for (const page of pages) {
|
for (const page of pagesMetadata) {
|
||||||
|
if (!page.slug) continue;
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|||||||
92
app/stats/api/send/route.ts
Normal file
92
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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();
|
||||||
|
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
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
commitlint.config.cjs
Normal file
9
commitlint.config.cjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'header-max-length': [2, 'always', 500],
|
||||||
|
'subject-case': [0],
|
||||||
|
'subject-full-stop': [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
84
components/CMSConnectivityNotice.tsx
Normal file
84
components/CMSConnectivityNotice.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
|
export default function CMSConnectivityNotice() {
|
||||||
|
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only show if we've detected an issue AND we are in a context where we want to see it
|
||||||
|
const checkCMS = async () => {
|
||||||
|
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||||
|
const isLocal = config.isDevelopment;
|
||||||
|
const isTesting = config.isTesting;
|
||||||
|
|
||||||
|
// Only proceed with check if it's developer context (Local or Testing)
|
||||||
|
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||||
|
if (!isLocal && !isTesting && !isDebug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health/cms');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'ok') {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg(data.message);
|
||||||
|
setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
setStatus('ok');
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If it's a connection error, only show if we are really debugging
|
||||||
|
if (isDebug || isLocal) {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg('Could not connect to CMS health endpoint');
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkCMS();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
|
||||||
|
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="bg-white/20 p-2 rounded-lg">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
||||||
|
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
||||||
|
{errorMsg === 'relation "products" does not exist'
|
||||||
|
? 'The database schema is missing. Please sync your local data to this environment.'
|
||||||
|
: errorMsg || 'The application cannot connect to the Directus CMS.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -17,10 +36,10 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendContactFormAction(formData);
|
const result = await sendContactFormAction(formData);
|
||||||
if (result.success) {
|
if (result?.success) {
|
||||||
trackEvent('contact_form_submission', {
|
trackEvent('contact_form_submission', {
|
||||||
form_type: 'general',
|
form_type: 'general',
|
||||||
email,
|
email,
|
||||||
@@ -29,19 +48,36 @@ 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 className="w-10 h-10 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
className="w-10 h-10 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +85,8 @@ export default function ContactForm() {
|
|||||||
{t('form.successTitle') || 'Message Sent!'}
|
{t('form.successTitle') || 'Message Sent!'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-text-secondary text-lg mb-8">
|
<p className="text-text-secondary text-lg mb-8">
|
||||||
{t('form.successDesc') || 'Thank you for your message. We will get back to you as soon as possible.'}
|
{t('form.successDesc') ||
|
||||||
|
'Thank you for your message. We will get back to you as soon as possible.'}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||||
{t('form.sendAnother') || 'Send another message'}
|
{t('form.sendAnother') || 'Send another message'}
|
||||||
@@ -60,9 +97,19 @@ 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 className="w-10 h-10 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
|
className="w-10 h-10 text-destructive-foreground"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
@@ -74,7 +121,12 @@ export default function ContactForm() {
|
|||||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||||
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setStatus('idle')} variant="saturated" size="lg" className="w-full bg-destructive hover:bg-destructive/90 text-destructive-foreground border-destructive shadow-lg shadow-destructive/20">
|
<Button
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
{t('form.tryAgain') || 'Try Again'}
|
{t('form.tryAgain') || 'Try Again'}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -88,58 +140,81 @@ 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>
|
||||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
{status === 'submitting' ? (
|
{status === 'submitting' ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-5 w-5 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('form.submitting') || 'Sending...'}
|
{t('form.submitting') || 'Sending...'}
|
||||||
</span>
|
</span>
|
||||||
) : t('form.submit')}
|
) : (
|
||||||
|
t('form.submit')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
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} />;
|
||||||
|
}
|
||||||
92
components/DatasheetDownload.tsx
Normal file
92
components/DatasheetDownload.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface DatasheetDownloadProps {
|
||||||
|
datasheetPath: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||||
|
const t = useTranslations('Products');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
||||||
|
<a
|
||||||
|
href={datasheetPath}
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{/* 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" />
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{/* 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="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
<svg
|
||||||
|
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-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>
|
||||||
|
</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">
|
||||||
|
{t('downloadDatasheet')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||||
|
{t('downloadDatasheetDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<svg
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</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,27 @@
|
|||||||
|
|
||||||
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 { m, LazyMotion, domAnimation } 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);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
const currentLocale = pathname.split('/')[1] || 'en';
|
const currentLocale = pathname.split('/')[1] || 'en';
|
||||||
|
|
||||||
// Check if homepage
|
// Check if homepage
|
||||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||||
|
|
||||||
@@ -30,20 +35,58 @@ export default function Header() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close mobile menu on route change
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
// Prevent scroll when mobile menu is open
|
// Prevent scroll when mobile menu is open
|
||||||
|
// Prevent scroll when mobile menu is open and handle focus trap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileMenuOpen) {
|
if (isMobileMenuOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
// Focus trap logic
|
||||||
|
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (focusableElements && focusableElements.length > 0) {
|
||||||
|
const firstElement = focusableElements[0] as HTMLElement;
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||||
|
|
||||||
|
const handleTabKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleTabKey);
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
|
||||||
|
// Focus the first element when menu opens
|
||||||
|
setTimeout(() => firstElement.focus(), 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleTabKey);
|
||||||
|
document.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
}
|
}
|
||||||
}, [isMobileMenuOpen]);
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
// Function to get path for a different locale
|
// Function to get path for a different locale
|
||||||
const getPathForLocale = (newLocale: string) => {
|
const getPathForLocale = (newLocale: string) => {
|
||||||
const segments = pathname.split('/');
|
const segments = pathname.split('/');
|
||||||
@@ -54,309 +97,377 @@ 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',
|
||||||
{
|
{
|
||||||
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
|
'bg-transparent py-4 md:py-8': 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
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={headerClass}
|
<m.header
|
||||||
initial={{ y: -100, opacity: 0 }}
|
className={headerClass}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
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">
|
>
|
||||||
<motion.div
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
className="flex-shrink-0 group touch-target"
|
<m.div
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
className="flex-shrink-0 group touch-target"
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
>
|
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||||
<Link href={`/${currentLocale}`}>
|
>
|
||||||
<Image
|
<Link
|
||||||
src={logoSrc}
|
href={`/${currentLocale}`}
|
||||||
alt={t('home')}
|
onClick={() =>
|
||||||
width={120}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
height={120}
|
target: 'home_logo',
|
||||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
location: 'header',
|
||||||
priority
|
})
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="flex items-center gap-4 md:gap-12"
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
variants={{
|
|
||||||
visible: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.08,
|
|
||||||
delayChildren: 0.3
|
|
||||||
}
|
}
|
||||||
}
|
>
|
||||||
}}
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={t('home')}
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
className="flex items-center gap-4 md:gap-12"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
visible: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.08,
|
||||||
|
delayChildren: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||||
|
{menuItems.map((item, _idx) => (
|
||||||
|
<m.div key={item.href} variants={navLinkVariants}>
|
||||||
|
<Link
|
||||||
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'header_nav',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
textColorClass,
|
||||||
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
))}
|
||||||
|
</m.nav>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||||
|
variants={headerRightVariants}
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.65 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
<m.div
|
||||||
|
className="w-px h-4 bg-current opacity-20"
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.75 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href={`/${currentLocale}/contact`}
|
||||||
|
variant="white"
|
||||||
|
size="md"
|
||||||
|
className="px-8 shadow-xl"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('contact'),
|
||||||
|
location: 'header_cta',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<m.button
|
||||||
|
className={cn(
|
||||||
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||||
|
textColorClass,
|
||||||
|
)}
|
||||||
|
aria-label={t('toggleMenu')}
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
delay: 0.5,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
const newState = !isMobileMenuOpen;
|
||||||
|
setIsMobileMenuOpen(newState);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: newState ? 'open' : 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<m.svg
|
||||||
|
className="w-7 h-7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? (
|
||||||
|
<m.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<m.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</m.svg>
|
||||||
|
</m.button>
|
||||||
|
</m.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
|
isMobileMenuOpen
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
|
)}
|
||||||
|
id="mobile-menu"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('menu')}
|
||||||
|
ref={mobileMenuRef}
|
||||||
>
|
>
|
||||||
<motion.nav
|
<m.nav
|
||||||
className="hidden lg:flex items-center space-x-10"
|
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||||
variants={navVariants}
|
initial="closed"
|
||||||
|
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||||
|
variants={{
|
||||||
|
open: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<motion.div
|
<m.div
|
||||||
key={item.href}
|
key={item.href}
|
||||||
variants={navLinkVariants}
|
variants={{
|
||||||
|
closed: { opacity: 0, y: 50, scale: 0.9 },
|
||||||
|
open: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeOut',
|
||||||
|
delay: idx * 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
className={cn(
|
onClick={() => {
|
||||||
textColorClass,
|
setIsMobileMenuOpen(false);
|
||||||
"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"
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
)}
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'mobile_menu',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</m.div>
|
||||||
))}
|
))}
|
||||||
</motion.nav>
|
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className={cn("hidden lg:flex items-center space-x-8", textColorClass)}
|
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||||
variants={headerRightVariants}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
>
|
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||||
<motion.div
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.6 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
|
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 0.4, delay: 0.65 }}
|
transition={{ duration: 0.4, delay: 0.9 }}
|
||||||
>
|
>
|
||||||
<Link
|
<m.div
|
||||||
href={getPathForLocale('en')}
|
initial={{ opacity: 0 }}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 1.0 }}
|
||||||
>
|
>
|
||||||
EN
|
<Link
|
||||||
</Link>
|
href={getPathForLocale('en')}
|
||||||
</motion.div>
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
<motion.div
|
>
|
||||||
className="w-px h-4 bg-current opacity-20"
|
EN
|
||||||
initial={{ scaleY: 0 }}
|
</Link>
|
||||||
animate={{ scaleY: 1 }}
|
</m.div>
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
<m.div
|
||||||
/>
|
className="w-px h-6 bg-white/20"
|
||||||
<motion.div
|
initial={{ scaleX: 0 }}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
animate={{ scaleX: 1 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
transition={{ duration: 0.4, delay: 1.05 }}
|
||||||
transition={{ duration: 0.4, delay: 0.75 }}
|
/>
|
||||||
>
|
<m.div
|
||||||
<Link
|
initial={{ opacity: 0 }}
|
||||||
href={getPathForLocale('de')}
|
animate={{ opacity: 1 }}
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
transition={{ duration: 0.3, delay: 1.1 }}
|
||||||
>
|
>
|
||||||
DE
|
<Link
|
||||||
</Link>
|
href={getPathForLocale('de')}
|
||||||
</motion.div>
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
</motion.div>
|
>
|
||||||
|
DE
|
||||||
<motion.div
|
</Link>
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
</m.div>
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
</m.div>
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
href={`/${currentLocale}/contact`}
|
|
||||||
variant="white"
|
|
||||||
size="md"
|
|
||||||
className="px-8 shadow-xl"
|
|
||||||
>
|
|
||||||
{t('contact')}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
<m.div
|
||||||
<motion.button
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
aria-label={t('toggleMenu')}
|
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 300, damping: 20, delay: 0.5 }}
|
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
||||||
>
|
|
||||||
<motion.svg
|
|
||||||
className="w-7 h-7"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.6 }}
|
|
||||||
>
|
|
||||||
{isMobileMenuOpen ? (
|
|
||||||
<motion.path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<motion.path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.svg>
|
|
||||||
</motion.button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
|
||||||
<div className={cn(
|
|
||||||
"fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col",
|
|
||||||
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
|
||||||
)}>
|
|
||||||
<motion.div
|
|
||||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
|
||||||
initial="closed"
|
|
||||||
animate={isMobileMenuOpen ? "open" : "closed"}
|
|
||||||
variants={{
|
|
||||||
open: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
delayChildren: 0.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{menuItems.map((item, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={item.href}
|
|
||||||
variants={{
|
|
||||||
closed: { opacity: 0, y: 50, scale: 0.9 },
|
|
||||||
open: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: "easeOut",
|
|
||||||
delay: idx * 0.08
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
|
||||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
|
||||||
>
|
>
|
||||||
{item.label}
|
<Button
|
||||||
</Link>
|
href={`/${currentLocale}/contact`}
|
||||||
</motion.div>
|
variant="accent"
|
||||||
))}
|
size="lg"
|
||||||
|
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
||||||
<motion.div
|
>
|
||||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
{t('contact')}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
</Button>
|
||||||
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
</m.div>
|
||||||
transition={{ duration: 0.5, delay: 0.8 }}
|
</m.div>
|
||||||
>
|
|
||||||
<motion.div
|
{/* Bottom Branding */}
|
||||||
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
<m.div
|
||||||
|
className="p-12 flex justify-center opacity-20"
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||||
transition={{ duration: 0.4, delay: 0.9 }}
|
transition={{ duration: 0.5, delay: 1.4 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ scale: 0.5 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ duration: 0.3, delay: 1.0 }}
|
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
href={getPathForLocale('en')}
|
</m.div>
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
</m.div>
|
||||||
>
|
</m.nav>
|
||||||
EN
|
</div>
|
||||||
</Link>
|
</m.header>
|
||||||
</motion.div>
|
</LazyMotion>
|
||||||
<motion.div
|
|
||||||
className="w-px h-6 bg-white/20"
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 1.05 }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 1.1 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={getPathForLocale('de')}
|
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 20, delay: 1.2 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
href={`/${currentLocale}/contact`}
|
|
||||||
variant="accent"
|
|
||||||
size="lg"
|
|
||||||
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
|
||||||
>
|
|
||||||
{t('contact')}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
|
||||||
<motion.div
|
|
||||||
className="p-12 flex justify-center opacity-20"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.5, delay: 1.4 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.5 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, delay: 1.5 }}
|
|
||||||
>
|
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.header>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -367,9 +478,9 @@ const navVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.06,
|
staggerChildren: 0.06,
|
||||||
delayChildren: 0.1
|
delayChildren: 0.1,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const navLinkVariants = {
|
const navLinkVariants = {
|
||||||
@@ -380,9 +491,9 @@ const navLinkVariants = {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: "easeOut"
|
ease: 'easeOut',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const headerRightVariants = {
|
const headerRightVariants = {
|
||||||
@@ -390,6 +501,6 @@ const headerRightVariants = {
|
|||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
x: 0,
|
x: 0,
|
||||||
transition: { duration: 0.6, ease: "easeOut" }
|
transition: { duration: 0.6, ease: 'easeOut' },
|
||||||
}
|
},
|
||||||
} as const;
|
} 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, domAnimation, 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={domAnimation}>
|
||||||
{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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function OGImageTemplate({
|
|||||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||||
padding: '80px',
|
padding: '80px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,10 +39,14 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -57,23 +61,26 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
top: 0,
|
||||||
background: 'linear-gradient(to right, rgba(0,26,77,0.9) 0%, rgba(0,26,77,0.4) 100%)',
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Decorative Scribble Circle (Simplified for Satori) */}
|
{/* Decorative Brand Accent (Top Right) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '-100px',
|
top: '-150px',
|
||||||
right: '-100px',
|
right: '-150px',
|
||||||
width: '600px',
|
width: '600px',
|
||||||
height: '600px',
|
height: '600px',
|
||||||
borderRadius: '50%',
|
borderRadius: '300px',
|
||||||
background: `radial-gradient(circle, ${accentGreen}1a 0%, transparent 70%)`,
|
backgroundColor: `${accentGreen}15`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -84,11 +91,11 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 700,
|
||||||
color: accentGreen,
|
color: accentGreen,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.2em',
|
letterSpacing: '0.3em',
|
||||||
marginBottom: '24px',
|
marginBottom: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -99,13 +106,14 @@ export function OGImageTemplate({
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '72px',
|
fontSize: title.length > 40 ? '64px' : '82px',
|
||||||
fontWeight: '900',
|
fontWeight: 700,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
lineHeight: '1.1',
|
lineHeight: '1.05',
|
||||||
maxWidth: '900px',
|
maxWidth: '950px',
|
||||||
marginBottom: '32px',
|
marginBottom: '40px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -116,13 +124,14 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '32px',
|
fontSize: '32px',
|
||||||
color: 'rgba(255,255,255,0.8)',
|
color: 'rgba(255,255,255,0.7)',
|
||||||
maxWidth: '800px',
|
maxWidth: '850px',
|
||||||
lineHeight: '1.4',
|
lineHeight: '1.4',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
fontWeight: 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,33 +148,34 @@ export function OGImageTemplate({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '120px',
|
width: '80px',
|
||||||
height: '8px',
|
height: '6px',
|
||||||
backgroundColor: accentGreen,
|
backgroundColor: accentGreen,
|
||||||
borderRadius: '4px',
|
borderRadius: '3px',
|
||||||
marginRight: '24px',
|
marginRight: '24px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 700,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.15em',
|
||||||
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
KLZ Cables
|
KLZ Cables
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saturated Blue Accent */}
|
{/* Saturated Blue Brand Strip */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
width: '10px',
|
width: '12px',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
backgroundColor: saturatedBlue,
|
backgroundColor: saturatedBlue,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||||
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
|
|
||||||
@@ -13,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 */}
|
||||||
@@ -45,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>
|
||||||
@@ -56,42 +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" />}
|
||||||
<a
|
</aside>
|
||||||
href={datasheetPath}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block bg-white rounded-2xl border border-neutral-medium overflow-hidden group transition-all duration-500 hover:shadow-xl hover:border-saturated/30 hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div className="p-4 flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-neutral-medium/20 flex items-center justify-center flex-shrink-0 group-hover:bg-saturated group-hover:text-white transition-all duration-500 text-saturated border border-transparent group-hover:border-white/20">
|
|
||||||
<svg className="w-6 h-6 transition-transform duration-500 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" 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" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm md:text-base font-heading font-black text-neutral-dark m-0 uppercase tracking-tighter leading-tight group-hover:text-saturated transition-colors duration-300">
|
|
||||||
{t('downloadDatasheet')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-text-secondary text-[10px] md:text-xs m-0 mt-0.5 font-semibold leading-tight truncate uppercase tracking-widest opacity-60">
|
|
||||||
{t('downloadDatasheetDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-neutral-dark/20 group-hover:text-saturated transition-all duration-500 transform group-hover:translate-x-1">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</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,24 +60,52 @@ 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">
|
<div
|
||||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-accent/20">
|
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||||
<svg className="w-5 h-5 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
role="alert"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
aria-live="polite"
|
||||||
</svg>
|
>
|
||||||
|
<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">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
|
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
|
{t('successTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||||
{t('successDesc', { productName })}
|
{t('successDesc', { productName })}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -73,26 +122,40 @@ 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">
|
<div
|
||||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-destructive/20">
|
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||||
<svg className="w-5 h-5 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
role="alert"
|
||||||
<circle cx="12" cy="12" r="10" />
|
aria-live="assertive"
|
||||||
<line x1="15" y1="9" x2="9" y2="15" />
|
>
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<div className="flex justify-center mb-3">
|
||||||
</svg>
|
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-destructive-foreground"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive">{t('errorTitle') || 'Submission Failed'}</h3>
|
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0">
|
{t('errorTitle') || 'Submission Failed'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||||
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setStatus('idle')}
|
onClick={() => setStatus('idle')}
|
||||||
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-destructive hover:text-destructive/80 transition-colors group"
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<span className="border-b-2 border-destructive/10 group-hover:border-destructive/30 transition-colors pb-1">
|
{t('tryAgain') || 'Try Again'}
|
||||||
{t('tryAgain') || 'Try Again'}
|
</Button>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,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>
|
||||||
@@ -133,22 +200,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
>
|
>
|
||||||
{status === 'submitting' ? (
|
{status === 'submitting' ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-3 w-3 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs">{t('submitting')}</span>
|
<span className="text-xs">{t('submitting')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs">{t('submit')}</span>
|
<span className="text-xs">{t('submit')}</span>
|
||||||
<svg className="w-3 h-3 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-3 h-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>
|
</svg>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||||
{t('privacyNote')}
|
{t('privacyNote')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, Variants } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface ScribbleProps {
|
interface ScribbleProps {
|
||||||
@@ -18,56 +18,60 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 1.8,
|
duration: 1.8,
|
||||||
ease: "easeInOut",
|
ease: 'easeInOut',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={cn("absolute pointer-events-none", className)}
|
<svg
|
||||||
role="presentation"
|
className={cn('absolute pointer-events-none', className)}
|
||||||
viewBox="0 0 800 350"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="none"
|
viewBox="0 0 800 350"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<motion.path
|
>
|
||||||
variants={pathVariants}
|
<m.path
|
||||||
initial="hidden"
|
variants={pathVariants}
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true }}
|
whileInView="visible"
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
viewport={{ once: true }}
|
||||||
strokeLinejoin="miter"
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
fillOpacity="0"
|
strokeLinejoin="miter"
|
||||||
strokeMiterlimit="4"
|
fillOpacity="0"
|
||||||
stroke={color}
|
strokeMiterlimit="4"
|
||||||
strokeOpacity="1"
|
stroke={color}
|
||||||
strokeWidth="20"
|
strokeOpacity="1"
|
||||||
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"
|
strokeWidth="20"
|
||||||
/>
|
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'underline') {
|
if (variant === 'underline') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={cn("absolute pointer-events-none", className)}
|
<svg
|
||||||
role="presentation"
|
className={cn('absolute pointer-events-none', className)}
|
||||||
viewBox="-400 -55 730 60"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="none"
|
viewBox="-400 -55 730 60"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<motion.path
|
>
|
||||||
variants={pathVariants}
|
<m.path
|
||||||
initial="hidden"
|
variants={pathVariants}
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true }}
|
whileInView="visible"
|
||||||
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"
|
viewport={{ once: true }}
|
||||||
stroke={color}
|
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"
|
||||||
strokeWidth="20"
|
stroke={color}
|
||||||
fill="none"
|
strokeWidth="20"
|
||||||
/>
|
fill="none"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
components/analytics/AnalyticsShell.tsx
Normal file
34
components/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'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(() => {
|
||||||
|
// 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',
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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,6 +1,5 @@
|
|||||||
'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';
|
||||||
@@ -19,19 +18,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 +28,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 +48,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 +62,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,95 +2,127 @@
|
|||||||
|
|
||||||
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 { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import HeroIllustration from './HeroIllustration';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
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">
|
<LazyMotion strict features={domAnimation}>
|
||||||
<motion.div
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
className="max-w-5xl mx-auto md:mx-0"
|
<m.div
|
||||||
initial="hidden"
|
className="max-w-5xl mx-auto md:mx-0"
|
||||||
animate="visible"
|
initial="hidden"
|
||||||
variants={containerVariants}
|
animate="visible"
|
||||||
>
|
variants={containerVariants}
|
||||||
<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', {
|
|
||||||
green: (chunks) => (
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<motion.span
|
|
||||||
className="relative z-10 text-accent italic"
|
|
||||||
variants={accentVariants}
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</motion.span>
|
|
||||||
<motion.div
|
|
||||||
variants={scribbleVariants}
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</motion.div>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Heading>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div variants={subtitleVariants}>
|
|
||||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
|
||||||
variants={buttonContainerVariants}
|
|
||||||
>
|
>
|
||||||
<motion.div variants={buttonVariants}>
|
<m.div variants={headingVariants}>
|
||||||
<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">
|
<Heading
|
||||||
{t('cta')}
|
level={1}
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
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]"
|
||||||
</Button>
|
>
|
||||||
</motion.div>
|
{t.rich('title', {
|
||||||
<motion.div variants={buttonVariants}>
|
green: (chunks) => (
|
||||||
<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">
|
<span className="relative inline-block">
|
||||||
{t('exploreProducts')}
|
<m.span
|
||||||
</Button>
|
className="relative z-10 text-accent italic"
|
||||||
</motion.div>
|
variants={accentVariants}
|
||||||
</motion.div>
|
>
|
||||||
</motion.div>
|
{chunks}
|
||||||
</Container>
|
</m.span>
|
||||||
|
<m.div
|
||||||
|
variants={scribbleVariants}
|
||||||
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
||||||
|
>
|
||||||
|
<Scribble variant="circle" />
|
||||||
|
</m.div>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Heading>
|
||||||
|
</m.div>
|
||||||
|
<m.div variants={subtitleVariants}>
|
||||||
|
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</m.div>
|
||||||
|
<m.div
|
||||||
|
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
||||||
|
variants={buttonContainerVariants}
|
||||||
|
>
|
||||||
|
<m.div variants={buttonVariants}>
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('cta')}
|
||||||
|
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
<m.div variants={buttonVariants}>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('exploreProducts')}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
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)' }}
|
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
||||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
||||||
>
|
>
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
<motion.div
|
<m.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"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1, ease: "easeOut", delay: 3 }}
|
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
|
<m.div
|
||||||
className="w-1 h-2 bg-white rounded-full"
|
className="w-1 h-2 bg-white rounded-full"
|
||||||
animate={{ y: [0, -10, 0] }}
|
animate={{ y: [0, -10, 0] }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 1.5,
|
duration: 1.5,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: "easeInOut"
|
ease: 'easeInOut',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,20 +132,20 @@ const containerVariants = {
|
|||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.12,
|
staggerChildren: 0.1,
|
||||||
delayChildren: 0.4
|
delayChildren: 0.1,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const headingVariants = {
|
const headingVariants = {
|
||||||
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
hidden: { opacity: 1, y: 10, scale: 0.98 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }
|
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const accentVariants = {
|
const accentVariants = {
|
||||||
@@ -122,8 +154,8 @@ const accentVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }
|
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const scribbleVariants = {
|
const scribbleVariants = {
|
||||||
@@ -132,37 +164,37 @@ const scribbleVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
transition: { duration: 1, type: "spring", stiffness: 300, damping: 20 }
|
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const subtitleVariants = {
|
const subtitleVariants = {
|
||||||
hidden: { opacity: 0, y: 40, scale: 0.95 },
|
hidden: { opacity: 1, y: 20, scale: 0.98 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] }
|
transition: { duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.1 },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const buttonContainerVariants = {
|
const buttonContainerVariants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 1 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.15,
|
staggerChildren: 0.15,
|
||||||
delayChildren: 0.4
|
delayChildren: 0.4,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const buttonVariants = {
|
const buttonVariants = {
|
||||||
hidden: { opacity: 0, y: 30, scale: 0.9 },
|
hidden: { opacity: 1, y: 30, scale: 0.9 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
transition: { type: "spring", stiffness: 400, damping: 20 }
|
transition: { type: 'spring', stiffness: 400, damping: 20 },
|
||||||
}
|
},
|
||||||
} as const;
|
} 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, domAnimation, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function PlaybackCursor() {
|
||||||
|
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
|
||||||
|
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Track scroll so cursor stays locked to the correct element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll(); // Init
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyMotion strict features={domAnimation}>
|
||||||
|
<m.div
|
||||||
|
className="fixed z-[10000] pointer-events-none"
|
||||||
|
animate={{
|
||||||
|
x: cursorPosition.x,
|
||||||
|
y: cursorPosition.y,
|
||||||
|
scale: isClicking ? 0.8 : 1,
|
||||||
|
rotateX: isClicking ? 15 : 0,
|
||||||
|
rotateY: isClicking ? -15 : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
|
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
|
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
|
}}
|
||||||
|
style={{ perspective: '1000px' }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isClicking && (
|
||||||
|
<m.div
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 2.5, opacity: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Outer Pulse Ring */}
|
||||||
|
<div
|
||||||
|
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual Cursor */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Soft Glow */}
|
||||||
|
<div
|
||||||
|
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pointer Arrow */}
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||||
|
fill={isClicking ? '#82ed20' : 'white'}
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
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, domAnimation } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
MousePointer2,
|
||||||
|
Scroll,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Settings2,
|
||||||
|
GripVertical,
|
||||||
|
Clock,
|
||||||
|
Maximize2,
|
||||||
|
Box,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { RecordEvent } from '@/types/record-mode';
|
||||||
|
import { PlaybackCursor } from './PlaybackCursor';
|
||||||
|
|
||||||
|
export function RecordModeOverlay() {
|
||||||
|
const {
|
||||||
|
isActive,
|
||||||
|
setIsActive,
|
||||||
|
events,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
removeEvent,
|
||||||
|
isPlaying,
|
||||||
|
playEvents,
|
||||||
|
saveSession,
|
||||||
|
clearEvents,
|
||||||
|
reorderEvents,
|
||||||
|
setHoveredEventId,
|
||||||
|
setEvents, // Added setEvents here
|
||||||
|
} = useRecordMode();
|
||||||
|
|
||||||
|
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||||
|
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||||
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || !isActive) return;
|
||||||
|
|
||||||
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||||
|
const { selector, rect, tagName } = e.data;
|
||||||
|
|
||||||
|
if (pickingMode === 'mouse') {
|
||||||
|
addEvent({
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: lastInteractionType,
|
||||||
|
selector,
|
||||||
|
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||||
|
motionBlur: false,
|
||||||
|
realClick: false,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
} else if (pickingMode === 'scroll') {
|
||||||
|
addEvent({
|
||||||
|
type: 'scroll',
|
||||||
|
selector,
|
||||||
|
duration: 1500,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Scroll to ${tagName}`,
|
||||||
|
motionBlur: false,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setPickingMode(null);
|
||||||
|
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||||
|
setPickingMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
if (pickingMode) {
|
||||||
|
// Find the iframe and signal start picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Signal stop picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
}, [isActive, pickingMode, addEvent, mounted]);
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingEventId) {
|
||||||
|
updateEvent(editingEventId, editForm);
|
||||||
|
setEditingEventId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showEvents, setShowEvents] = useState(true);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
// Failsafe: Never render host toggle in embedded mode
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window.self !== window.top ||
|
||||||
|
window.name === 'record-mode-iframe' ||
|
||||||
|
window.location.search.includes('embedded=true'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(true)}
|
||||||
|
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyMotion strict features={domAnimation}>
|
||||||
|
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||||
|
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||||
|
{/* Identity Tag */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||||
|
Event Builder
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||||
|
Manual Mode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Action Tools */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode('mouse');
|
||||||
|
setLastInteractionType('click');
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={16} />
|
||||||
|
<span>Mouse</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPickingMode('scroll')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={16} />
|
||||||
|
<span>Scroll</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
addEvent({
|
||||||
|
type: 'wait',
|
||||||
|
duration: 2000,
|
||||||
|
zoom: 1,
|
||||||
|
description: 'Wait for 2s',
|
||||||
|
motionBlur: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Sequence Controls */}
|
||||||
|
<div className="flex items-center gap-1 p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={playEvents}
|
||||||
|
disabled={isPlaying || events.length === 0}
|
||||||
|
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||||
|
title="Preview Sequence"
|
||||||
|
>
|
||||||
|
<Play size={18} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEvents(!showEvents)}
|
||||||
|
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
{events.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||||
|
{events.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const session = {
|
||||||
|
events,
|
||||||
|
name: 'Recording',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/save-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(session),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Visual feedback could be improved, but alert is fine for dev tool
|
||||||
|
alert('Session saved to remotion/session.json');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Failed to save: ${err.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error saving session');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||||
|
title="Save to Project (Dev)"
|
||||||
|
>
|
||||||
|
<Save size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const data = JSON.stringify(
|
||||||
|
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'remotion-session.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||||
|
title="Download JSON"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(false)}
|
||||||
|
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||||
|
title="Exit Studio"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Event Timeline Popover */}
|
||||||
|
{showEvents && (
|
||||||
|
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||||
|
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||||
|
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||||
|
{events.length} Actions Recorded
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearEvents}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={events}
|
||||||
|
onReorder={setEvents}
|
||||||
|
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||||
|
>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||||
|
<Plus size={40} strokeWidth={1} />
|
||||||
|
<p className="text-xs mt-4">Timeline is empty</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={event.id}
|
||||||
|
value={event}
|
||||||
|
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||||
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||||
|
onMouseLeave={() => setHoveredEventId(null)}
|
||||||
|
>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{event.type === 'mouse'
|
||||||
|
? `Mouse (${event.interactionType})`
|
||||||
|
: event.type}
|
||||||
|
</span>
|
||||||
|
{event.clickOrigin &&
|
||||||
|
event.clickOrigin !== 'center' &&
|
||||||
|
event.interactionType === 'click' && (
|
||||||
|
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
|
{event.clickOrigin}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||||
|
{event.duration}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||||
|
{event.selector || 'system:wait'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEventId(event.id);
|
||||||
|
setEditForm(event);
|
||||||
|
}}
|
||||||
|
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEvent(event.id)}
|
||||||
|
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Reorder.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||||
|
|
||||||
|
{/* Picking Tooltip */}
|
||||||
|
{pickingMode && (
|
||||||
|
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||||
|
<span className="font-black uppercase tracking-widest text-xs">
|
||||||
|
Assigning {pickingMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-primary-dark/20" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
ESC to Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaybackCursor />
|
||||||
|
|
||||||
|
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingEventId && (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||||
|
Event Options
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingEventId(null)}
|
||||||
|
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||||
|
{/* Type Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Interaction Type
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'click',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Click</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'hover',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precise Click Origin */}
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Click Origin
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
{[
|
||||||
|
{ id: 'top-left', label: 'TL' },
|
||||||
|
{ id: 'top-right', label: 'TR' },
|
||||||
|
{ id: 'center', label: 'CTR' },
|
||||||
|
{ id: 'bottom-left', label: 'BL' },
|
||||||
|
{ id: 'bottom-right', label: 'BR' },
|
||||||
|
].map((origin) => (
|
||||||
|
<button
|
||||||
|
key={origin.id}
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||||
|
}
|
||||||
|
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||||
|
>
|
||||||
|
{origin.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||||
|
<span>Timeline Allocation</span>
|
||||||
|
<span className="text-accent">{editForm.duration}ms</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="5000"
|
||||||
|
step="100"
|
||||||
|
value={editForm.duration || 1000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom & Effects */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Maximize2 size={18} className="text-white/40" />
|
||||||
|
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
Zoom Shift
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
value={editForm.zoom || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
|
||||||
|
}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Box size={18} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Motion Blur
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
|
||||||
|
}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Trigger Navigation
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] opacity-60">
|
||||||
|
Allows URL transitions in Studio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
Commit Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
components/record-mode/RecordModeVisuals.tsx
Normal file
261
components/record-mode/RecordModeVisuals.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [isEmbedded, setIsEmbedded] = React.useState(false);
|
||||||
|
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
// Explicit non-magical detection
|
||||||
|
const embedded =
|
||||||
|
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
|
||||||
|
setIsEmbedded(embedded);
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('embedded', 'true');
|
||||||
|
setIframeUrl(url.toString());
|
||||||
|
}
|
||||||
|
}, [isEmbedded]);
|
||||||
|
|
||||||
|
// Hydration Guard: Match server on first render
|
||||||
|
if (!mounted) return <>{children}</>;
|
||||||
|
|
||||||
|
// Recursion Guard: If we are already in an embedded iframe,
|
||||||
|
// strictly return just the children to prevent Inception.
|
||||||
|
if (isEmbedded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
|
||||||
|
#nextjs-portal,
|
||||||
|
#nextjs-portal-root,
|
||||||
|
[data-nextjs-toast-wrapper],
|
||||||
|
.nextjs-static-indicator,
|
||||||
|
[data-nextjs-indicator],
|
||||||
|
[class*="nextjs-"],
|
||||||
|
[id*="nextjs-"],
|
||||||
|
nextjs-portal,
|
||||||
|
#feedback-overlay,
|
||||||
|
.feedback-ui-root,
|
||||||
|
.feedback-ui-ignore,
|
||||||
|
[class*="z-[9999]"],
|
||||||
|
[class*="z-[10000]"],
|
||||||
|
[style*="z-index: 9999"],
|
||||||
|
[style*="z-index: 10000"],
|
||||||
|
.fixed.bottom-6.left-6,
|
||||||
|
.fixed.bottom-6.left-1/2,
|
||||||
|
.feedback-ui-overlay,
|
||||||
|
[id^="feedback-"],
|
||||||
|
[class^="feedback-"] {
|
||||||
|
display: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
z-index: -10000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
border-radius: 3rem;
|
||||||
|
background: #050505 !important;
|
||||||
|
color: white !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global Style for Body Lock */}
|
||||||
|
{isActive && (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
html, body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
position: fixed !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
}
|
||||||
|
/* Kill Next.js Dev tools on host while Studio is active */
|
||||||
|
#nextjs-portal,
|
||||||
|
[data-nextjs-toast-wrapper],
|
||||||
|
.nextjs-static-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}
|
||||||
|
>
|
||||||
|
{/* Studio Background - Only visible when active */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
|
||||||
|
<div
|
||||||
|
className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #10b981 0%, transparent 70%)',
|
||||||
|
filter: 'blur(160px)',
|
||||||
|
animation: 'mesh-float-1 18s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)',
|
||||||
|
filter: 'blur(150px)',
|
||||||
|
animation: 'mesh-float-2 22s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)',
|
||||||
|
filter: 'blur(130px)',
|
||||||
|
animation: 'mesh-float-3 14s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)',
|
||||||
|
filter: 'blur(140px)',
|
||||||
|
animation: 'mesh-float-4 20s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||||
|
backgroundSize: '128px 128px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.06]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
|
||||||
|
style={{
|
||||||
|
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
|
||||||
|
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
|
||||||
|
filter: isBlurry ? 'blur(4px)' : 'none',
|
||||||
|
willChange: 'transform, filter',
|
||||||
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate'
|
||||||
|
: 'w-full h-full'
|
||||||
|
}
|
||||||
|
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))',
|
||||||
|
animation: 'pulse-ring 4s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'w-full h-full rounded-[3rem] overflow-hidden relative'
|
||||||
|
: 'w-full h-full relative'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
|
||||||
|
transform: isActive ? 'translateZ(0)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActive && iframeUrl ? (
|
||||||
|
<iframe
|
||||||
|
src={iframeUrl}
|
||||||
|
name="record-mode-iframe"
|
||||||
|
className="w-full h-full border-0 block"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#050505',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700'
|
||||||
|
: 'transition-all duration-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
|
||||||
|
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
|
||||||
|
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
|
||||||
|
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
|
||||||
|
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
|
||||||
|
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
|
||||||
|
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||||
|
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,24 +3,43 @@ import Link from 'next/link';
|
|||||||
import { cn } from './utils';
|
import { cn } from './utils';
|
||||||
|
|
||||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'accent' | 'saturated' | 'outline' | 'ghost' | 'white';
|
variant?:
|
||||||
|
| 'primary'
|
||||||
|
| 'secondary'
|
||||||
|
| 'accent'
|
||||||
|
| 'saturated'
|
||||||
|
| 'outline'
|
||||||
|
| 'ghost'
|
||||||
|
| 'white'
|
||||||
|
| 'destructive';
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
href?: string;
|
href?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
|
export function Button({
|
||||||
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
href,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||||
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
||||||
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
||||||
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||||
outline: 'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
outline:
|
||||||
|
'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||||
ghost: 'text-primary hover:shadow-lg',
|
ghost: 'text-primary hover:shadow-lg',
|
||||||
white: 'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
white:
|
||||||
|
'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-md hover:shadow-destructive/30 hover:shadow-2xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
|
|||||||
outline: 'bg-primary',
|
outline: 'bg-primary',
|
||||||
ghost: 'bg-primary-light/10',
|
ghost: 'bg-primary-light/10',
|
||||||
white: 'bg-primary-light',
|
white: 'bg-primary-light',
|
||||||
|
destructive: 'bg-destructive/90',
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<span className={cn(
|
<span
|
||||||
"relative z-10 flex items-center justify-center gap-2 transition-colors duration-500",
|
className={cn(
|
||||||
variant === 'white' ? "group-hover/btn:text-primary-dark" : ""
|
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
|
||||||
)}>
|
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span
|
||||||
"absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out",
|
className={cn(
|
||||||
overlayColors[variant]
|
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
|
||||||
)} />
|
overlayColors[variant],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52
config/lighthouserc.json
Normal file
52
config/lighthouserc.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"numberOfRuns": 1,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user