Compare commits
334 Commits
baec7cc94a
...
1.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a4ea42a043 | |||
| ee04d2422c | |||
| 26fc34299e | |||
| 6d13611a16 | |||
| 4a9246be5e | |||
| 2ed038174d | |||
| c1304403a1 | |||
| 5036c5fe28 | |||
| 50a524c515 | |||
| 57886a01d6 | |||
| c89bd8e80f | |||
| 9c54322654 | |||
| 8a80eb7b9a | |||
| c1773a7072 | |||
| 33ed13d255 | |||
| 0f5811edb9 | |||
| 06bbed8c21 | |||
| f5a879fa60 | |||
| e4eabd7a86 | |||
| 757df76f36 | |||
| 14b2f83971 | |||
| 51565fdf41 | |||
| be9f9cf483 | |||
| 506c8682fe | |||
| a909de30f3 | |||
| a2f94f15bc | |||
| 13e56a88bc | |||
| bb7d17001b | |||
| 920efa0083 | |||
| 0b81d1a4cb | |||
| 1d5bdeba26 | |||
| a0c3fbbc7e | |||
| 8101a9f156 | |||
| 7b6f4b5ea4 | |||
| 658057cdb1 | |||
| 2aa5d5b00e | |||
| 7f2f6f5aca | |||
| 4e50482769 | |||
| 1da1f05cdd | |||
| 15cfb314b1 | |||
| a3da6192e3 | |||
| af33c6225d | |||
| 9ee09bbe4b | |||
| 3f0858a1ba | |||
| a85fe64ccb | |||
| 21b16a5e6c | |||
| 6115e0e0d4 | |||
| 859d034ed7 | |||
| 91ebc54571 | |||
| d6c1d6bae6 | |||
| 407b2227b3 | |||
| 2896556659 | |||
| 8242687b07 | |||
| dab4f3f5b5 | |||
| b18ee8d7a0 | |||
| cbca29cbcf | |||
| 5a5c10ca36 | |||
| ad6bfe1457 | |||
| b5c3fc6649 | |||
| 03609f113d | |||
| 622180c483 | |||
| 41865ab9ab | |||
| 986fcd067e | |||
| d27616ed43 | |||
| b9a3e47662 | |||
| a5e34053f7 | |||
| e6f9ad36d3 | |||
| 2e0456b081 | |||
| ad37a372a7 | |||
| 8490b691e1 | |||
| 334c76935e | |||
| 6624cfc3ad | |||
| 20d7d8405a | |||
| 12501ea51a | |||
| 70f1813e33 | |||
| 69e39b06cf | |||
| 3b5174cd12 | |||
| 875cf1bd07 | |||
| e284bb94af | |||
| 8eea94ceda | |||
| 05b10018a6 | |||
| 3b493abb3d | |||
| f54d8277b3 | |||
| 5c71e9a064 | |||
| 3cab376cd1 | |||
| 3c45e5563e | |||
| 13ab4bde75 | |||
| a805c7b8de | |||
| b8fdbfb10b | |||
| 081adb02be | |||
| 3f17d08b04 | |||
| d40f4544ea | |||
| 041b5534c9 | |||
| dea3b57627 |
@@ -1,10 +1,10 @@
|
||||
node_modules
|
||||
.next
|
||||
!.next/cache
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
*.md
|
||||
docs
|
||||
reference
|
||||
scripts
|
||||
public/datasheets/*.pdf
|
||||
|
||||
42
.env
42
.env
@@ -1,20 +1,38 @@
|
||||
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'
|
||||
|
||||
# Umami Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# GlitchTip (Sentry protocol)
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@klz-cables.com/errors/1
|
||||
LOG_LEVEL=info
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=postmaster@mg.mintel.me
|
||||
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||
MAIL_FROM=KLZ Cables <postmaster@mg.mintel.me>
|
||||
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=http://klz-cms:8055
|
||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=klz_db_user
|
||||
DIRECTUS_DB_PASSWORD=klz_db_pass
|
||||
# Local Development
|
||||
PROJECT_NAME=klz-cables
|
||||
GATEKEEPER_BYPASS_ENABLED=true
|
||||
TRAEFIK_HOST=klz.localhost
|
||||
DIRECTUS_HOST=cms.klz.localhost
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
COOKIE_DOMAIN=localhost
|
||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||
98
.env.example
98
.env.example
@@ -1,19 +1,39 @@
|
||||
# WooCommerce API (Legacy - not currently used)
|
||||
WOOCOMMERCE_URL=https://klz-cables.com
|
||||
WOOCOMMERCE_CONSUMER_KEY=
|
||||
WOOCOMMERCE_CONSUMER_SECRET=
|
||||
WORDPRESS_APP_PASSWORD=
|
||||
# ============================================================================
|
||||
# KLZ Cables - Environment Configuration
|
||||
# ============================================================================
|
||||
# Copy this file to .env for local development
|
||||
# For production, use .env.production as a template
|
||||
# ============================================================================
|
||||
|
||||
# Umami Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Application Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
DIRECTUS_PORT=8055
|
||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||
TARGET=development
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||
|
||||
# GlitchTip (Sentry protocol)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Analytics (Umami)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable analytics
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable error tracking
|
||||
SENTRY_DSN=
|
||||
# Client-side DSN should use the proxy path: https://[key]@[domain]/errors/[id]
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# SMTP Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Email Configuration (SMTP)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Required for contact form functionality
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
@@ -21,6 +41,54 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Redis Configuration (optional)
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
SENTRY_DSN=
|
||||
# For Directus Error Tracking
|
||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Deployment Configuration (CI/CD only)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# These are typically set by the CI/CD workflow
|
||||
IMAGE_TAG=latest
|
||||
TRAEFIK_HOST=klz-cables.com
|
||||
ENV_FILE=.env
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
VARNISH_CACHE_SIZE=256M
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT NOTES
|
||||
# ============================================================================
|
||||
#
|
||||
# BUILD-TIME vs RUNTIME Variables:
|
||||
# ─────────────────────────────────
|
||||
# • NEXT_PUBLIC_* variables are baked into the client bundle at BUILD time
|
||||
# They must be provided as --build-arg when building the Docker image
|
||||
#
|
||||
# • All other variables are used at RUNTIME only
|
||||
# They are loaded from the .env file by docker-compose
|
||||
#
|
||||
# Docker Deployment:
|
||||
# ──────────────────
|
||||
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
||||
# 2. Runtime: All vars are loaded from .env file on the server
|
||||
# 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:
|
||||
# ─────────
|
||||
# • NEVER commit .env files with real credentials to git
|
||||
# • Use Gitea/GitHub secrets for CI/CD workflows
|
||||
# • Store production .env file securely on the server only
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
30
.env.production
Normal file
30
.env.production
Normal file
@@ -0,0 +1,30 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# This file contains runtime environment variables for the production deployment.
|
||||
# It should be placed on the production server at: /home/deploy/sites/klz-cables.com/.env
|
||||
#
|
||||
# IMPORTANT: This file contains sensitive data and should NEVER be committed to git.
|
||||
# ============================================================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=
|
||||
|
||||
# Email Configuration (Mailgun)
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
36
.gitea/workflows/ci.yml
Normal file
36
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CI - Lint, Typecheck & Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality-assurance:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: 🔐 Configure Private Registry
|
||||
run: |
|
||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
run: pnpm lint && pnpm typecheck && pnpm test
|
||||
@@ -1,231 +1,428 @@
|
||||
name: Build & Deploy KLZ Cables
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
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:
|
||||
build-and-deploy:
|
||||
# ────────────────────────────────────────────────
|
||||
# WICHTIG: Kein "docker" mehr – sondern eines der neuen Labels
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 1: Prepare Environment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
prepare:
|
||||
name: 🔍 Prepare
|
||||
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:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Workflow Start - Full Transparency
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 📋 Log Workflow Start
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KLZ Cables Deployment Workflow Started ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📋 Workflow Information:"
|
||||
echo " • Repository: ${{ github.repository }}"
|
||||
echo " • Branch: ${{ github.ref }}"
|
||||
echo " • Commit: ${{ github.sha }}"
|
||||
echo " • Actor: ${{ github.actor }}"
|
||||
echo " • Run ID: ${{ github.run_id }}"
|
||||
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "🔍 Environment Details:"
|
||||
echo " • Runner OS: ${{ runner.os }}"
|
||||
echo " • Workspace: ${{ github.workspace }}"
|
||||
echo ""
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Registry Login Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🔐 Login to private registry
|
||||
- name: 🔍 Environment ermitteln
|
||||
id: determine
|
||||
shell: bash
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Registry Login ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🔐 Authenticating with private registry..."
|
||||
echo " Registry: registry.infra.mintel.me"
|
||||
echo " User: ${{ secrets.REGISTRY_USER != '' && '***' || 'NOT SET' }}"
|
||||
echo ""
|
||||
|
||||
# Execute login with error handling
|
||||
if echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin 2>&1; then
|
||||
echo "✅ Registry login successful"
|
||||
REF="${{ github.ref_name }}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
DOMAIN="klz-cables.com"
|
||||
PRJ="klz"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.prod"
|
||||
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||
else
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.staging"
|
||||
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||
fi
|
||||
else
|
||||
echo "❌ Registry login failed"
|
||||
exit 1
|
||||
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
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Build Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🏗️ Build Docker image
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Build Docker Image ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🏗️ Building Docker image with buildx..."
|
||||
echo " Platform: linux/arm64"
|
||||
echo " Target: registry.infra.mintel.me/mintel/klz-cables.com:latest"
|
||||
echo ""
|
||||
echo "📦 Build Arguments:"
|
||||
echo " • NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID != '' && '***' || 'NOT SET' }}"
|
||||
echo " • NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN != '' && '***' || 'NOT SET' }}"
|
||||
echo ""
|
||||
echo "⏱️ Build started at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
|
||||
# Execute build with detailed logging
|
||||
set -e
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
||||
--push .
|
||||
|
||||
BUILD_EXIT_CODE=$?
|
||||
if [ $BUILD_EXIT_CODE -eq 0 ]; then
|
||||
echo ""
|
||||
echo "✅ Build completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "📊 Image Details:"
|
||||
IMAGE_SIZE=$(docker inspect registry.infra.mintel.me/mintel/klz-cables.com:latest --format='{{.Size}}')
|
||||
IMAGE_SIZE_MB=$((IMAGE_SIZE / 1024 / 1024))
|
||||
echo " • Size: ${IMAGE_SIZE_MB}MB"
|
||||
docker inspect registry.infra.mintel.me/mintel/klz-cables.com:latest --format=' • Created: {{.Created}}'
|
||||
docker inspect registry.infra.mintel.me/mintel/klz-cables.com:latest --format=' • Architecture: {{.Architecture}}'
|
||||
# Standardize Traefik Rule
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Build failed with exit code: $BUILD_EXIT_CODE"
|
||||
exit $BUILD_EXIT_CODE
|
||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Deployment Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🚀 Deploy to production server
|
||||
{
|
||||
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 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Deploy to Production Server ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🚀 Starting deployment process..."
|
||||
echo " Target Server: alpha.mintel.me"
|
||||
echo " Deploy User: deploy"
|
||||
echo " Target Path: /home/deploy/sites/klz-cables.com"
|
||||
echo ""
|
||||
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, qa]
|
||||
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 }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max
|
||||
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
|
||||
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 with logging
|
||||
echo "🔐 Setting up SSH connection..."
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
AUTH_MIDDLEWARE="$STD_MW"
|
||||
COMPOSE_PROFILES=""
|
||||
else
|
||||
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||
COMPOSE_PROFILES="gatekeeper"
|
||||
fi
|
||||
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||
|
||||
# Gatekeeper Origin
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
{
|
||||
echo "# Generated by CI - $TARGET"
|
||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||
echo "MAIL_HOST=$MAIL_HOST"
|
||||
echo "MAIL_PORT=$MAIL_PORT"
|
||||
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||
echo "MAIL_FROM=$MAIL_FROM"
|
||||
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||
echo ""
|
||||
echo "# Directus"
|
||||
echo "DIRECTUS_URL=$DIRECTUS_URL"
|
||||
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
|
||||
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
|
||||
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
|
||||
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
|
||||
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
|
||||
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
|
||||
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
|
||||
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
|
||||
echo "DIRECTUS_DB_CLIENT=pg"
|
||||
echo "DIRECTUS_DB_HOST=directus-db"
|
||||
echo "DIRECTUS_DB_PORT=5432"
|
||||
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
|
||||
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
|
||||
echo ""
|
||||
echo "# Gatekeeper"
|
||||
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||
echo ""
|
||||
echo "# Analytics"
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||
echo "TRAEFIK_TLS=true"
|
||||
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||
echo "ENV_FILE=$ENV_FILE"
|
||||
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||
} > .env.deploy
|
||||
|
||||
echo "--- Generated .env.deploy ---"
|
||||
cat .env.deploy
|
||||
echo "----------------------------"
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
env:
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
echo "🔑 Adding host to known_hosts..."
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Host key added successfully"
|
||||
else
|
||||
echo "⚠️ Warning: Could not add host key"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Execute deployment commands with detailed logging
|
||||
echo "📡 Connecting to server and executing deployment commands..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Execute deployment with proper command chaining - bypass login shell
|
||||
ssh -o StrictHostKeyChecking=accept-new \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ConnectTimeout=10 \
|
||||
-o LogLevel=ERROR \
|
||||
deploy@alpha.mintel.me \
|
||||
"set -ex && \
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \
|
||||
echo '📡 Remote Deployment Started' && \
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \
|
||||
echo '' && \
|
||||
echo '🔐 Logging into registry on remote server...' && \
|
||||
echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin && \
|
||||
echo '✅ Remote registry login completed' && \
|
||||
echo '' && \
|
||||
echo '📂 Changing to deployment directory...' && \
|
||||
cd /home/deploy/sites/klz-cables.com && \
|
||||
echo '✅ Current directory:' && pwd && \
|
||||
echo '' && \
|
||||
echo '📥 Pulling latest images (Explicit Pull)...' && \
|
||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest && \
|
||||
echo '✅ Image pull completed' && \
|
||||
echo '' && \
|
||||
echo '🔄 Starting containers with pull and force-recreate...' && \
|
||||
docker compose up -d --pull always --force-recreate --remove-orphans && \
|
||||
echo '✅ Containers started' && \
|
||||
echo '' && \
|
||||
echo '🧹 Cleaning up old images...' && \
|
||||
docker image prune -f && \
|
||||
echo '✅ Cleanup completed' && \
|
||||
echo '' && \
|
||||
echo '📊 Container Status:' && \
|
||||
docker compose ps && \
|
||||
echo '' && \
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \
|
||||
echo '✅ Remote Deployment Completed Successfully' && \
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'"
|
||||
|
||||
DEPLOY_EXIT_CODE=$?
|
||||
echo ""
|
||||
|
||||
if [ $DEPLOY_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Deployment completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
else
|
||||
echo "❌ Deployment failed with exit code: $DEPLOY_EXIT_CODE"
|
||||
echo ""
|
||||
echo "🔍 Troubleshooting Tips:"
|
||||
echo " • Check server connectivity: ping alpha.mintel.me"
|
||||
echo " • Verify SSH key permissions on server"
|
||||
echo " • Check disk space on target server"
|
||||
echo " • Review docker compose configuration"
|
||||
exit $DEPLOY_EXIT_CODE
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Workflow Summary
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 📊 Workflow Summary
|
||||
# Transfer and Restart
|
||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
|
||||
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)
|
||||
if: always()
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Smoke Test (OG Images)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
smoke_test:
|
||||
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 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Workflow Summary ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📊 Final Status:"
|
||||
echo " • Workflow: ${{ job.status }}"
|
||||
echo " • Completed: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "🎯 Deployment Target:"
|
||||
echo " • Image: registry.infra.mintel.me/mintel/klz-cables.com:latest"
|
||||
echo " • Server: alpha.mintel.me"
|
||||
echo " • Service: klz-cables.com"
|
||||
echo ""
|
||||
echo "🔐 Security Notes:"
|
||||
echo " • All secrets are masked (*** ) in logs"
|
||||
echo " • SSH keys are created with 600 permissions"
|
||||
echo " • Passwords are never displayed in plain text"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
if [ "${{ job.status }}" == "success" ]; then
|
||||
echo "║ ✅ DEPLOYMENT SUCCESSFUL ║"
|
||||
else
|
||||
echo "║ ❌ DEPLOYMENT FAILED ║"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
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: 🚀 Run OG Image Check
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
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
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,2 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.next
|
||||
.DS_Store
|
||||
|
||||
# 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
|
||||
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'],
|
||||
};
|
||||
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.
|
||||
102
Dockerfile
102
Dockerfile
@@ -1,66 +1,66 @@
|
||||
FROM node:20-alpine 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
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
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
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
|
||||
# Configure private registry and install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
rm .npmrc
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Stage 2: Development (Hot-Reloading)
|
||||
FROM base AS development
|
||||
ENV NODE_ENV=development
|
||||
CMD ["pnpm", "dev:local"]
|
||||
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||
# Build application
|
||||
# Stage 3: Builder (Production)
|
||||
FROM base AS builder
|
||||
RUN pnpm build
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Stage 3: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||
WORKDIR /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
|
||||
|
||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||
USER root
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
# set hostname to localhost
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
114
README.md
114
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
|
||||
````bash
|
||||
# Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
@@ -29,9 +31,40 @@ npm run export
|
||||
|
||||
# Or run development server
|
||||
npm run dev
|
||||
|
||||
### 🏗️ CMS (Strapi)
|
||||
The CMS runs in Docker. Use the following npm scripts for local development:
|
||||
|
||||
```bash
|
||||
# Start Strapi and its database
|
||||
npm run cms:dev
|
||||
|
||||
# View logs
|
||||
npm run cms:logs
|
||||
|
||||
# Stop the CMS
|
||||
npm run cms:stop
|
||||
````
|
||||
|
||||
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||
|
||||
### 🔄 Data & Migration
|
||||
|
||||
To sync data or migrate existing content:
|
||||
|
||||
```bash
|
||||
# Export local data
|
||||
npm run cms:export -- my-data.tar.gz
|
||||
|
||||
# Import data
|
||||
npm run cms:import -- my-data.tar.gz
|
||||
|
||||
# Migrate existing MDX data to Strapi
|
||||
npm run cms:migrate
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SITE_URL=https://klz-cables.com
|
||||
@@ -40,23 +73,19 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||
|
||||
# Umami
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# GlitchTip (Sentry compatible)
|
||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
|
||||
# Redis (optional cache)
|
||||
# Platform provides a shared redis container reachable as `redis`.
|
||||
# Pick a dedicated DB index per app, e.g. redis://redis:6379/2
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
```
|
||||
|
||||
## 📊 Project Overview
|
||||
|
||||
### Migration Statistics
|
||||
|
||||
- **Content Exported**: 141 items
|
||||
- 18 pages (9 EN + 9 DE)
|
||||
- 59 posts (29 EN + 30 DE)
|
||||
@@ -67,6 +96,7 @@ REDIS_KEY_PREFIX=klz:
|
||||
- **Translation Pairs**: 16
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
- **Before**: Dynamic WordPress with database queries
|
||||
- **After**: Static HTML with CDN delivery
|
||||
- **Load Time**: <100ms (vs 500ms+)
|
||||
@@ -75,15 +105,18 @@ REDIS_KEY_PREFIX=klz:
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: SCSS
|
||||
- **Data**: Static JSON (WordPress export)
|
||||
- **CMS**: Strapi (Source of Truth)
|
||||
- **Data**: Static JSON (WordPress export) & Strapi API
|
||||
- **Email**: Resend
|
||||
- **Analytics**: Vercel (consent-based)
|
||||
- **CAPTCHA**: Cloudflare Turnstile
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
@@ -108,7 +141,7 @@ app/
|
||||
├── api/
|
||||
│ └── contact/route.ts # Contact API
|
||||
├── sitemap.ts # Sitemap generator
|
||||
└── robots.ts # Robots.txt generator
|
||||
├── robots.ts # Robots.txt generator
|
||||
|
||||
lib/
|
||||
├── data.ts # Data access
|
||||
@@ -119,7 +152,7 @@ components/
|
||||
├── LocaleSwitcher.tsx # Language switcher
|
||||
├── ContactForm.tsx # Contact form
|
||||
├── CookieConsent.tsx # GDPR banner
|
||||
└── SEO.tsx # SEO utilities
|
||||
├── SEO.tsx # SEO utilities
|
||||
|
||||
data/
|
||||
├── raw/ # WordPress export
|
||||
@@ -138,6 +171,7 @@ scripts/
|
||||
## 🎯 Features
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||
- **Contact Forms**: Resend integration with validation
|
||||
- **GDPR Compliance**: Cookie consent banner
|
||||
@@ -150,12 +184,14 @@ scripts/
|
||||
- **Asset Management**: WordPress → local path mapping
|
||||
|
||||
### 🔄 In Progress
|
||||
|
||||
- Analytics integration (consent-based)
|
||||
- Turnstile CAPTCHA
|
||||
- Build testing
|
||||
- Deployment configuration
|
||||
|
||||
### 📝 Remaining
|
||||
|
||||
- Performance optimization
|
||||
- Final QA testing
|
||||
- Documentation updates
|
||||
@@ -163,6 +199,7 @@ scripts/
|
||||
## 📝 Content Management
|
||||
|
||||
### Data Export
|
||||
|
||||
```bash
|
||||
# Export from WordPress
|
||||
npm run data:export
|
||||
@@ -178,6 +215,7 @@ npm run data:improve-mapping
|
||||
```
|
||||
|
||||
### Adding New Content
|
||||
|
||||
1. Export new content from WordPress
|
||||
2. Process the data
|
||||
3. Rebuild the site
|
||||
@@ -185,17 +223,20 @@ npm run data:improve-mapping
|
||||
## 🎨 Design System
|
||||
|
||||
### Colors
|
||||
|
||||
- Primary: `#0066cc` (KLZ Blue)
|
||||
- Secondary: `#00a896` (Teal)
|
||||
- Text: `#1a1a1a`
|
||||
- Background: `#f8f9fa`
|
||||
|
||||
### Typography
|
||||
|
||||
- Font: Inter
|
||||
- Base: 16px
|
||||
- Scale: 1.25 (Major Third)
|
||||
|
||||
### Layout
|
||||
|
||||
- Max width: 1200px
|
||||
- Responsive grid
|
||||
- Mobile-first
|
||||
@@ -203,6 +244,7 @@ npm run data:improve-mapping
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Contact Form
|
||||
|
||||
```
|
||||
POST /api/contact
|
||||
{
|
||||
@@ -214,11 +256,13 @@ POST /api/contact
|
||||
```
|
||||
|
||||
### Sitemap
|
||||
|
||||
```
|
||||
GET /sitemap.xml
|
||||
```
|
||||
|
||||
### Robots
|
||||
|
||||
```
|
||||
GET /robots.txt
|
||||
```
|
||||
@@ -227,21 +271,32 @@ GET /robots.txt
|
||||
|
||||
### 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`
|
||||
2. **Push**: Image pushed to `registry.infra.mintel.me`
|
||||
3. **Deploy**: SSH to production server, pull and restart containers
|
||||
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
|
||||
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
|
||||
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
|
||||
|
||||
**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):
|
||||
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
@@ -259,6 +314,7 @@ docker image prune -f
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-webhook.sh
|
||||
```
|
||||
@@ -266,17 +322,18 @@ bash scripts/deploy-webhook.sh
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Client → Traefik (TLS) → Varnish (Cache) → Next.js App
|
||||
Client → Traefik (TLS) → Next.js App
|
||||
```
|
||||
|
||||
**Domains**:
|
||||
|
||||
- `klz-cables.com` - Production
|
||||
- `www.klz-cables.com` - Production (www)
|
||||
- `staging.klz-cables.com` - Staging
|
||||
|
||||
**Services**:
|
||||
|
||||
- `app`: Next.js application (port 3000)
|
||||
- `varnish`: HTTP cache layer
|
||||
- `traefik`: Reverse proxy (external)
|
||||
|
||||
For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
@@ -284,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
## 📈 Performance
|
||||
|
||||
### Build Time
|
||||
|
||||
- **Target**: < 2 minutes
|
||||
- **Current**: ~1-2 minutes
|
||||
|
||||
### Page Load
|
||||
|
||||
- **Target**: < 100ms
|
||||
- **Current**: Static HTML from CDN
|
||||
|
||||
### Bundle Size
|
||||
|
||||
- **Target**: < 100KB gzipped
|
||||
- **Current**: Optimized with code splitting
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Never commit `.env` file
|
||||
- Rotate keys regularly
|
||||
- Use secrets in deployment platform
|
||||
|
||||
### Form Security
|
||||
|
||||
- Email validation
|
||||
- Rate limiting (recommended)
|
||||
- Turnstile CAPTCHA (pending)
|
||||
@@ -310,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
## 🎓 WordPress Specifics
|
||||
|
||||
### WPBakery Shortcodes Removed
|
||||
|
||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||
- `[nectar_*]` (Salient theme)
|
||||
- `[image_with_animation]`
|
||||
@@ -317,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
- `[divider]`
|
||||
|
||||
### HTML Sanitization
|
||||
|
||||
- Removes inline event handlers
|
||||
- Strips scripts
|
||||
- Normalizes classes
|
||||
- Preserves structure
|
||||
|
||||
### Asset Mapping
|
||||
|
||||
WordPress URLs → Local paths:
|
||||
|
||||
```
|
||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
```
|
||||
@@ -331,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
## 📚 Documentation
|
||||
|
||||
### Internal
|
||||
|
||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||
- `FINAL_SUMMARY.md` - Complete overview
|
||||
|
||||
### External
|
||||
|
||||
- [Next.js Docs](https://nextjs.org/docs)
|
||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||
- [Resend Docs](https://resend.com/docs)
|
||||
@@ -346,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
### Common Issues
|
||||
|
||||
**TypeScript Errors**
|
||||
|
||||
- The TypeScript errors shown in the editor are expected
|
||||
- They occur because modules reference each other
|
||||
- The build process resolves these correctly
|
||||
- Run `npm run build` to verify
|
||||
|
||||
**Build Failures**
|
||||
|
||||
- Check environment variables
|
||||
- Verify data files exist
|
||||
- Clear `.next` cache: `rm -rf .next`
|
||||
|
||||
**Missing Modules**
|
||||
|
||||
- Run `npm install --legacy-peer-deps`
|
||||
- Check `package.json` dependencies
|
||||
|
||||
@@ -371,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
✅ **i18n**: Multi-language support
|
||||
✅ **SEO**: Metadata and sitemaps
|
||||
✅ **Compatibility**: WPBakery content handled
|
||||
✅ **Media**: All images downloaded
|
||||
✅ **Media**: All images downloaded
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the documentation
|
||||
2. Review the troubleshooting section
|
||||
3. Check environment variables
|
||||
|
||||
35
app/[locale]/[slug]/opengraph-image.tsx
Normal file
35
app/[locale]/[slug]/opengraph-image.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
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 default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
if (!pageData) {
|
||||
return new Response('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,55 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Section, Container, Heading, Badge } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
|
||||
const { getPageBySlug } = await import('@/lib/pages');
|
||||
export async function generateStaticParams() {
|
||||
const locales = ['en', 'de'];
|
||||
const params = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
const pages = await getAllPages(locale);
|
||||
for (const page of pages) {
|
||||
params.push({ locale, slug: page.slug });
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
|
||||
if (!pageData) return {};
|
||||
|
||||
return {
|
||||
title: pageData.frontmatter.title,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
alternates: {
|
||||
canonical: `/${locale}/${slug}`,
|
||||
canonical: `${SITE_URL}/${locale}/${slug}`,
|
||||
languages: {
|
||||
'de': `/de/${slug}`,
|
||||
'en': `/en/${slug}`,
|
||||
'x-default': `/en/${slug}`,
|
||||
de: `${SITE_URL}/de/${slug}`,
|
||||
en: `${SITE_URL}/en/${slug}`,
|
||||
'x-default': `${SITE_URL}/en/${slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `https://klz-cables.com/${locale}/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/${slug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -41,8 +59,9 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
||||
};
|
||||
}
|
||||
|
||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
||||
const { getPageBySlug } = await import('@/lib/pages');
|
||||
export default async function StandardPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
const t = await getTranslations('StandardPage');
|
||||
|
||||
@@ -51,7 +70,7 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
@@ -59,68 +78,56 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
||||
<Heading level={1} className="text-3xl md:text-5xl lg:text-6xl text-white mb-0">
|
||||
<span className="text-white">{pageData.frontmatter.title}</span>
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||
{t('badge')}
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-0">
|
||||
{pageData.frontmatter.title}
|
||||
</Heading>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section className="bg-white -mt-8 md:-mt-12 relative z-20 rounded-t-[32px] md:rounded-t-[60px] shadow-2xl py-12 md:py-28">
|
||||
<Container>
|
||||
<div className="sticky-narrative-container">
|
||||
{/* Sticky Narrative Sidebar - Mobile Optimized */}
|
||||
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
|
||||
<div className="lg:sticky lg:top-32 space-y-4 md:space-y-8">
|
||||
{/* Mobile-only chip/stepper feel */}
|
||||
<div className="flex lg:hidden overflow-x-auto pb-4 gap-3 no-scrollbar -mx-4 px-4">
|
||||
<Badge variant="primary" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm">{t('overview')}</Badge>
|
||||
<Badge variant="neutral" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm opacity-60">{t('details')}</Badge>
|
||||
<Badge variant="neutral" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm opacity-60">{t('support')}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 bg-neutral-light rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm">
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-accent rounded-full" />
|
||||
{t('quickNavigation')}
|
||||
</h3>
|
||||
<nav className="space-y-3 md:space-y-4">
|
||||
<p className="text-sm md:text-base text-text-secondary leading-relaxed">
|
||||
{t('exploreDetails', { title: pageData.frontmatter.title })}
|
||||
</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 bg-primary-dark rounded-2xl md:rounded-3xl text-white shadow-xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-24 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 relative z-10">{t('needHelp')}</h3>
|
||||
<p className="text-sm md:text-base text-white/70 mb-4 md:mb-6 relative z-10">{t('supportTeamAvailable')}</p>
|
||||
<a href={`/${locale}/contact`} className="inline-flex items-center text-accent font-bold hover:underline touch-target relative z-10 group/link">
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Main Content Area */}
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{pageData.frontmatter.excerpt && (
|
||||
<div className="mb-16 animate-slight-fade-in-from-bottom">
|
||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||
{pageData.frontmatter.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="sticky-narrative-content">
|
||||
<article className="prose prose-sm md:prose-lg lg:prose-xl prose-primary max-w-none
|
||||
prose-headings:text-primary prose-headings:font-bold prose-headings:tracking-tight
|
||||
prose-p:text-text-secondary prose-p:leading-relaxed
|
||||
prose-strong:text-primary prose-strong:font-extrabold
|
||||
prose-a:text-primary prose-a:font-bold prose-a:no-underline hover:prose-a:underline
|
||||
prose-img:rounded-2xl md:prose-img:rounded-3xl prose-img:shadow-2xl
|
||||
prose-ul:list-disc prose-ul:pl-5 md:prose-ul:pl-6
|
||||
prose-li:text-text-secondary
|
||||
">
|
||||
<MDXRemote source={pageData.content} />
|
||||
</article>
|
||||
{/* Main content with shared blog components */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom">
|
||||
<MDXRemote source={pageData.content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||
<TrackedLink
|
||||
href={`/${locale}/contact`}
|
||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||
eventProperties={{
|
||||
location: 'generic_page_support_cta',
|
||||
page_slug: slug,
|
||||
}}
|
||||
>
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
app/[locale]/api/og/product/route.tsx
Normal file
78
app/[locale]/api/og/product/route.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
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 async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const { locale } = await params;
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
if (categories.includes(slug)) {
|
||||
const categoryKey = slug
|
||||
.replace(/-cables$/, '')
|
||||
.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(
|
||||
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(slug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new Response('Product not found', { status: 404 });
|
||||
}
|
||||
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,113 +1,47 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPostBySlug } from '@/lib/blog';
|
||||
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 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);
|
||||
|
||||
if (!post) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
return new Response('Post not found', { status: 404 });
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: '#001a4d',
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background Image Overlay if available */}
|
||||
{post.frontmatter.featuredImage && (
|
||||
<img
|
||||
src={post.frontmatter.featuredImage.startsWith('http') ? post.frontmatter.featuredImage : `https://klz-cables.com${post.frontmatter.featuredImage}`}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,26,77,1) 0%, rgba(0,26,77,0.4) 100%)',
|
||||
}}
|
||||
/>
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
||||
{post.frontmatter.category && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#00ff99',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: 'rgba(0,255,153,0.1)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{post.frontmatter.category}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
fontWeight: '900',
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '900px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{post.frontmatter.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
KLZ Cables Blog
|
||||
</div>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#00ff99', margin: '0 16px' }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
// 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
|
||||
? post.frontmatter.featuredImage.startsWith('http')
|
||||
? post.frontmatter.featuredImage
|
||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
/>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
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 { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: 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);
|
||||
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const description = post.frontmatter.excerpt || '';
|
||||
@@ -23,11 +31,11 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
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}`,
|
||||
de: `${SITE_URL}/de/blog/${slug}`,
|
||||
en: `${SITE_URL}/en/blog/${slug}`,
|
||||
'x-default': `${SITE_URL}/en/blog/${slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
@@ -36,7 +44,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -46,152 +54,9 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
};
|
||||
}
|
||||
|
||||
import Link from 'next/link';
|
||||
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
||||
import { Callout } from '@/components/ui';
|
||||
import HighlightBox from '@/components/blog/HighlightBox';
|
||||
import Stats from '@/components/blog/Stats';
|
||||
import AnimatedImage from '@/components/blog/AnimatedImage';
|
||||
import ChatBubble from '@/components/blog/ChatBubble';
|
||||
import SplitHeading from '@/components/blog/SplitHeading';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
||||
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
||||
|
||||
const components = {
|
||||
VisualLinkPreview,
|
||||
Callout,
|
||||
HighlightBox,
|
||||
Stats,
|
||||
AnimatedImage,
|
||||
ChatBubble,
|
||||
PowerCTA,
|
||||
SplitHeading,
|
||||
StickyNarrative,
|
||||
TechnicalGrid,
|
||||
ComparisonGrid,
|
||||
h1: () => null,
|
||||
a: ({ href, children, ...props }: any) => {
|
||||
if (href?.startsWith('/')) {
|
||||
return (
|
||||
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
|
||||
>
|
||||
{children}
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: (props: any) => (
|
||||
<AnimatedImage src={props.src} alt={props.alt} />
|
||||
),
|
||||
h2: ({ children, ...props }: any) => (
|
||||
<SplitHeading {...props} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
||||
{children}
|
||||
</SplitHeading>
|
||||
),
|
||||
h3: ({ children, ...props }: any) => (
|
||||
<h3 {...props} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children, ...props }: any) => (
|
||||
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul {...props} className="my-8 space-y-3">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }: any) => (
|
||||
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }: any) => (
|
||||
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
||||
<span className="text-primary mt-1.5 flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ children, ...props }: any) => (
|
||||
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
|
||||
<div className="text-lg text-text-primary italic">
|
||||
{children}
|
||||
</div>
|
||||
</blockquote>
|
||||
),
|
||||
strong: ({ children, ...props }: any) => (
|
||||
<strong {...props} className="font-bold text-primary">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children, ...props }: any) => (
|
||||
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children, ...props }: any) => (
|
||||
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }: any) => (
|
||||
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
|
||||
<table {...props} className="w-full text-left text-sm text-text-secondary">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }: any) => (
|
||||
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children, ...props }: any) => (
|
||||
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }: any) => (
|
||||
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children, ...props }: any) => (
|
||||
<th {...props} className="px-6 py-4 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }: any) => (
|
||||
<td {...props} className="px-6 py-4">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
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 { prev, next } = await getAdjacentPosts(slug, locale);
|
||||
|
||||
@@ -203,16 +68,21 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||
|
||||
<BlogEngagementTracker
|
||||
title={post.frontmatter.title}
|
||||
slug={slug}
|
||||
category={post.frontmatter.category}
|
||||
readingTime={getReadingTime(post.content)}
|
||||
/>
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||
|
||||
|
||||
{/* Title overlay on image */}
|
||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -224,15 +94,18 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-8 leading-[1.1] 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}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
@@ -252,15 +125,15 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-text-primary mb-8 leading-tight">
|
||||
<Heading level={1} className="mb-8">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||
@@ -286,7 +159,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
|
||||
{/* Main content with enhanced styling */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
|
||||
<MDXRemote source={post.content} components={components} />
|
||||
<MDXRemote source={post.content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
{/* Power CTA */}
|
||||
@@ -305,8 +178,18 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
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"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||
</Link>
|
||||
@@ -325,57 +208,63 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
{/* Structured Data */}
|
||||
<JsonLd
|
||||
id={`jsonld-${slug}`}
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.frontmatter.title,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
logo: 'https://klz-cables.com/logo-blue.svg'
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://klz-cables.com/logo-blue.svg',
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.frontmatter.title,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
image: post.frontmatter.featuredImage
|
||||
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: undefined,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/logo-blue.svg`,
|
||||
},
|
||||
},
|
||||
description: post.frontmatter.excerpt,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
},
|
||||
articleSection: post.frontmatter.category,
|
||||
wordCount: post.content.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(post.content)}M`
|
||||
} as any}
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${SITE_URL}/logo-blue.svg`,
|
||||
},
|
||||
},
|
||||
description: post.frontmatter.excerpt,
|
||||
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
|
||||
id={`breadcrumb-${slug}`}
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `https://klz-cables.com/${locale}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: post.frontmatter.title,
|
||||
item: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
},
|
||||
],
|
||||
} as any}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `${SITE_URL}/${locale}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: post.frontmatter.title,
|
||||
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
],
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
|
||||
22
app/[locale]/blog/opengraph-image.tsx
Normal file
22
app/[locale]/blog/opengraph-image.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
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 default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,36 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
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' });
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `/${locale}/blog`,
|
||||
canonical: `${SITE_URL}/${locale}/blog`,
|
||||
languages: {
|
||||
'de': '/de/blog',
|
||||
'en': '/en/blog',
|
||||
'x-default': '/en/blog',
|
||||
de: `${SITE_URL}/de/blog`,
|
||||
en: `${SITE_URL}/en/blog`,
|
||||
'x-default': `${SITE_URL}/en/blog`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `https://klz-cables.com/${locale}/blog`,
|
||||
url: `${SITE_URL}/${locale}/blog`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -36,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 posts = await getAllPosts(locale);
|
||||
|
||||
|
||||
// Sort posts by date descending
|
||||
const sortedPosts = [...posts].sort((a, b) =>
|
||||
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
||||
const sortedPosts = [...posts].sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
const featuredPost = sortedPosts[0];
|
||||
@@ -55,29 +61,41 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<section 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 && (
|
||||
<>
|
||||
<img
|
||||
<Image
|
||||
src={featuredPost.frontmatter.featuredImage}
|
||||
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"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">
|
||||
{t('featuredPost')}
|
||||
</Badge>
|
||||
{featuredPost && (
|
||||
<>
|
||||
<h1 className="text-3xl md:text-6xl font-extrabold text-white mb-4 md:mb-8 leading-[1.1] line-clamp-3 md:line-clamp-none">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{featuredPost.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||
{featuredPost.frontmatter.excerpt}
|
||||
</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')}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -95,10 +113,30 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
</Heading>
|
||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||
{/* 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 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>
|
||||
<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
|
||||
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>
|
||||
</Reveal>
|
||||
@@ -111,14 +149,19 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<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">
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<img
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
<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-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}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -129,7 +172,7 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</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">
|
||||
@@ -143,8 +186,18 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
{t('readMore')}
|
||||
</span>
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,13 +207,21 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Pagination Placeholder */}
|
||||
<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="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>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
|
||||
{t('prev')}
|
||||
</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>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
25
app/[locale]/contact/opengraph-image.tsx
Normal file
25
app/[locale]/contact/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
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 default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3,57 +3,46 @@ import JsonLd from '@/components/JsonLd';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Container, Heading, Section } from '@/components/ui';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } 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>
|
||||
),
|
||||
});
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
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 title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('subtitle'),
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `https://klz-cables.com/${locale}/contact`,
|
||||
canonical: `${SITE_URL}/${locale}/contact`,
|
||||
languages: {
|
||||
'de-DE': '/de/contact',
|
||||
'en-US': '/en/contact',
|
||||
de: `${SITE_URL}/de/contact`,
|
||||
en: `${SITE_URL}/en/contact`,
|
||||
'x-default': `${SITE_URL}/en/contact`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('subtitle'),
|
||||
url: `https://klz-cables.com/${locale}/contact`,
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/contact`,
|
||||
siteName: 'KLZ Cables',
|
||||
images: [
|
||||
{
|
||||
url: 'https://klz-cables.com/logo.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'KLZ Cables Contact',
|
||||
},
|
||||
],
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('subtitle'),
|
||||
images: ['https://klz-cables.com/logo.png'],
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -67,7 +56,8 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
const { locale } = params;
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
@@ -81,7 +71,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('title'),
|
||||
item: `https://klz-cables.com/${locale}/contact`,
|
||||
item: `${SITE_URL}/${locale}/contact`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
@@ -92,39 +82,30 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'KLZ Cables',
|
||||
image: 'https://klz-cables.com/logo.png',
|
||||
'@id': 'https://klz-cables.com',
|
||||
url: 'https://klz-cables.com',
|
||||
telephone: '+49 881 92537298',
|
||||
image: `${SITE_URL}/logo.png`,
|
||||
'@id': SITE_URL,
|
||||
url: SITE_URL,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: 'Trifthofstraße 57',
|
||||
addressLocality: 'Weilheim i. OB',
|
||||
postalCode: '82362',
|
||||
streetAddress: 'Raiffeisenstraße 22',
|
||||
addressLocality: 'Remshalden',
|
||||
postalCode: '73630',
|
||||
addressCountry: 'DE',
|
||||
},
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: 47.8407,
|
||||
longitude: 11.1421,
|
||||
latitude: 48.8144,
|
||||
longitude: 9.4144,
|
||||
},
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
'@type': 'OpeningHoursSpecification',
|
||||
dayOfWeek: [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday'
|
||||
],
|
||||
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||
opens: '08:00',
|
||||
closes: '17:00'
|
||||
}
|
||||
closes: '17:00',
|
||||
},
|
||||
],
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/klz-cables'
|
||||
]
|
||||
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||
}}
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
@@ -158,13 +139,30 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<div className="space-y-4 md:space-y-8">
|
||||
<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">
|
||||
<svg 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="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
|
||||
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="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>
|
||||
</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">
|
||||
{t('info.address')}
|
||||
</p>
|
||||
@@ -173,32 +171,39 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
|
||||
<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">
|
||||
<svg 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 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
<svg
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.phone')}</h4>
|
||||
<a href="tel:+4988192537298" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">+49 881 92537298</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<svg 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>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.email')}</h4>
|
||||
<a href="mailto:info@klz-cables.com" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">info@klz-cables.com</a>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.email')}
|
||||
</h4>
|
||||
<a
|
||||
href="mailto:info@klz-cables.com"
|
||||
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 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">
|
||||
<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>
|
||||
@@ -214,24 +219,28 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
|
||||
{/* Contact Form */}
|
||||
<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 />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* Map Section */}
|
||||
<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">
|
||||
<div className="text-primary font-medium">Loading Map...</div>
|
||||
</div>}>
|
||||
<LeafletMap
|
||||
address={t('info.address')}
|
||||
lat={47.8407}
|
||||
lng={11.1421}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
||||
<div className="text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,37 @@ import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import { Viewport } from 'next';
|
||||
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
|
||||
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 { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
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 = {
|
||||
width: 'device-width',
|
||||
@@ -16,31 +42,78 @@ export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: {locale}
|
||||
}: {
|
||||
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: {locale: string};
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Providing all messages to the client
|
||||
// side is the easiest way to get started
|
||||
const messages = await getMessages();
|
||||
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
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 = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||
messages = {};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
serverServices.analytics.trackPageview();
|
||||
} 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 (
|
||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
||||
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||
<head></head>
|
||||
<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}>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{/* Sends pageviews for client-side navigations */}
|
||||
<AnalyticsProvider />
|
||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||
<RecordModeVisuals>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
</RecordModeVisuals>
|
||||
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
<ScrollDepthTracker />
|
||||
</Suspense>
|
||||
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||
</RecordModeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useEffect } from 'react';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
|
||||
export default function 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 (
|
||||
<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">
|
||||
404
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
||||
{t('description')}
|
||||
</p>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button href="/" variant="accent" size="lg">
|
||||
|
||||
@@ -1,92 +1,27 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
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 fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#001a4d', // Primary Blue from Styleguide
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background Pattern / Scribble placeholder */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-100px',
|
||||
right: '-100px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(0,255,153,0.1) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#00ff99', // Accent Green
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: '900',
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '800px',
|
||||
marginBottom: '30px',
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '700px',
|
||||
}}
|
||||
>
|
||||
{t('description')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Accent Line */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
backgroundColor: '#00ff99',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
/>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,107 @@
|
||||
import Hero from '@/components/home/Hero';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema } from '@/lib/schema';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import ProductCategories from '@/components/home/ProductCategories';
|
||||
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 dynamic from 'next/dynamic';
|
||||
import Reveal from '@/components/Reveal';
|
||||
|
||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
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 { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<JsonLd
|
||||
id="breadcrumb-home"
|
||||
data={getBreadcrumbSchema([
|
||||
{ name: 'Home', item: `/${locale}` },
|
||||
])}
|
||||
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||
/>
|
||||
<Hero />
|
||||
<Reveal><ProductCategories /></Reveal>
|
||||
<Reveal><WhatWeDo /></Reveal>
|
||||
<Reveal><RecentPosts locale={locale} /></Reveal>
|
||||
<Reveal><Experience /></Reveal>
|
||||
<Reveal><WhyChooseUs /></Reveal>
|
||||
<Reveal><MeetTheTeam /></Reveal>
|
||||
<Reveal><GallerySection /></Reveal>
|
||||
<Reveal><VideoSection /></Reveal>
|
||||
<Reveal><CTA /></Reveal>
|
||||
<Reveal>
|
||||
<ProductCategories />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<WhatWeDo />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<RecentPosts locale={locale} />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<Experience />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<WhyChooseUs />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<MeetTheTeam />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<GallerySection />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<VideoSection />
|
||||
</Reveal>
|
||||
<Reveal className="content-visibility-auto">
|
||||
<CTA />
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
// Use translations for meta where available (namespace: Index.meta)
|
||||
// Fallback to a sensible default if translation keys are missing.
|
||||
let t;
|
||||
try {
|
||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
} catch {
|
||||
// If translations for Index.meta are not present, try generic Index namespace
|
||||
try {
|
||||
t = await getTranslations({ locale, namespace: 'Index' });
|
||||
} catch {
|
||||
t = () => '';
|
||||
}
|
||||
}
|
||||
|
||||
const title = t('title') || 'KLZ Cables';
|
||||
const description = t('description') || '';
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de`,
|
||||
en: `${SITE_URL}/en`,
|
||||
'x-default': `${SITE_URL}/en`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}`,
|
||||
images: getOGImageMetadata('', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,55 +1,70 @@
|
||||
import Script from 'next/script';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import { Badge, Container, Section } from '@/components/ui';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = params;
|
||||
const { locale, slug } = await params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// 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`) : '';
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||
if (categories.includes(fileSlug)) {
|
||||
const categoryKey = fileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.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 {
|
||||
title: categoryTitle,
|
||||
description: categoryDesc,
|
||||
alternates: {
|
||||
canonical: `/${locale}/products/${productSlug}`,
|
||||
canonical: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||
languages: {
|
||||
'de': `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
'en': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${categoryTitle} | KLZ Cables`,
|
||||
description: categoryDesc,
|
||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
||||
url: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -66,18 +81,19 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
alternates: {
|
||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
||||
canonical: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
languages: {
|
||||
'de': `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
'en': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||
description: product.frontmatter.description,
|
||||
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),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -90,20 +106,36 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
const components = {
|
||||
ProductTechnicalData,
|
||||
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) => (
|
||||
<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>
|
||||
),
|
||||
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" />,
|
||||
section: (props: any) => <div {...props} className="block" />,
|
||||
li: (props: any) => (
|
||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
||||
<span {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium" />
|
||||
<span
|
||||
{...props}
|
||||
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
||||
@@ -112,43 +144,67 @@ const components = {
|
||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||
</div>
|
||||
),
|
||||
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60" />,
|
||||
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium" />,
|
||||
th: (props: any) => (
|
||||
<th
|
||||
{...props}
|
||||
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
||||
/>
|
||||
),
|
||||
td: (props: any) => (
|
||||
<td
|
||||
{...props}
|
||||
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
||||
blockquote: (props: any) => (
|
||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight" {...props} />
|
||||
<div
|
||||
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = params;
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// 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 categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||
|
||||
if (categories.includes(fileSlug)) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
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 categoryKey = 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
|
||||
const filteredProducts = allProducts.filter(p =>
|
||||
p.frontmatter.categories.some(cat =>
|
||||
cat.toLowerCase().replace(/\s+/g, '-') === productSlug ||
|
||||
cat === categoryTitle
|
||||
)
|
||||
const filteredProducts = allProducts.filter((p) =>
|
||||
p.frontmatter.categories.some(
|
||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||
),
|
||||
);
|
||||
|
||||
// Get translated product slugs
|
||||
const productsWithTranslatedSlugs = await Promise.all(
|
||||
filteredProducts.map(async (p) => ({
|
||||
...p,
|
||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale)
|
||||
}))
|
||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -157,13 +213,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||
<Link href={`/${locale}/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="text-white/90">{categoryTitle}</span>
|
||||
</nav>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-8">
|
||||
{categoryTitle}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
</Container>
|
||||
@@ -186,6 +247,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
@@ -195,7 +257,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
@@ -210,8 +275,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<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
|
||||
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>
|
||||
@@ -231,7 +306,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
if (technicalDataMatch) {
|
||||
try {
|
||||
@@ -245,11 +322,16 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryKey = categorySlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : categorySlug;
|
||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||
const categoryKey = 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 = (
|
||||
<ProductSidebar
|
||||
<ProductSidebar
|
||||
productName={product.frontmatter.title}
|
||||
productImage={product.frontmatter.images?.[0]}
|
||||
datasheetPath={datasheetPath}
|
||||
@@ -275,21 +357,37 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white relative">
|
||||
{/* 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">
|
||||
{/* Background Decorative Elements */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
<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>
|
||||
<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="text-white/90">{product.frontmatter.title}</span>
|
||||
</nav>
|
||||
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||
<div className="flex-1">
|
||||
{isFallback && (
|
||||
@@ -300,14 +398,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
{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}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl lg:text-9xl font-black text-white mb-8 tracking-tighter leading-[0.9] uppercase">
|
||||
<Heading level={1} className="text-white mb-8 uppercase">
|
||||
{product.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
@@ -321,11 +423,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<Container className="relative">
|
||||
{/* Large Product Image Section */}
|
||||
{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="relative w-full aspect-[21/9]">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||
@@ -334,12 +439,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{/* 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>
|
||||
|
||||
|
||||
{product.frontmatter.images.length > 1 && (
|
||||
<div className="flex justify-center gap-8 mt-20">
|
||||
{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">
|
||||
<Image src={img} alt="" fill className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" />
|
||||
<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"
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -352,51 +465,68 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<div className="w-full">
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-none">
|
||||
<MDXRemote source={processedContent} components={productComponents} />
|
||||
<MDXRemote source={processedContent} components={productComponents} />
|
||||
</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 */}
|
||||
<JsonLd
|
||||
id={`jsonld-${product.slug}`}
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
} as any}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0]
|
||||
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products Section */}
|
||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
locale={locale}
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
25
app/[locale]/products/opengraph-image.tsx
Normal file
25
app/[locale]/products/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
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 default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,60 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Section } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
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 title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('subtitle'),
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${locale}/products`,
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||
languages: {
|
||||
'de': '/de/products',
|
||||
'en': '/en/products',
|
||||
'x-default': '/en/products',
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('subtitle'),
|
||||
url: `https://klz-cables.com/${locale}/products`,
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('subtitle'),
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Get translated category slugs
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
@@ -54,29 +62,29 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||
href: `/${params.locale}/products/${lowVoltageSlug}`
|
||||
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
desc: t('categories.mediumVoltage.description'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||
href: `/${params.locale}/products/${mediumVoltageSlug}`
|
||||
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
desc: t('categories.highVoltage.description'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||
href: `/${params.locale}/products/${highVoltageSlug}`
|
||||
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
desc: t('categories.solar.description'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${params.locale}/products/${solarSlug}`
|
||||
}
|
||||
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -85,24 +93,35 @@ 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">
|
||||
<Container className="relative z-10">
|
||||
<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')}
|
||||
</Badge>
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-white mb-4 md:mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
),
|
||||
})}
|
||||
</h1>
|
||||
</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">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
<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')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
||||
</Button>
|
||||
@@ -116,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">
|
||||
{categories.map((category, idx) => (
|
||||
<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]">
|
||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||
<Image
|
||||
src={category.img}
|
||||
<Image
|
||||
src={category.img}
|
||||
alt={category.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
<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">
|
||||
<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 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')}
|
||||
</Badge>
|
||||
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
||||
@@ -151,20 +186,30 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
{t('viewProducts')}
|
||||
</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">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</TrackedLink>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* Technical Support CTA */}
|
||||
<Reveal>
|
||||
<Section className="bg-white py-12 md:py-28">
|
||||
@@ -173,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="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{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">
|
||||
{t('cta.description')}
|
||||
</p>
|
||||
</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')}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
25
app/[locale]/team/opengraph-image.tsx
Normal file
25
app/[locale]/team/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
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 default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
@@ -6,49 +6,53 @@ import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import Image from 'next/image';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||
|
||||
interface TeamPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
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 title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
return {
|
||||
title: t('hero.subtitle'),
|
||||
description: t('hero.title'),
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${locale}/team`,
|
||||
canonical: `${SITE_URL}/${locale}/team`,
|
||||
languages: {
|
||||
'de': '/de/team',
|
||||
'en': '/en/team',
|
||||
'x-default': '/en/team',
|
||||
de: `${SITE_URL}/de/team`,
|
||||
en: `${SITE_URL}/en/team`,
|
||||
'x-default': `${SITE_URL}/en/team`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t('hero.subtitle')} | KLZ Cables`,
|
||||
description: t('hero.title'),
|
||||
url: `https://klz-cables.com/${locale}/team`,
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/team`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${t('hero.subtitle')} | KLZ Cables`,
|
||||
description: t('hero.title'),
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<JsonLd
|
||||
id="breadcrumb-team"
|
||||
data={getBreadcrumbSchema([
|
||||
{ name: t('hero.subtitle'), item: `/team` },
|
||||
])}
|
||||
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||
/>
|
||||
<JsonLd
|
||||
id="person-michael"
|
||||
@@ -61,10 +65,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/michael-bodemer-33b493122/'
|
||||
],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`
|
||||
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||
}}
|
||||
/>
|
||||
<JsonLd
|
||||
@@ -78,10 +80,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/klaus-mintel-b80a8b193/'
|
||||
],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`
|
||||
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||
}}
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
@@ -93,16 +93,19 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
alt="KLZ Team"
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
<h1 className="text-3xl md:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-4 md:mb-8">
|
||||
<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">
|
||||
{t('hero.subtitle')}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
|
||||
{t('hero.title')}
|
||||
</p>
|
||||
@@ -116,7 +119,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-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="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">
|
||||
<span className="text-white">{t('michael.name')}</span>
|
||||
</Heading>
|
||||
@@ -129,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">
|
||||
{t('michael.description')}
|
||||
</p>
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||
variant="accent"
|
||||
<TrackedButton
|
||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||
variant="accent"
|
||||
size="lg"
|
||||
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')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</TrackedButton>
|
||||
</div>
|
||||
</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">
|
||||
@@ -169,26 +179,36 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<Container className="relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||
<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>
|
||||
</Heading>
|
||||
<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">
|
||||
{t('legacy.p1')}
|
||||
</p>
|
||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">
|
||||
{t('legacy.p2')}
|
||||
</p>
|
||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.expertise')}</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.expertiseDesc')}</div>
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||
{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 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-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.networkDesc')}</div>
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||
{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>
|
||||
@@ -212,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">
|
||||
<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">
|
||||
<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">
|
||||
{t('klaus.name')}
|
||||
</Heading>
|
||||
@@ -225,15 +247,20 @@ 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">
|
||||
{t('klaus.description')}
|
||||
</p>
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="saturated"
|
||||
<TrackedButton
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
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')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</TrackedButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
@@ -251,11 +278,14 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
||||
{t('manifesto.tagline')}
|
||||
</p>
|
||||
|
||||
|
||||
{/* Mobile-only progress indicator */}
|
||||
<div className="flex lg:hidden mt-8 gap-2">
|
||||
{[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>
|
||||
))}
|
||||
@@ -264,12 +294,21 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
</div>
|
||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
||||
{[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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{t(`manifesto.items.${idx}.title`)}</h3>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{t(`manifesto.items.${idx}.description`)}</p>
|
||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
||||
{t(`manifesto.items.${idx}.title`)}
|
||||
</h3>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||
{t(`manifesto.items.${idx}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,153 @@
|
||||
"use server";
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from "@/lib/mail/mailer";
|
||||
import ContactEmail from "@/components/emails/ContactEmail";
|
||||
import React from "react";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||
import { createItem } from '@directus/sdk';
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||
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;
|
||||
|
||||
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
const 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) {
|
||||
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
|
||||
return { success: false, error: "Missing required fields" };
|
||||
logger.warn('Missing required fields in contact form', {
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
});
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
logger.info('Sending contact form email', { email, productName });
|
||||
|
||||
const subject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: "New Contact Form Submission";
|
||||
|
||||
const result = await sendEmail({
|
||||
subject,
|
||||
template: React.createElement(ContactEmail, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
subject,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
||||
} else {
|
||||
logger.error('Failed to send contact form email', { error: result.error });
|
||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
||||
// 1. Save to Directus
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
if (productName) {
|
||||
await client.request(
|
||||
createItem('product_requests', {
|
||||
product_name: productName,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Product request stored in Directus');
|
||||
} else {
|
||||
await client.request(
|
||||
createItem('contact_submissions', {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Contact submission stored in Directus');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to store submission in Directus', { error });
|
||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||
}
|
||||
|
||||
return result;
|
||||
// 2. Send Emails
|
||||
logger.info('Sending branded emails', { email, productName });
|
||||
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
{
|
||||
src: '/logo.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/apple-touch-icon.png',
|
||||
sizes: '180x180',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
@@ -11,8 +13,8 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||
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 { getAllProducts } from '@/lib/mdx';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getAllPages } from '@/lib/pages';
|
||||
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||
import { getAllPostsMetadata } from '@/lib/blog';
|
||||
import { getAllPagesMetadata } from '@/lib/pages';
|
||||
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
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 routes = [
|
||||
@@ -33,12 +36,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Products
|
||||
const products = await getAllProducts(locale);
|
||||
for (const product of products) {
|
||||
// We need to find the category for the product to build the URL
|
||||
// In this project, products are under /products/[category]/[slug]
|
||||
// The category is in product.frontmatter.categories
|
||||
const category = product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||
const productsMetadata = await getAllProductsMetadata(locale);
|
||||
for (const product of productsMetadata) {
|
||||
if (!product.frontmatter || !product.slug) continue;
|
||||
|
||||
const category =
|
||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
||||
lastModified: new Date(),
|
||||
@@ -48,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Blog posts
|
||||
const posts = await getAllPosts(locale);
|
||||
for (const post of posts) {
|
||||
const postsMetadata = await getAllPostsMetadata(locale);
|
||||
for (const post of postsMetadata) {
|
||||
if (!post.frontmatter || !post.slug) continue;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.frontmatter.date),
|
||||
@@ -59,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Static pages
|
||||
const pages = await getAllPages(locale);
|
||||
for (const page of pages) {
|
||||
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||
for (const page of pagesMetadata) {
|
||||
if (!page.slug) continue;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||
lastModified: new Date(),
|
||||
|
||||
73
app/stats/api/send/route.ts
Normal file
73
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy 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) {
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { 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 { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
|
||||
export default function ContactForm() {
|
||||
const t = useTranslations('Contact');
|
||||
const { trackEvent } = useAnalytics();
|
||||
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>) {
|
||||
e.preventDefault();
|
||||
@@ -17,10 +36,10 @@ export default function ContactForm() {
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
if (result?.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'general',
|
||||
email,
|
||||
@@ -29,10 +48,18 @@ export default function ContactForm() {
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
} catch (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');
|
||||
}
|
||||
}
|
||||
@@ -41,7 +68,12 @@ export default function ContactForm() {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -49,7 +81,8 @@ export default function ContactForm() {
|
||||
{t('form.successTitle') || 'Message Sent!'}
|
||||
</Heading>
|
||||
<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>
|
||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||
{t('form.sendAnother') || 'Send another message'}
|
||||
@@ -58,6 +91,40 @@ export default function ContactForm() {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
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">
|
||||
<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"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<Heading level={3} className="mb-4 text-destructive font-black">
|
||||
{t('form.errorTitle') || 'Submission Failed!'}
|
||||
</Heading>
|
||||
<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.'}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setStatus('idle')}
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{t('form.tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
|
||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||
@@ -66,62 +133,77 @@ export default function ContactForm() {
|
||||
<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">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.namePlaceholder')}
|
||||
onFocus={() => handleFocus('name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('message')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{status === 'error' && (
|
||||
<div className="md:col-span-2 text-red-500 text-sm font-bold">
|
||||
{t('form.error') || 'An error occurred. Please try again later.'}
|
||||
</div>
|
||||
)}
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
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"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<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">
|
||||
<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
|
||||
className="animate-spin h-5 w-5 text-white"
|
||||
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>
|
||||
{t('form.submitting') || 'Sending...'}
|
||||
</span>
|
||||
) : t('form.submit')}
|
||||
) : (
|
||||
t('form.submit')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</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} />;
|
||||
}
|
||||
85
components/DatasheetDownload.tsx
Normal file
85
components/DatasheetDownload.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'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"
|
||||
>
|
||||
<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">
|
||||
<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 Image from 'next/image';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
const navT = useTranslations('Navigation');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
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" />
|
||||
|
||||
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<Link href={`/${locale}`} className="inline-block group">
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt={t('products')}
|
||||
width={150}
|
||||
height={40}
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-block group"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt={t('products')}
|
||||
width={150}
|
||||
height={40}
|
||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
||||
{t('tagline')}
|
||||
</p>
|
||||
<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>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
@@ -42,38 +67,172 @@ export default function Footer() {
|
||||
|
||||
{/* Links Columns */}
|
||||
<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>
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('legal')}
|
||||
</h4>
|
||||
<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-white transition-colors">{t('legalNotice')}</Link></li>
|
||||
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="text-white/70 hover:text-white transition-colors">{t('privacyPolicy')}</Link></li>
|
||||
<li><Link href={`/${locale}/${t('termsSlug')}`} className="text-white/70 hover:text-white transition-colors">{t('terms')}</Link></li>
|
||||
<li>
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('company')}
|
||||
</h4>
|
||||
<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-white transition-colors">{navT('team')}</Link></li>
|
||||
<li><Link href={`/${locale}/products`} className="text-white/70 hover:text-white transition-colors">{navT('products')}</Link></li>
|
||||
<li><Link href={`/${locale}/blog`} className="text-white/70 hover:text-white transition-colors">{navT('blog')}</Link></li>
|
||||
<li><Link href={`/${locale}/contact`} className="text-white/70 hover:text-white transition-colors">{navT('contact')}</Link></li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/team`}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts Column */}
|
||||
<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>
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('recentPosts')}
|
||||
</h4>
|
||||
<ul className="space-y-6 list-none m-0 p-0">
|
||||
{[
|
||||
"Focus on wind farm construction: three typical cable challenges",
|
||||
"Why the N2XS(F)2Y is the ideal cable for your energy project"
|
||||
{
|
||||
title:
|
||||
locale === 'de'
|
||||
? 'Windparkbau im Fokus: drei typische Kabelherausforderungen'
|
||||
: 'Focus on wind farm construction: three typical cable challenges',
|
||||
slug:
|
||||
locale === 'de'
|
||||
? 'windparkbau-im-fokus-drei-typische-kabelherausforderungen'
|
||||
: 'focus-on-wind-farm-construction-three-typical-cable-challenges',
|
||||
},
|
||||
{
|
||||
title:
|
||||
locale === 'de'
|
||||
? 'Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist'
|
||||
: 'Why the N2XS(F)2Y is the ideal cable for your energy project',
|
||||
slug:
|
||||
locale === 'de'
|
||||
? 'n2xsf2y-mittelspannungskabel-energieprojekt'
|
||||
: 'why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||
},
|
||||
].map((post, i) => (
|
||||
<li key={i}>
|
||||
<Link href="#" 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">
|
||||
{post}
|
||||
{post.title}
|
||||
</p>
|
||||
<span className="text-xs text-white/40 uppercase tracking-widest">{t('readArticle')} →</span>
|
||||
<span className="text-xs text-white/40 uppercase tracking-widest">
|
||||
{t('readArticle')} →
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -84,8 +243,36 @@ export default function Footer() {
|
||||
<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">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
<Link href="/en" className="hover:text-white transition-colors">English</Link>
|
||||
<Link href="/de" className="hover:text-white transition-colors">Deutsch</Link>
|
||||
<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>
|
||||
</Container>
|
||||
|
||||
@@ -8,16 +8,19 @@ import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations('Navigation');
|
||||
const pathname = usePathname();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
|
||||
|
||||
// Check if homepage
|
||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||
|
||||
@@ -30,11 +33,6 @@ export default function Header() {
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
@@ -43,7 +41,7 @@ export default function Header() {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
|
||||
// Function to get path for a different locale
|
||||
const getPathForLocale = (newLocale: string) => {
|
||||
const segments = pathname.split('/');
|
||||
@@ -54,20 +52,20 @@ export default function Header() {
|
||||
const menuItems = [
|
||||
{ label: t('home'), href: '/' },
|
||||
{ label: t('team'), href: '/team' },
|
||||
{ label: t('products'), href: '/products' },
|
||||
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
|
||||
{ label: t('blog'), href: '/blog' },
|
||||
];
|
||||
|
||||
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-primary py-3 md:py-4 shadow-2xl": !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,
|
||||
},
|
||||
);
|
||||
|
||||
const textColorClass = "text-white";
|
||||
const logoSrc = "/logo-white.svg";
|
||||
const textColorClass = 'text-white';
|
||||
const logoSrc = '/logo-white.svg';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,16 +73,24 @@ export default function Header() {
|
||||
className={headerClass}
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
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
|
||||
className="flex-shrink-0 group touch-target"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||
>
|
||||
<Link href={`/${currentLocale}`}>
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
@@ -92,7 +98,6 @@ export default function Header() {
|
||||
height={120}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
@@ -105,37 +110,38 @@ export default function Header() {
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.3
|
||||
}
|
||||
}
|
||||
delayChildren: 0.3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<motion.nav
|
||||
className="hidden lg:flex items-center space-x-10"
|
||||
variants={navVariants}
|
||||
>
|
||||
{menuItems.map((item, idx) => (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
variants={navLinkVariants}
|
||||
custom={idx}
|
||||
>
|
||||
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||
{menuItems.map((item, _idx) => (
|
||||
<motion.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 text-base md:text-lg tracking-tight relative group inline-block"
|
||||
'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-300 group-hover:w-full rounded-full" />
|
||||
<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>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.nav>
|
||||
|
||||
<motion.div
|
||||
className={cn("hidden lg:flex items-center space-x-8", textColorClass)}
|
||||
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||
variants={headerRightVariants}
|
||||
>
|
||||
<motion.div
|
||||
@@ -151,6 +157,14 @@ export default function Header() {
|
||||
>
|
||||
<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
|
||||
@@ -169,23 +183,37 @@ export default function Header() {
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }}
|
||||
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>
|
||||
@@ -194,12 +222,28 @@ export default function Header() {
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)}
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||
textColorClass,
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
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)}
|
||||
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',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<motion.svg
|
||||
className="w-7 h-7"
|
||||
@@ -237,21 +281,25 @@ export default function Header() {
|
||||
</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"
|
||||
)}>
|
||||
<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"}
|
||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||
variants={{
|
||||
open: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2
|
||||
}
|
||||
}
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item, idx) => (
|
||||
@@ -265,21 +313,29 @@ export default function Header() {
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
delay: idx * 0.08
|
||||
}
|
||||
}
|
||||
ease: 'easeOut',
|
||||
delay: idx * 0.08,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'mobile_menu',
|
||||
});
|
||||
}}
|
||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
|
||||
<motion.div
|
||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -323,11 +379,11 @@ export default function Header() {
|
||||
</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 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
@@ -339,23 +395,23 @@ export default function Header() {
|
||||
</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 }}
|
||||
>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, delay: 1.5 }}
|
||||
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 }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.header>
|
||||
</>
|
||||
@@ -368,23 +424,22 @@ const navVariants = {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.1
|
||||
}
|
||||
}
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const navLinkVariants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.9 },
|
||||
visible: (idx: number) => ({
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
delay: idx * 0.06
|
||||
}
|
||||
})
|
||||
ease: 'easeOut',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headerRightVariants = {
|
||||
@@ -392,6 +447,6 @@ const headerRightVariants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease: "easeOut" }
|
||||
}
|
||||
transition: { duration: 0.6, ease: 'easeOut' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix for default marker icon in Leaflet with Next.js
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
}
|
||||
|
||||
interface LeafletMapProps {
|
||||
address: string;
|
||||
@@ -21,25 +21,46 @@ interface 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 (
|
||||
<MapContainer
|
||||
center={position}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
className="h-full w-full z-0"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={position}>
|
||||
<Popup>
|
||||
<div className="text-primary font-bold">KLZ Cables</div>
|
||||
<div className="text-sm whitespace-pre-line">{address}</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Initialize map
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [lat, lng],
|
||||
zoom: 15,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
|
||||
// Add tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
// Add marker
|
||||
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,7 +1,10 @@
|
||||
`use client`;
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean;
|
||||
@@ -11,82 +14,198 @@ interface LightboxProps {
|
||||
}
|
||||
|
||||
export default function Lightbox({ isOpen, images, initialIndex, onClose }: LightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(initialIndex);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(index: number | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (index !== null) {
|
||||
params.set('photo', index.toString());
|
||||
} else {
|
||||
params.delete('photo');
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
const next = prev === 0 ? images.length - 1 : prev - 1;
|
||||
updateUrl(next);
|
||||
return next;
|
||||
});
|
||||
}, [images.length, updateUrl]);
|
||||
|
||||
const nextImage = useCallback(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
const next = prev === images.length - 1 ? 0 : prev + 1;
|
||||
updateUrl(next);
|
||||
return next;
|
||||
});
|
||||
}, [images.length, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const photoParam = searchParams.get('photo');
|
||||
if (photoParam !== null) {
|
||||
const index = parseInt(photoParam, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}
|
||||
}, [searchParams, images.length]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
updateUrl(null);
|
||||
onClose();
|
||||
}, [updateUrl, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
updateUrl(currentIndex);
|
||||
}
|
||||
}, [isOpen, currentIndex, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
document.body.style.overflow = originalStyle;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!mounted) return null;
|
||||
|
||||
const prevImage = () => {
|
||||
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
};
|
||||
|
||||
const nextImage = () => {
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-6 right-6 text-white text-3xl hover:text-accent transition-colors z-[10000] rounded-full w-12 h-12 flex items-center justify-center hover:bg-white/20"
|
||||
aria-label="Close lightbox"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-6 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-accent transition-colors w-12 h-12 flex items-center justify-center hover:bg-white/20 rounded-full z-[10000]"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-6 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-accent transition-colors w-12 h-12 flex items-center justify-center hover:bg-white/20 rounded-full z-[10000]"
|
||||
aria-label="Next image"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<div className="relative max-w-6xl max-h-[90vh] w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
src={images[currentIndex]}
|
||||
alt={`Gallery image ${currentIndex + 1}`}
|
||||
fill
|
||||
className="object-contain max-h-[90vh] max-w-full"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 bg-black/50 text-white px-6 py-3 rounded-full text-sm font-medium">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard navigation */}
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9998]"
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||
aria-label="Close lightbox"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<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" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
185
components/OGImageTemplate.tsx
Normal file
185
components/OGImageTemplate.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OGImageTemplateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
mode?: 'dark' | 'light' | 'image';
|
||||
}
|
||||
|
||||
export function OGImageTemplate({
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
image,
|
||||
mode = 'dark',
|
||||
}: OGImageTemplateProps) {
|
||||
const primaryBlue = '#001a4d';
|
||||
const accentGreen = '#82ed20';
|
||||
const saturatedBlue = '#011dff';
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Inter',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Background Image with Overlay */}
|
||||
{image && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
width="1200"
|
||||
height="630"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative Brand Accent (Top Right) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-150px',
|
||||
right: '-150px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}15`,
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
||||
{/* Label / Category */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: accentGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3em',
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 40 ? '64px' : '82px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.05',
|
||||
maxWidth: '950px',
|
||||
marginBottom: '40px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: 'flex',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand Footer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: accentGreen,
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '12px',
|
||||
height: '100%',
|
||||
backgroundColor: saturatedBlue,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -64,33 +65,7 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && (
|
||||
<a
|
||||
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>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { RelatedProductLink } from './RelatedProductLink';
|
||||
|
||||
interface RelatedProductsProps {
|
||||
currentSlug: string;
|
||||
@@ -10,15 +9,19 @@ interface RelatedProductsProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
export default async function RelatedProducts({
|
||||
currentSlug,
|
||||
categories,
|
||||
locale,
|
||||
}: RelatedProductsProps) {
|
||||
const products = await getAllProducts(locale);
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
|
||||
// Filter products: same category, not current product
|
||||
const related = allProducts
|
||||
.filter(p =>
|
||||
p.slug !== currentSlug &&
|
||||
p.frontmatter.categories.some(cat => categories.includes(cat))
|
||||
const related = products
|
||||
.filter(
|
||||
(p) =>
|
||||
p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)),
|
||||
)
|
||||
.slice(0, 3); // Limit to 3 for better spacing
|
||||
|
||||
@@ -36,23 +39,31 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
||||
</div>
|
||||
|
||||
<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
|
||||
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
const catSlug = categorySlugs.find(slug => {
|
||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const title = t(`categories.${key}.title`);
|
||||
return product.frontmatter.categories.some(cat =>
|
||||
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
|
||||
);
|
||||
}) || 'low-voltage-cables';
|
||||
|
||||
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||
|
||||
const categorySlugs = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const catSlug =
|
||||
categorySlugs.find((slug) => {
|
||||
const key = slug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const title = t(`categories.${key}.title`);
|
||||
return product.frontmatter.categories.some(
|
||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
|
||||
);
|
||||
}) || 'low-voltage-cables';
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
||||
<RelatedProductLink
|
||||
key={product.slug}
|
||||
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"
|
||||
>
|
||||
<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 className="p-8">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
|
||||
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
||||
{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"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
@@ -87,12 +101,22 @@ 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">
|
||||
{t('details')}
|
||||
</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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-4 h-4 ml-2 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>
|
||||
</RelatedProductLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Input, Textarea, Button } from '@/components/ui';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
|
||||
interface RequestQuoteFormProps {
|
||||
productName: string;
|
||||
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
const [email, setEmail] = useState('');
|
||||
const [request, setRequest] = useState('');
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -39,27 +60,51 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
setEmail('');
|
||||
setRequest('');
|
||||
} else {
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'quote_request_form',
|
||||
product_name: productName,
|
||||
error: result.error || 'submission_failed',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (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');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'success') {
|
||||
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 className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 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 className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div className="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>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||
{t('successTitle')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('successDesc', { productName })}
|
||||
</p>
|
||||
<button
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-primary hover:text-accent transition-colors group"
|
||||
>
|
||||
@@ -71,6 +116,42 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||
<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>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||
{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.'}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setStatus('idle')}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{t('tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
<div className="space-y-2 !mt-0">
|
||||
@@ -81,6 +162,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => handleFocus('email')}
|
||||
placeholder={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
@@ -93,6 +175,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
rows={3}
|
||||
value={request}
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
onFocus={() => handleFocus('request')}
|
||||
placeholder={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
@@ -107,22 +190,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
>
|
||||
{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">
|
||||
<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
|
||||
className="animate-spin h-3 w-3 text-white"
|
||||
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>
|
||||
<span className="text-xs">{t('submitting')}</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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
|
||||
@@ -3,25 +3,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
*
|
||||
* 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
|
||||
* ```tsx
|
||||
* // In your layout.tsx
|
||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
||||
* <UmamiScript />
|
||||
* <Header />
|
||||
* <main>{children}</main>
|
||||
* <Footer />
|
||||
* <AnalyticsProvider />
|
||||
* </NextIntlClientProvider>
|
||||
* ```
|
||||
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||
* so it's no longer needed as a prop here.
|
||||
*/
|
||||
export default function AnalyticsProvider() {
|
||||
const pathname = usePathname();
|
||||
@@ -29,29 +19,17 @@ export default function AnalyticsProvider() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
|
||||
// Track pageview with the full URL
|
||||
// The service will relay this to our internal proxy which injects the Website ID
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Umami] Tracked pageview:', url);
|
||||
}
|
||||
|
||||
// Services like logger are already sub-initialized in getAppServices()
|
||||
// so we don't need to log here manually.
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
if (!websiteId) return null;
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="umami-analytics"
|
||||
src="/stats/script.js"
|
||||
data-website-id={websiteId}
|
||||
strategy="afterInteractive"
|
||||
defer
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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
|
||||
*
|
||||
*
|
||||
* Centralized definitions for common analytics events and their properties.
|
||||
* This helps maintain consistency across the application and makes it easier
|
||||
* to track meaningful events.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
*
|
||||
*
|
||||
* function ProductPage() {
|
||||
* const { trackEvent } = useAnalytics();
|
||||
*
|
||||
*
|
||||
* const handleAddToCart = (productId: string, productName: string) => {
|
||||
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||
* product_id: productId,
|
||||
@@ -20,7 +20,7 @@
|
||||
* page: 'product-detail'
|
||||
* });
|
||||
* };
|
||||
*
|
||||
*
|
||||
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
|
||||
* }
|
||||
* ```
|
||||
@@ -31,6 +31,7 @@ export const AnalyticsEvents = {
|
||||
PAGE_VIEW: 'pageview',
|
||||
PAGE_SCROLL: 'page_scroll',
|
||||
PAGE_EXIT: 'page_exit',
|
||||
SCROLL_DEPTH: 'scroll_depth',
|
||||
|
||||
// User Interaction Events
|
||||
BUTTON_CLICK: 'button_click',
|
||||
@@ -38,6 +39,7 @@ export const AnalyticsEvents = {
|
||||
FORM_SUBMIT: 'form_submit',
|
||||
FORM_START: 'form_start',
|
||||
FORM_ERROR: 'form_error',
|
||||
FORM_FIELD_FOCUS: 'form_field_focus',
|
||||
|
||||
// E-commerce Events
|
||||
PRODUCT_VIEW: 'product_view',
|
||||
@@ -46,6 +48,7 @@ export const AnalyticsEvents = {
|
||||
PRODUCT_PURCHASE: 'product_purchase',
|
||||
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
||||
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
||||
PRODUCT_TAB_SWITCH: 'product_tab_switch',
|
||||
|
||||
// Search & Filter Events
|
||||
SEARCH: 'search',
|
||||
@@ -71,6 +74,7 @@ export const AnalyticsEvents = {
|
||||
TOGGLE_SWITCH: 'toggle_switch',
|
||||
ACCORDION_TOGGLE: 'accordion_toggle',
|
||||
TAB_SWITCH: 'tab_switch',
|
||||
TOC_CLICK: 'toc_click',
|
||||
|
||||
// Error & Performance Events
|
||||
ERROR: 'error',
|
||||
|
||||
171
components/blog/MDXComponents.tsx
Normal file
171
components/blog/MDXComponents.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import Link from 'next/link';
|
||||
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
||||
import { Callout } from '@/components/ui';
|
||||
import HighlightBox from '@/components/blog/HighlightBox';
|
||||
import Stats from '@/components/blog/Stats';
|
||||
import AnimatedImage from '@/components/blog/AnimatedImage';
|
||||
import ChatBubble from '@/components/blog/ChatBubble';
|
||||
import SplitHeading from '@/components/blog/SplitHeading';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
||||
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
||||
|
||||
export const mdxComponents = {
|
||||
VisualLinkPreview,
|
||||
Callout,
|
||||
HighlightBox,
|
||||
Stats,
|
||||
AnimatedImage,
|
||||
ChatBubble,
|
||||
PowerCTA,
|
||||
SplitHeading,
|
||||
StickyNarrative,
|
||||
TechnicalGrid,
|
||||
ComparisonGrid,
|
||||
h1: () => null,
|
||||
a: ({ href, children, ...props }: any) => {
|
||||
// Special handling for PDF downloads to make them prominent
|
||||
if (href?.endsWith('.pdf')) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>{children}</span>
|
||||
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">(PDF)</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (href?.startsWith('/')) {
|
||||
return (
|
||||
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
|
||||
>
|
||||
{children}
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: (props: any) => (
|
||||
<AnimatedImage src={props.src} alt={props.alt} />
|
||||
),
|
||||
h2: ({ children, ...props }: any) => {
|
||||
const id = typeof children === 'string'
|
||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
||||
: props.id;
|
||||
return (
|
||||
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
||||
{children}
|
||||
</SplitHeading>
|
||||
);
|
||||
},
|
||||
h3: ({ children, ...props }: any) => {
|
||||
const id = typeof children === 'string'
|
||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
||||
: props.id;
|
||||
return (
|
||||
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
p: ({ children, ...props }: any) => (
|
||||
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul {...props} className="my-8 space-y-3">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }: any) => (
|
||||
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }: any) => (
|
||||
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
||||
<span className="text-primary mt-1.5 flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ children, ...props }: any) => (
|
||||
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
|
||||
<div className="text-lg text-text-primary italic">
|
||||
{children}
|
||||
</div>
|
||||
</blockquote>
|
||||
),
|
||||
strong: ({ children, ...props }: any) => (
|
||||
<strong {...props} className="font-bold text-primary">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children, ...props }: any) => (
|
||||
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children, ...props }: any) => (
|
||||
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }: any) => (
|
||||
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
|
||||
<table {...props} className="w-full text-left text-sm text-text-secondary">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }: any) => (
|
||||
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children, ...props }: any) => (
|
||||
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }: any) => (
|
||||
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children, ...props }: any) => (
|
||||
<th {...props} className="px-6 py-4 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }: any) => (
|
||||
<td {...props} className="px-6 py-4">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useAnalytics } from '../analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
|
||||
interface TocItem {
|
||||
id: string;
|
||||
@@ -16,11 +18,12 @@ interface TableOfContentsProps {
|
||||
|
||||
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
const observerOptions = {
|
||||
rootMargin: '-100px 0% -80% 0%',
|
||||
threshold: 0
|
||||
rootMargin: '-10% 0% -70% 0%',
|
||||
threshold: 0,
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
@@ -31,12 +34,18 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
const elements = headings.map((h) => document.getElementById(h.id));
|
||||
elements.forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
// Use a small delay to ensure MDX content is rendered and IDs are present
|
||||
const timer = setTimeout(() => {
|
||||
const elements = headings.map((h) => document.getElementById(h.id));
|
||||
elements.forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => observer.disconnect();
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [headings]);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
@@ -60,15 +69,20 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
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
|
||||
? "text-primary font-bold translate-x-1"
|
||||
: "text-text-secondary font-medium hover:translate-x-1"
|
||||
? 'text-primary font-bold translate-x-1'
|
||||
: 'text-text-secondary font-medium hover:translate-x-1',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(heading.id);
|
||||
if (element) {
|
||||
trackEvent(AnalyticsEvents.TOC_CLICK, {
|
||||
heading_id: heading.id,
|
||||
heading_text: heading.text,
|
||||
location: 'blog_sidebar',
|
||||
});
|
||||
const yOffset = -100;
|
||||
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||
|
||||
@@ -9,17 +9,17 @@ export default function Experience() {
|
||||
return (
|
||||
<Section className="relative py-32 md:py-48 overflow-hidden text-white">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||
alt={t('subtitle')}
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||
alt={t('subtitle')}
|
||||
fill
|
||||
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-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-3xl">
|
||||
<Heading level={2} subtitle={t('subtitle')} className="text-white">
|
||||
@@ -29,19 +29,25 @@ export default function Experience() {
|
||||
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
||||
{t('p1')}
|
||||
</p>
|
||||
<p className="pl-9">
|
||||
{t('p2')}
|
||||
</p>
|
||||
<p className="pl-9">{t('p2')}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="animate-fade-in">
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('certifiedQuality')}</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('vdeApproved')}</div>
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('certifiedQuality')}
|
||||
</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('vdeApproved')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('fullSpectrum')}</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('solutionsRange')}</div>
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('fullSpectrum')}
|
||||
</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('solutionsRange')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
import Lightbox from '../Lightbox';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function GallerySection() {
|
||||
const t = useTranslations('Home.gallery');
|
||||
const searchParams = useSearchParams();
|
||||
const images = [
|
||||
'/uploads/2024/12/DSC07433-Large-600x400.webp',
|
||||
'/uploads/2024/12/DSC07460-Large-600x400.webp',
|
||||
@@ -17,8 +18,9 @@ export default function GallerySection() {
|
||||
'/uploads/2024/12/DSC07768-Large.webp',
|
||||
];
|
||||
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const photoParam = searchParams.get('photo');
|
||||
const lightboxOpen = photoParam !== null;
|
||||
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||
|
||||
return (
|
||||
<Section className="bg-white text-white py-32">
|
||||
@@ -26,14 +28,16 @@ export default function GallerySection() {
|
||||
<Heading level={2} subtitle={t('subtitle')} align="center">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{images.map((src, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setLightboxIndex(idx);
|
||||
setLightboxOpen(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
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"
|
||||
>
|
||||
@@ -42,7 +46,7 @@ export default function GallerySection() {
|
||||
alt={`${t('alt')} ${idx + 1}`}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
<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" />
|
||||
@@ -55,7 +59,11 @@ export default function GallerySection() {
|
||||
isOpen={lightboxOpen}
|
||||
images={images}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
onClose={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('photo');
|
||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Container, Button, Section, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import HeroIllustration from './HeroIllustration';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useAnalytics } from '../analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||
|
||||
export default function Hero() {
|
||||
const t = useTranslations('Home.hero');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0"
|
||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
||||
>
|
||||
<HeroIllustration />
|
||||
</motion.div>
|
||||
|
||||
<Container className="relative z-10 text-left text-white w-full">
|
||||
<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">
|
||||
<motion.div
|
||||
className="max-w-5xl"
|
||||
className="max-w-5xl mx-auto md:mx-0"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div variants={headingVariants}>
|
||||
<Heading level={1} className="mb-6 md:mb-8 max-w-[12ch] md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl">
|
||||
<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">
|
||||
@@ -41,12 +39,12 @@ export default function Hero() {
|
||||
</motion.span>
|
||||
<motion.div
|
||||
variants={scribbleVariants}
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute"
|
||||
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>
|
||||
@@ -56,29 +54,60 @@ export default function Hero() {
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 md:gap-6"
|
||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
||||
variants={buttonContainerVariants}
|
||||
>
|
||||
<motion.div variants={buttonVariants}>
|
||||
<Button href="/contact" variant="accent" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg">
|
||||
<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="ml-3 transition-transform group-hover:translate-x-1">→</span>
|
||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div variants={buttonVariants}>
|
||||
<Button href="/products" variant="ghost" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/20 hover:bg-white/10 md:bg-white md:text-saturated md:hover:bg-neutral-light md:border-none">
|
||||
<Button
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
variant="white"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('exploreProducts'),
|
||||
location: 'home_hero_secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('exploreProducts')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
|
||||
<motion.div
|
||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
||||
>
|
||||
<HeroIllustration />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut", delay: 3 }}
|
||||
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
|
||||
>
|
||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||
<motion.div
|
||||
@@ -87,7 +116,7 @@ export default function Hero() {
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -102,9 +131,9 @@ const containerVariants = {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.4
|
||||
}
|
||||
}
|
||||
delayChildren: 0.4,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headingVariants = {
|
||||
@@ -113,8 +142,8 @@ const headingVariants = {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }
|
||||
}
|
||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const accentVariants = {
|
||||
@@ -123,8 +152,8 @@ const accentVariants = {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
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;
|
||||
|
||||
const scribbleVariants = {
|
||||
@@ -133,8 +162,8 @@ const scribbleVariants = {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
transition: { duration: 1, type: "spring", stiffness: 300, damping: 20 }
|
||||
}
|
||||
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const subtitleVariants = {
|
||||
@@ -143,8 +172,8 @@ const subtitleVariants = {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] }
|
||||
}
|
||||
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buttonContainerVariants = {
|
||||
@@ -153,9 +182,9 @@ const buttonContainerVariants = {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
delayChildren: 0.4
|
||||
}
|
||||
}
|
||||
delayChildren: 0.4,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const buttonVariants = {
|
||||
@@ -164,6 +193,6 @@ const buttonVariants = {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring", stiffness: 400, damping: 20 }
|
||||
}
|
||||
transition: { type: 'spring', stiffness: 400, damping: 20 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
// Isometric grid configuration - true 2:1 isometric projection
|
||||
const CELL_WIDTH = 120;
|
||||
const CELL_HEIGHT = 60; // Half of width for 2:1 isometric
|
||||
|
||||
// Convert grid coordinates to isometric screen coordinates
|
||||
// Using standard isometric projection where x goes right-down, y goes right-up
|
||||
function gridToScreen(col: number, row: number): { x: number; y: number } {
|
||||
return {
|
||||
x: (col - row) * (CELL_WIDTH / 2),
|
||||
@@ -16,65 +15,53 @@ function gridToScreen(col: number, row: number): { x: number; y: number } {
|
||||
}
|
||||
|
||||
// Grid layout (10 columns x 8 rows)
|
||||
// Energy flow: Solar/Wind (left) → Substations (center) → Transmission → City (right)
|
||||
const GRID = {
|
||||
cols: 10,
|
||||
rows: 8,
|
||||
};
|
||||
|
||||
// Infrastructure positions - precisely on grid intersections
|
||||
// Infrastructure positions
|
||||
const INFRASTRUCTURE = {
|
||||
// Solar panels (two groups)
|
||||
solar: [
|
||||
// Group 1 - bottom-left
|
||||
{ col: 0, row: 5 },
|
||||
{ col: 1, row: 5 },
|
||||
{ col: 0, row: 6 },
|
||||
{ col: 1, row: 6 },
|
||||
// Group 2 - middle-bottom
|
||||
{ col: 2, row: 7 },
|
||||
{ col: 3, row: 7 },
|
||||
{ col: 2, row: 8 },
|
||||
{ col: 3, row: 8 },
|
||||
],
|
||||
// Wind turbines (two groups)
|
||||
wind: [
|
||||
// Group 1 - top-left
|
||||
{ col: 0, row: 1 },
|
||||
{ col: 1, row: 2 },
|
||||
{ col: 2, row: 1 },
|
||||
// Group 2 - top-center
|
||||
{ col: 3, row: 0 },
|
||||
{ col: 4, row: 1 },
|
||||
{ col: 5, row: 0 },
|
||||
],
|
||||
// Substations
|
||||
substations: [
|
||||
{ col: 3, row: 3, type: 'collection' }, // Main collection substation
|
||||
{ col: 6, row: 4, type: 'distribution' }, // Distribution substation (right)
|
||||
{ col: 5, row: 7, type: 'distribution' }, // Distribution substation (bottom-left)
|
||||
{ col: 3, row: 3, type: 'collection' },
|
||||
{ col: 6, row: 4, type: 'distribution' },
|
||||
{ col: 5, row: 7, type: 'distribution' },
|
||||
],
|
||||
// Transmission towers (along the routes)
|
||||
towers: [
|
||||
{ col: 4, row: 3 },
|
||||
{ col: 5, row: 4 },
|
||||
{ col: 4, row: 5 },
|
||||
{ col: 5, row: 6 },
|
||||
],
|
||||
// City/Buildings (right side)
|
||||
city: [
|
||||
{ col: 8, row: 3, type: 'tall' },
|
||||
{ col: 9, row: 4, type: 'medium' },
|
||||
{ col: 8, row: 5, type: 'small' },
|
||||
{ col: 9, row: 5, type: 'medium' },
|
||||
],
|
||||
// City 2 (bottom-left area)
|
||||
city2: [
|
||||
{ col: 6, row: 8, type: 'medium' },
|
||||
{ col: 7, row: 7, type: 'tall' },
|
||||
{ col: 7, row: 8, type: 'small' },
|
||||
],
|
||||
// Trees (decorative, scattered around)
|
||||
trees: [
|
||||
{ col: 0, row: 3 },
|
||||
{ col: 2, row: 6 },
|
||||
@@ -84,77 +71,43 @@ const INFRASTRUCTURE = {
|
||||
],
|
||||
};
|
||||
|
||||
// Power line connections - grid-aligned paths only (no diagonals)
|
||||
// Each group meets at a collection point, then flows to main substation
|
||||
const POWER_LINES = [
|
||||
// === WIND GROUP 1 (top-left) - meet at (1,1) then to substation ===
|
||||
// Turbine at (0,1) → collection point (1,1)
|
||||
{ from: { col: 0, row: 1 }, to: { col: 1, row: 1 } },
|
||||
// Turbine at (1,2) → up to (1,1)
|
||||
{ from: { col: 1, row: 2 }, to: { col: 1, row: 1 } },
|
||||
// Turbine at (2,1) → left to (1,1)
|
||||
{ from: { col: 2, row: 1 }, to: { col: 1, row: 1 } },
|
||||
// Collection point (1,1) → down to (1,3) → right to substation (3,3)
|
||||
{ from: { col: 1, row: 1 }, to: { col: 1, row: 3 } },
|
||||
{ from: { col: 1, row: 3 }, to: { col: 3, row: 3 } },
|
||||
|
||||
// === WIND GROUP 2 (top-center) - meet at (4,1) then to substation ===
|
||||
// Turbine at (3,0) → right to (4,0) → down to (4,1)
|
||||
{ from: { col: 3, row: 0 }, to: { col: 4, row: 0 } },
|
||||
{ from: { col: 4, row: 0 }, to: { col: 4, row: 1 } },
|
||||
// Turbine at (4,1) is the collection point
|
||||
// Turbine at (5,0) → down to (5,1) → left to (4,1)
|
||||
{ from: { col: 5, row: 0 }, to: { col: 5, row: 1 } },
|
||||
{ from: { col: 5, row: 1 }, to: { col: 4, row: 1 } },
|
||||
// Collection point (4,1) → down to (4,3) → left to substation (3,3)
|
||||
{ from: { col: 4, row: 1 }, to: { col: 4, row: 3 } },
|
||||
{ from: { col: 4, row: 3 }, to: { col: 3, row: 3 } },
|
||||
|
||||
// === SOLAR GROUP 1 (bottom-left) - meet at (1,5) then to substation ===
|
||||
// Panels at (0,5), (1,5), (0,6), (1,6) → collection at (1,5)
|
||||
{ from: { col: 0, row: 5 }, to: { col: 1, row: 5 } },
|
||||
{ from: { col: 0, row: 6 }, to: { col: 0, row: 5 } },
|
||||
{ from: { col: 1, row: 6 }, to: { col: 1, row: 5 } },
|
||||
// Collection point (1,5) → up to (1,3) → right to substation (3,3)
|
||||
{ from: { col: 1, row: 5 }, to: { col: 1, row: 3 } },
|
||||
|
||||
// === SOLAR GROUP 2 (middle-bottom) - meet at (3,7) then to substation ===
|
||||
// Panels at (2,7), (3,7), (2,8), (3,8) → collection at (3,7)
|
||||
{ from: { col: 2, row: 7 }, to: { col: 3, row: 7 } },
|
||||
{ from: { col: 2, row: 8 }, to: { col: 2, row: 7 } },
|
||||
{ from: { col: 3, row: 8 }, to: { col: 3, row: 7 } },
|
||||
// Collection point (3,7) → up to (3,3) substation
|
||||
{ from: { col: 3, row: 7 }, to: { col: 3, row: 5 } },
|
||||
{ from: { col: 3, row: 5 }, to: { col: 3, row: 3 } },
|
||||
|
||||
// === MAIN TRANSMISSION: Substation (3,3) → Towers → Distribution → City ===
|
||||
// Substation to first tower
|
||||
{ from: { col: 3, row: 3 }, to: { col: 4, row: 3 } },
|
||||
// First tower to second tower (grid-aligned)
|
||||
{ from: { col: 4, row: 3 }, to: { col: 5, row: 3 } },
|
||||
{ from: { col: 5, row: 3 }, to: { col: 5, row: 4 } },
|
||||
// Second tower to distribution substation (right)
|
||||
{ from: { col: 5, row: 4 }, to: { col: 6, row: 4 } },
|
||||
// Distribution to city 1 (grid-aligned)
|
||||
{ from: { col: 6, row: 4 }, to: { col: 7, row: 4 } },
|
||||
{ from: { col: 7, row: 4 }, to: { col: 8, row: 4 } },
|
||||
// Branch to buildings (city 1)
|
||||
{ from: { col: 8, row: 4 }, to: { col: 8, row: 3 } },
|
||||
{ from: { col: 8, row: 4 }, to: { col: 8, row: 5 } },
|
||||
{ from: { col: 8, row: 3 }, to: { col: 9, row: 3 } },
|
||||
{ from: { col: 9, row: 3 }, to: { col: 9, row: 4 } },
|
||||
{ from: { col: 8, row: 5 }, to: { col: 9, row: 5 } },
|
||||
|
||||
// === SECOND ROUTE: Substation (3,3) → Towers → Distribution (5,7) → City 2 ===
|
||||
// Branch from main substation down
|
||||
{ from: { col: 3, row: 3 }, to: { col: 3, row: 5 } },
|
||||
{ from: { col: 3, row: 5 }, to: { col: 4, row: 5 } },
|
||||
// Tower at (4,5) to tower at (5,6)
|
||||
{ from: { col: 4, row: 5 }, to: { col: 5, row: 5 } },
|
||||
{ from: { col: 5, row: 5 }, to: { col: 5, row: 6 } },
|
||||
// Tower to distribution substation (bottom-left)
|
||||
{ from: { col: 5, row: 6 }, to: { col: 5, row: 7 } },
|
||||
// Distribution to city 2
|
||||
{ from: { col: 5, row: 7 }, to: { col: 6, row: 7 } },
|
||||
{ from: { col: 6, row: 7 }, to: { col: 6, row: 8 } },
|
||||
{ from: { col: 6, row: 7 }, to: { col: 7, row: 7 } },
|
||||
@@ -162,17 +115,30 @@ const POWER_LINES = [
|
||||
];
|
||||
|
||||
export default function HeroIllustration() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100';
|
||||
const scale = isMobile ? 1.44 : 1;
|
||||
const opacity = isMobile ? 0.6 : 0.85;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 overflow-hidden bg-primary">
|
||||
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
|
||||
<svg
|
||||
viewBox="-400 -200 1800 1100"
|
||||
className="w-full h-full opacity-30 md:opacity-100 scale-[2] md:scale-100 translate-x-1/3 md:translate-x-0"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox={viewBox}
|
||||
className="w-full h-full transition-all duration-700"
|
||||
style={{ opacity, transform: `scale(${scale})` }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
{/* Electric energy flow gradient */}
|
||||
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#82ed20" stopOpacity="0" />
|
||||
<stop offset="30%" stopColor="#82ed20" stopOpacity="0.6" />
|
||||
@@ -180,8 +146,6 @@ export default function HeroIllustration() {
|
||||
<stop offset="70%" stopColor="#82ed20" stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor="#82ed20" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Wind flow gradient */}
|
||||
<linearGradient id="wind-flow" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="30%" stopColor="white" stopOpacity="0.4" />
|
||||
@@ -189,25 +153,13 @@ export default function HeroIllustration() {
|
||||
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Sun ray gradient */}
|
||||
<linearGradient id="sun-ray" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#FFD700" stopOpacity="0.6" />
|
||||
<stop offset="50%" stopColor="#FFD700" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#82ed20" stopOpacity="0.1" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filter */}
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Soft glow for nodes */}
|
||||
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
@@ -215,23 +167,11 @@ export default function HeroIllustration() {
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Sun glow filter */}
|
||||
<filter id="sun-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Main scene container - positioned to the right */}
|
||||
<g transform="translate(900, 100)">
|
||||
|
||||
{/* === ISOMETRIC GRID === */}
|
||||
<g opacity="0.15">
|
||||
{/* Horizontal grid lines (going from top-left to bottom-right) */}
|
||||
{/* ISOMETRIC GRID */}
|
||||
<g opacity="0.1">
|
||||
{[...Array(GRID.rows + 1)].map((_, row) => {
|
||||
const start = gridToScreen(0, row);
|
||||
const end = gridToScreen(GRID.cols, row);
|
||||
@@ -247,7 +187,6 @@ export default function HeroIllustration() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Vertical grid lines (going from top-right to bottom-left) */}
|
||||
{[...Array(GRID.cols + 1)].map((_, col) => {
|
||||
const start = gridToScreen(col, 0);
|
||||
const end = gridToScreen(col, GRID.rows);
|
||||
@@ -265,49 +204,23 @@ export default function HeroIllustration() {
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Grid intersection nodes */}
|
||||
<g opacity="0.2">
|
||||
{[...Array(GRID.cols + 1)].map((_, col) =>
|
||||
[...Array(GRID.rows + 1)].map((_, row) => {
|
||||
const pos = gridToScreen(col, row);
|
||||
return (
|
||||
<circle
|
||||
key={`node-${col}-${row}`}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="2"
|
||||
fill="white"
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* === POWER LINES (Base cables) === */}
|
||||
<g stroke="white" strokeWidth="2" strokeOpacity="0.25">
|
||||
{/* POWER LINES */}
|
||||
<g stroke="white" strokeWidth="2" strokeOpacity="0.2">
|
||||
{POWER_LINES.map((line, i) => {
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
return (
|
||||
<line
|
||||
key={`cable-${i}`}
|
||||
x1={from.x}
|
||||
y1={from.y}
|
||||
x2={to.x}
|
||||
y2={to.y}
|
||||
/>
|
||||
);
|
||||
return <line key={`cable-${i}`} x1={from.x} y1={from.y} x2={to.x} y2={to.y} />;
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* === ANIMATED ENERGY FLOW === */}
|
||||
{/* ANIMATED ENERGY FLOW */}
|
||||
<g filter="url(#glow)">
|
||||
{POWER_LINES.map((line, i) => {
|
||||
// Only animate a subset of lines to reduce main-thread work
|
||||
if (i % 2 !== 0) return null;
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
const length = Math.sqrt(
|
||||
Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2)
|
||||
);
|
||||
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
|
||||
return (
|
||||
<line
|
||||
key={`flow-${i}`}
|
||||
@@ -332,52 +245,53 @@ export default function HeroIllustration() {
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* === SOLAR PANELS === */}
|
||||
{/* SOLAR PANELS */}
|
||||
{INFRASTRUCTURE.solar.map((panel, i) => {
|
||||
const pos = gridToScreen(panel.col, panel.row);
|
||||
return (
|
||||
<g key={`solar-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Panel base */}
|
||||
<path
|
||||
d="M -20 0 L 0 -10 L 20 0 L 0 10 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M -15 -5 L 0 -15 L 15 -5 L 0 5 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.4"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
{/* Panel surface (tilted) */}
|
||||
<path
|
||||
d="M -15 -5 L 0 -15 L 15 -5 L 0 5 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.15"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.5"
|
||||
/>
|
||||
{/* Panel grid lines */}
|
||||
<line x1="-7" y1="-10" x2="7" y2="0" stroke="white" strokeWidth="0.5" strokeOpacity="0.3" />
|
||||
<line x1="0" y1="-15" x2="0" y2="5" stroke="white" strokeWidth="0.5" strokeOpacity="0.3" />
|
||||
{/* Connection glow */}
|
||||
<circle r="4" fill="#82ed20" fillOpacity="0.4" filter="url(#soft-glow)">
|
||||
<animate attributeName="fillOpacity" values="0.3;0.6;0.3" dur="2s" repeatCount="indefinite" />
|
||||
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate
|
||||
attributeName="fillOpacity"
|
||||
values="0.2;0.5;0.2"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === WIND TURBINES === */}
|
||||
{/* WIND TURBINES */}
|
||||
{INFRASTRUCTURE.wind.map((turbine, i) => {
|
||||
const pos = gridToScreen(turbine.col, turbine.row);
|
||||
return (
|
||||
<g key={`wind-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Base */}
|
||||
<ellipse cx="0" cy="0" rx="10" ry="5" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="1" strokeOpacity="0.3" />
|
||||
{/* Tower */}
|
||||
<line x1="0" y1="0" x2="0" y2="-60" stroke="white" strokeWidth="2" strokeOpacity="0.5" />
|
||||
{/* Nacelle */}
|
||||
<ellipse cx="0" cy="-60" rx="6" ry="3" fill="white" fillOpacity="0.3" stroke="white" strokeWidth="1" />
|
||||
{/* Blades */}
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-60"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<g transform="translate(0, -60)">
|
||||
{[0, 120, 240].map((angle, j) => (
|
||||
<line
|
||||
@@ -388,7 +302,7 @@ export default function HeroIllustration() {
|
||||
y2="-30"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.6"
|
||||
strokeOpacity="0.4"
|
||||
transform={`rotate(${angle})`}
|
||||
>
|
||||
<animateTransform
|
||||
@@ -401,347 +315,94 @@ export default function HeroIllustration() {
|
||||
/>
|
||||
</line>
|
||||
))}
|
||||
<circle r="3" fill="white" fillOpacity="0.4" />
|
||||
</g>
|
||||
{/* Connection glow */}
|
||||
<circle r="5" fill="#82ed20" fillOpacity="0.4" filter="url(#soft-glow)">
|
||||
<animate attributeName="fillOpacity" values="0.3;0.6;0.3" dur="2.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === SUBSTATIONS === */}
|
||||
{/* SUBSTATIONS */}
|
||||
{INFRASTRUCTURE.substations.map((sub, i) => {
|
||||
const pos = gridToScreen(sub.col, sub.row);
|
||||
const isCollection = sub.type === 'collection';
|
||||
return (
|
||||
<g key={`substation-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Base platform */}
|
||||
<path
|
||||
d="M -25 0 L 0 -12 L 25 0 L 0 12 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.4"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
{/* Building */}
|
||||
<path
|
||||
d={isCollection
|
||||
? "M -18 0 L -18 -20 L 0 -32 L 18 -20 L 18 0"
|
||||
: "M -22 0 L -22 -25 L 0 -37 L 22 -25 L 22 0"
|
||||
d={
|
||||
isCollection
|
||||
? 'M -18 0 L -18 -20 L 0 -32 L 18 -20 L 18 0'
|
||||
: 'M -22 0 L -22 -25 L 0 -37 L 22 -25 L 22 0'
|
||||
}
|
||||
fill="white"
|
||||
fillOpacity="0.08"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.5"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
{/* Equipment */}
|
||||
<rect x="-10" y="-12" width="6" height="8" fill="white" fillOpacity="0.2" stroke="white" strokeWidth="0.5" />
|
||||
<rect x="4" y="-12" width="6" height="8" fill="white" fillOpacity="0.2" stroke="white" strokeWidth="0.5" />
|
||||
{/* Insulators */}
|
||||
<line x1="-7" y1="-12" x2="-7" y2="-22" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
|
||||
<line x1="7" y1="-12" x2="7" y2="-22" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
|
||||
<circle cx="-7" cy="-22" r="2" fill="white" fillOpacity="0.4" />
|
||||
<circle cx="7" cy="-22" r="2" fill="white" fillOpacity="0.4" />
|
||||
{/* Connection glow */}
|
||||
<circle r="8" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate attributeName="r" values="6;10;6" dur="3s" repeatCount="indefinite" />
|
||||
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur="3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === TRANSMISSION TOWERS === */}
|
||||
{/* TRANSMISSION TOWERS */}
|
||||
{INFRASTRUCTURE.towers.map((tower, i) => {
|
||||
const pos = gridToScreen(tower.col, tower.row);
|
||||
return (
|
||||
<g key={`tower-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Base */}
|
||||
<ellipse cx="0" cy="0" rx="8" ry="4" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="1" strokeOpacity="0.3" />
|
||||
{/* Tower legs */}
|
||||
<path d="M -6 0 L -3 -45 M 6 0 L 3 -45" stroke="white" strokeWidth="1.5" strokeOpacity="0.5" />
|
||||
{/* Cross braces */}
|
||||
<path d="M -5 -10 L 5 -10 M -4 -20 L 4 -20 M -3 -30 L 3 -30 M -3 -45 L 3 -45" stroke="white" strokeWidth="1" strokeOpacity="0.3" />
|
||||
{/* Cross arms */}
|
||||
<line x1="-12" y1="-40" x2="12" y2="-40" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
|
||||
<line x1="-10" y1="-32" x2="10" y2="-32" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
|
||||
{/* Insulators */}
|
||||
<circle cx="-10" cy="-40" r="1.5" fill="white" fillOpacity="0.4" />
|
||||
<circle cx="10" cy="-40" r="1.5" fill="white" fillOpacity="0.4" />
|
||||
{/* Connection glow */}
|
||||
<circle r="5" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<path
|
||||
d="M -6 0 L -3 -45 M 6 0 L 3 -45"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="-12"
|
||||
y1="-40"
|
||||
x2="12"
|
||||
y2="-40"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === CITY BUILDINGS === */}
|
||||
{INFRASTRUCTURE.city.map((building, i) => {
|
||||
{/* CITY BUILDINGS */}
|
||||
{[...INFRASTRUCTURE.city, ...INFRASTRUCTURE.city2].map((building, i) => {
|
||||
const pos = gridToScreen(building.col, building.row);
|
||||
const heights = { tall: 70, medium: 45, small: 30 };
|
||||
const height = heights[building.type as keyof typeof heights];
|
||||
const height = heights[building.type as keyof typeof heights] || 45;
|
||||
return (
|
||||
<g key={`building-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Base */}
|
||||
<path
|
||||
d="M -12 0 L 0 -6 L 12 0 L 0 6 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
{/* Building front */}
|
||||
<path
|
||||
d={`M -12 0 L -12 -${height} L 0 -${height + 6} L 0 -6 Z`}
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
fillOpacity="0.08"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.4"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
{/* Building side */}
|
||||
<path
|
||||
d={`M 0 -6 L 0 -${height + 6} L 12 -${height} L 12 0 Z`}
|
||||
fill="white"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
strokeOpacity="0.15"
|
||||
/>
|
||||
{/* Windows */}
|
||||
{[...Array(Math.floor(height / 15))].map((_, w) => (
|
||||
<g key={`window-${i}-${w}`}>
|
||||
<rect x="-9" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
|
||||
<rect x="-4" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
|
||||
<rect x="3" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
|
||||
<rect x="7" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
|
||||
</g>
|
||||
))}
|
||||
{/* Connection glow */}
|
||||
<circle r="4" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur={`${2 + i * 0.3}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === CITY 2 BUILDINGS (bottom-left) === */}
|
||||
{INFRASTRUCTURE.city2.map((building, i) => {
|
||||
const pos = gridToScreen(building.col, building.row);
|
||||
const heights = { tall: 70, medium: 45, small: 30 };
|
||||
const height = heights[building.type as keyof typeof heights];
|
||||
return (
|
||||
<g key={`building2-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Base */}
|
||||
<path
|
||||
d="M -12 0 L 0 -6 L 12 0 L 0 6 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
{/* Building front */}
|
||||
<path
|
||||
d={`M -12 0 L -12 -${height} L 0 -${height + 6} L 0 -6 Z`}
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.4"
|
||||
/>
|
||||
{/* Building side */}
|
||||
<path
|
||||
d={`M 0 -6 L 0 -${height + 6} L 12 -${height} L 12 0 Z`}
|
||||
fill="white"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
{/* Windows */}
|
||||
{[...Array(Math.floor(height / 15))].map((_, w) => (
|
||||
<g key={`window2-${i}-${w}`}>
|
||||
<rect x="-9" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
|
||||
<rect x="-4" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
|
||||
<rect x="3" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
|
||||
<rect x="7" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
|
||||
</g>
|
||||
))}
|
||||
{/* Connection glow */}
|
||||
<circle r="4" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur={`${2.5 + i * 0.3}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === TREES === */}
|
||||
{INFRASTRUCTURE.trees.map((tree, i) => {
|
||||
const pos = gridToScreen(tree.col, tree.row);
|
||||
return (
|
||||
<g key={`tree-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Trunk */}
|
||||
<line x1="0" y1="0" x2="0" y2="-15" stroke="white" strokeWidth="2" strokeOpacity="0.3" />
|
||||
{/* Foliage - layered circles for tree crown */}
|
||||
<ellipse cx="0" cy="-22" rx="10" ry="8" fill="white" fillOpacity="0.12" stroke="white" strokeWidth="0.5" strokeOpacity="0.2" />
|
||||
<ellipse cx="-5" cy="-26" rx="7" ry="6" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="0.5" strokeOpacity="0.15" />
|
||||
<ellipse cx="5" cy="-26" rx="7" ry="6" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="0.5" strokeOpacity="0.15" />
|
||||
<ellipse cx="0" cy="-30" rx="6" ry="5" fill="white" fillOpacity="0.08" stroke="white" strokeWidth="0.5" strokeOpacity="0.1" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === ABSTRACT WIND EFFECTS === */}
|
||||
{INFRASTRUCTURE.wind.map((turbine, i) => {
|
||||
const pos = gridToScreen(turbine.col, turbine.row);
|
||||
return (
|
||||
<g key={`wind-effect-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Wind swoosh lines - curved paths flowing toward turbine */}
|
||||
{[0, 1, 2].map((j) => (
|
||||
<path
|
||||
key={`wind-line-${i}-${j}`}
|
||||
d={`M ${-80 - j * 15} ${-70 - j * 8} Q ${-50 - j * 10} ${-65 - j * 5} ${-20} ${-60}`}
|
||||
stroke="url(#wind-flow)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.6;0"
|
||||
dur={`${2 + j * 0.5}s`}
|
||||
begin={`${j * 0.7 + i * 0.3}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="100"
|
||||
to="0"
|
||||
dur={`${2 + j * 0.5}s`}
|
||||
begin={`${j * 0.7 + i * 0.3}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
))}
|
||||
{/* Additional wind particles */}
|
||||
{[0, 1, 2, 3].map((j) => (
|
||||
<circle
|
||||
key={`wind-particle-${i}-${j}`}
|
||||
r="1.5"
|
||||
fill="white"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="cx"
|
||||
values={`${-70 - j * 10};${-10}`}
|
||||
dur={`${1.5 + j * 0.3}s`}
|
||||
begin={`${j * 0.4 + i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values={`${-75 - j * 5};${-60}`}
|
||||
dur={`${1.5 + j * 0.3}s`}
|
||||
begin={`${j * 0.4 + i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.5;0"
|
||||
dur={`${1.5 + j * 0.3}s`}
|
||||
begin={`${j * 0.4 + i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === SCHEMATIC SUN RAYS === */}
|
||||
{/* Simple downward rays above each solar panel */}
|
||||
{INFRASTRUCTURE.solar.map((panel, i) => {
|
||||
const pos = gridToScreen(panel.col, panel.row);
|
||||
return (
|
||||
<g key={`sun-ray-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
{/* Three short schematic rays coming down to panel */}
|
||||
{[-8, 0, 8].map((offset, j) => (
|
||||
<line
|
||||
key={`ray-${i}-${j}`}
|
||||
x1={offset}
|
||||
y1={-45}
|
||||
x2={offset * 0.3}
|
||||
y2={-18}
|
||||
stroke="#FFD700"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 6"
|
||||
>
|
||||
<animate
|
||||
attributeName="strokeOpacity"
|
||||
values="0.2;0.5;0.2"
|
||||
dur={`${2 + j * 0.3}s`}
|
||||
begin={`${i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="10"
|
||||
to="0"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === ENERGY PARTICLES === */}
|
||||
{POWER_LINES.map((line, i) => {
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
return (
|
||||
<circle
|
||||
key={`particle-${i}`}
|
||||
r="3"
|
||||
fill="#82ed20"
|
||||
filter="url(#soft-glow)"
|
||||
>
|
||||
<animate
|
||||
attributeName="cx"
|
||||
values={`${from.x};${to.x}`}
|
||||
dur={`${1 + (i % 4) * 0.3}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values={`${from.y};${to.y}`}
|
||||
dur={`${1 + (i % 4) * 0.3}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.8;0"
|
||||
dur={`${1 + (i % 4) * 0.3}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-primary/90" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-primary/9 pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,43 +11,53 @@ export default function MeetTheTeam() {
|
||||
return (
|
||||
<Section className="relative py-32 md:py-48 overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||
alt={t('subtitle')}
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||
alt={t('subtitle')}
|
||||
fill
|
||||
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-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-3xl text-white animate-slide-up">
|
||||
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
||||
<span className="text-white">{t('title')}</span>
|
||||
</Heading>
|
||||
|
||||
|
||||
<div className="relative mb-12">
|
||||
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
||||
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
||||
"{t('description')}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap gap-8 items-center">
|
||||
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
|
||||
{t('cta')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex -space-x-4">
|
||||
<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 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>
|
||||
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
||||
|
||||
@@ -8,63 +8,77 @@ export default function ProductCategories() {
|
||||
const t = useTranslations('Products');
|
||||
const locale = useLocale();
|
||||
|
||||
const productsBase = locale === 'de' ? 'produkte' : 'products';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: t('categories.lowVoltage.title'),
|
||||
{
|
||||
title: t('categories.lowVoltage.title'),
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
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'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
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'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
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'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${locale}/products/solar-cables`
|
||||
}
|
||||
href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
{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">
|
||||
<Image
|
||||
src={category.img}
|
||||
<Link
|
||||
key={idx}
|
||||
href={category.href}
|
||||
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0"
|
||||
>
|
||||
<Image
|
||||
src={category.img}
|
||||
alt={category.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
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 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="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>
|
||||
<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">
|
||||
{category.desc}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||
{t('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>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
@@ -22,8 +23,11 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
||||
{t('allArticles')}
|
||||
</Heading>
|
||||
<Link href={`/${locale}/blog`} className="group flex items-center text-primary font-bold text-base md:text-lg touch-target">
|
||||
{t('allArticles')}
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -34,10 +38,12 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<img
|
||||
src={post.frontmatter.featuredImage}
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
@@ -53,7 +59,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
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">
|
||||
@@ -61,8 +67,18 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
</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
|
||||
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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
90
components/record-mode/PlaybackCursor.tsx
Normal file
90
components/record-mode/PlaybackCursor.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function PlaybackCursor() {
|
||||
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
|
||||
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Track scroll so cursor stays locked to the correct element
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||
};
|
||||
|
||||
handleScroll(); // Init
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isPlaying]);
|
||||
|
||||
if (!isPlaying) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed z-[10000] pointer-events-none"
|
||||
animate={{
|
||||
x: cursorPosition.x,
|
||||
y: cursorPosition.y,
|
||||
scale: isClicking ? 0.8 : 1,
|
||||
rotateX: isClicking ? 15 : 0,
|
||||
rotateY: isClicking ? -15 : 0,
|
||||
}}
|
||||
transition={{
|
||||
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
}}
|
||||
style={{ perspective: '1000px' }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isClicking && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 2.5, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Outer Pulse Ring */}
|
||||
<div
|
||||
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||
/>
|
||||
|
||||
{/* Visual Cursor */}
|
||||
<div className="relative">
|
||||
{/* Soft Glow */}
|
||||
<div
|
||||
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||
/>
|
||||
|
||||
{/* Pointer Arrow */}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||
>
|
||||
<path
|
||||
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||
fill={isClicking ? '#82ed20' : 'white'}
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
className="transition-colors duration-150"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
583
components/record-mode/RecordModeOverlay.tsx
Normal file
583
components/record-mode/RecordModeOverlay.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { Reorder, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
MousePointer2,
|
||||
Scroll,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit2,
|
||||
X,
|
||||
Check,
|
||||
Download,
|
||||
Settings2,
|
||||
GripVertical,
|
||||
Clock,
|
||||
Maximize2,
|
||||
Box,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { RecordEvent } from '@/types/record-mode';
|
||||
import { PlaybackCursor } from './PlaybackCursor';
|
||||
|
||||
export function RecordModeOverlay() {
|
||||
const {
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
saveSession,
|
||||
clearEvents,
|
||||
reorderEvents,
|
||||
setHoveredEventId,
|
||||
setEvents, // Added setEvents here
|
||||
} = useRecordMode();
|
||||
|
||||
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !isActive) return;
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||
const { selector, rect, tagName } = e.data;
|
||||
|
||||
if (pickingMode === 'mouse') {
|
||||
addEvent({
|
||||
type: 'mouse',
|
||||
interactionType: lastInteractionType,
|
||||
selector,
|
||||
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||
zoom: 1,
|
||||
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||
motionBlur: false,
|
||||
realClick: false,
|
||||
rect,
|
||||
});
|
||||
} else if (pickingMode === 'scroll') {
|
||||
addEvent({
|
||||
type: 'scroll',
|
||||
selector,
|
||||
duration: 1500,
|
||||
zoom: 1,
|
||||
description: `Scroll to ${tagName}`,
|
||||
motionBlur: false,
|
||||
rect,
|
||||
});
|
||||
}
|
||||
setPickingMode(null);
|
||||
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||
setPickingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
if (pickingMode) {
|
||||
// Find the iframe and signal start picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||
}
|
||||
} else {
|
||||
// Signal stop picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [isActive, pickingMode, addEvent, mounted]);
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingEventId) {
|
||||
updateEvent(editingEventId, editForm);
|
||||
setEditingEventId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [showEvents, setShowEvents] = useState(true);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
if (!isActive) {
|
||||
// Failsafe: Never render host toggle in embedded mode
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.self !== window.top ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.location.search.includes('embedded=true'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsActive(true)}
|
||||
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||
{/* Identity Tag */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||
Event Builder
|
||||
</span>
|
||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||
Manual Mode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Action Tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode('mouse');
|
||||
setLastInteractionType('click');
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
<span>Mouse</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
<span>Scroll</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
addEvent({
|
||||
type: 'wait',
|
||||
duration: 2000,
|
||||
zoom: 1,
|
||||
description: 'Wait for 2s',
|
||||
motionBlur: false,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Sequence Controls */}
|
||||
<div className="flex items-center gap-1 p-0.5">
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isPlaying || events.length === 0}
|
||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||
title="Preview Sequence"
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
{events.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||
{events.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
|
||||
try {
|
||||
const res = await fetch('/api/save-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(session),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Visual feedback could be improved, but alert is fine for dev tool
|
||||
alert('Session saved to remotion/session.json');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Failed to save: ${err.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error saving session');
|
||||
}
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||
title="Save to Project (Dev)"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify(
|
||||
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'remotion-session.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||
title="Download JSON"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setIsActive(false)}
|
||||
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||
title="Exit Studio"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Event Timeline Popover */}
|
||||
{showEvents && (
|
||||
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||
{events.length} Actions Recorded
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={events}
|
||||
onReorder={setEvents}
|
||||
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||
<Plus size={40} strokeWidth={1} />
|
||||
<p className="text-xs mt-4">Timeline is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, index) => (
|
||||
<Reorder.Item
|
||||
key={event.id}
|
||||
value={event}
|
||||
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||
onMouseLeave={() => setHoveredEventId(null)}
|
||||
>
|
||||
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||
{event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
|
||||
</span>
|
||||
{event.clickOrigin &&
|
||||
event.clickOrigin !== 'center' &&
|
||||
event.interactionType === 'click' && (
|
||||
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{event.clickOrigin}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||
{event.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||
{event.selector || 'system:wait'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingEventId(event.id);
|
||||
setEditForm(event);
|
||||
}}
|
||||
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeEvent(event.id)}
|
||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))
|
||||
)}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||
|
||||
{/* Picking Tooltip */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||
<span className="font-black uppercase tracking-widest text-xs">
|
||||
Assigning {pickingMode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-primary-dark/20" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}}
|
||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||
>
|
||||
ESC to Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlaybackCursor />
|
||||
|
||||
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||
<AnimatePresence>
|
||||
{editingEventId && (
|
||||
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||
Event Options
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setEditingEventId(null)}
|
||||
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||
{/* Type Display */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Interaction Type
|
||||
</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<MousePointer2 size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Click</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Scroll size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Precise Click Origin */}
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Click Origin
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||
{[
|
||||
{ id: 'top-left', label: 'TL' },
|
||||
{ id: 'top-right', label: 'TR' },
|
||||
{ id: 'center', label: 'CTR' },
|
||||
{ id: 'bottom-left', label: 'BL' },
|
||||
{ id: 'bottom-right', label: 'BR' },
|
||||
].map((origin) => (
|
||||
<button
|
||||
key={origin.id}
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||
}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||
>
|
||||
{origin.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||
<span>Timeline Allocation</span>
|
||||
<span className="text-accent">{editForm.duration}ms</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={editForm.duration || 1000}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||
}
|
||||
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom & Effects */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<Maximize2 size={18} className="text-white/40" />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Zoom Shift
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="1"
|
||||
max="3"
|
||||
value={editForm.zoom || 1}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||
}
|
||||
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Box size={18} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
|
||||
</div>
|
||||
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExternalLink size={18} />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-xs font-bold uppercase tracking-wider">
|
||||
Trigger Navigation
|
||||
</span>
|
||||
<span className="text-[8px] opacity-60">
|
||||
Allows URL transitions in Studio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||
>
|
||||
Commit Changes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
components/record-mode/ToolCoordinator.tsx
Normal file
66
components/record-mode/ToolCoordinator.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback/FeedbackOverlay';
|
||||
import { RecordModeOverlay } from './RecordModeOverlay';
|
||||
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';
|
||||
|
||||
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';
|
||||
href?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-300 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';
|
||||
|
||||
export function Button({
|
||||
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 = {
|
||||
primary: 'bg-primary text-white hover:bg-primary-dark shadow-md hover:shadow-lg',
|
||||
secondary: 'bg-secondary text-white hover:bg-secondary-light shadow-md hover:shadow-lg',
|
||||
accent: 'bg-accent text-primary-dark hover:bg-accent-dark shadow-md hover:shadow-lg',
|
||||
saturated: 'bg-saturated text-white hover:bg-primary shadow-md hover:shadow-lg',
|
||||
outline: 'border-2 border-primary bg-transparent hover:bg-primary hover:text-white text-primary',
|
||||
ghost: 'hover:bg-primary-light text-primary',
|
||||
white: 'bg-white text-primary hover:bg-neutral-light shadow-md hover:shadow-lg',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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 = {
|
||||
@@ -32,15 +51,47 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
|
||||
|
||||
const styles = cn(baseStyles, variants[variant], sizes[size], className);
|
||||
|
||||
const overlayColors = {
|
||||
primary: 'bg-primary-dark',
|
||||
secondary: 'bg-secondary-light',
|
||||
accent: 'bg-accent-dark',
|
||||
saturated: 'bg-primary',
|
||||
outline: 'bg-primary',
|
||||
ghost: 'bg-primary-light/10',
|
||||
white: 'bg-primary-light',
|
||||
destructive: 'bg-destructive/90',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
|
||||
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
|
||||
overlayColors[variant],
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={styles}>
|
||||
{props.children}
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={styles} {...props} />
|
||||
<button className={styles} {...props}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ export function Heading({
|
||||
const Tag = `h${level}` as any;
|
||||
|
||||
const sizes = {
|
||||
1: 'text-3xl md:text-5xl lg:text-6xl font-extrabold leading-[1.1] tracking-tight',
|
||||
2: 'text-2xl md:text-4xl lg:text-5xl font-bold leading-[1.2] tracking-tight',
|
||||
3: 'text-xl md:text-2xl lg:text-3xl font-bold leading-[1.3] tracking-tight',
|
||||
1: 'text-2xl md:text-4xl lg:text-5xl font-bold leading-[1.1] tracking-tight',
|
||||
2: 'text-xl md:text-3xl lg:text-4xl font-bold leading-[1.2] tracking-tight',
|
||||
3: 'text-lg md:text-2xl lg:text-3xl font-bold leading-[1.3] tracking-tight',
|
||||
4: 'text-lg md:text-xl lg:text-2xl font-bold leading-[1.4]',
|
||||
5: 'text-base md:text-lg font-bold leading-[1.5]',
|
||||
6: 'text-base md:text-lg font-semibold leading-[1.6]',
|
||||
|
||||
@@ -24,15 +24,9 @@ image="https://www.zdf.de/assets/bundestag-berlin-118~1280x720?cb=1741856505967"
|
||||
### Warum Kabelhersteller jetzt durchstarten sollten
|
||||
Es wird viel über Subventionen, Fördergelder und deren Verwendung gesprochen. Doch die eigentliche Herausforderung bleibt: Die notwendige Infrastruktur muss geschaffen werden – und das gelingt nur mit leistungsfähigen Kabeln.
|
||||
Die folgenden Trends sind für uns besonders relevant:
|
||||
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br />
|
||||
|
||||
</strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
|
||||
- <strong>Dezentralisierung der Energieversorgung:<br />
|
||||
|
||||
</strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt – und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
|
||||
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br />
|
||||
|
||||
</strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
|
||||
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br /></strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
|
||||
- <strong>Dezentralisierung der Energieversorgung:<br /></strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt – und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
|
||||
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br /></strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
|
||||
Dies betrifft insbesondere Kabelsysteme, die für hohe Leistung und Stabilität ausgelegt sind – so wie die, die wir bei **KLZ** liefern.
|
||||
### **Die Rolle von KLZ in dieser gigantischen Investitionsoffensive**
|
||||
Mit diesen milliardenschweren Investitionen wird der Bedarf an Erdkabeln, insbesondere Mittelspannungskabeln, geradezu explodieren. Die Frage ist nicht, **ob** Kabel gebraucht werden – sondern **wann und in welchen Mengen**. Und genau da kommen wir ins Spiel.
|
||||
|
||||
@@ -48,18 +48,15 @@ Ein hochwertiges Netzanschlusskabel kombiniert all diese Eigenschaften und garan
|
||||
Ein Kabel allein reicht nicht – die richtige Installation entscheidet über seine Lebensdauer. Fehler in der Verlegung können dazu führen, dass selbst die besten Materialien frühzeitig versagen.
|
||||
### Warum die richtige Verlegeart entscheidend ist
|
||||
Die Art der Verlegung hat einen direkten Einfluss auf die Kabelbelastung:
|
||||
- <p><strong>Direkte Erdverlegung:<br />
|
||||
|
||||
– </strong>Hohe Wärmeableitung, da der Boden Wärme aufnimmt.<br />
|
||||
– Gefahr durch Erdbewegungen und Setzungen.
|
||||
- <p><strong>Kabelschutzrohre:<br />
|
||||
|
||||
</strong>– Schutz vor mechanischen Belastungen.<br />
|
||||
– Kann Wärmeabfuhr einschränken, wenn nicht ausreichend belüftet.
|
||||
- <p><strong>Freiluftverlegung:<br />
|
||||
|
||||
</strong>– Schnelle Wartung und Austauschmöglichkeit.<br />
|
||||
– Höhere Beanspruchung durch UV-Strahlung und Witterung.
|
||||
- **Direkte Erdverlegung:**
|
||||
– Hohe Wärmeableitung, da der Boden Wärme aufnimmt.
|
||||
– Gefahr durch Erdbewegungen und Setzungen.
|
||||
- **Kabelschutzrohre:**
|
||||
– Schutz vor mechanischen Belastungen.
|
||||
– Kann Wärmeabfuhr einschränken, wenn nicht ausreichend belüftet.
|
||||
- **Freiluftverlegung:**
|
||||
– Schnelle Wartung und Austauschmöglichkeit.
|
||||
– Höhere Beanspruchung durch UV-Strahlung und Witterung.
|
||||
### Thermische Belastung: Ein oft unterschätzter Faktor
|
||||
Die Betriebstemperatur beeinflusst maßgeblich die Lebensdauer eines Kabels. Jede** Temperaturerhöhung **um 10 °C** halbiert **die** Lebensdauer **des** Isolationsmaterials.**
|
||||
Daher müssen Kabel richtig dimensioniert werden, um eine Überhitzung zu vermeiden. Zusätzliche Maßnahmen wie Wärmeableitungsgräben oder spezielle Bettungsmaterialien können helfen, die Temperaturen im Betrieb zu kontrollieren.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Windparkbau im Fokus: drei typische Kabelherausforderungen'
|
||||
date: '2025-11-05T10:16:10'
|
||||
date: '2025-01-05T10:16:10'
|
||||
featuredImage: /uploads/2025/04/image_fx_-2025-02-20T193520.620.webp
|
||||
locale: de
|
||||
category: Kabel Technologie
|
||||
@@ -81,11 +81,13 @@ title="Wann klagt der NABU gegen Windkraftprojekte?"
|
||||
summary="45 Klagen wurden wegen Fehlplanungen bei Windenergie zwischen 2010 und 2019 vom NABU auf den Weg gebracht. Nicht weil der Windenergieausbau aufgehalten werden soll, sondern weil immer wieder Vorhaben und Planungen eklatant gegen Naturschutzrecht verstoßen."
|
||||
image="https://www.nabu.de/imperia/md/nabu/images/umwelt/energie/energietraeger/windkraft/161125-nabu-windrad-allgaeu-heidrun-burchard.jpeg"
|
||||
/>
|
||||
<AnimatedImage src="/uploads/2025/02/image_fx_-7.webp" alt="Windpark Baustelle" width={1408} height={768} />
|
||||
|
||||
## Qualität und Nachhaltigkeit als Erfolgsfaktor
|
||||
Neben Zeit und Logistik spielt auch die [**Kabelqualität**](https://www.windkraft-journal.de/2025/07/14/planungsempfehlung-bei-der-verkabelung-von-windparks-durch-wind-turbine-com/214028) eine entscheidende Rolle für die langfristige Performance eines **Windparks**. Schließlich sollen die installierten **[Mittelspannungs](/de/stromkabel/mittelspannungskabel/)– und [Hochspannungskabel](/de/stromkabel/hochspannungskabel/)** über Jahrzehnte zuverlässig Energie übertragen – selbst unter extremen Witterungsbedingungen und wechselnden Lastzyklen.
|
||||
Ein hochwertiges **Kabelsystem für Windkraftanlagen** zeichnet sich durch mehrere Faktoren aus:
|
||||
- **Materialqualität:** VPE-isolierte Kabel wie [**NA2XS(F)2Y** ](/de/products/medium-voltage-cables/na2xsf2y/)oder [**N2XS(F)2Y**](/de/products/medium-voltage-cables/n2xsf2y/) bieten hohe elektrische Festigkeit und exzellenten Langzeitschutz.
|
||||
- **[Normkonformität](https://www.zvei.org/fileadmin/user_upload/Presse_und_Medien/Publikationen/2017/September/ZVEI_Leitfaden_Kabel_und_Leitungen_in_Windkraftanlagen/ZVEI-Leitfaden-Kabel-und-Leitungen-in-Windkraftanlagen-September-2017.pdf):** Alle eingesetzten Komponenten sollten den einschlägigen Normen wie **DIN VDE 0276**, **VDE 0298** oder **IEC 60502** entsprechen.
|
||||
- **Normkonformität (PDF):** [Alle eingesetzten Komponenten sollten den einschlägigen Normen](https://www.zvei.org/fileadmin/user_upload/Presse_und_Medien/Publikationen/2017/September/ZVEI_Leitfaden_Kabel_und_Leitungen_in_Windkraftanlagen/ZVEI-Leitfaden-Kabel-und-Leitungen-in-Windkraftanlagen-September-2017.pdf) wie **DIN VDE 0276**, **VDE 0298** oder **IEC 60502** entsprechen.
|
||||
- **Montagefreundlichkeit:** Die Kabelkonstruktion muss so ausgelegt sein, dass sie sich effizient und sicher verlegen lässt – auch bei schwierigen Bodenbedingungen.
|
||||
- **Umweltaspekte:** Recyclingfähige Materialien und die [Wiederverwendung von Trommeln oder Leitermaterialien](/de/recycling-von-kabeltrommeln-nachhaltigkeit-im-windkraftprojekt/) reduzieren den ökologischen Fußabdruck.
|
||||
|
||||
@@ -93,6 +95,8 @@ Immer mehr Projektentwickler legen Wert auf **nachhaltige Kabelsysteme**, die En
|
||||
Die Kombination aus **technischer Qualität**, **ökologischer Verantwortung** und **effizienter Logistik** macht moderne **Windparkverkabelung** zu einem zentralen Erfolgsfaktor im Netzausbau. Wer hier auf durchdachte Lösungen setzt, schafft die Basis für einen stabilen und nachhaltigen Energiefluss – heute und in Zukunft.
|
||||
[Welche Kabel Sie für Ihr Windparkprojekt brauchen und welche Unterschiede es gibt, erklären wir Ihnen hier.](/de/welche-kabel-fuer-windkraft-unterschiede-von-nieder-bis-hoechstspannung-erklaert/)
|
||||
|
||||
<AnimatedImage src="/uploads/2025/01/offshore-wind-power-and-energy-farm-with-many-wind-2023-11-27-04-51-29-utc-scaled.webp" alt="Windpark Landschaft" width={2560} height={1707} />
|
||||
|
||||
## Fazit: Erfolgreich ans Netz
|
||||
Die Verkabelung ist das Rückgrat jedes **Windparks** – und gleichzeitig einer der sensibelsten Projektbereiche. Enge Zeitpläne, komplexe Logistik und spontane Änderungen sind dabei keine Ausnahme, sondern Alltag. Wer diese Herausforderungen frühzeitig erkennt und gezielt plant, verhindert Stillstand, Kostensteigerungen und Terminverschiebungen.
|
||||
Erfolgreiche **Windpark-Kabelprojekte** zeichnen sich durch drei Dinge aus:
|
||||
@@ -106,4 +110,4 @@ Ob [**Mittelspannung**](/de/stromkabel/mittelspannungskabel/), **Erdkabel** oder
|
||||
Unser Vorteil liegt in der **Praxisnähe**: Wir wissen, wie eng Bauzeiten im Windparkbau sind, welche Kabelsysteme sich bewährt haben und worauf es bei der Logistik wirklich ankommt. Durch unsere **Lagerkapazitäten in der Mitte Deutschlands** reagieren wir schnell auf Änderungen und halten Lieferketten stabil – auch wenn Projekte dynamisch verlaufen.
|
||||
Mit unserem Netzwerk, unserer Marktkenntnis und unserer Leidenschaft für erneuerbare Energien sorgen wir dafür, dass Ihr **Windkraftprojekt** pünktlich und reibungslos ans Netz geht.
|
||||
➡️ **Planen Sie ein neues Windparkprojekt oder benötigen Unterstützung bei der Kabelauswahl?**Dann sprechen Sie uns an – wir liefern die **Kabel, Lösungen und Erfahrung**, die Ihr Projekt erfolgreich machen.
|
||||
[Jetzt Kontakt aufnehmen](/de/contact/)
|
||||
[Jetzt Kontakt aufnehmen](/de/kontakt/)
|
||||
|
||||
@@ -10,16 +10,13 @@ What is particularly interesting is that **100 billion euros of this is specific
|
||||
While politicians are still debating the sense and nonsense of the use of the funds, one thing is certain for us as a cable supplier: nothing will work without cables. Neither in the expansion of wind farms, nor in the laying of power lines or the modernization of energy infrastructures. The demand for cable will therefore increase – considerably.
|
||||
### The billion-euro package and its distribution – who gets what?
|
||||
The distribution of the money is clearly defined and comprises three major areas:
|
||||
- <p>**500 billion euros total budget:**<br />
|
||||
|
||||
This sum will be made available over **twelve** years. An ambitious project that is being pursued with a lot of hope and just as much skepticism.
|
||||
- <p>**100 billion euros for the federal states:**<br />
|
||||
|
||||
This is intended to enable the federal states to push ahead with their own infrastructure projects. These include the expansion of electricity grids, the connection of new wind and solar parks and measures to increase grid stability.
|
||||
- <p>**100 billion euros for climate protection:**<br />
|
||||
|
||||
The green part of the package, which is clearly aimed at converting the economy to climate-friendly technologies. This means: more onshore wind turbines, more solar parks, more cables.<br />
|
||||
These funds will be made available via the existing **Climate and Transformation Fund (KTF)** and are intended to help reduce CO2 emissions while guaranteeing a stable energy supply.
|
||||
- **500 billion euros total budget:**
|
||||
This sum will be made available over **twelve** years. An ambitious project that is being pursued with a lot of hope and just as much skepticism.
|
||||
- **100 billion euros for the federal states:**
|
||||
This is intended to enable the federal states to push ahead with their own infrastructure projects. These include the expansion of electricity grids, the connection of new wind and solar parks and measures to increase grid stability.
|
||||
- **100 billion euros for climate protection:**
|
||||
The green part of the package, which is clearly aimed at converting the economy to climate-friendly technologies. This means: more onshore wind turbines, more solar parks, more cables.
|
||||
These funds will be made available via the existing **Climate and Transformation Fund (KTF)** and are intended to help reduce CO2 emissions while guaranteeing a stable energy supply.
|
||||
### Why cable suppliers should hit the ground running now
|
||||
There is a lot of talk about subsidies, funding and how to use it. But the real challenge remains: The necessary infrastructure must be created – and that only works with high-performance cables.
|
||||
The following trends are particularly relevant for us:
|
||||
@@ -34,15 +31,12 @@ This applies in particular to cable systems that are designed for high performan
|
||||
### KLZ’s role in this gigantic investment offensive
|
||||
With these billion-euro investments, the demand for underground cables, especially medium-voltage cables, will virtually explode. The question is not **whether** cables will be needed – but **when** and in **what** quantities. And that’s where we come in.
|
||||
<h4>Our strengths:</h4>
|
||||
- <p>**High-quality cables:**<br />
|
||||
|
||||
We only supply [high-quality cables](/power-cables/), such as the **NA2XS(F)2Y**, **NAYY** or even the **NAYY-J**. These are ideally suited for use in onshore wind farms, solar fields and transformer stations. They offer high reliability, resilience and durability.
|
||||
- <p>**Fast delivery thanks to logistical efficiency:**<br />
|
||||
|
||||
Thanks to our central logistics hub, we can deliver quickly and reliably – including to our customers in the Netherlands. This is a decisive advantage when projects have to be realized under time pressure.
|
||||
- <p>**Sustainability:**<br />
|
||||
|
||||
While the German government is pushing ahead with its climate targets, we are also doing our bit. We have long attached great importance to sustainable solutions that meet the requirements of the future.
|
||||
- **High-quality cables:**
|
||||
We only supply [high-quality cables](/power-cables/), such as the **NA2XS(F)2Y**, **NAYY** or even the **NAYY-J**. These are ideally suited for use in onshore wind farms, solar fields and transformer stations. They offer high reliability, resilience and durability.
|
||||
- **Fast delivery thanks to logistical efficiency:**
|
||||
Thanks to our central logistics hub, we can deliver quickly and reliably – including to our customers in the Netherlands. This is a decisive advantage when projects have to be realized under time pressure.
|
||||
- **Sustainability:**
|
||||
While the German government is pushing ahead with its climate targets, we are also doing our bit. We have long attached great importance to sustainable solutions that meet the requirements of the future.
|
||||
### Why the timing is ideal for grid expansion
|
||||
Of course, not everyone approves of this mega project. There are those who criticize the project as being too ambitious or poorly planned. But one thing is certain: the demand for modern infrastructure will increase, and it will increase dramatically.
|
||||
Instead of discussing whether it is the best solution, we are concentrating on **ensuring that the best cable technology is available when it is needed**. The energy transition will come – and we will make sure that it really works.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Focus on wind farm construction: three typical cable challenges'
|
||||
date: '2025-11-05T10:16:15'
|
||||
date: '2025-01-05T10:16:15'
|
||||
featuredImage: /uploads/2025/04/image_fx_-2025-02-20T193520.620.webp
|
||||
locale: en
|
||||
category: Kabel Technologie
|
||||
@@ -28,21 +28,14 @@ In this article, we look at the** 3 biggest challenges in wind farm construction
|
||||
|
||||
Find out why onshore wind farms are a crucial pillar of the energy transition here:
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
<VisualLinkPreview
|
||||
url="https://www.enbw.com/unternehmen/themen/windkraft/onshore-wind-pfeiler-der-energiewende.html"
|
||||
title="Onshore-Windenergie als Pfeiler der Energiewende | EnBW"
|
||||
summary="Viele Faktoren haben den Bau von Windenergieanlagen in den letzten Jahren gebremst. Lesen Sie hier die Gründe!"
|
||||
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus50x49,zoom1.0/https://www.enbw.com/media/presse/images/newsroom/onshore-windpark-langenburg-7zu4_1701415033580.jpg"
|
||||
/>
|
||||
"
|
||||
title="Onshore-Windenergie als Pfeiler der Energiewende | EnBW"
|
||||
summary="Viele Faktoren haben den Bau von Windenergieanlagen in den letzten Jahren gebremst. Lesen Sie hier die Gründe!"
|
||||
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus50x49,zoom1.0/https://www.enbw.com/media/presse/images/newsroom/onshore-windpark-langenburg-7zu4_1701415033580.jpg"
|
||||
/>
|
||||
|
||||
<AnimatedImage src="/uploads/2025/04/image_fx_-7.webp" alt="Wind farm construction site" width={1408} height={768} />
|
||||
<AnimatedImage src="/uploads/2025/02/image_fx_-7.webp" alt="Wind farm construction site" width={1408} height={768} />
|
||||
|
||||
## Challenge 1: Tight construction timelines and fixed deadlines
|
||||
|
||||
@@ -70,19 +63,12 @@ With precise [**cable capacity**](https://www.a-eberle.de/infobrief/infobrief-20
|
||||
|
||||
Want to know which cable types are used in wind farms? Check out this article:
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
<VisualLinkPreview
|
||||
url="https://wind-turbine.com/magazin/ratgeber/250713/welche-arten-von-kabeln-benoetigt-man-fuer-den-bau-eines-windparks.html"
|
||||
title="Welche Arten von Kabeln benötigt man für den Bau eines Windparks?"
|
||||
summary="Die Verkabelung ist ein zentrales Element jeder Windkraftanlage und beeinflusst maßgeblich die Effizienz, Sicherheit und Wirtschaftlichkeit eines Windparks.…"
|
||||
image="https://wind-turbine.com/i/53689/68738caa5e58ffdf06031cf2/2/1200/630/68738c85497af_KabelfreinenWindparkpng.png"
|
||||
/>
|
||||
"
|
||||
title="Welche Arten von Kabeln benötigt man für den Bau eines Windparks?"
|
||||
summary="Die Verkabelung ist ein zentrales Element jeder Windkraftanlage und beeinflusst maßgeblich die Effizienz, Sicherheit und Wirtschaftlichkeit eines Windparks.…"
|
||||
image="https://wind-turbine.com/i/53689/68738caa5e58ffdf06031cf2/2/1200/630/68738c85497af_KabelfreinenWindparkpng.png"
|
||||
/>
|
||||
|
||||
## Challenge 2: Large delivery volumes and specialized packaging
|
||||
|
||||
@@ -109,7 +95,7 @@ A clear [**cable logistics strategy**](https://logistik-heute.de/galerien/mammut
|
||||
|
||||
Anyone who integrates **packaging, storage and labeling** early into the planning process ensures that the **wind farm cables** arrive exactly where they’re needed – with no time lost and no disruption to the construction flow.
|
||||
|
||||
<AnimatedImage src="/uploads/2025/08/NA2XSF2Y_3x1x300_RM-25_12-20kV-0.webp" alt="NA2XSF2Y Cable" width={1920} height={1080} />
|
||||
<AnimatedImage src="/uploads/2025/08/NA2XSF2X_3x1x300_RM-25_12-20kV-3.webp" alt="NA2XSF2Y Cable" width={1920} height={1080} />
|
||||
|
||||
## Challenge 3: Last-minute project changes
|
||||
|
||||
@@ -132,19 +118,12 @@ Short-term changes aren’t the exception – they’re part of everyday life in
|
||||
|
||||
Avoid delays or issues during your wind power project by understanding early on why NABU may file objections to certain sites:
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
<VisualLinkPreview
|
||||
url="https://www.nabu.de/umwelt-und-ressourcen/energie/erneuerbare-energien-energiewende/windenergie/26913.html"
|
||||
title="Wann klagt der NABU gegen Windkraftprojekte?"
|
||||
summary="45 Klagen wurden wegen Fehlplanungen bei Windenergie zwischen 2010 und 2019 vom NABU auf den Weg gebracht. Nicht weil der Windenergieausbau aufgehalten werden soll, sondern weil immer wieder Vorhaben und Planungen eklatant gegen Naturschutzrecht verstoßen."
|
||||
image="https://www.nabu.de/imperia/md/nabu/images/umwelt/energie/energietraeger/windkraft/161125-nabu-windrad-allgaeu-heidrun-burchard.jpeg"
|
||||
/>
|
||||
"
|
||||
title="Wann klagt der NABU gegen Windkraftprojekte?"
|
||||
summary="45 Klagen wurden wegen Fehlplanungen bei Windenergie zwischen 2010 und 2019 vom NABU auf den Weg gebracht. Nicht weil der Windenergieausbau aufgehalten werden soll, sondern weil immer wieder Vorhaben und Planungen eklatant gegen Naturschutzrecht verstoßen."
|
||||
image="https://www.nabu.de/imperia/md/nabu/images/umwelt/energie/energietraeger/windkraft/161125-nabu-windrad-allgaeu-heidrun-burchard.jpeg"
|
||||
/>
|
||||
|
||||
## Quality and sustainability as success factors
|
||||
|
||||
@@ -152,7 +131,7 @@ In addition to time and logistics, [**cable quality**](https://www.windkraft-jou
|
||||
|
||||
A high-quality **cable system for wind power** stands out due to several factors:
|
||||
- **Material quality:** XLPE-insulated cables like [**NA2XS(F)2Y**](/en/products/medium-voltage-cables/na2xsf2y/) or [**N2XS(F)2Y**](/en/products/medium-voltage-cables/n2xsf2y/) provide high dielectric strength and excellent long-term protection.
|
||||
- **[Standards compliance](https://www.zvei.org/fileadmin/user_upload/Presse_und_Medien/Publikationen/2017/September/ZVEI_Leitfaden_Kabel_und_Leitungen_in_Windkraftanlagen/ZVEI-Leitfaden-Kabel-und-Leitungen-in-Windkraftanlagen-September-2017.pdf):** All components used should meet key standards such as **DIN VDE 0276**, **VDE 0298**, or **IEC 60502**.
|
||||
- **Standards compliance (PDF):** [All components used should meet key standards](https://www.zvei.org/fileadmin/user_upload/Presse_und_Medien/Publikationen/2017/September/ZVEI_Leitfaden_Kabel_und_Leitungen_in_Windkraftanlagen/ZVEI-Leitfaden-Kabel-und-Leitungen-in-Windkraftanlagen-September-2017.pdf) such as **DIN VDE 0276**, **VDE 0298**, or **IEC 60502**.
|
||||
- **Ease of installation:** Cable design must allow for efficient and safe installation – even under difficult ground conditions.
|
||||
- **Environmental aspects:** Recyclable materials and the [reuse of drums or conductor materials](/de/recycling-von-kabeltrommeln-nachhaltigkeit-im-windkraftprojekt/) help reduce ecological footprint.
|
||||
|
||||
@@ -162,7 +141,7 @@ The combination of **technical quality**, **ecological responsibility**, and **e
|
||||
|
||||
[Find out here which cables are suitable for your wind farm project and what makes the difference between low and high voltage options.](/de/welche-kabel-fuer-windkraft-unterschiede-von-nieder-bis-hoechstspannung-erklaert/)
|
||||
|
||||
<AnimatedImage src="/uploads/2025/01/green-alternative-eco-friendly-energy-windmill-tu-2023-11-27-05-10-51-utc-scaled.webp" alt="Wind farm landscape" width={2560} height={1707} />
|
||||
<AnimatedImage src="/uploads/2025/01/offshore-wind-power-and-energy-farm-with-many-wind-2023-11-27-04-51-29-utc-scaled.webp" alt="Wind farm landscape" width={2560} height={1707} />
|
||||
|
||||
## Conclusion: Successfully connected to the grid
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,12 +1,11 @@
|
||||
---
|
||||
title: Terms – Deutsch
|
||||
excerpt: Liefer- und Zahlungsbedingungen der KLZ Vertriebs GmbH
|
||||
title: AGB
|
||||
excerpt: Liefer- und Zahlungsbedingungen der KLZ Vertriebs GmbH.
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Liefer- und Zahlungsbedingungen
|
||||
|
||||
Stand November 2024
|
||||
*Stand Januar 2026*
|
||||
|
||||
## 1. Allgemeines
|
||||
|
||||
@@ -22,13 +21,12 @@ Sofern nicht ausdrücklich als bindend bezeichnet, sind unsere Angebote freiblei
|
||||
|
||||
## 3. Preise
|
||||
|
||||
Alle von uns genannten Preise verstehen sich zzgl. der jeweiligen gesetzlichen Mehrwertsteuer vor Metallzuschlag fracht- frei innerhalb der Bundesrepublik Deutschland (Festland), jedoch ohne Abladen. Die Verkaufspreise, soweit sie als Hohlpreis deklariert sind, enthalten keinerlei Metallwerte. Diese werden zusätzlich separat berechnet.
|
||||
Die Preise gelten für den in unseren Angeboten und Auftragsbestätigungen aufgeführten Leistungs- und Lieferumfang. Mehrleistungen werden gesondert berechnet. Die Hohlpreise verstehen sich in Euro zuzüglich Metallzuschlag, gegebenenfalls Verpackung, auftragsspezifischer Schnittkosten und der gesetzlichen Mehrwertsteuer.
|
||||
|
||||
## 4. Metallnotierung
|
||||
|
||||
Basis zur Kupferabrechnung ist die Notierung "LME Copper official price cash offer", Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
|
||||
|
||||
Basis zur Aluminiumabrechnung ist die Notierung "LME Aluminium official price cash offer", Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite www.westmetall.com entnehmen. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
Basis zur Kupferabrechnung ist die Notierung „LME Copper official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
|
||||
Basis zur Aluminiumabrechnung ist die Notierung „LME Aluminium official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite [www.westmetall.com](https://www.westmetall.com) entnehmen. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
|
||||
## 5. Metallzahl
|
||||
|
||||
@@ -44,7 +42,7 @@ Wir behalten uns an den von uns gelieferten Waren – nachfolgend: Vorbehaltswar
|
||||
|
||||
## 8. Zahlungsbedingungen | Aufrechnung | Zurückbehaltungsrechte
|
||||
|
||||
Unsere Rechnungen sind 14 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
|
||||
Unsere Rechnungen sind 10 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Rechnungsstellung bzw. Datum ist grundsätzlich der Tag der Übergabe an den Spediteur soweit wir aus unseren deutschen Lägern liefern. Ansonsten gilt bei Direktimporten der Tag der Verzollung, der zeitnah zum Anliefertag liegt. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
|
||||
|
||||
## 9. Liefervorbehalt | Teillieferungen
|
||||
|
||||
@@ -58,7 +56,7 @@ Der Eintritt unseres Lieferverzugs bestimmt sich nach den gesetzlichen Vorschrif
|
||||
|
||||
Die gesetzlichen Rechte bleiben im Übrigen unberührt.
|
||||
|
||||
Fixgeschäfte setzen die ausdrückliche schriftliche Bezeichnung als solche voraus. Ansonsten ist der Besteller stets verpflichtet, uns schriftlich eine angemessene Nachfrist zu setzen, wenn von uns zugesagte Termine und/ oder Fristen nicht eingehalten werden. Wird auch die Nachfrist nicht eingehalten, ist der Besteller berechtigt, vom Vertrag zurückzutreten.
|
||||
Fixgeschäfte setzen die ausdrückliche schriftliche Bezeichnung als solche voraus. Ansonsten ist der Besteller stets verpflichtet, uns schriftlich eine angemessene Nachfrist zu setzen, wenn von uns zugesagte Termine und/ oder Fristen nicht einhalten werden. Wird auch die Nachfrist nicht eingehalten, ist der Besteller berechtigt, vom Vertrag zurückzutreten.
|
||||
|
||||
Im Fall höherer Gewalt und/oder sonstiger von uns nicht vorhersehbarer außergewöhnlicher und/oder unverschuldeter Umstände, auch wenn sie bei unserem Vorlieferanten eintreten, verlängert sich eine von uns zugesagte Lieferfrist bis zur Behebung des vorerwähnten Ereignisses. Ist dieser Zeitpunkt nicht überblickbar, sind sowohl der Besteller als auch wir berechtigt, von dem abgeschlossenen Vertrag zurückzutreten. In diesem Fall sind beiderseits Schadensersatzansprüche ausgeschlossen. Wir verpflichten uns, bei Bekanntwerden vorerwähnter Umstände den Besteller hiervon unverzüglich zu benachrichtigen.
|
||||
|
||||
@@ -70,13 +68,13 @@ Wird uns ein Abrufauftrag erteilt und werden über die Abruftermine keine gesond
|
||||
|
||||
## 12. Maß- und Gewichtsangaben
|
||||
|
||||
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lassen, begründen keine Mängelhaftungsansprüche des Bestellers.
|
||||
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lässt, begründen keine Mängelhaftungsansprüche des Bestellers.
|
||||
|
||||
## 13. Gefahrübergang und -tragung
|
||||
|
||||
Die Lieferung erfolgt DAP frei Bestimmungsort Deutschland, wo auch der Erfüllungsort für die Lieferung und eine etwaige Nacherfüllung ist.
|
||||
|
||||
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H.v 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. – mangels einer Lieferfrist – mit der Mitteilung der Versandbereitschaft der Ware.
|
||||
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen für Einlagerungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H. von 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. – mangels einer Lieferfrist – mit der Mitteilung der Versandbereitschaft der Ware.
|
||||
|
||||
Der Nachweis eines höheren Schadens und unsere gesetzlichen Ansprüche (insbesondere Ersatz von Mehraufwendungen, angemessene Entschädigung, Kündigung) bleiben unberührt; die Pauschale ist aber auf weitergehende Geldansprüche anzurechnen. Dem Besteller bleibt der Nachweis gestattet, dass uns überhaupt kein oder nur ein wesentlich geringerer Schaden als vorstehende Pauschale entstanden ist. Rücksendungen an uns, die nicht vorher von uns schriftlich bestätigt worden sind, erfolgen auf alleinige Gefahr des Bestellers.
|
||||
|
||||
@@ -94,7 +92,9 @@ Weitergehende Ansprüche des Bestellers, gleich aus welchem Rechtsgrund, sind na
|
||||
|
||||
Die Verjährungsfristen für Mängelhaftungsansprüche beträgt 24 Monate ab Übergabe der Ware.
|
||||
|
||||
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht. Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Graben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein.
|
||||
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht.
|
||||
|
||||
Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Gräben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein. Dies gilt auch bei der Annahme der Ware, wo offensichtliche Beschädigungen direkt zu kommunizieren sind. Spätere Ansprüche nach Akzeptanz einer einwandfreien Belieferung sind detailliert zu beweisen.
|
||||
|
||||
## 15. Schadenersatz | Gesamthaftung
|
||||
|
||||
@@ -110,4 +110,6 @@ Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschlus
|
||||
|
||||
Mit der Veröffentlichung der vorliegenden L&Z im Internet werden alle von uns früher verwendeten Bedingungen gegenstandslos.
|
||||
|
||||
Remshalden, 28.1.2026
|
||||
|
||||
[Download als PDF](/AGB-KLZ-1-2026.pdf)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: Thanks – Deutsch
|
||||
excerpt: '[vc_column…'
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Thanks – Deutsch
|
||||
|
||||
<div class="flex-shrink-0 flex flex-col relative items-end">
|
||||
<div>
|
||||
<div class="pt-0">
|
||||
<div class="gizmo-bot-avatar flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<h2 class="relative p-1 rounded-sm flex items-center justify-center bg-token-main-surface-primary text-token-text-primary h-8 w-8">Vielen Dank!</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
|
||||
<div class="flex-col gap-1 md:gap-3">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="f524f802-9a51-4037-b74f-9dc5f97ba9ca" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen. Unser Team ist bereits startklar, um Ihnen weiterzuhelfen!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
JTNDJTIxLS0lMjBHb29nbGUlMjB0YWclMjAlMjhndGFnLmpzJTI5JTIwLS0lM0UlMjAlM0NzY3JpcHQlMjBhc3luYyUyMHNyYyUzRCUyMmh0dHBzJTNBJTJGJTJGd3d3Lmdvb2dsZXRhZ21hbmFnZXIuY29tJTJGZ3RhZyUyRmpzJTNGaWQlM0RBVy0xNzA5NTg5MjIzOCUyMiUzRSUzQyUyRnNjcmlwdCUzRSUyMCUzQ3NjcmlwdCUzRSUyMHdpbmRvdy5kYXRhTGF5ZXIlMjAlM0QlMjB3aW5kb3cuZGF0YUxheWVyJTIwJTdDJTdDJTIwJTVCJTVEJTNCJTIwZnVuY3Rpb24lMjBndGFnJTI4JTI5JTdCZGF0YUxheWVyLnB1c2glMjhhcmd1bWVudHMlMjklM0IlN0QlMjBndGFnJTI4JTI3anMlMjclMkMlMjBuZXclMjBEYXRlJTI4JTI5JTI5JTNCJTIwZ3RhZyUyOCUyN2NvbmZpZyUyNyUyQyUyMCUyN0FXLTE3MDk1ODkyMjM4JTI3JTI5JTNCJTIwJTNDJTJGc2NyaXB0JTNF
|
||||
@@ -1,53 +1,59 @@
|
||||
---
|
||||
title: Privacy Policy – Deutsch
|
||||
excerpt: >-
|
||||
[vc_column column_padding=”no-extra-padding”
|
||||
column_padding_tablet=”inherit”
|
||||
column_padding_phone=”inherit”
|
||||
column_padding_position=”all”
|
||||
column_element_direction_desktop=”default”
|
||||
column_element_spacing=”default”
|
||||
desktop_text_alignment=”default”
|
||||
tablet_text_alignment=”default”
|
||||
phone_text_alignment=”default”
|
||||
background_color_opacity=”1″
|
||||
background_hover_color_opacity=”1″
|
||||
column_backdrop_filter=”none”
|
||||
column_shadow=”none”…
|
||||
title: Datenschutzerklärung
|
||||
excerpt: Informationen zum Umgang mit Ihren persönlichen Daten bei der KLZ Vertriebs GmbH.
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Privacy Policy – Deutsch
|
||||
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
<h3>1. Datenschutz auf einen Blick</h3>
|
||||
<p><strong>Allgemeine Hinweise</strong><br />
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie unsere Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können. Ausführliche Informationen zum Thema Datenschutz entnehmen Sie unserer unten aufgeführten Datenschutzerklärung.</p>
|
||||
<hr />
|
||||
<h3>2. Allgemeine Hinweise und Pflichtinformationen</h3>
|
||||
<p><strong>Datenschutz</strong><br />
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.</p>
|
||||
<p>Wenn Sie diese Website nutzen, werden verschiedene personenbezogene Daten erhoben. Personenbezogene Daten sind Daten, mit denen Sie persönlich identifiziert werden können. Diese Datenschutzerklärung erläutert, welche Daten wir erheben und wofür wir sie nutzen. Sie erläutert auch, wie und zu welchem Zweck dies geschieht.</p>
|
||||
<p>Wir weisen darauf hin, dass die Datenübertragung im Internet (z. B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.</p>
|
||||
<hr />
|
||||
<p><strong>Widerruf Ihrer Einwilligung zur Datenverarbeitung</strong><br />
|
||||
Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung möglich. Sie können eine bereits erteilte Einwilligung jederzeit widerrufen. Dazu reicht eine formlose Mitteilung per E-Mail an uns. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.</p>
|
||||
<hr />
|
||||
<p><strong>Beschwerderecht bei der zuständigen Aufsichtsbehörde</strong><br />
|
||||
Im Falle von Verstößen gegen das Datenschutzrecht steht dem Betroffenen ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu. Die zuständige Aufsichtsbehörde für Datenschutzfragen ist der Datenschutzbeauftragte des Bundeslandes, in dem unser Unternehmen seinen Sitz hat. Eine Liste der Datenschutzbeauftragten sowie deren Kontaktdaten können Sie folgendem Link entnehmen:<br />
|
||||
<a href="https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html" target="_new" rel="noopener noreferrer nofollow" target="_blank">https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html</a>.</p>
|
||||
<hr />
|
||||
<p><strong>Recht auf Datenübertragbarkeit</strong><br />
|
||||
Sie haben das Recht, Daten, die wir auf Grundlage Ihrer Einwilligung oder zur Erfüllung eines Vertrags automatisiert verarbeiten, an sich oder an einen Dritten in einem gängigen, maschinenlesbaren Format aushändigen zu lassen. Sofern Sie die direkte Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt dies nur, soweit es technisch machbar ist.</p>
|
||||
<hr />
|
||||
<p><strong>Auskunft, Sperrung, Löschung</strong><br />
|
||||
Im Rahmen der geltenden gesetzlichen Bestimmungen haben Sie jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft und Empfänger sowie den Zweck der Datenverarbeitung. Gegebenenfalls haben Sie ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Hierzu sowie zu weiteren Fragen zum Thema personenbezogene Daten können Sie sich jederzeit unter der im Impressum angegebenen Adresse an uns wenden.</p>
|
||||
<hr />
|
||||
<p><strong>Widerspruch gegen Werbe-E-Mails</strong><br />
|
||||
Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-E-Mails, vor.</p>
|
||||
<hr />
|
||||
<h3>3. Datenerfassung in unserem Unternehmen</h3>
|
||||
<p><strong>Datenübermittlung bei Vertragsschluss für Dienstleistungen und digitale Inhalte</strong><br />
|
||||
Wir übermitteln personenbezogene Daten an Dritte nur dann, wenn dies im Rahmen der Vertragsabwicklung notwendig ist, z. B. an das mit der Zahlungsabwicklung beauftragte Kreditinstitut.</p>
|
||||
<p>Eine weitergehende Übermittlung der Daten erfolgt nicht bzw. nur dann, wenn Sie der Übermittlung ausdrücklich zugestimmt haben. Eine Weitergabe Ihrer Daten an Dritte ohne ausdrückliche Einwilligung, etwa zu Werbezwecken, erfolgt nicht.</p>
|
||||
<p>Rechtsgrundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. b DSGVO, der die Verarbeitung von Daten zur Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen gestattet.
|
||||
## 1. Datenschutz auf einen Blick
|
||||
|
||||
### Allgemeine Hinweise
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie unsere Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können. Ausführliche Informationen zum Thema Datenschutz entnehmen Sie unserer unten aufgeführten Datenschutzerklärung.
|
||||
|
||||
---
|
||||
|
||||
## 2. Allgemeine Hinweise und Pflichtinformationen
|
||||
|
||||
### Datenschutz
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||
|
||||
Wenn Sie diese Website nutzen, werden verschiedene personenbezogene Daten erhoben. Personenbezogene Daten sind Daten, mit denen Sie persönlich identifiziert werden können. Diese Datenschutzerklärung erläutert, welche Daten wir erheben und wofür wir sie nutzen. Sie erläutert auch, wie und zu welchem Zweck dies geschieht.
|
||||
|
||||
Wir weisen darauf hin, dass die Datenübertragung im Internet (z. B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
|
||||
|
||||
---
|
||||
|
||||
### Widerruf Ihrer Einwilligung zur Datenverarbeitung
|
||||
Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung möglich. Sie können eine bereits erteilte Einwilligung jederzeit widerrufen. Dazu reicht eine formlose Mitteilung per E-Mail an uns. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.
|
||||
|
||||
---
|
||||
|
||||
### Beschwerderecht bei der zuständigen Aufsichtsbehörde
|
||||
Im Falle von Verstößen gegen das Datenschutzrecht steht dem Betroffenen ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu. Die zuständige Aufsichtsbehörde für Datenschutzfragen ist der Datenschutzbeauftragte des Bundeslandes, in dem unser Unternehmen seinen Sitz hat. Eine Liste der Datenschutzbeauftragten sowie deren Kontaktdaten können Sie folgendem Link entnehmen:
|
||||
[https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html](https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html).
|
||||
|
||||
---
|
||||
|
||||
### Recht auf Datenübertragbarkeit
|
||||
Sie haben das Recht, Daten, die wir auf Grundlage Ihrer Einwilligung oder zur Erfüllung eines Vertrags automatisiert verarbeiten, an sich oder an einen Dritten in einem gängigen, maschinenlesbaren Format aushändigen zu lassen. Sofern Sie die direkte Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt dies nur, soweit es technisch machbar ist.
|
||||
|
||||
---
|
||||
|
||||
### Auskunft, Sperrung, Löschung
|
||||
Im Rahmen der geltenden gesetzlichen Bestimmungen haben Sie jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft und Empfänger sowie den Zweck der Datenverarbeitung. Gegebenenfalls haben Sie ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Hierzu sowie zu weiteren Fragen zum Thema personenbezogene Daten können Sie sich jederzeit unter der im Impressum angegebenen Adresse an uns wenden.
|
||||
|
||||
---
|
||||
|
||||
### Widerspruch gegen Werbe-E-Mails
|
||||
Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-E-Mails, vor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenerfassung in unserem Unternehmen
|
||||
|
||||
### Datenübermittlung bei Vertragsschluss für Dienstleistungen und digitale Inhalte
|
||||
Wir übermitteln personenbezogene Daten an Dritte nur dann, wenn dies im Rahmen der Vertragsabwicklung notwendig ist, z. B. an das mit der Zahlungsabwicklung beauftragte Kreditinstitut.
|
||||
|
||||
Eine weitergehende Übermittlung der Daten erfolgt nicht bzw. nur dann, wenn Sie der Übermittlung ausdrücklich zugestimmt haben. Eine Weitergabe Ihrer Daten an Dritte ohne ausdrückliche Einwilligung, etwa zu Werbezwecken, erfolgt nicht.
|
||||
|
||||
Rechtsgrundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. b DSGVO, der die Verarbeitung von Daten zur Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen gestattet.
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
---
|
||||
title: Legal Notice – Deutsch
|
||||
excerpt: >-
|
||||
[vc_column column_padding=”no-extra-padding”
|
||||
column_padding_tablet=”inherit”
|
||||
column_padding_phone=”inherit”
|
||||
column_padding_position=”all”
|
||||
column_element_direction_desktop=”default”
|
||||
column_element_spacing=”default”
|
||||
desktop_text_alignment=”default”
|
||||
tablet_text_alignment=”default”
|
||||
phone_text_alignment=”default”
|
||||
background_color_opacity=”1″
|
||||
background_hover_color_opacity=”1″
|
||||
column_backdrop_filter=”none”
|
||||
column_shadow=”none”…
|
||||
title: Impressum
|
||||
excerpt: Rechtliche Informationen und Kontaktdaten der KLZ Vertriebs GmbH.
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Legal Notice – Deutsch
|
||||
|
||||
<h1>Impressum</h1>
|
||||
<p><strong>Verantwortlich für den Inhalt:</strong><br />
|
||||
Michael Bodemer</p>
|
||||
<p><strong>KLZ Vertriebs GmbH</strong><br />
|
||||
Raiffeisenstraße 22<br />
|
||||
73630 Remshalden</p>
|
||||
<p><a rel="noopener">info@klz-cables.com</a><br />
|
||||
<a href="http://www.klz-cables.com" target="_new" rel="noopener noreferrer nofollow" target="_blank">www.klz-cables.com</a></p>
|
||||
<p>Amtsgericht Stuttgart<br />
|
||||
HRB-Nr. 798037<br />
|
||||
Gerichtsstand: Stuttgart</p>
|
||||
<p><strong>Urheberrecht:</strong><br />
|
||||
## Verantwortlich für den Inhalt
|
||||
Michael Bodemer
|
||||
|
||||
**KLZ Vertriebs GmbH**
|
||||
Raiffeisenstraße 22
|
||||
73630 Remshalden
|
||||
Deutschland
|
||||
|
||||
## Kontakt
|
||||
E-Mail: [info@klz-cables.com](mailto:info@klz-cables.com)
|
||||
Web: [www.klz-cables.com](https://www.klz-cables.com)
|
||||
|
||||
## Registereintrag
|
||||
Amtsgericht Stuttgart
|
||||
HRB-Nr. 798037
|
||||
Gerichtsstand: Stuttgart
|
||||
|
||||
## Urheberrecht
|
||||
Alle auf dieser Website veröffentlichten Texte, Bilder und sonstigen Informationen unterliegen dem Urheberrecht, sofern nicht anders angegeben. Jegliche Vervielfältigung, Verbreitung, Speicherung, Übermittlung, Nachbildung oder Weitergabe der Inhalte ist ohne vorherige schriftliche Genehmigung ausdrücklich untersagt. Für weitere Informationen wenden Sie sich bitte an die oben genannte Adresse.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
title: Contact – Deutsch
|
||||
excerpt: '[vc_column column_padding=”no-extra-padding”…'
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Contact – Deutsch
|
||||
|
||||
<h5>Wie können wir Ihnen helfen?</h5>
|
||||
<h2>Schwebt Ihnen bereits ein Projekt vor?</h2>
|
||||
|
||||
<p style="text-align: left;"><div class="frm_forms with_frm_style frm_style_klz" id="frm_form_2_container" data-token="1bab1f2ae16527f65d9f48545407888d">
|
||||
<form enctype="multipart/form-data" method="post" class="frm-show-form frm_pro_form " id="form_contact-deutsch" data-token="1bab1f2ae16527f65d9f48545407888d">
|
||||
<div class="frm_form_fields ">
|
||||
<fieldset>
|
||||
<legend class="frm_screen_reader">Contact Us - Deutsch</legend>
|
||||
|
||||
<div class="frm_fields_container">
|
||||
<input type="hidden" name="frm_action" value="create" />
|
||||
<input type="hidden" name="form_id" value="2" />
|
||||
<input type="hidden" name="frm_hide_fields_2" id="frm_hide_fields_2" value="" />
|
||||
<input type="hidden" name="form_key" value="contact-deutsch" />
|
||||
<input type="hidden" name="item_meta[0]" value="" />
|
||||
<input type="hidden" id="frm_submit_entry_2" name="frm_submit_entry_2" value="2aee9616df" /><input type="hidden" name="_wp_http_referer" value="/wp-json/wp/v2/pages?per_page=100&page=1&_embed=true" /><div id="frm_field_8_container" class="frm_form_field form-field frm_required_field frm_top_container frm_first frm_half">
|
||||
<label for="field_qh4icy2" id="field_qh4icy2_label" class="frm_primary_label">Name
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_qh4icy2" name="item_meta[8]" value="" data-reqmsg="Name cannot be blank." aria-required="true" data-invmsg="Name is invalid" aria-invalid="false" aria-describedby="frm_desc_field_qh4icy2" />
|
||||
<div class="frm_description" id="frm_desc_field_qh4icy2">First Name</div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_9_container" class="frm_form_field form-field frm_required_field frm_hidden_container frm_half">
|
||||
<label for="field_ocfup12" id="field_ocfup12_label" class="frm_primary_label">Nachname
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_ocfup12" name="item_meta[9]" value="" data-reqmsg="Nachname cannot be blank." aria-required="true" data-invmsg="Nachname is invalid" aria-invalid="false" aria-describedby="frm_desc_field_ocfup12" />
|
||||
<div class="frm_description" id="frm_desc_field_ocfup12">Last Name</div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_10_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_29yf4d2" id="field_29yf4d2_label" class="frm_primary_label">E-Mail
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="email" id="field_29yf4d2" name="item_meta[10]" value="" data-reqmsg="E-Mail cannot be blank." aria-required="true" data-invmsg="Please enter a valid email address" aria-invalid="false" />
|
||||
|
||||
</div>
|
||||
<div id="frm_field_11_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_e6lis62" id="field_e6lis62_label" class="frm_primary_label">Betreff
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_e6lis62" name="item_meta[11]" value="" data-reqmsg="Betreff cannot be blank." aria-required="true" data-invmsg="Betreff is invalid" aria-invalid="false" />
|
||||
|
||||
</div>
|
||||
<div id="frm_field_12_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_9jv0r12" id="field_9jv0r12_label" class="frm_primary_label">Nachricht
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<textarea name="item_meta[12]" id="field_9jv0r12" rows="5" data-reqmsg="Nachricht cannot be blank." aria-required="true" data-invmsg="Nachricht is invalid" aria-invalid="false" ></textarea>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_15_container" class="frm_form_field form-field frm_none_container">
|
||||
<label for="g-recaptcha-response" id="field_fvtwy_label" class="frm_primary_label">Captcha
|
||||
<span class="frm_required" aria-hidden="true"></span>
|
||||
</label>
|
||||
<div id="field_fvtwy" class="frm-g-recaptcha" data-sitekey="6LczZ7wqAAAAANwlgLaISgENVDZ1rTPe6LnTJgEk" data-size="invisible" data-theme="light"></div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_13_container" class="frm_form_field form-field ">
|
||||
<div class="frm_submit frm_flex">
|
||||
<button class="frm_button_submit frm_final_submit" type="submit" formnovalidate="formnovalidate">Senden</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="item_key" value="" />
|
||||
<div id="frm_field_27_container">
|
||||
<label for="field_eeiza" >
|
||||
If you are human, leave this field blank. </label>
|
||||
<input id="field_eeiza" type="text" class="frm_form_field form-field frm_verify" name="item_meta[27]" value="" />
|
||||
</div>
|
||||
<input name="frm_state" type="hidden" value="LzUMsLqCGT3RC/3NDZwBs8xiLSRtku+v3mS6FD5Px53CgMo7ngsrjnaIkQVSZFX3" /></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
KLZ Cables<br />
|
||||
Raiffeisenstraße 22<br />
|
||||
73630 Remshalden
|
||||
@@ -1,212 +0,0 @@
|
||||
---
|
||||
title: Home – Deutsch
|
||||
excerpt: >-
|
||||
[vc_row type=”full_width_background”
|
||||
full_screen_row_position=”middle”
|
||||
column_margin=”default” equal_height=”yes”
|
||||
content_placement=”bottom” column_direction=”default”
|
||||
column_direction_tablet=”default”
|
||||
column_direction_phone=”default” bg_color=”#d1d1ca”
|
||||
bg_image=”45569″ bg_position=”center bottom”
|
||||
background_image_loading=”lazy-load”
|
||||
bg_repeat=”no-repeat” video_bg=”use_video”
|
||||
video_mp4=”/uploads/2025/02/header.mp4″
|
||||
video_webm=”/uploads/2025/02/header.webm”
|
||||
background_video_loading=”lazy-load”
|
||||
scene_position=”center” top_padding=”15%”
|
||||
bottom_padding=”13%” text_color=”light”
|
||||
text_align=”left” row_border_radius=”none”
|
||||
row_border_radius_applies=”bg” overflow=”visible”
|
||||
enable_gradient=”true”
|
||||
color_overlay=”rgba(0,0,0,0.01)”
|
||||
color_overlay_2=”rgba(0,0,0,0.32)”…
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Home – Deutsch
|
||||
|
||||
<h1><strong>Wir tragen zum Ausbau der Energiekabelnetze für eine <em>grüne</em> Zukunft bei</strong></h1>
|
||||
|
||||
<h4>Niederspannung</h4>
|
||||
<p>Zuverlässige und sichere Stromversorgung für den Alltag.
|
||||
<h4>Mittelspannung</h4>
|
||||
<p>Das perfekte Gleichgewicht zwischen Kraft und Leistung für industrielle und städtische Netze.
|
||||
<h4>Hochspannung</h4>
|
||||
<p>Maximale Leistung über große Entfernungen – ohne Kompromisse.
|
||||
<h4>Solar Cables</h4>
|
||||
<p>Connecting the sun’s energy to your sustainable future.[fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6486″ image_url=”6521″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Zuverlässige und sichere Stromversorgung für den Alltag.” link_url=”/de/stromkabel/niederspannungskabel/”]
|
||||
<h3>Niederspannung</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6487″ image_url=”6517″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”” min_height=”500″ hover_content=”Das perfekte Gleichgewicht zwischen Kraft und Leistung für industrielle und städtische Netze.” link_url=”/de/stromkabel/mittelspannungskabel/”]
|
||||
<h3>Mittelspannung</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6485″ image_url=”6527″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Maximale Leistung über große Entfernungen – ohne Kompromisse.” link_url=”/de/stromkabel/hochspannungskabel/”]
|
||||
<h3>Hochspannung</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6484″ image_url=”6519″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Verbindet die Energie der Sonne mit einer nachhaltigen Zukunft.” link_url=”/de/solarkabel”]
|
||||
<h3>Solar</h3>
|
||||
[/fancy_box]
|
||||
<h3>Was wir machen</h3>
|
||||
Wir sorgen dafür, dass der Strom fließt – mit qualitätsgeprüften Kabeln. Von Niederspannung bis zur Hochspannung
|
||||
<h6>01</h6>
|
||||
|
||||
<h4>Belieferung von Energieversorgen, Wind- und Solarparks, Industrie und Handel</h4>
|
||||
Wir begleiten Ihre Projekte von 1 bis 220 kV, vom simplem <strong>NYY</strong> bis hin zum Hochspannungskabel mit Segmentleiter und Aluminium-Mantel, und der Schwerpunkt Mittelspannungskabel besonders hervorgehoben. Ob <strong>NA2XS(F)2Y</strong> in Standardausführung, oder mal bis zu 1200 mm2 Querschnitt, mit dickem Mantel oder in gewünschten Passlängen. Wir haben Partner mit ungeheurer Vielfalt.
|
||||
<h6>02</h6>
|
||||
|
||||
<h4>Lieferung von Kabeln, deren Qualität zertifiziert ist</h4>
|
||||
Kabel sind Produkte, die 100% funktionieren müssen. Jahrzehnte, oft 80 bis 100 Jahre. Unsere Kabel haben nicht nur die Approbation durch VDE. Die namhaftesten Energieversorger in Deutschland, den Niederlanden und in Österreich vertrauen uns und unseren Herstellern. Und oft liegen die Anforderungen noch über denen der schon strengen Vorschriften der VDE.
|
||||
<h6>03</h6>
|
||||
|
||||
<h4>Wir liefern pünktlich, denn wir kennen die Konsequenz für Sie</h4>
|
||||
Windpark Norddeutschland, Koordinaten XYZ, Anlieferung Mittwoch 14-16 Uhr, keine Ablademöglichkeit. Ja, das kennen wir. Wir organisieren die Logistik mit einem Backoffice-Team, was bis zu 20 Jahre Kabelerfahrung hat. Verzollung und ordentliche Papierabwicklung inklusive.
|
||||
<h6>04</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="06bd4556-b30a-464e-a28a-e8865e9dd302" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Das Kabel allein ist noch nicht die Lösung</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="6f143441-86ad-449a-a14e-67511b818d06" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Steiniger Boden? Besser vielleicht einen dickeren Außenmantel? Feuchter Boden? Darf es einen querwasserdichten Schutz noch zusätzlich zum längswasserdichten Band geben? Längere Einzellängen, aber nicht an die Limitierung des Verlegungskran gedacht? Oder oft unterschätzt? Was trägt denn der Boden im Lager. Ein Kupfer-Kabel wiegt gerne schon mal 10 Tonnen pro Kilometer. Wir denken für Sie mit und fragen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="1dd27af8-cd3b-409c-9f01-e578c14f4e43" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h3><strong>Jahrzehntelange Kabelkompetenz mit Tradition</strong></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Bei KLZ fließt Kabelgeschichte durch unsere Adern. Klaus begann seine Laufbahn bei der renommierten Felten & Guilleaume – in den Fußstapfen seiner Eltern, die ihr Leben derselben ikonischen Firma widmeten. Für Klaus ist das mehr als nur ein Beruf – es ist ein Erbe, das auf Handwerkskunst, Innovation und Stolz aufbaut.</p>
|
||||
<p>Wir ehren diese Geschichte mit originalen Illustrationen aus der Ära von Felten & Guilleaume, die einst als Postkarten verwendet wurden. Diese Bilder erinnern uns an die Generationen, die die Welt miteinander verbunden haben – eine Tradition, die wir mit Stolz fortführen.
|
||||
<h3>Warum Sie uns wählen sollten</h3>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="a3eafd75-c9c1-458b-ad89-f34df883f8e5" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Erfahrung verhindert zwar viele Fehler, aber wir lernen jeden Tag dazu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>01</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="c27756df-b62e-4794-b493-89d6b740edd6" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Fachkompetenz mit Tiefgang</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="b38876c8-b795-4cb4-be1e-2cd1ea9807b5" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Unser Team bringt jahrzehntelange Erfahrung mit – weit über die Gründung von KLZ im Jahr 2009 hinaus. Im Gesamtteam haben wir über 100 Jahre Kabelerfahrung, gesammelt in verschiedensten Werken, von Niederspannung, über Mittelspannung, bis zur Hochspannung. Wir wissen, wie Kabel riechen, was der Kollege an der Schirmmaschine zu verantworten hat, wie getestet wird. Wir kennen die wesentlichen Rohstoffhersteller, kennen die Risiken einer Fertigung, und können Werke vergleichen. Ob in alten oder neuen Gebäuden. Wer Jahrzehnte Audits und Präqualifikationen hinter sich hat, der weiß, wo er schauen muss. Und was die richtigen Fragen sind.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>02</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="ee8e3a2a-6dbb-4936-aa24-a2a489900578" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Maßgeschneiderte Lösungen für Ihr Projekt</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="e68fb13a-070e-458e-9a18-b0adb60ab50b" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Wenn es komplexer wird, binden wir unsere technischen Berater ein. Da braucht man Fachleute, die nicht gerade ihre Karriere gestartet haben. Da braucht es Leute die Normen lesen und verstehen, und manchmal mit begleitet haben. Die haben wir, und mit deren und unserer Erfahrung differenzieren wir uns vom einfachen Handel mit Kabeln</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>03</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="c714857c-00c3-44e4-98b9-e39eb7384fab" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Zuverlässigkeit, die Ihre Projekte auf Kurs hält</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="dca855b3-88c2-472f-92f9-1f9490411197" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Erreichbarkeit, schnell reagieren in einer schnelllebigen Welt. Sie haben noch Fragen nach 17 Uhr? Oder am Wochenende? Wir sind immer da. Und so haben wir unsere Partner entwickelt, damit wir als Team das realisieren, wofür Sie bezahlt haben. Und wenn mal doch was nicht gerade läuft, versteckt sich keiner.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>04</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="1f8b2646-94f8-437d-b199-8b99c9f4b45d" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Nachhaltigkeit ohne Kompromisse</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="50b94e59-2920-4a97-b8b4-daf5f83404bd" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Wir sind überzeugt davon, die Welt besser zu hinterlassen, als wir sie vorgefunden haben. Mit Initiativen wie unserem Trommelrückführungsservice und einem klaren Fokus auf Recycling sorgen wir dafür, dass jede Verbindung so umweltfreundlich wie möglich ist. Jeder unserer Partner hat entsprechende Zertifizierungen, die zunehmend von allen Kunden auch erwartet werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex flex-col relative items-end">
|
||||
<div class="pt-0">
|
||||
<div class="gizmo-shadow-stroke flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<div class="h-full w-full">
|
||||
<h3><strong>Das Team hinter KLZ</strong></h3>
|
||||
<p>Bei KLZ steckt die Energie nicht nur in den Kabeln, sondern vor allem im Team. Von erfahrenen Experten wie Michael und Klaus bis hin zu engagierten Planern, Logistikern und Kundenbetreuern – jeder spielt eine entscheidende Rolle. Gemeinsam verbinden wir jahrzehntelange Erfahrung mit innovativem Denken und dem klaren Ziel, zuverlässige Energielösungen zu liefern.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="0c8817e4-3d8c-41b1-9223-0468ae5ddd01" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2 style="text-align: center;">Vom einzelnen Draht zur grenzenlosen Energie – die <em>Zukunft</em> beginnt hier.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
title: Team – Deutsch
|
||||
excerpt: >-
|
||||
[vc_row type=”full_width_background”
|
||||
full_screen_row_position=”middle”
|
||||
column_margin=”default” column_direction=”default”
|
||||
column_direction_tablet=”default”
|
||||
column_direction_phone=”default” bg_color=”#ffffff”
|
||||
bg_image=”10440″ bg_position=”center center”
|
||||
background_image_loading=”default”
|
||||
bg_repeat=”no-repeat” scene_position=”center”
|
||||
top_padding=”14%” bottom_padding=”12%”
|
||||
text_color=”light” text_align=”left”
|
||||
row_border_radius=”none”
|
||||
row_border_radius_applies=”bg” overflow=”visible”
|
||||
enable_gradient=”true” color_overlay=”#0a0000″
|
||||
color_overlay_2=”rgba(10,10,10,0.5)”
|
||||
overlay_strength=”0.8″
|
||||
gradient_direction=”left_to_right”
|
||||
shape_divider_color=”#ffffff”
|
||||
shape_divider_position=”bottom”
|
||||
shape_divider_height=”350″
|
||||
bg_image_animation=”none”…
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Team – Deutsch
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="c62f4969-7567-4dbf-99d2-97426de29e09" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p><strong>Die Köpfe, die Energie zum Laufen bringen</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden @container/thread">
|
||||
<div class="h-full">
|
||||
<div class="react-scroll-to-bottom--css-jvmup-79elbk h-full">
|
||||
<div class="react-scroll-to-bottom--css-jvmup-1n7m0yu">
|
||||
<div class="flex flex-col text-sm md:pb-9">
|
||||
<article class="w-full scroll-mb-[var(--thread-trailing-height,150px)] text-token-text-primary focus-visible:outline-2 focus-visible:outline-offset-[-4px]" dir="auto" data-testid="conversation-turn-19" data-scroll-anchor="true">
|
||||
<div class="m-auto text-base py-[18px] px-3 md:px-4 w-full md:px-5 lg:px-4 xl:px-5">
|
||||
<div class="mx-auto flex flex-1 gap-4 text-base md:gap-5 lg:gap-6 md:max-w-3xl lg:max-w-[40rem] xl:max-w-[48rem]">
|
||||
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
|
||||
<div class="flex-col gap-1 md:gap-3">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="9b042263-4f19-47df-a312-d13f7eb5e2b1" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="bcfa4bbf-0457-47d6-8a1e-7ec5a650fc98" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>Wir verbinden Energie, Know-how und Innovation, um eine nachhaltigere Zukunft zu gestalten.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Michael Bodemer</h1>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="66eb3f45-dd35-419f-8c8e-be50fee94d71" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>Herausforderungen sind da, um gelöst zu werden – nicht, um über ihre Komplexität zu diskutieren.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="637cd8c0-70ac-4835-b453-5f50c9c188eb" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Michael Bodemer ist unser Mann, wenn es kompliziert wird – und das ist bei Kabelnetzen oft der Fall. Mit seinem scharfen Blick und einem Händchen für praktikable Lösungen ist er eine unserer zentralen Säulen. Michael denkt nicht nur an Details, er treibt Projekte voran – sei es in der Planung, im Kundengespräch oder bei der Auswahl der besten Kabel für jedes Vorhaben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/vcf/michael-bodemer" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 no-underline">
|
||||
vCard Michael Bodemer herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3>Verbindungen, die Geschichte schreiben</h3>
|
||||
<p>Bei KLZ vereinen wir Tradition und Innovation zu zuverlässigen Energielösungen. Unsere Wurzeln reichen tief in die Geschichte der Kabeltechnologie zurück – mit jeder Menge praktischer Erfahrung und einem Blick für zukunftsweisende Entwicklungen.</p>
|
||||
<p>In jedem Projekt steckt nicht nur technisches Know-how, sondern auch das Bewusstsein für das Handwerk, das die Welt seit Generationen verbindet. Historische Illustrationen aus den frühen Tagen der Energiebranche erinnern uns daran, wie weit wir gekommen sind – und dass echte Exzellenz immer mit Sorgfalt beginnt.
|
||||
<h1>Klaus Mintel</h1>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="d3bd1bc9-d279-4699-991f-cd5809bda6d7" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="58971071-dfeb-4164-b61b-b73c04879b2c" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="60674c1d-d9f3-43f5-baa6-5d0effc3ada4" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Klaus ist der Fels in der Brandung – selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/vcf/klaus-mintel" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 no-underline">
|
||||
vCard Klaus Mintel herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>Unser Manifest</h2>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user