Compare commits
429 Commits
49b27d9628
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| baec7cc94a | |||
| b43b1c4314 | |||
| 615757963c | |||
| 0094871358 | |||
| 8fef3b6c7f | |||
| cbb7855804 | |||
| a8f7c5370b | |||
| f459bf230d | |||
| 570a4977dd | |||
| da04c108ef | |||
| cb207d6a01 | |||
| 6dc0ff4644 | |||
| e648d30767 | |||
| 9c26ddddbf | |||
| 61b4b37111 | |||
| feedf30be1 | |||
| b596c22011 | |||
| 377f583ff1 | |||
| 4d72a5bf86 | |||
| f50f41530d | |||
| f8274cad1b | |||
| f2dd76a7a6 | |||
| 574d5a8a9a | |||
| ac1e22017e | |||
| a503d3e539 | |||
| c2eeeafd56 | |||
| 78cb7207e6 | |||
| ca4c36ad01 | |||
| c9dcf73021 | |||
| 8146ee78fa | |||
| 58878e9f64 | |||
| f43c97e712 | |||
| 888b46eed0 | |||
| f1c64f000c | |||
| 9b561f2176 | |||
| f7930503d5 | |||
| bb98014ea1 | |||
| defde86fc9 | |||
| 69141175cf | |||
| 4fdbc0f5cf | |||
| 97950d574e | |||
| c04a134eca | |||
| 3b03572fb0 | |||
| a4c926ceb1 | |||
| f5edc08e9d | |||
| 28a1cb4b4c | |||
| 8dcc27ffcd | |||
| a9d256ea55 | |||
| 97b1a94012 | |||
| 831c8be588 | |||
| c121398381 | |||
| a831bed335 | |||
| 5659073f95 | |||
| 2d93321a91 | |||
| fb6af84a42 | |||
| 8affb7878f | |||
| c074a5d935 | |||
| 4dbf566f0c | |||
| a0cfa8ef62 | |||
| ae1e0ad8a9 | |||
| 5ba3afc393 | |||
| 1380d40b4d | |||
| cf5df1b46b | |||
| dd9f427ad5 | |||
| 2c4345a7bd | |||
| 6fdf9c3464 | |||
| aac2cb2041 | |||
| b3827b95c2 | |||
| 339a272105 | |||
| 3582370449 | |||
| 3288bbd745 | |||
| c2d6e082e8 | |||
| 4777091d8e | |||
| 5afa5395d4 | |||
| 1568ecdf7d | |||
| 807a604e39 | |||
| 72711c74ba | |||
| 7e94feaf19 | |||
| d90d7502c3 | |||
| e5e2b646a0 | |||
| 899b3c7ed4 | |||
| 1a646282a0 | |||
| 84438f1492 | |||
| dd5636450c | |||
| ae60037708 | |||
| 41dd897f08 | |||
| 1d472062b1 | |||
| 656852e983 | |||
| a668dc60a9 | |||
| 83411789a5 | |||
| 33f188c16a | |||
| 22bd212c11 | |||
| e74c309461 | |||
| 2fcca45e4a | |||
| 846319b2c7 | |||
| ded9150df6 | |||
| d16c28edb1 | |||
| d8b268fc23 | |||
| a7f5c1c16d | |||
| abf283c9ab | |||
| f62485a67d | |||
| 1293adbef2 | |||
| 2ffbe8b449 | |||
| 45234c844d | |||
| a62a29b1ad | |||
| ef47fa914c | |||
| 6e34392976 | |||
| 6adf97a096 | |||
| 4abcc3fdf5 | |||
| 6797303628 | |||
| 2feb73b982 | |||
| b99258f886 | |||
| 5c9b2e3f5a | |||
| e8b6b13a3b | |||
| 1f624f3d7f | |||
| 40c553d6f6 | |||
| a32c12692c | |||
| 79016fbe97 | |||
| 4f6264f2e2 | |||
| 46266a7bbc | |||
| ac2add1984 | |||
| c57cf1daa1 | |||
| c14556816e | |||
| b05a21350c | |||
| 619b699f14 | |||
| 21ce9b2ca7 | |||
| 9f62c0a7bf | |||
| f6cd5ec5aa | |||
| cfc16593f6 | |||
| 8c319cd969 | |||
| 013049e631 | |||
| 0f3eecbc48 | |||
| d8184caa9d | |||
| d4e4142381 | |||
| e6651761f3 | |||
| f64cb71170 | |||
| 29168a9778 | |||
| 021d23ab93 | |||
| de87c62312 | |||
| ef2817e5be | |||
| 7c5b91749b | |||
| c8f61257c9 | |||
| c12b32ed5e | |||
| c258e5c695 |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
!.next/cache
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
docs
|
||||||
|
reference
|
||||||
|
public/datasheets/*.pdf
|
||||||
39
.env
39
.env
@@ -1,4 +1,35 @@
|
|||||||
WOOCOMMERCE_URL=https://klz-cables.com
|
# Application
|
||||||
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
NODE_ENV=production
|
||||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
WORDPRESS_APP_PASSWORD=DlJH 49dp fC3a Itc3 Sl7Z Wz0k‘
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
||||||
|
|
||||||
|
# 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_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=https://cms.klz-cables.com
|
||||||
|
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=directus
|
||||||
|
DIRECTUS_DB_PASSWORD=directus
|
||||||
|
# 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
|
||||||
|
|||||||
93
.env.example
Normal file
93
.env.example
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# KLZ Cables - Environment Configuration
|
||||||
|
# ============================================================================
|
||||||
|
# Copy this file to .env for local development
|
||||||
|
# For production, use .env.production as a template
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Email Configuration (SMTP)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Required for contact form functionality
|
||||||
|
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
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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
|
||||||
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
|
||||||
375
.gitea/workflows/deploy.yml
Normal file
375
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
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:
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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:
|
||||||
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: 🔍 Environment ermitteln
|
||||||
|
id: determine
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
TARGET="branch"
|
||||||
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Standardize Traefik Rule
|
||||||
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||||
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
|
else
|
||||||
|
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||||
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
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 "@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 }}
|
||||||
|
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' }}
|
||||||
|
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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
cat > .env.deploy << EOF
|
||||||
|
# Generated by CI - $TARGET
|
||||||
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
|
||||||
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
|
LOG_LEVEL=$LOG_LEVEL
|
||||||
|
MAIL_HOST=$MAIL_HOST
|
||||||
|
MAIL_PORT=$MAIL_PORT
|
||||||
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
|
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||||
|
MAIL_FROM=$MAIL_FROM
|
||||||
|
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
DIRECTUS_HOST=$DIRECTUS_HOST
|
||||||
|
DIRECTUS_KEY=$DIRECTUS_KEY
|
||||||
|
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
||||||
|
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
||||||
|
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||||
|
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||||
|
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||||
|
DIRECTUS_DB_CLIENT=pg
|
||||||
|
DIRECTUS_DB_HOST=directus-db
|
||||||
|
DIRECTUS_DB_PORT=5432
|
||||||
|
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||||
|
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||||
|
|
||||||
|
# Gatekeeper
|
||||||
|
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||||
|
AUTH_COOKIE_NAME=klz_gatekeeper_session
|
||||||
|
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
|
|
||||||
|
TARGET=$TARGET
|
||||||
|
SENTRY_ENVIRONMENT=$TARGET
|
||||||
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
|
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
||||||
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
|
ENV_FILE=$ENV_FILE
|
||||||
|
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||||
|
AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE
|
||||||
|
AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- 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
|
||||||
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Transfer and Restart
|
||||||
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||||
|
|
||||||
|
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: Notifications
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
notifications:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
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
|
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
|
||||||
|
}
|
||||||
55
Dockerfile
Normal file
55
Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Stage 1: Builder
|
||||||
|
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Arguments for build-time configuration
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_TARGET
|
||||||
|
ARG DIRECTUS_URL
|
||||||
|
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 SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
# 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 . .
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 2: Runner
|
||||||
|
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
|
USER root
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV 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
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
167
README.md
167
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
|||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
|
||||||
|
- Node.js 18+
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
```bash
|
|
||||||
|
````bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
@@ -29,21 +31,61 @@ npm run export
|
|||||||
|
|
||||||
# Or run development server
|
# Or run development server
|
||||||
npm run dev
|
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
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
SITE_URL=https://klz-cables.com
|
SITE_URL=https://klz-cables.com
|
||||||
RESEND_API_KEY=your_resend_key
|
RESEND_API_KEY=your_resend_key
|
||||||
TURNSTILE_SITE_KEY=your_turnstile_key
|
TURNSTILE_SITE_KEY=your_turnstile_key
|
||||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||||
VERCEL_ANALYTICS_ID=your_analytics_id
|
|
||||||
|
# Umami
|
||||||
|
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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Project Overview
|
## 📊 Project Overview
|
||||||
|
|
||||||
### Migration Statistics
|
### Migration Statistics
|
||||||
|
|
||||||
- **Content Exported**: 141 items
|
- **Content Exported**: 141 items
|
||||||
- 18 pages (9 EN + 9 DE)
|
- 18 pages (9 EN + 9 DE)
|
||||||
- 59 posts (29 EN + 30 DE)
|
- 59 posts (29 EN + 30 DE)
|
||||||
@@ -54,6 +96,7 @@ VERCEL_ANALYTICS_ID=your_analytics_id
|
|||||||
- **Translation Pairs**: 16
|
- **Translation Pairs**: 16
|
||||||
|
|
||||||
### Performance Benefits
|
### Performance Benefits
|
||||||
|
|
||||||
- **Before**: Dynamic WordPress with database queries
|
- **Before**: Dynamic WordPress with database queries
|
||||||
- **After**: Static HTML with CDN delivery
|
- **After**: Static HTML with CDN delivery
|
||||||
- **Load Time**: <100ms (vs 500ms+)
|
- **Load Time**: <100ms (vs 500ms+)
|
||||||
@@ -62,15 +105,18 @@ VERCEL_ANALYTICS_ID=your_analytics_id
|
|||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 14 (App Router)
|
- **Framework**: Next.js 14 (App Router)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Styling**: SCSS
|
- **Styling**: SCSS
|
||||||
- **Data**: Static JSON (WordPress export)
|
- **CMS**: Strapi (Source of Truth)
|
||||||
|
- **Data**: Static JSON (WordPress export) & Strapi API
|
||||||
- **Email**: Resend
|
- **Email**: Resend
|
||||||
- **Analytics**: Vercel (consent-based)
|
- **Analytics**: Vercel (consent-based)
|
||||||
- **CAPTCHA**: Cloudflare Turnstile
|
- **CAPTCHA**: Cloudflare Turnstile
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
@@ -95,7 +141,7 @@ app/
|
|||||||
├── api/
|
├── api/
|
||||||
│ └── contact/route.ts # Contact API
|
│ └── contact/route.ts # Contact API
|
||||||
├── sitemap.ts # Sitemap generator
|
├── sitemap.ts # Sitemap generator
|
||||||
└── robots.ts # Robots.txt generator
|
├── robots.ts # Robots.txt generator
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
├── data.ts # Data access
|
├── data.ts # Data access
|
||||||
@@ -106,7 +152,7 @@ components/
|
|||||||
├── LocaleSwitcher.tsx # Language switcher
|
├── LocaleSwitcher.tsx # Language switcher
|
||||||
├── ContactForm.tsx # Contact form
|
├── ContactForm.tsx # Contact form
|
||||||
├── CookieConsent.tsx # GDPR banner
|
├── CookieConsent.tsx # GDPR banner
|
||||||
└── SEO.tsx # SEO utilities
|
├── SEO.tsx # SEO utilities
|
||||||
|
|
||||||
data/
|
data/
|
||||||
├── raw/ # WordPress export
|
├── raw/ # WordPress export
|
||||||
@@ -125,6 +171,7 @@ scripts/
|
|||||||
## 🎯 Features
|
## 🎯 Features
|
||||||
|
|
||||||
### ✅ Implemented
|
### ✅ Implemented
|
||||||
|
|
||||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||||
- **Contact Forms**: Resend integration with validation
|
- **Contact Forms**: Resend integration with validation
|
||||||
- **GDPR Compliance**: Cookie consent banner
|
- **GDPR Compliance**: Cookie consent banner
|
||||||
@@ -137,12 +184,14 @@ scripts/
|
|||||||
- **Asset Management**: WordPress → local path mapping
|
- **Asset Management**: WordPress → local path mapping
|
||||||
|
|
||||||
### 🔄 In Progress
|
### 🔄 In Progress
|
||||||
|
|
||||||
- Analytics integration (consent-based)
|
- Analytics integration (consent-based)
|
||||||
- Turnstile CAPTCHA
|
- Turnstile CAPTCHA
|
||||||
- Build testing
|
- Build testing
|
||||||
- Deployment configuration
|
- Deployment configuration
|
||||||
|
|
||||||
### 📝 Remaining
|
### 📝 Remaining
|
||||||
|
|
||||||
- Performance optimization
|
- Performance optimization
|
||||||
- Final QA testing
|
- Final QA testing
|
||||||
- Documentation updates
|
- Documentation updates
|
||||||
@@ -150,6 +199,7 @@ scripts/
|
|||||||
## 📝 Content Management
|
## 📝 Content Management
|
||||||
|
|
||||||
### Data Export
|
### Data Export
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Export from WordPress
|
# Export from WordPress
|
||||||
npm run data:export
|
npm run data:export
|
||||||
@@ -165,6 +215,7 @@ npm run data:improve-mapping
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Content
|
### Adding New Content
|
||||||
|
|
||||||
1. Export new content from WordPress
|
1. Export new content from WordPress
|
||||||
2. Process the data
|
2. Process the data
|
||||||
3. Rebuild the site
|
3. Rebuild the site
|
||||||
@@ -172,17 +223,20 @@ npm run data:improve-mapping
|
|||||||
## 🎨 Design System
|
## 🎨 Design System
|
||||||
|
|
||||||
### Colors
|
### Colors
|
||||||
|
|
||||||
- Primary: `#0066cc` (KLZ Blue)
|
- Primary: `#0066cc` (KLZ Blue)
|
||||||
- Secondary: `#00a896` (Teal)
|
- Secondary: `#00a896` (Teal)
|
||||||
- Text: `#1a1a1a`
|
- Text: `#1a1a1a`
|
||||||
- Background: `#f8f9fa`
|
- Background: `#f8f9fa`
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- Font: Inter
|
- Font: Inter
|
||||||
- Base: 16px
|
- Base: 16px
|
||||||
- Scale: 1.25 (Major Third)
|
- Scale: 1.25 (Major Third)
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
- Max width: 1200px
|
- Max width: 1200px
|
||||||
- Responsive grid
|
- Responsive grid
|
||||||
- Mobile-first
|
- Mobile-first
|
||||||
@@ -190,6 +244,7 @@ npm run data:improve-mapping
|
|||||||
## 🔧 API Endpoints
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
### Contact Form
|
### Contact Form
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/contact
|
POST /api/contact
|
||||||
{
|
{
|
||||||
@@ -201,65 +256,115 @@ POST /api/contact
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sitemap
|
### Sitemap
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /sitemap.xml
|
GET /sitemap.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Robots
|
### Robots
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /robots.txt
|
GET /robots.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
### Vercel (Recommended)
|
### Automatic Deployment (Current Setup)
|
||||||
```bash
|
|
||||||
# Install Vercel CLI
|
|
||||||
npm i -g vercel
|
|
||||||
|
|
||||||
# Deploy
|
The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
|
||||||
vercel --prod
|
|
||||||
|
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_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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into production server
|
||||||
|
ssh deploy@alpha.mintel.me
|
||||||
|
|
||||||
|
# Navigate to project
|
||||||
|
cd /home/deploy/sites/klz-cables.com
|
||||||
|
|
||||||
|
# Pull latest image and restart
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate --remove-orphans
|
||||||
|
docker image prune -f
|
||||||
```
|
```
|
||||||
|
|
||||||
### Static Export
|
Or use the convenience script:
|
||||||
```bash
|
|
||||||
# Build and export
|
|
||||||
npm run build
|
|
||||||
npm run export
|
|
||||||
|
|
||||||
# Deploy to any static host
|
```bash
|
||||||
# Upload /out directory
|
bash scripts/deploy-webhook.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Netlify
|
### Architecture
|
||||||
```bash
|
|
||||||
# Connect repository
|
|
||||||
# Set build command: npm run build
|
|
||||||
# Set publish directory: out
|
|
||||||
```
|
```
|
||||||
|
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)
|
||||||
|
- `traefik`: Reverse proxy (external)
|
||||||
|
|
||||||
|
For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
## 📈 Performance
|
## 📈 Performance
|
||||||
|
|
||||||
### Build Time
|
### Build Time
|
||||||
|
|
||||||
- **Target**: < 2 minutes
|
- **Target**: < 2 minutes
|
||||||
- **Current**: ~1-2 minutes
|
- **Current**: ~1-2 minutes
|
||||||
|
|
||||||
### Page Load
|
### Page Load
|
||||||
|
|
||||||
- **Target**: < 100ms
|
- **Target**: < 100ms
|
||||||
- **Current**: Static HTML from CDN
|
- **Current**: Static HTML from CDN
|
||||||
|
|
||||||
### Bundle Size
|
### Bundle Size
|
||||||
|
|
||||||
- **Target**: < 100KB gzipped
|
- **Target**: < 100KB gzipped
|
||||||
- **Current**: Optimized with code splitting
|
- **Current**: Optimized with code splitting
|
||||||
|
|
||||||
## 🔒 Security
|
## 🔒 Security
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- Never commit `.env` file
|
- Never commit `.env` file
|
||||||
- Rotate keys regularly
|
- Rotate keys regularly
|
||||||
- Use secrets in deployment platform
|
- Use secrets in deployment platform
|
||||||
|
|
||||||
### Form Security
|
### Form Security
|
||||||
|
|
||||||
- Email validation
|
- Email validation
|
||||||
- Rate limiting (recommended)
|
- Rate limiting (recommended)
|
||||||
- Turnstile CAPTCHA (pending)
|
- Turnstile CAPTCHA (pending)
|
||||||
@@ -267,6 +372,7 @@ npm run export
|
|||||||
## 🎓 WordPress Specifics
|
## 🎓 WordPress Specifics
|
||||||
|
|
||||||
### WPBakery Shortcodes Removed
|
### WPBakery Shortcodes Removed
|
||||||
|
|
||||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||||
- `[nectar_*]` (Salient theme)
|
- `[nectar_*]` (Salient theme)
|
||||||
- `[image_with_animation]`
|
- `[image_with_animation]`
|
||||||
@@ -274,13 +380,16 @@ npm run export
|
|||||||
- `[divider]`
|
- `[divider]`
|
||||||
|
|
||||||
### HTML Sanitization
|
### HTML Sanitization
|
||||||
|
|
||||||
- Removes inline event handlers
|
- Removes inline event handlers
|
||||||
- Strips scripts
|
- Strips scripts
|
||||||
- Normalizes classes
|
- Normalizes classes
|
||||||
- Preserves structure
|
- Preserves structure
|
||||||
|
|
||||||
### Asset Mapping
|
### Asset Mapping
|
||||||
|
|
||||||
WordPress URLs → Local paths:
|
WordPress URLs → Local paths:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||||
```
|
```
|
||||||
@@ -288,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||||
- `FINAL_SUMMARY.md` - Complete overview
|
- `FINAL_SUMMARY.md` - Complete overview
|
||||||
|
|
||||||
### External
|
### External
|
||||||
|
|
||||||
- [Next.js Docs](https://nextjs.org/docs)
|
- [Next.js Docs](https://nextjs.org/docs)
|
||||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||||
- [Resend Docs](https://resend.com/docs)
|
- [Resend Docs](https://resend.com/docs)
|
||||||
@@ -303,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
**TypeScript Errors**
|
**TypeScript Errors**
|
||||||
|
|
||||||
- The TypeScript errors shown in the editor are expected
|
- The TypeScript errors shown in the editor are expected
|
||||||
- They occur because modules reference each other
|
- They occur because modules reference each other
|
||||||
- The build process resolves these correctly
|
- The build process resolves these correctly
|
||||||
- Run `npm run build` to verify
|
- Run `npm run build` to verify
|
||||||
|
|
||||||
**Build Failures**
|
**Build Failures**
|
||||||
|
|
||||||
- Check environment variables
|
- Check environment variables
|
||||||
- Verify data files exist
|
- Verify data files exist
|
||||||
- Clear `.next` cache: `rm -rf .next`
|
- Clear `.next` cache: `rm -rf .next`
|
||||||
|
|
||||||
**Missing Modules**
|
**Missing Modules**
|
||||||
|
|
||||||
- Run `npm install --legacy-peer-deps`
|
- Run `npm install --legacy-peer-deps`
|
||||||
- Check `package.json` dependencies
|
- Check `package.json` dependencies
|
||||||
|
|
||||||
@@ -328,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
✅ **i18n**: Multi-language support
|
✅ **i18n**: Multi-language support
|
||||||
✅ **SEO**: Metadata and sitemaps
|
✅ **SEO**: Metadata and sitemaps
|
||||||
✅ **Compatibility**: WPBakery content handled
|
✅ **Compatibility**: WPBakery content handled
|
||||||
✅ **Media**: All images downloaded
|
✅ **Media**: All images downloaded
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
For issues or questions:
|
For issues or questions:
|
||||||
|
|
||||||
1. Check the documentation
|
1. Check the documentation
|
||||||
2. Review the troubleshooting section
|
2. Review the troubleshooting section
|
||||||
3. Check environment variables
|
3. Check environment variables
|
||||||
@@ -346,4 +461,4 @@ Proprietary - KLZ Cables
|
|||||||
|
|
||||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0.0
|
||||||
**Last Updated**: December 27, 2025
|
**Last Updated**: December 27, 2025
|
||||||
|
|||||||
31
app/[locale]/[slug]/opengraph-image.tsx
Normal file
31
app/[locale]/[slug]/opengraph-image.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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 runtime = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
||||||
|
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,35 +1,129 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import { getPostBySlug } from '@/lib/blog';
|
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 { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
languages: {
|
||||||
|
de: `/de/${slug}`,
|
||||||
|
en: `/en/${slug}`,
|
||||||
|
'x-default': `/en/${slug}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
|
description: pageData.frontmatter.excerpt || '',
|
||||||
|
url: `${SITE_URL}/${locale}/${slug}`,
|
||||||
|
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
|
description: pageData.frontmatter.excerpt || '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
export default async function StandardPage({ params }: PageProps) {
|
||||||
const page = await getPostBySlug(slug, locale); // Reusing blog logic for now as structure is same
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
// If not found in blog, try pages directory (we need to implement getPageBySlug)
|
|
||||||
// Actually, let's implement getPageBySlug in lib/mdx.ts or similar
|
|
||||||
|
|
||||||
// For now, let's assume we use a unified loader or separate.
|
|
||||||
// Let's use a separate loader for pages.
|
|
||||||
|
|
||||||
const { getPageBySlug } = await import('@/lib/pages');
|
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12 max-w-4xl">
|
<div className="flex flex-col min-h-screen bg-white">
|
||||||
<h1 className="text-4xl font-bold text-primary mb-8">{pageData.frontmatter.title}</h1>
|
{/* Hero Section */}
|
||||||
<div className="prose prose-lg max-w-none">
|
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
|
||||||
<MDXRemote source={pageData.content} />
|
<div className="absolute inset-0 opacity-20">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||||
|
</div>
|
||||||
|
<Container className="relative z-10">
|
||||||
|
<div className="max-w-4xl animate-slide-up">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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 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>
|
||||||
|
<a
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
|
>
|
||||||
|
{t('contactUs')}
|
||||||
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
76
app/[locale]/api/og/product/route.tsx
Normal file
76
app/[locale]/api/og/product/route.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ locale: string }> },
|
||||||
|
) {
|
||||||
|
const { searchParams, origin } = 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]
|
||||||
|
: `${origin}${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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
44
app/[locale]/blog/[slug]/opengraph-image.tsx
Normal file
44
app/[locale]/blog/[slug]/opengraph-image.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 runtime = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({
|
||||||
|
params: { locale, slug },
|
||||||
|
}: {
|
||||||
|
params: { locale: string; slug: string };
|
||||||
|
}) {
|
||||||
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return new Response('Post not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
|
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||||
|
// For local images during dev, relative paths in <img> might not work in Satori
|
||||||
|
// but if we are in nodejs runtime, we could potentially read from disk.
|
||||||
|
// For now, let's just make sure it's absolute.
|
||||||
|
const featuredImage = post.frontmatter.featuredImage
|
||||||
|
? 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}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
...OG_IMAGE_SIZE,
|
||||||
|
fonts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,233 +1,266 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import { getPostBySlug } from '@/lib/blog';
|
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 { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || '';
|
||||||
|
return {
|
||||||
|
title: post.frontmatter.title,
|
||||||
|
description: description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `/${locale}/blog/${slug}`,
|
||||||
|
languages: {
|
||||||
|
de: `/de/blog/${slug}`,
|
||||||
|
en: `/en/blog/${slug}`,
|
||||||
|
'x-default': `/en/blog/${slug}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
|
description: description,
|
||||||
|
type: 'article',
|
||||||
|
publishedTime: post.frontmatter.date,
|
||||||
|
authors: ['KLZ Cables'],
|
||||||
|
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
|
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
|
description: description,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import Link from 'next/link';
|
export default async function BlogPost({ params }: BlogPostProps) {
|
||||||
import Image from 'next/image';
|
const { locale, slug } = await params;
|
||||||
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
setRequestLocale(locale);
|
||||||
import Callout from '@/components/blog/Callout';
|
|
||||||
import HighlightBox from '@/components/blog/HighlightBox';
|
|
||||||
import Stats from '@/components/blog/Stats';
|
|
||||||
|
|
||||||
const components = {
|
|
||||||
VisualLinkPreview,
|
|
||||||
Callout,
|
|
||||||
HighlightBox,
|
|
||||||
Stats,
|
|
||||||
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) => (
|
|
||||||
<div className="my-12">
|
|
||||||
<img {...props} className="rounded-xl shadow-lg max-w-full h-auto mx-auto" />
|
|
||||||
{props.alt && (
|
|
||||||
<p className="text-sm text-text-secondary text-center mt-3 italic">{props.alt}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
h2: ({ children, ...props }: any) => (
|
|
||||||
<h2 {...props} className="text-3xl font-bold text-text-primary mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
),
|
|
||||||
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>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headings = getHeadings(post.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-gradient-to-b from-neutral-light/30 to-white min-h-screen">
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||||
{/* Featured Image Header */}
|
{/* Featured Image Header */}
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage ? (
|
||||||
<div className="relative w-full h-[300px] md:h-[500px] overflow-hidden">
|
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||||
<img
|
<div
|
||||||
src={post.frontmatter.featuredImage}
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
||||||
alt={post.frontmatter.title}
|
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-12">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto px-4">
|
||||||
{post.frontmatter.category && (
|
<div className="max-w-4xl">
|
||||||
<span className="inline-block px-4 py-2 bg-primary text-white text-sm font-medium rounded-full mb-4">
|
{post.frontmatter.category && (
|
||||||
{post.frontmatter.category}
|
<div className="overflow-hidden mb-6">
|
||||||
</span>
|
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
|
||||||
)}
|
{post.frontmatter.category}
|
||||||
<h1 className="text-3xl md:text-5xl lg:text-6xl font-bold text-white mb-4 leading-tight drop-shadow-lg">
|
</span>
|
||||||
{post.frontmatter.title}
|
</div>
|
||||||
</h1>
|
)}
|
||||||
<time dateTime={post.frontmatter.date} className="text-white/90 text-sm md:text-base">
|
<Heading
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
level={1}
|
||||||
year: 'numeric',
|
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
|
||||||
month: 'long',
|
>
|
||||||
day: 'numeric'
|
{post.frontmatter.title}
|
||||||
})}
|
</Heading>
|
||||||
</time>
|
<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',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
|
<span>{getReadingTime(post.content)} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<header className="pt-32 pb-16 bg-neutral-50 border-b border-neutral-100">
|
||||||
{/* Content */}
|
<div className="container mx-auto px-4 max-w-4xl">
|
||||||
<div className="container mx-auto px-4 py-12 md:py-16 max-w-4xl">
|
|
||||||
{/* If no featured image, show header here */}
|
|
||||||
{!post.frontmatter.featuredImage && (
|
|
||||||
<header className="mb-12">
|
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<div className="mb-4">
|
<div className="mb-6">
|
||||||
<span className="inline-block px-4 py-2 bg-primary text-white text-sm font-medium rounded-full">
|
<span className="inline-block px-4 py-1.5 bg-primary/10 text-primary text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-text-primary mb-6 leading-tight">
|
<Heading level={1} className="mb-8">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h1>
|
</Heading>
|
||||||
<time dateTime={post.frontmatter.date} className="text-text-secondary">
|
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
<time dateTime={post.frontmatter.date}>
|
||||||
year: 'numeric',
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
month: 'long',
|
year: 'numeric',
|
||||||
day: 'numeric'
|
month: 'long',
|
||||||
})}
|
day: 'numeric',
|
||||||
</time>
|
})}
|
||||||
</header>
|
</time>
|
||||||
)}
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
|
<span>{getReadingTime(post.content)} min read</span>
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
</div>
|
||||||
{post.frontmatter.excerpt && (
|
|
||||||
<div className="mb-12 p-6 md:p-8 bg-white rounded-xl shadow-sm border-l-4 border-primary">
|
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-light">
|
|
||||||
{post.frontmatter.excerpt}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content with enhanced styling */}
|
{/* Main Content Area with Sticky Narrative Layout */}
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10">
|
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||||
<div className="prose prose-lg max-w-none">
|
<div className="sticky-narrative-container">
|
||||||
<MDXRemote source={post.content} components={components} />
|
{/* Left Column: Content */}
|
||||||
|
<div className="sticky-narrative-content">
|
||||||
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
|
{post.frontmatter.excerpt && (
|
||||||
|
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
|
||||||
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
|
{post.frontmatter.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content with enhanced styling */}
|
||||||
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
|
||||||
|
<MDXRemote source={post.content} components={mdxComponents} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Power CTA */}
|
||||||
|
<div className="mt-24 animate-slight-fade-in-from-bottom">
|
||||||
|
<PowerCTA locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Post Navigation */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<PostNavigation prev={prev} next={next} locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back to blog link */}
|
||||||
|
<div className="mt-16 pt-10 border-t border-neutral-100">
|
||||||
|
<Link
|
||||||
|
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>
|
||||||
|
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Call to action section */}
|
{/* Right Column: Sticky Sidebar */}
|
||||||
<div className="mt-12 p-8 bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl border border-primary/20">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<h3 className="text-2xl font-bold text-text-primary mb-4">
|
<div className="space-y-12">
|
||||||
{locale === 'de' ? 'Haben Sie Fragen?' : 'Have questions?'}
|
<TableOfContents headings={headings} locale={locale} />
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-text-secondary mb-6">
|
</aside>
|
||||||
{locale === 'de'
|
|
||||||
? 'Unser Team steht Ihnen gerne zur Verfügung. Kontaktieren Sie uns für weitere Informationen zu unseren Kabellösungen.'
|
|
||||||
: 'Our team is happy to help. Contact us for more information about our cable solutions.'}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/contact`}
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
{locale === 'de' ? 'Kontakt aufnehmen' : 'Get in touch'}
|
|
||||||
<svg className="w-5 h-5" 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>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back to blog link */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-neutral-dark/20">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/blog`}
|
|
||||||
className="inline-flex items-center gap-2 text-primary hover:underline font-medium text-lg group"
|
|
||||||
>
|
|
||||||
<svg className="w-5 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="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
{locale === 'de' ? 'Zurück zum Blog' : 'Back to Blog'}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
|
: undefined,
|
||||||
|
author: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: `${SITE_URL}/logo-blue.svg`,
|
||||||
|
},
|
||||||
|
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: `${SITE_URL}/${locale}/blog`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: post.frontmatter.title,
|
||||||
|
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/[locale]/blog/opengraph-image.tsx
Normal file
25
app/[locale]/blog/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 runtime = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
|
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,105 +1,230 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
|
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||||
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||||
const t = await getTranslations({ locale, namespace: 'blog' });
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
return {
|
return {
|
||||||
title: locale === 'de' ? 'Neuigkeiten zu Kabeln und Energielösungen' : 'News on Cables and Energy Solutions',
|
title: t('title'),
|
||||||
description: locale === 'de'
|
description: t('description'),
|
||||||
? 'Bleiben Sie auf dem Laufenden! Lesen Sie aktuelle Themen und Insights zu Kabeltechnologie, Energielösungen und branchenspezifischen Innovationen.'
|
alternates: {
|
||||||
: 'Stay up to date! Read current topics and insights on cable technology, energy solutions and industry-specific innovations.',
|
canonical: `/${locale}/blog`,
|
||||||
|
languages: {
|
||||||
|
de: '/de/blog',
|
||||||
|
en: '/en/blog',
|
||||||
|
'x-default': '/en/blog',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${t('title')} | KLZ Cables`,
|
||||||
|
description: t('description'),
|
||||||
|
url: `${SITE_URL}/${locale}/blog`,
|
||||||
|
images: getOGImageMetadata('blog', t('title'), locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${t('title')} | KLZ Cables`,
|
||||||
|
description: t('description'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'blog' });
|
|
||||||
|
|
||||||
// Get unique categories
|
// Sort posts by date descending
|
||||||
const categories = Array.from(new Set(posts.map(post => post.frontmatter.category).filter(Boolean)));
|
const sortedPosts = [...posts].sort(
|
||||||
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const featuredPost = sortedPosts[0];
|
||||||
|
const remainingPosts = sortedPosts.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
<div className="text-center mb-12">
|
{/* Hero Section - Immersive Magazine Feel */}
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-4">
|
<Reveal>
|
||||||
{locale === 'de' ? 'Neuigkeiten zu Kabeln und Energielösungen' : 'News on Cables and Energy Solutions'}
|
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||||
</h1>
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<p className="text-lg text-text-secondary max-w-3xl mx-auto">
|
<>
|
||||||
{locale === 'de'
|
<Image
|
||||||
? 'Bleiben Sie auf dem Laufenden! Lesen Sie aktuelle Themen und Insights zu Kabeltechnologie, Energielösungen und branchenspezifischen Innovationen.'
|
src={featuredPost.frontmatter.featuredImage}
|
||||||
: 'Stay up to date! Read current topics and insights on cable technology, energy solutions and industry-specific innovations.'}
|
alt={featuredPost.frontmatter.title}
|
||||||
</p>
|
fill
|
||||||
</div>
|
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" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Category filter - could be made interactive with client component */}
|
<Container className="relative z-10">
|
||||||
{categories.length > 0 && (
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<div className="mb-8 flex flex-wrap gap-2 justify-center">
|
<Badge variant="saturated" className="mb-4 md:mb-6">
|
||||||
{categories.map((category) => (
|
{t('featuredPost')}
|
||||||
<span
|
</Badge>
|
||||||
key={category}
|
{featuredPost && (
|
||||||
className="px-4 py-2 bg-neutral-light text-text-primary rounded-full text-sm font-medium"
|
<>
|
||||||
>
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{category}
|
{featuredPost.frontmatter.title}
|
||||||
</span>
|
</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">
|
||||||
</div>
|
{featuredPost.frontmatter.excerpt}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Masonry-style grid */}
|
|
||||||
<div className="columns-1 md:columns-2 gap-8 space-y-8">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<Link
|
|
||||||
key={post.slug}
|
|
||||||
href={`/${locale}/blog/${post.slug}`}
|
|
||||||
className="group block break-inside-avoid mb-8"
|
|
||||||
>
|
|
||||||
<article className="bg-white rounded-lg shadow-sm overflow-hidden border border-neutral-dark transition-all hover:shadow-md hover:-translate-y-1">
|
|
||||||
{post.frontmatter.featuredImage && (
|
|
||||||
<div className="relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={post.frontmatter.featuredImage}
|
|
||||||
alt={post.frontmatter.title}
|
|
||||||
className="w-full h-auto object-cover transition-transform group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
{post.frontmatter.category && (
|
|
||||||
<span className="absolute top-4 left-4 px-3 py-1 bg-primary text-white text-xs font-medium rounded-full">
|
|
||||||
{post.frontmatter.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="text-sm text-text-secondary mb-2">
|
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2">
|
|
||||||
{post.frontmatter.title}
|
|
||||||
</h2>
|
|
||||||
{post.frontmatter.excerpt && (
|
|
||||||
<p className="text-text-secondary line-clamp-3 mb-4">
|
|
||||||
{post.frontmatter.excerpt}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<Button
|
||||||
<span className="text-primary font-medium group-hover:underline inline-flex items-center">
|
href={`/${locale}/blog/${featuredPost.slug}`}
|
||||||
{locale === 'de' ? 'Weiterlesen' : 'Read more'} →
|
variant="accent"
|
||||||
</span>
|
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>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Section className="bg-neutral-light py-12 md:py-28">
|
||||||
|
<Container>
|
||||||
|
<Reveal>
|
||||||
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-8 md:mb-16 gap-4 md:gap-6">
|
||||||
|
<Heading level={2} subtitle={t('latestNews')} className="mb-0">
|
||||||
|
{t('allArticles')}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</Link>
|
</Reveal>
|
||||||
))}
|
|
||||||
</div>
|
{/* Grid for remaining posts */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
|
||||||
|
{remainingPosts.map((post, idx) => (
|
||||||
|
<Reveal key={post.slug} delay={idx * 100}>
|
||||||
|
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||||
|
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
||||||
|
{post.frontmatter.featuredImage && (
|
||||||
|
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{post.frontmatter.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-5 md:p-10 flex flex-col flex-1">
|
||||||
|
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
||||||
|
{post.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
|
||||||
|
{post.frontmatter.excerpt}
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
||||||
|
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/[locale]/contact/opengraph-image.tsx
Normal file
28
app/[locale]/contact/opengraph-image.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,89 +1,249 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import ContactForm from '@/components/ContactForm';
|
||||||
import { Section, Container, Button } from '@/components/ui';
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
import Reveal from '@/components/Reveal';
|
||||||
|
import { Container, Heading, Section } from '@/components/ui';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import ContactMap from '@/components/ContactMap';
|
||||||
|
|
||||||
export default function ContactPage() {
|
interface ContactPageProps {
|
||||||
const t = useTranslations('Navigation'); // Reusing navigation translations for now
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `${SITE_URL}/${locale}/contact`,
|
||||||
|
languages: {
|
||||||
|
'de-DE': '/de/contact',
|
||||||
|
'en-US': '/en/contact',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${title} | KLZ Cables`,
|
||||||
|
description,
|
||||||
|
url: `${SITE_URL}/${locale}/contact`,
|
||||||
|
siteName: 'KLZ Cables',
|
||||||
|
images: getOGImageMetadata('contact', title, locale),
|
||||||
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${title} | KLZ Cables`,
|
||||||
|
description,
|
||||||
|
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return [{ locale: 'de' }, { locale: 'en' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<Section className="bg-neutral">
|
<JsonLd
|
||||||
<Container>
|
id="breadcrumb-contact"
|
||||||
<div className="max-w-3xl mx-auto text-center mb-12">
|
data={{
|
||||||
<h1 className="text-4xl font-bold mb-4">Get in Touch</h1>
|
'@context': 'https://schema.org',
|
||||||
<p className="text-xl text-text-secondary">
|
'@type': 'BreadcrumbList',
|
||||||
Have questions about our products or need a custom solution? We're here to help.
|
itemListElement: [
|
||||||
</p>
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: t('title'),
|
||||||
|
item: `${SITE_URL}/${locale}/contact`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
id="local-business-contact"
|
||||||
|
data={{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
image: `${SITE_URL}/logo.png`,
|
||||||
|
'@id': SITE_URL,
|
||||||
|
url: SITE_URL,
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
streetAddress: 'Raiffeisenstraße 22',
|
||||||
|
addressLocality: 'Remshalden',
|
||||||
|
postalCode: '73630',
|
||||||
|
addressCountry: 'DE',
|
||||||
|
},
|
||||||
|
geo: {
|
||||||
|
'@type': 'GeoCoordinates',
|
||||||
|
latitude: 48.8144,
|
||||||
|
longitude: 9.4144,
|
||||||
|
},
|
||||||
|
openingHoursSpecification: [
|
||||||
|
{
|
||||||
|
'@type': 'OpeningHoursSpecification',
|
||||||
|
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||||
|
opens: '08:00',
|
||||||
|
closes: '17:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Reveal>
|
||||||
|
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-20">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
<Container className="relative z-10">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Heading level={1} subtitle={t('heroSubtitle')} className="text-white mb-4 md:mb-6">
|
||||||
|
<span className="text-white">{t('title')}</span>
|
||||||
|
</Heading>
|
||||||
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 max-w-5xl mx-auto">
|
<Section className="bg-neutral-light -mt-8 md:-mt-20 relative z-20 py-12 md:py-28">
|
||||||
|
<Container>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||||
{/* Contact Info */}
|
{/* Contact Info */}
|
||||||
<div className="space-y-8">
|
<div className="lg:col-span-5 space-y-6 md:space-y-12">
|
||||||
<div>
|
<div className="animate-fade-in">
|
||||||
<h3 className="text-xl font-bold mb-4">Contact Information</h3>
|
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||||
<div className="space-y-4 text-text-secondary">
|
{t('info.howToReachUs')}
|
||||||
<p className="flex items-start">
|
</Heading>
|
||||||
<svg className="w-6 h-6 text-primary mr-3 mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div className="space-y-4 md:space-y-8">
|
||||||
<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" />
|
<div className="flex items-start gap-4 md:gap-6 group">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||||
</svg>
|
<svg
|
||||||
<span>
|
className="w-5 h-5 md:w-7 md:h-7"
|
||||||
Raiffeisenstraße 22<br />
|
fill="none"
|
||||||
73630 Remshalden<br />
|
viewBox="0 0 24 24"
|
||||||
Germany
|
stroke="currentColor"
|
||||||
</span>
|
>
|
||||||
</p>
|
<path
|
||||||
<p className="flex items-center">
|
strokeLinecap="round"
|
||||||
<svg className="w-6 h-6 text-primary mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
strokeLinejoin="round"
|
||||||
<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" />
|
strokeWidth={2}
|
||||||
</svg>
|
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"
|
||||||
<a href="tel:+4988192537298" className="hover:text-primary transition-colors">+49 881 92537298</a>
|
/>
|
||||||
</p>
|
<path
|
||||||
<p className="flex items-center">
|
strokeLinecap="round"
|
||||||
<svg className="w-6 h-6 text-primary mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
strokeLinejoin="round"
|
||||||
<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" />
|
strokeWidth={2}
|
||||||
</svg>
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
<a href="mailto:info@klz-vertriebs-gmbh.com" className="hover:text-primary transition-colors">info@klz-vertriebs-gmbh.com</a>
|
/>
|
||||||
</p>
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
||||||
<h3 className="text-xl font-bold mb-4">Business Hours</h3>
|
<Heading level={4} className="mb-4 md:mb-6">
|
||||||
<ul className="space-y-2 text-text-secondary">
|
{t('hours.title')}
|
||||||
<li className="flex justify-between">
|
</Heading>
|
||||||
<span>Monday - Friday</span>
|
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
||||||
<span>8:00 AM - 5:00 PM</span>
|
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
||||||
|
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
||||||
|
<span className="text-text-secondary">{t('hours.weekdaysTime')}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between">
|
<li className="flex justify-between items-center text-sm md:text-base">
|
||||||
<span>Saturday - Sunday</span>
|
<span className="font-bold text-primary">{t('hours.weekend')}</span>
|
||||||
<span>Closed</span>
|
<span className="text-accent-dark font-bold">{t('hours.closed')}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Form Placeholder */}
|
{/* Contact Form */}
|
||||||
<div className="bg-white p-8 rounded-lg shadow-sm border border-neutral-dark">
|
<div className="lg:col-span-7">
|
||||||
<h3 className="text-xl font-bold mb-6">Send us a message</h3>
|
<Suspense
|
||||||
<form className="space-y-4">
|
fallback={
|
||||||
<div>
|
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text-primary mb-1">Name</label>
|
}
|
||||||
<input type="text" id="name" className="w-full px-4 py-2 border border-neutral-dark rounded-md focus:ring-primary focus:border-primary" />
|
>
|
||||||
</div>
|
<ContactForm />
|
||||||
<div>
|
</Suspense>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-primary mb-1">Email</label>
|
|
||||||
<input type="email" id="email" className="w-full px-4 py-2 border border-neutral-dark rounded-md focus:ring-primary focus:border-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-text-primary mb-1">Message</label>
|
|
||||||
<textarea id="message" rows={4} className="w-full px-4 py-2 border border-neutral-dark rounded-md focus:ring-primary focus:border-primary"></textarea>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full">Send Message</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/[locale]/error.tsx
Normal file
66
app/[locale]/error.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
|
import Scribble from '@/components/Scribble';
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('Error');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const services = getAppServices();
|
||||||
|
services.errors.captureException(error);
|
||||||
|
services.logger.error('Application error caught by boundary', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
digest: error.digest
|
||||||
|
});
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
|
{/* Industrial Background Element */}
|
||||||
|
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||||
|
<span className="text-[20rem] font-bold select-none">500</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
||||||
|
500
|
||||||
|
</Heading>
|
||||||
|
<Scribble
|
||||||
|
variant="underline"
|
||||||
|
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
||||||
|
{t('title')}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className="text-white/60 mb-10 max-w-md text-lg">
|
||||||
|
{t('description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={() => reset()} variant="saturated" size="lg">
|
||||||
|
{t('tryAgain')}
|
||||||
|
</Button>
|
||||||
|
<Button href="/" variant="outline" size="lg">
|
||||||
|
{t('goHome')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative Industrial Line */}
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-b from-saturated/50 to-transparent" />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,113 @@
|
|||||||
import {NextIntlClientProvider} from 'next-intl';
|
|
||||||
import {getMessages} from 'next-intl/server';
|
|
||||||
import '../../styles/globals.css';
|
|
||||||
import Header from '@/components/Header';
|
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||||
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
|
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||||
|
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',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#001a4d',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params: {locale}
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: {locale: string};
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Providing all messages to the client
|
const { locale } = await params;
|
||||||
// side is the easiest way to get started
|
|
||||||
const messages = await getMessages();
|
// Ensure locale is a valid string, fallback to 'en'
|
||||||
|
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 = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pageview on the server with high-fidelity header context
|
||||||
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
|
// We wrap this in a try-catch to allow static rendering during build
|
||||||
|
// headers() and cookies() force dynamic rendering in Next.js
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track initial server-side pageview
|
||||||
|
serverServices.analytics.trackPageview();
|
||||||
|
} catch {
|
||||||
|
// Falls back to noop or client-side only during static generation
|
||||||
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
|
console.warn(
|
||||||
|
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||||
<body className="flex flex-col min-h-screen">
|
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||||
|
<JsonLd />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow">
|
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AnalyticsProvider />
|
||||||
|
</Suspense>
|
||||||
|
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
46
app/[locale]/not-found.tsx
Normal file
46
app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
|
import Scribble from '@/components/Scribble';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const t = useTranslations('Error.notFound');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
|
{/* Industrial Background Element */}
|
||||||
|
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||||
|
<span className="text-[20rem] font-bold select-none">404</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||||
|
404
|
||||||
|
</Heading>
|
||||||
|
<Scribble
|
||||||
|
variant="circle"
|
||||||
|
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||||
|
{t('title')}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{t('cta')}
|
||||||
|
</Button>
|
||||||
|
<Button href="/contact" variant="outline" size="lg">
|
||||||
|
Contact Support
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative Industrial Line */}
|
||||||
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/[locale]/opengraph-image.tsx
Normal file
26
app/[locale]/opengraph-image.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<OGImageTemplate
|
||||||
|
title={t('title')}
|
||||||
|
description={t('description')}
|
||||||
|
label="Reliable Energy Infrastructure"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...OG_IMAGE_SIZE,
|
||||||
|
fonts,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,26 +1,107 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import Hero from '@/components/home/Hero';
|
import Hero from '@/components/home/Hero';
|
||||||
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import ProductCategories from '@/components/home/ProductCategories';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||||
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
import dynamic from 'next/dynamic';
|
||||||
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
import Reveal from '@/components/Reveal';
|
||||||
import GallerySection from '@/components/home/GallerySection';
|
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
|
||||||
import CTA from '@/components/home/CTA';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||||
const t = useTranslations('Index');
|
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 (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<JsonLd
|
||||||
|
id="breadcrumb-home"
|
||||||
|
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||||
|
/>
|
||||||
<Hero />
|
<Hero />
|
||||||
<ProductCategories />
|
<Reveal>
|
||||||
<WhatWeDo />
|
<ProductCategories />
|
||||||
<WhyChooseUs />
|
</Reveal>
|
||||||
<MeetTheTeam />
|
<Reveal>
|
||||||
<GallerySection />
|
<WhatWeDo />
|
||||||
<VideoSection />
|
</Reveal>
|
||||||
<CTA />
|
<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>
|
</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: `/${locale}`,
|
||||||
|
languages: {
|
||||||
|
de: '/de',
|
||||||
|
en: '/en',
|
||||||
|
'x-default': '/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,24 +1,299 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getProductBySlug } from '@/lib/mdx';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
|
import ProductTabs from '@/components/ProductTabs';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
|
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, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string[];
|
slug: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
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}`,
|
||||||
|
languages: {
|
||||||
|
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
|
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
|
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${categoryTitle} | KLZ Cables`,
|
||||||
|
description: categoryDesc,
|
||||||
|
url: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||||
|
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${categoryTitle} | KLZ Cables`,
|
||||||
|
description: categoryDesc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
|
if (!product) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: product.frontmatter.title,
|
||||||
|
description: product.frontmatter.description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `/${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')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||||
|
description: product.frontmatter.description,
|
||||||
|
type: 'website',
|
||||||
|
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||||
|
description: product.frontmatter.description,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
ProductTechnicalData,
|
ProductTechnicalData,
|
||||||
p: (props: any) => <div {...props} className="mb-4" />,
|
ProductTabs,
|
||||||
|
p: (props: any) => (
|
||||||
|
<p
|
||||||
|
{...props}
|
||||||
|
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h2: (props: any) => (
|
||||||
|
<div className="relative mb-16">
|
||||||
|
<h2
|
||||||
|
{...props}
|
||||||
|
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
|
||||||
|
/>
|
||||||
|
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
h3: (props: any) => (
|
||||||
|
<h3
|
||||||
|
{...props}
|
||||||
|
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
||||||
|
section: (props: any) => <div {...props} className="block" />,
|
||||||
|
li: (props: any) => (
|
||||||
|
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
||||||
|
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
||||||
|
table: (props: any) => (
|
||||||
|
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
|
||||||
|
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: (props: any) => (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
td: (props: any) => (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
||||||
|
blockquote: (props: any) => (
|
||||||
|
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||||
|
<div
|
||||||
|
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
const productSlug = slug[slug.length - 1]; // Use the last segment as the slug
|
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',
|
||||||
|
];
|
||||||
|
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||||
|
|
||||||
|
if (categories.includes(fileSlug)) {
|
||||||
|
const allProducts = await getAllProducts(locale);
|
||||||
|
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, '-') === fileSlug || cat === categoryTitle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get translated product slugs
|
||||||
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
|
filteredProducts.map(async (p) => ({
|
||||||
|
...p,
|
||||||
|
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen bg-white">
|
||||||
|
<section className="relative min-h-[50vh] flex items-center pt-32 pb-20 overflow-hidden bg-primary-dark">
|
||||||
|
<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>
|
||||||
|
<span className="mx-3 opacity-30">/</span>
|
||||||
|
<span className="text-white/90">{categoryTitle}</span>
|
||||||
|
</nav>
|
||||||
|
<Heading level={1} className="text-white mb-8">
|
||||||
|
{categoryTitle}
|
||||||
|
</Heading>
|
||||||
|
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Section className="bg-neutral-light relative">
|
||||||
|
<Container>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{productsWithTranslatedSlugs.map((product) => (
|
||||||
|
<Link
|
||||||
|
key={product.slug}
|
||||||
|
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
||||||
|
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||||
|
>
|
||||||
|
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||||
|
{product.frontmatter.images?.[0] && (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={product.frontmatter.images[0]}
|
||||||
|
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" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||||
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||||
|
{t('details')}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
|
|
||||||
@@ -26,59 +301,223 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Extract technical data for schema
|
||||||
<div className="container mx-auto px-4 py-8">
|
const technicalDataMatch = product.content.match(
|
||||||
<div className="mb-8">
|
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
||||||
<h1 className="text-4xl font-bold text-primary mb-4">{product.frontmatter.title}</h1>
|
);
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
let technicalItems = [];
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
if (technicalDataMatch) {
|
||||||
<span key={idx} className="bg-neutral-dark text-text-secondary px-2 py-1 rounded text-sm">
|
try {
|
||||||
{cat}
|
const data = JSON.parse(technicalDataMatch[1]);
|
||||||
</span>
|
technicalItems = data.technicalItems || [];
|
||||||
))}
|
} catch (e) {
|
||||||
</div>
|
console.error('Failed to parse technical data for schema', e);
|
||||||
</div>
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||||
<div className="lg:col-span-2">
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
<div className="prose max-w-none mb-8">
|
const categorySlug = slug[0];
|
||||||
<MDXRemote source={product.content} components={components} />
|
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||||
</div>
|
const categoryKey = categoryFileSlug
|
||||||
</div>
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
<div className="lg:col-span-1">
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
? t(`categories.${categoryKey}.title`)
|
||||||
<div className="sticky top-4">
|
: categoryFileSlug;
|
||||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-neutral-dark">
|
|
||||||
<div className="relative aspect-square mb-4">
|
const sidebar = (
|
||||||
{/* Note: Images from WC might be external URLs. Next/Image requires configuration for external domains. */}
|
<ProductSidebar
|
||||||
{/* For now using standard img tag if domain not configured, or configure domains. */}
|
productName={product.frontmatter.title}
|
||||||
<img
|
productImage={product.frontmatter.images?.[0]}
|
||||||
src={product.frontmatter.images[0]}
|
datasheetPath={datasheetPath}
|
||||||
alt={product.frontmatter.title}
|
/>
|
||||||
className="w-full h-full object-contain"
|
);
|
||||||
/>
|
|
||||||
</div>
|
const productComponents = {
|
||||||
<div className="grid grid-cols-4 gap-2">
|
...components,
|
||||||
{product.frontmatter.images.slice(1, 5).map((img, idx) => (
|
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
|
||||||
<div key={idx} className="relative aspect-square border border-neutral-dark rounded overflow-hidden">
|
};
|
||||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
|
||||||
|
const processedContent = product.content
|
||||||
|
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
|
||||||
|
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
|
||||||
|
.replace(/<p[^>]*>(.*?)<\/p>/g, '\n$1\n')
|
||||||
|
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
|
||||||
|
.replace(/<li[^>]*>(.*?)<\/li>/g, '\n- $1\n')
|
||||||
|
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
|
||||||
|
.replace(/<section[^>]*>/g, '')
|
||||||
|
.replace(/<\/section>/g, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen bg-white relative">
|
||||||
|
{/* Product Hero */}
|
||||||
|
<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>
|
||||||
|
<span className="mx-4 opacity-20">/</span>
|
||||||
|
<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 && (
|
||||||
|
<div className="mb-8 inline-flex items-center px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-[10px] font-black uppercase tracking-[0.2em] backdrop-blur-md">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent mr-3 animate-pulse" />
|
||||||
|
{t('englishVersion')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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]"
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<Heading level={1} className="text-white mb-8 uppercase">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</Heading>
|
||||||
|
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-6 bg-primary-light p-6 rounded-lg">
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-primary-dark mb-2">Contact Us</h3>
|
</Container>
|
||||||
<p className="text-text-secondary mb-4">Need more information about {product.frontmatter.title}?</p>
|
</section>
|
||||||
<button className="w-full bg-primary text-white py-2 px-4 rounded hover:bg-primary-dark transition-colors">
|
|
||||||
Request Quote
|
<Section className="bg-white relative">
|
||||||
</button>
|
<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="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]}
|
||||||
|
alt={product.frontmatter.title}
|
||||||
|
fill
|
||||||
|
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
{/* 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
<div className="relative">
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="max-w-none">
|
||||||
|
<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]
|
||||||
|
? `${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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/[locale]/products/opengraph-image.tsx
Normal file
29
app/[locale]/products/opengraph-image.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
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="Products"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...OG_IMAGE_SIZE,
|
||||||
|
fonts,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,84 +1,238 @@
|
|||||||
import Link from 'next/link';
|
import Reveal from '@/components/Reveal';
|
||||||
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import Link from 'next/link';
|
||||||
import { Section, Container } from '@/components/ui';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
export default function ProductsPage() {
|
interface ProductsPageProps {
|
||||||
const t = useTranslations('Navigation');
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('title');
|
||||||
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `/${locale}/products`,
|
||||||
|
languages: {
|
||||||
|
de: '/de/products',
|
||||||
|
en: '/en/products',
|
||||||
|
'x-default': '/en/products',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${title} | KLZ Cables`,
|
||||||
|
description,
|
||||||
|
url: `${SITE_URL}/${locale}/products`,
|
||||||
|
images: getOGImageMetadata('products', title, locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
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', 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 categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
title: 'Low Voltage Cables',
|
title: t('categories.lowVoltage.title'),
|
||||||
desc: 'Powering everyday essentials with reliability and safety.',
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/12/low-voltage-scaled.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: '/products/low-voltage-cables'
|
href: `/${locale}/products/${lowVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Medium Voltage Cables',
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: 'The perfect balance between power and performance for industrial and urban grids.',
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/12/medium-voltage-scaled.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: '/products/medium-voltage-cables'
|
href: `/${locale}/products/${mediumVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'High Voltage Cables',
|
title: t('categories.highVoltage.title'),
|
||||||
desc: 'Delivering maximum power over long distances—without compromise.',
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2025/06/na2xsfl2y-rendered.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: '/products/high-voltage-cables'
|
href: `/${locale}/products/${highVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Solar Cables',
|
title: t('categories.solar.title'),
|
||||||
desc: 'Connecting the sun’s energy to your sustainable future.',
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2025/04/3.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: '/products/solar-cables'
|
href: `/${locale}/products/${solarSlug}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<Section className="bg-neutral-light">
|
{/* Hero Section */}
|
||||||
<Container>
|
<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">
|
||||||
<div className="max-w-3xl mx-auto text-center mb-16">
|
<Container className="relative z-10">
|
||||||
<h1 className="text-4xl font-bold mb-6">Our Products</h1>
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<p className="text-xl text-text-secondary">
|
<Badge
|
||||||
Explore our comprehensive range of high-quality cables designed for every application.
|
variant="saturated"
|
||||||
</p>
|
className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5"
|
||||||
</div>
|
>
|
||||||
|
{t('heroSubtitle')}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
</Badge>
|
||||||
{categories.map((category, idx) => (
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
<Link key={idx} href={category.href} className="group block bg-white rounded-lg overflow-hidden shadow-sm border border-neutral-dark hover:shadow-md transition-all">
|
{t.rich('title', {
|
||||||
<div className="relative h-64 overflow-hidden">
|
green: (chunks) => (
|
||||||
<Image
|
<span className="relative inline-block">
|
||||||
src={category.img}
|
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||||
alt={category.title}
|
<Scribble
|
||||||
fill
|
variant="circle"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mr-4">
|
|
||||||
<img src={category.icon} alt="" className="w-8 h-8" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-text-primary group-hover:text-primary transition-colors">{category.title}</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary text-lg mb-6">
|
|
||||||
{category.desc}
|
|
||||||
</p>
|
|
||||||
<span className="text-primary font-medium group-hover:translate-x-1 transition-transform inline-flex items-center">
|
|
||||||
View Products →
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
),
|
||||||
</Link>
|
})}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{t('viewProducts')}
|
||||||
|
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Section id="categories" className="bg-neutral-light relative">
|
||||||
|
<Container>
|
||||||
|
<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">
|
||||||
|
<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}
|
||||||
|
alt={category.title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
{category.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 md:p-10">
|
||||||
|
<p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8 line-clamp-2 md:line-clamp-none">
|
||||||
|
{category.desc}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
|
||||||
|
<span className="border-b-2 border-saturated/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Technical Support CTA */}
|
||||||
|
<Reveal>
|
||||||
|
<Section className="bg-white py-12 md:py-28">
|
||||||
|
<Container>
|
||||||
|
<div className="bg-primary-dark rounded-[32px] md:rounded-[64px] p-6 md:p-20 lg:p-24 relative overflow-hidden">
|
||||||
|
<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>
|
||||||
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
|
{t('cta.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/[locale]/team/opengraph-image.tsx
Normal file
29
app/[locale]/team/opengraph-image.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
|
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,151 +1,305 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Section, Container } from '@/components/ui';
|
import { Metadata } from 'next';
|
||||||
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
|
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||||
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Reveal from '@/components/Reveal';
|
||||||
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
|
||||||
export default function TeamPage() {
|
interface TeamPageProps {
|
||||||
const t = useTranslations('Navigation');
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `/${locale}/team`,
|
||||||
|
languages: {
|
||||||
|
de: '/de/team',
|
||||||
|
en: '/en/team',
|
||||||
|
'x-default': '/en/team',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${title} | KLZ Cables`,
|
||||||
|
description,
|
||||||
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
|
images: getOGImageMetadata('team', title, locale),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${title} | KLZ Cables`,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TeamPage({ params }: TeamPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
|
<JsonLd
|
||||||
|
id="breadcrumb-team"
|
||||||
|
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
id="person-michael"
|
||||||
|
data={{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: t('michael.name'),
|
||||||
|
jobTitle: t('michael.role'),
|
||||||
|
worksFor: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
},
|
||||||
|
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||||
|
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
id="person-klaus"
|
||||||
|
data={{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: t('klaus.name'),
|
||||||
|
jobTitle: t('klaus.role'),
|
||||||
|
worksFor: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
},
|
||||||
|
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||||
|
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative flex items-center justify-center overflow-hidden bg-neutral-dark pt-[14%] pb-[12%]">
|
<Reveal>
|
||||||
<div className="absolute inset-0 z-0">
|
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
|
||||||
<Image
|
<div className="absolute inset-0 z-0">
|
||||||
src="/uploads/2024/12/DSC07655-Large.webp"
|
<Image
|
||||||
alt="KLZ Team"
|
src="/uploads/2024/12/DSC07655-Large.webp"
|
||||||
fill
|
alt="KLZ Team"
|
||||||
className="object-cover"
|
fill
|
||||||
priority
|
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||||
/>
|
sizes="100vw"
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-[#0a0000] to-[rgba(10,10,10,0.5)] opacity-80" />
|
priority
|
||||||
</div>
|
|
||||||
|
|
||||||
<Container className="relative z-10 text-center text-white max-w-4xl">
|
|
||||||
<h5 className="text-xl md:text-2xl font-medium mb-4 text-primary">
|
|
||||||
The bright sparks behind the power
|
|
||||||
</h5>
|
|
||||||
<h2 className="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
|
||||||
We connect energy, expertise, and innovation to power a greener future.
|
|
||||||
</h2>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Michael Bodemer Section */}
|
|
||||||
<section className="relative bg-[#011fff] text-white overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row">
|
|
||||||
<div className="w-full md:w-1/2 p-12 md:p-24 flex flex-col justify-center">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-8">Michael Bodemer</h1>
|
|
||||||
<div className="w-12 h-1 bg-white mb-8" />
|
|
||||||
<h2 className="text-2xl md:text-3xl font-medium italic mb-8 leading-relaxed">
|
|
||||||
"Challenges exist to be solved, not to debate how complicated they are."
|
|
||||||
</h2>
|
|
||||||
<div className="w-12 h-1 bg-white mb-8" />
|
|
||||||
<p className="text-lg leading-relaxed opacity-90 mb-8">
|
|
||||||
Michael Bodemer is the go-to guy when things get complicated—and let’s face it, that’s often the case with cable networks. With sharp insight and a knack for practical solutions, Michael is one of our key players. He’s not just detail-oriented; he’s a driving force—whether it’s in planning, customer interactions, or securing the best cables for every project.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center text-white font-bold border-2 border-white px-6 py-3 rounded-full hover:bg-white hover:text-[#011fff] transition-colors w-fit group"
|
|
||||||
>
|
|
||||||
Check Michael's LinkedIn
|
|
||||||
<span className="ml-2 group-hover:translate-x-1 transition-transform">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="w-full md:w-1/2 relative min-h-[50vh] md:min-h-full">
|
|
||||||
<Image
|
|
||||||
src="/uploads/2024/12/DSC07768-Large.webp"
|
|
||||||
alt="Michael Bodemer"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Legacy Section */}
|
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||||
<section className="relative py-[10%] bg-neutral-dark text-white overflow-hidden">
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||||
<div className="absolute inset-0 z-0">
|
{t('hero.badge')}
|
||||||
<Image
|
</Badge>
|
||||||
src="/uploads/2024/12/medium-voltage-1920x400.webp"
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
alt="Legacy"
|
{t('hero.subtitle')}
|
||||||
fill
|
</Heading>
|
||||||
className="object-cover"
|
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
|
||||||
/>
|
{t('hero.title')}
|
||||||
<div className="absolute inset-0 bg-[#263336] opacity-90" />
|
</p>
|
||||||
</div>
|
</Container>
|
||||||
<Container className="relative z-10">
|
</section>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-12">
|
</Reveal>
|
||||||
<div className="md:col-span-5">
|
|
||||||
<h3 className="text-3xl font-bold mb-6">A Legacy of Excellence in Every Connection</h3>
|
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||||
<div className="space-y-6 text-lg opacity-90">
|
<section className="relative bg-white overflow-hidden">
|
||||||
<p>
|
<div className="flex flex-col lg:flex-row">
|
||||||
At KLZ, our expertise is built on generations of dedication to the energy industry. With decades of hands-on experience, we’ve grown alongside the evolution of cable technology, combining traditional craftsmanship with modern innovation. Each project we take on reflects a deep understanding of what it takes to create lasting, reliable energy solutions.
|
<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">
|
||||||
</p>
|
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||||
<p>
|
<div className="relative z-10">
|
||||||
Paired with historic illustrations from the industry’s early days, our story is a reminder of how far cables have come – and how much care has always gone into connecting the world.
|
<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>
|
||||||
|
<div className="relative mb-6 md:mb-12">
|
||||||
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||||
|
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
|
{t('michael.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
{t('michael.linkedin')}
|
||||||
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
</Container>
|
<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">
|
||||||
</section>
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||||
{/* Klaus Mintel Section */}
|
alt={t('michael.name')}
|
||||||
<section className="relative bg-[#011fff] text-white overflow-hidden">
|
fill
|
||||||
<div className="flex flex-col md:flex-row">
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
<div className="w-full md:w-1/2 relative min-h-[50vh] md:min-h-full order-2 md:order-1">
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
<Image
|
|
||||||
src="/uploads/2024/12/DSC07963-Large.webp"
|
|
||||||
alt="Klaus Mintel"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
<div className="w-full md:w-1/2 p-12 md:p-24 flex flex-col justify-center order-1 md:order-2">
|
</Reveal>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-8">Klaus Mintel</h1>
|
|
||||||
<div className="w-12 h-1 bg-white mb-8" />
|
|
||||||
<h2 className="text-2xl md:text-3xl font-medium italic mb-8 leading-relaxed">
|
|
||||||
"Sometimes all it takes is a clear head and a good cable to make the world a little better."
|
|
||||||
</h2>
|
|
||||||
<div className="w-12 h-1 bg-white mb-8" />
|
|
||||||
<p className="text-lg leading-relaxed opacity-90 mb-8">
|
|
||||||
Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isn’t just a problem solver; he’s a strategic thinker who knows how to get to the point with a touch of humor.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center text-white font-bold border-2 border-white px-6 py-3 rounded-full hover:bg-white hover:text-[#011fff] transition-colors w-fit group"
|
|
||||||
>
|
|
||||||
Check Klaus' LinkedIn
|
|
||||||
<span className="ml-2 group-hover:translate-x-1 transition-transform">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Manifesto Section */}
|
{/* Legacy Section - Immersive Background */}
|
||||||
<Section className="bg-white text-neutral-dark">
|
<Reveal>
|
||||||
<Container>
|
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
<div className="absolute inset-0 z-0">
|
||||||
<div className="lg:col-span-4">
|
<Image
|
||||||
<h2 className="text-4xl font-bold text-primary mb-6 sticky top-24">Our manifesto</h2>
|
src="/uploads/2024/12/1694273920124-copy.webp"
|
||||||
|
alt={t('legacy.subtitle')}
|
||||||
|
fill
|
||||||
|
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8">
|
</Container>
|
||||||
{[
|
</section>
|
||||||
{ title: 'Competence', desc: 'Decades of experience and Europe-wide know-how combined with commitment and new ideas. Production partners up to 525 kV and the most modern systems, test laboratories and ready to invest in the future.' },
|
</Reveal>
|
||||||
{ title: 'Availability', desc: 'Always there for you - no waiting, no delays, just fast and reliable support. Maybe it\'s because we love what we do.' },
|
|
||||||
{ title: 'Solutions', desc: 'Solutions require a lot of questions. We ask them. You, the manufacturer and ourselves. If you don\'t ask questions, you\'ll pay for it later. We need to prevent that.' },
|
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||||
{ title: 'Logistics', desc: 'Monitoring production, regular exchanges, freight tracking, customs clearance, reloading, paying attention to the delivery time tunnel, invoices, delivery notes - our everyday life. We have the right team for it.' },
|
<section className="relative bg-white overflow-hidden">
|
||||||
{ title: 'Open to new things', desc: 'We listen. From the inquiry, through the offer, to delivery. What can be done better needs to be discussed. If you don\'t adapt your processes, you\'ll no longer be on the highway at some point. Instead, you\'ll end up in a dead end.' },
|
<div className="flex flex-col lg:flex-row">
|
||||||
{ title: 'Reliability', desc: 'We deliver what we promise – every time, without fail.' }
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||||
].map((item, idx) => (
|
<Image
|
||||||
<div key={idx} className="bg-neutral-light p-6 rounded-lg border border-neutral hover:border-primary transition-colors">
|
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||||
<div className="text-primary font-mono text-xl mb-4">0{idx + 1}</div>
|
alt={t('klaus.name')}
|
||||||
<h3 className="text-xl font-bold mb-3">{item.title}</h3>
|
fill
|
||||||
<p className="text-text-secondary">{item.desc}</p>
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
|
</Reveal>
|
||||||
|
<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>
|
||||||
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||||
|
{t('klaus.name')}
|
||||||
|
</Heading>
|
||||||
|
<div className="relative mb-6 md:mb-12">
|
||||||
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||||
|
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
|
{t('klaus.quote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
{t('klaus.linkedin')}
|
||||||
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Manifesto Section - Modern Grid */}
|
||||||
|
<Section className="bg-white text-primary py-16 md:py-28">
|
||||||
|
<Container>
|
||||||
|
<div className="sticky-narrative-container">
|
||||||
|
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
|
||||||
|
<div className="lg:sticky lg:top-32">
|
||||||
|
<Heading level={2} subtitle={t('manifesto.subtitle')} className="mb-4 md:mb-8">
|
||||||
|
{t('manifesto.title')}
|
||||||
|
</Heading>
|
||||||
|
<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 className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 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>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,23 +307,9 @@ export default function TeamPage() {
|
|||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Gallery Section */}
|
<Reveal>
|
||||||
<Section className="bg-neutral-dark py-0 pb-24 pt-24">
|
<Gallery />
|
||||||
<Container>
|
</Reveal>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
'/uploads/2024/12/DSC07539-Large-600x400.webp',
|
|
||||||
'/uploads/2024/12/DSC07460-Large-600x400.webp',
|
|
||||||
'/uploads/2024/12/DSC07469-Large-600x400.webp',
|
|
||||||
'/uploads/2024/12/DSC07433-Large-600x400.webp'
|
|
||||||
].map((src, idx) => (
|
|
||||||
<div key={idx} className="relative aspect-video rounded-lg overflow-hidden opacity-80 hover:opacity-100 transition-opacity">
|
|
||||||
<Image src={src} alt="Team Gallery" fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
153
app/actions/contact.ts
Normal file
153
app/actions/contact.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use 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' });
|
||||||
|
|
||||||
|
// 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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/global-error.tsx
Normal file
24
app/global-error.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import Error from 'next/error';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
{/* This is the default Next.js error component but it doesn't allow omitting the statusCode */}
|
||||||
|
<Error statusCode={0} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/health/route.ts
Normal file
9
app/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
services.logger.debug('Health check requested');
|
||||||
|
return new Response('OK', { status: 200 });
|
||||||
|
}
|
||||||
30
app/manifest.ts
Normal file
30
app/manifest.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
short_name: 'KLZ',
|
||||||
|
description: 'Premium Cable Solutions',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#001a4d',
|
||||||
|
theme_color: '#001a4d',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/favicon.ico',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/x-icon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/logo.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/apple-touch-icon.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/api/', '/health/'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
81
app/sitemap.ts
Normal file
81
app/sitemap.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
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 = config.baseUrl || 'https://klz-cables.com';
|
||||||
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
'',
|
||||||
|
'/blog',
|
||||||
|
'/contact',
|
||||||
|
'/team',
|
||||||
|
'/products',
|
||||||
|
'/products/low-voltage-cables',
|
||||||
|
'/products/medium-voltage-cables',
|
||||||
|
'/products/high-voltage-cables',
|
||||||
|
'/products/solar-cables',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
// Static routes
|
||||||
|
for (const route of routes) {
|
||||||
|
sitemapEntries.push({
|
||||||
|
url: `${baseUrl}/${locale}${route}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: route === '' ? 'daily' : 'weekly',
|
||||||
|
priority: route === '' ? 1 : 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products
|
||||||
|
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(),
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blog 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),
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static 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(),
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sitemapEntries;
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
85
build_output.txt
Normal file
85
build_output.txt
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
> klz-cables-nextjs@1.0.0 build /Users/marcmintel/Projects/klz-2026
|
||||||
|
> next build
|
||||||
|
|
||||||
|
▲ Next.js 16.1.6 (Turbopack)
|
||||||
|
- Environments: .env.production, .env
|
||||||
|
- Experiments (use with caution):
|
||||||
|
· clientTraceMetadata
|
||||||
|
|
||||||
|
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
|
||||||
|
Creating an optimized production build ...
|
||||||
|
✓ Compiled successfully in 5.2s
|
||||||
|
Running next.config.js provided runAfterProductionCompile ...
|
||||||
|
✓ Completed runAfterProductionCompile in 329ms
|
||||||
|
Running TypeScript ...
|
||||||
|
Collecting page data using 15 workers ...
|
||||||
|
Generating static pages using 15 workers (0/21) ...
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop notification service initialized (notifications disabled)"}
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
|
||||||
|
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
|
||||||
|
Generating static pages using 15 workers (5/21)
|
||||||
|
Generating static pages using 15 workers (10/21)
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
|
||||||
|
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Notification service initialized (noop)"}
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
|
||||||
|
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
|
||||||
|
Generating static pages using 15 workers (15/21)
|
||||||
|
✓ Generating static pages using 15 workers (21/21) in 512.4ms
|
||||||
|
Finalizing page optimization ...
|
||||||
|
|
||||||
|
Route (app)
|
||||||
|
┌ ○ /_not-found
|
||||||
|
├ ƒ /[locale]
|
||||||
|
├ ƒ /[locale]/[slug]
|
||||||
|
├ ƒ /[locale]/[slug]/opengraph-image
|
||||||
|
├ ƒ /[locale]/api/og/product
|
||||||
|
├ ƒ /[locale]/blog
|
||||||
|
├ ƒ /[locale]/blog/[slug]
|
||||||
|
├ ƒ /[locale]/blog/[slug]/opengraph-image
|
||||||
|
├ ƒ /[locale]/blog/opengraph-image
|
||||||
|
├ ƒ /[locale]/contact
|
||||||
|
├ ƒ /[locale]/contact/opengraph-image
|
||||||
|
├ ƒ /[locale]/opengraph-image
|
||||||
|
├ ƒ /[locale]/products
|
||||||
|
├ ƒ /[locale]/products/[...slug]
|
||||||
|
├ ƒ /[locale]/products/opengraph-image
|
||||||
|
├ ƒ /[locale]/team
|
||||||
|
├ ƒ /[locale]/team/opengraph-image
|
||||||
|
├ ƒ /api/feedback
|
||||||
|
├ ƒ /api/health/cms
|
||||||
|
├ ƒ /api/whoami
|
||||||
|
├ ƒ /errors/api/relay
|
||||||
|
├ ƒ /health
|
||||||
|
├ ○ /manifest.webmanifest
|
||||||
|
├ ○ /robots.txt
|
||||||
|
├ ƒ /sitemap.xml
|
||||||
|
└ ƒ /stats/api/send
|
||||||
|
|
||||||
|
|
||||||
|
ƒ Proxy (Middleware)
|
||||||
|
|
||||||
|
○ (Static) prerendered as static content
|
||||||
|
ƒ (Dynamic) server-rendered on demand
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
components/ContactForm.tsx
Normal file
183
components/ContactForm.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default function ContactForm() {
|
||||||
|
const t = useTranslations('Contact');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus('submitting');
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendContactFormAction(formData);
|
||||||
|
if (result?.success) {
|
||||||
|
trackEvent('contact_form_submission', {
|
||||||
|
form_type: 'general',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
setStatus('success');
|
||||||
|
(e.target as HTMLFormElement).reset();
|
||||||
|
} else {
|
||||||
|
console.error('Contact form submission failed:', { email, error: result.error });
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Contact form submission error:', { email, error });
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<Heading level={3} className="mb-4">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||||
|
{t('form.sendAnother') || 'Send another message'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{t('form.title')}
|
||||||
|
</Heading>
|
||||||
|
<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"
|
||||||
|
name="name"
|
||||||
|
autoComplete="name"
|
||||||
|
enterKeyHint="next"
|
||||||
|
placeholder={t('form.namePlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 md:space-y-2">
|
||||||
|
<Label htmlFor="email">{t('form.email')}</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
enterKeyHint="next"
|
||||||
|
placeholder={t('form.emailPlaceholder')}
|
||||||
|
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"
|
||||||
|
name="message"
|
||||||
|
rows={4}
|
||||||
|
enterKeyHint="send"
|
||||||
|
placeholder={t('form.messagePlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||||
|
<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>
|
||||||
|
{t('form.submitting') || 'Sending...'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('form.submit')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
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} />;
|
||||||
|
}
|
||||||
68
components/DatasheetDownload.tsx
Normal file
68
components/DatasheetDownload.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface DatasheetDownloadProps {
|
||||||
|
datasheetPath: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||||
|
const t = useTranslations('Products');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
||||||
|
<a
|
||||||
|
href={datasheetPath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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,98 +1,177 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { Container } from './ui';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
|
const navT = useTranslations('Navigation');
|
||||||
|
const locale = useLocale();
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-white text-neutral-dark py-16 border-t border-neutral-light">
|
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="container mx-auto px-4">
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 mb-12">
|
|
||||||
{/* Column 1: Legal & Languages */}
|
<Container>
|
||||||
<div className="space-y-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||||
<div>
|
{/* Brand Column */}
|
||||||
<h4 className="text-lg font-bold mb-4">Legal</h4>
|
<div className="lg:col-span-4 space-y-8">
|
||||||
<ul className="space-y-2 text-sm text-text-secondary">
|
<Link href={`/${locale}`} className="inline-block group">
|
||||||
<li><Link href="/legal-notice" className="hover:text-primary">Legal Notice</Link></li>
|
<Image
|
||||||
<li><Link href="/privacy-policy" className="hover:text-primary">Privacy Policy</Link></li>
|
src="/logo-white.svg"
|
||||||
<li><Link href="/terms" className="hover:text-primary">Terms</Link></li>
|
alt={t('products')}
|
||||||
</ul>
|
width={150}
|
||||||
</div>
|
height={40}
|
||||||
<div>
|
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||||
<h4 className="text-lg font-bold mb-4">Languages</h4>
|
/>
|
||||||
<ul className="space-y-2 text-sm text-text-secondary">
|
</Link>
|
||||||
<li><Link href="/en" className="hover:text-primary">English</Link></li>
|
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
||||||
<li><Link href="/de" className="hover:text-primary">Deutsch</Link></li>
|
{t('tagline')}
|
||||||
</ul>
|
</p>
|
||||||
</div>
|
<div className="flex gap-4">
|
||||||
<div>
|
<a
|
||||||
<p className="text-sm text-text-secondary">
|
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
|
||||||
Copyright © {currentYear} KLZ Cables.<br />
|
target="_blank"
|
||||||
All rights reserved.
|
rel="noopener noreferrer"
|
||||||
</p>
|
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"
|
||||||
</div>
|
>
|
||||||
<div>
|
<span className="sr-only">LinkedIn</span>
|
||||||
<Image
|
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||||
src="/logo-white.svg"
|
<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" />
|
||||||
alt="KLZ Cables"
|
</svg>
|
||||||
width={100}
|
</a>
|
||||||
height={25}
|
|
||||||
className="h-6 w-auto invert"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column 2: Archives */}
|
{/* Links Columns */}
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-bold mb-4">Archives</h4>
|
|
||||||
<ul className="space-y-2 text-sm text-text-secondary">
|
|
||||||
<li><Link href="#" className="hover:text-primary">November 2025</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">September 2025</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">August 2025</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">June 2025</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">May 2025</Link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column 3: Categories */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-bold mb-4">Categories</h4>
|
|
||||||
<ul className="space-y-2 text-sm text-text-secondary">
|
|
||||||
<li><Link href="#" className="hover:text-primary">Cable Logistics</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">Cable Technology</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">KLZ News</Link></li>
|
|
||||||
<li><Link href="#" className="hover:text-primary">Renewable Energy</Link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column 4: Recent Posts */}
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h4 className="text-lg font-bold mb-4">Recent Posts</h4>
|
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||||
<ul className="space-y-4 text-sm text-text-secondary">
|
{t('legal')}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li>
|
<li>
|
||||||
<Link href="#" className="hover:text-primary block font-medium">Focus on wind farm construction: three typical cable challenges</Link>
|
<Link
|
||||||
|
href={`/${locale}/${t('legalNoticeSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{t('legalNotice')}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="#" className="hover:text-primary block font-medium">Why the N2XS(F)2Y is the ideal cable for your energy project</Link>
|
<Link
|
||||||
|
href={`/${locale}/${t('privacyPolicySlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{t('privacyPolicy')}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="#" className="hover:text-primary block font-medium">Shortage of NA2XSF2Y? We have the three-core medium-voltage cable</Link>
|
<Link
|
||||||
|
href={`/${locale}/${t('termsSlug')}`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{t('terms')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</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>
|
||||||
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/team`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{navT('team')}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="#" className="hover:text-primary block font-medium">Which cables for wind power? Differences from low to extra-high voltage explained</Link>
|
<Link
|
||||||
|
href={`/${locale}/products`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{navT('products')}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{navT('blog')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
|
>
|
||||||
|
{navT('contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</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>
|
||||||
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
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={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
|
||||||
|
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||||
|
{post.title}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-white/40 uppercase tracking-widest">
|
||||||
|
{t('readArticle')} →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
||||||
{/* Slide out widget area (hidden for now, but referenced in HTML) */}
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl transform translate-x-full transition-transform duration-300 z-50">
|
<div className="flex gap-8">
|
||||||
{/* Content would go here */}
|
<Link href="/en" locale="en" className="hover:text-white transition-colors">
|
||||||
</div>
|
English
|
||||||
|
</Link>
|
||||||
|
<Link href="/de" locale="de" className="hover:text-white transition-colors">
|
||||||
|
Deutsch
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
@@ -12,10 +13,11 @@ export default function Header() {
|
|||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
const currentLocale = pathname.split('/')[1] || 'en';
|
const currentLocale = pathname.split('/')[1] || 'en';
|
||||||
|
|
||||||
// Check if homepage
|
// Check if homepage
|
||||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||||
|
|
||||||
@@ -27,7 +29,16 @@ export default function Header() {
|
|||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', handleScroll);
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Prevent scroll when mobile menu is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobileMenuOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
// Function to get path for a different locale
|
// Function to get path for a different locale
|
||||||
const getPathForLocale = (newLocale: string) => {
|
const getPathForLocale = (newLocale: string) => {
|
||||||
const segments = pathname.split('/');
|
const segments = pathname.split('/');
|
||||||
@@ -43,89 +54,345 @@ export default function Header() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const headerClass = cn(
|
const headerClass = cn(
|
||||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
|
||||||
{
|
{
|
||||||
"bg-transparent": isHomePage && !isScrolled,
|
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
"bg-white shadow-md": !isHomePage || isScrolled,
|
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
"py-4": !isScrolled,
|
},
|
||||||
"py-2": isScrolled
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const textColorClass = (isHomePage && !isScrolled) ? "text-white" : "text-text-primary";
|
const textColorClass = 'text-white';
|
||||||
const logoSrc = (isHomePage && !isScrolled)
|
const logoSrc = '/logo-white.svg';
|
||||||
? "/logo-white.svg"
|
|
||||||
: "/logo-blue.svg";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={headerClass}>
|
<motion.header
|
||||||
<div className="container mx-auto px-4 flex items-center justify-between">
|
className={headerClass}
|
||||||
<Link href={`/${currentLocale}`} className="flex-shrink-0">
|
initial={{ y: -100, opacity: 0 }}
|
||||||
<Image
|
animate={{ y: 0, opacity: 1 }}
|
||||||
src={logoSrc}
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
alt="KLZ Cables"
|
>
|
||||||
width={100}
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
height={100}
|
<motion.div
|
||||||
className="h-12 w-auto transition-all duration-300"
|
className="flex-shrink-0 group touch-target"
|
||||||
priority
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
unoptimized
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
/>
|
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||||
</Link>
|
>
|
||||||
|
<Link href={`/${currentLocale}`}>
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={t('home')}
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex items-center gap-8">
|
<motion.div
|
||||||
<nav className="hidden lg:flex items-center space-x-8">
|
className="flex items-center gap-4 md:gap-12"
|
||||||
{menuItems.map((item) => (
|
initial="hidden"
|
||||||
<Link
|
animate="visible"
|
||||||
key={item.href}
|
variants={{
|
||||||
|
visible: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.08,
|
||||||
|
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}>
|
||||||
|
<Link
|
||||||
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
textColorClass,
|
||||||
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.nav>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||||
|
variants={headerRightVariants}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.65 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="w-px h-4 bg-current opacity-20"
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.75 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href={`/${currentLocale}/contact`}
|
||||||
|
variant="white"
|
||||||
|
size="md"
|
||||||
|
className="px-8 shadow-xl"
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 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,
|
||||||
|
)}
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<motion.svg
|
||||||
|
className="w-7 h-7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? (
|
||||||
|
<motion.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.svg>
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
|
isMobileMenuOpen
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||||
|
initial="closed"
|
||||||
|
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||||
|
variants={{
|
||||||
|
open: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.href}
|
||||||
|
variants={{
|
||||||
|
closed: { opacity: 0, y: 50, scale: 0.9 },
|
||||||
|
open: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeOut',
|
||||||
|
delay: idx * 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
className={cn(textColorClass, "hover:text-primary font-medium transition-colors text-lg")}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
</motion.div>
|
||||||
</nav>
|
))}
|
||||||
|
|
||||||
<div className={cn("hidden lg:flex items-center space-x-4", textColorClass)}>
|
<motion.div
|
||||||
<div className="flex items-center space-x-2 text-sm font-medium">
|
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||||
<Link
|
initial={{ opacity: 0, y: 30 }}
|
||||||
href={getPathForLocale('en')}
|
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||||
className={`hover:text-primary transition-colors flex items-center gap-1 ${currentLocale === 'en' ? 'text-primary font-bold' : 'opacity-80'}`}
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
>
|
>
|
||||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAALCAMAAABBPP0LAAAAmVBMVEViZsViZMJiYrf9gnL8eWrlYkjgYkjZYkj8/PujwPybvPz4+PetraBEgfo+fvo3efkydfkqcvj8Y2T8UlL8Q0P8MzP9k4Hz8/Lu7u4DdPj9/VrKysI9fPoDc/EAZ7z7IiLHYkjp6ekCcOTk5OIASbfY/v21takAJrT5Dg6sYkjc3Nn94t2RkYD+y8KeYkjs/v7l5fz0dF22YkjWvcOLAAAAgElEQVR4AR2KNULFQBgGZ5J13KGGKvc/Cw1uPe62eb9+Jr1EUBFHSgxxjP2Eca6AfUSfVlUfBvm1Ui1bqafctqMndNkXpb01h5TLx4b6TIXgwOCHfjv+/Pz+5vPRw7txGWT2h6yO0/GaYltIp5PT1dEpLNPL/SdWjYjAAZtvRPgHJX4Xio+DSrkAAAAASUVORK5CYII=" alt="English" width="16" height="11" />
|
<motion.div
|
||||||
</Link>
|
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
||||||
<Link
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
href={getPathForLocale('de')}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className={`hover:text-primary transition-colors flex items-center gap-1 ${currentLocale === 'de' ? 'text-primary font-bold' : 'opacity-80'}`}
|
transition={{ duration: 0.4, delay: 0.9 }}
|
||||||
>
|
|
||||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAALCAIAAAD5gJpuAAABLElEQVR4AY2QgUZEQRSGz9ydmzbYkBWABBJYABHEFhJ6m0WP0DMEQNIr9AKrN8ne2Tt3Zs7MOdOZmRBEv+v34Tvub9R6fdNlAzU+snSME/wdjbjbbJ6EiEg6BA8102QbjKNpoMzw8v6qD/sOALbbT2MC1NgaAWOKOgxf5czY+4dbAX2G/THzcozLrvPV85IQyqVz0rvg2p9Pei4HjzSsiFbV4JgyhhxCjpGdZ0RhdikLB9/b8Qig7MkpSovR7Cp59q6CazaNFiTt4J82o6uvdMVwTsztKTXZod4jgOJJuqNAjFyGrBR8gM6XwKfIC4KanBSTZ0rClKh08D9DFh3egW7ebH7NcRDQWrz9rM2Ne+mDOXB2mZJ8agL19nwxR2iZXGm1gDbQKhDjd4yHb2oW/KR8xHicAAAAAElFTkSuQmCC" alt="Deutsch" width="16" height="11" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
href="/contact"
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-6 transition-colors",
|
|
||||||
isHomePage && !isScrolled
|
|
||||||
? "border-white text-white hover:bg-white hover:text-black"
|
|
||||||
: "border-primary text-primary hover:bg-primary hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Get in touch
|
<motion.div
|
||||||
</Button>
|
initial={{ opacity: 0 }}
|
||||||
</div>
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 1.0 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="w-px h-6 bg-white/20"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 1.05 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 1.1 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
<motion.div
|
||||||
<button className={cn("lg:hidden p-2", textColorClass)}>
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||||
</svg>
|
>
|
||||||
</button>
|
<Button
|
||||||
</div>
|
href={`/${currentLocale}/contact`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Bottom Branding */}
|
||||||
|
<motion.div
|
||||||
|
className="p-12 flex justify-center opacity-20"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.5, delay: 1.4 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.5 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||||
|
>
|
||||||
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</motion.header>
|
||||||
{!isHomePage && <div className="h-24 md:h-32" />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.06,
|
||||||
|
delayChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const navLinkVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20, scale: 0.9 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'easeOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const headerRightVariants = {
|
||||||
|
hidden: { opacity: 0, x: 30 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: { duration: 0.6, ease: 'easeOut' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|||||||
51
components/JsonLd.tsx
Normal file
51
components/JsonLd.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Thing, WithContext } from 'schema-dts';
|
||||||
|
|
||||||
|
interface JsonLdProps {
|
||||||
|
id?: string;
|
||||||
|
data?: WithContext<Thing> | WithContext<Thing>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JsonLd({ id, data }: JsonLdProps) {
|
||||||
|
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
|
||||||
|
const schemaData = data || [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
url: 'https://klz-cables.com',
|
||||||
|
logo: 'https://klz-cables.com/logo-blue.svg',
|
||||||
|
sameAs: [
|
||||||
|
'https://www.linkedin.com/company/klz-cables',
|
||||||
|
],
|
||||||
|
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressCountry: 'DE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'KLZ Cables',
|
||||||
|
url: 'https://klz-cables.com',
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: {
|
||||||
|
'@type': 'EntryPoint',
|
||||||
|
urlTemplate: 'https://klz-cables.com/search?q={search_term_string}',
|
||||||
|
},
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
id={id}
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(schemaData),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
components/LeafletMap.tsx
Normal file
45
components/LeafletMap.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
|
||||||
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
|
interface LeafletMapProps {
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
||||||
|
const position: [number, number] = [lat, lng];
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
components/Lightbox.tsx
Normal file
211
components/Lightbox.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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;
|
||||||
|
images: string[];
|
||||||
|
initialIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Lightbox({ isOpen, images, initialIndex, onClose }: LightboxProps) {
|
||||||
|
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) {
|
||||||
|
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 = originalStyle;
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
components/ProductSidebar.tsx
Normal file
72
components/ProductSidebar.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface ProductSidebarProps {
|
||||||
|
productName: string;
|
||||||
|
productImage?: string;
|
||||||
|
datasheetPath?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
|
||||||
|
const t = useTranslations('Products');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-4 animate-slight-fade-in-from-bottom", className)}>
|
||||||
|
{/* Request Quote Form Card */}
|
||||||
|
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||||
|
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||||
|
{/* Background Accent - Saturated Blue Glow */}
|
||||||
|
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Product Thumbnail with Reflection */}
|
||||||
|
{productImage && (
|
||||||
|
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||||
|
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||||
|
<Image
|
||||||
|
src={productImage}
|
||||||
|
alt={productName}
|
||||||
|
fill
|
||||||
|
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||||
|
/>
|
||||||
|
{/* Subtle Reflection Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-transparent opacity-30 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="inline-block relative mb-2">
|
||||||
|
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
||||||
|
{t('requestQuote')}
|
||||||
|
</h3>
|
||||||
|
<Scribble
|
||||||
|
variant="underline"
|
||||||
|
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||||
|
color="var(--color-accent)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60 text-xs md:text-sm m-0 mt-2 leading-relaxed font-medium max-w-[90%]">
|
||||||
|
{t('requestQuoteDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-neutral-light/50">
|
||||||
|
<RequestQuoteForm productName={productName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datasheet Download */}
|
||||||
|
{datasheetPath && (
|
||||||
|
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/ProductTabs.tsx
Normal file
44
components/ProductTabs.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ProductTabsProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
technicalData?: React.ReactNode;
|
||||||
|
sidebar?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductTabs({ children, technicalData, sidebar }: ProductTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-24">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{sidebar && (
|
||||||
|
<div className="lg:hidden mb-12">
|
||||||
|
{sidebar}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-w-none">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sidebar && (
|
||||||
|
<div className="hidden lg:block sticky top-32 w-[350px] xl:w-[400px] flex-shrink-0">
|
||||||
|
{sidebar}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{technicalData && (
|
||||||
|
<div className="pt-24 border-t-2 border-neutral-dark/5">
|
||||||
|
<div className="mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">Technical Specifications</h2>
|
||||||
|
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="not-prose">
|
||||||
|
{technicalData}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import React from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface KeyValueItem {
|
interface KeyValueItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -21,19 +24,33 @@ interface ProductTechnicalDataProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductTechnicalData({ data }: ProductTechnicalDataProps) {
|
export default function ProductTechnicalData({ data }: ProductTechnicalDataProps) {
|
||||||
const { technicalItems, voltageTables } = data;
|
const t = useTranslations('Products');
|
||||||
|
const [expandedTables, setExpandedTables] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
const { technicalItems = [], voltageTables = [] } = data;
|
||||||
|
|
||||||
|
const toggleTable = (idx: number) => {
|
||||||
|
setExpandedTables(prev => ({
|
||||||
|
...prev,
|
||||||
|
[idx]: !prev[idx]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-16">
|
||||||
{technicalItems.length > 0 && (
|
{technicalItems.length > 0 && (
|
||||||
<div className="bg-neutral-light p-6 rounded-lg shadow-sm">
|
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||||
<h3 className="text-xl font-semibold mb-4">General Data</h3>
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
|
General Data
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex flex-col border-b border-neutral-dark pb-2 last:border-0">
|
<div key={idx} className="flex flex-col group">
|
||||||
<dt className="text-sm text-text-secondary">{item.label}</dt>
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">{item.label}</dt>
|
||||||
<dd className="text-base font-medium text-text-primary">
|
<dd className="text-lg font-semibold text-text-primary">
|
||||||
{item.value} {item.unit && <span className="text-sm text-text-secondary ml-1">{item.unit}</span>}
|
{item.value} {item.unit && <span className="text-sm font-normal text-text-secondary ml-1">{item.unit}</span>}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -41,57 +58,84 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{voltageTables.map((table, idx) => (
|
{voltageTables.map((table, idx) => {
|
||||||
<div key={idx} className="bg-neutral-light p-6 rounded-lg shadow-sm overflow-hidden">
|
const isExpanded = expandedTables[idx];
|
||||||
<h3 className="text-xl font-semibold mb-4">
|
const hasManyRows = table.rows.length > 10;
|
||||||
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
|
||||||
? table.voltageLabel
|
return (
|
||||||
: 'Technical Specifications'}
|
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
|
||||||
</h3>
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
{table.metaItems.length > 0 && (
|
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-6 bg-neutral p-4 rounded">
|
? table.voltageLabel
|
||||||
{table.metaItems.map((item, mIdx) => (
|
: 'Technical Specifications'}
|
||||||
<div key={mIdx}>
|
</h3>
|
||||||
<dt className="text-xs text-text-secondary uppercase tracking-wider">{item.label}</dt>
|
|
||||||
<dd className="font-medium">{item.value} {item.unit}</dd>
|
{table.metaItems.length > 0 && (
|
||||||
</div>
|
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
||||||
))}
|
{table.metaItems.map((item, mIdx) => (
|
||||||
</dl>
|
<div key={mIdx}>
|
||||||
)}
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
|
||||||
|
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
|
||||||
<div className="overflow-x-auto">
|
</div>
|
||||||
<table className="min-w-full divide-y divide-neutral-dark">
|
|
||||||
<thead className="bg-neutral">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="px-3 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider sticky left-0 bg-neutral z-10">
|
|
||||||
Configuration
|
|
||||||
</th>
|
|
||||||
{table.columns.map((col, cIdx) => (
|
|
||||||
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider whitespace-nowrap">
|
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-neutral-dark">
|
|
||||||
{table.rows.map((row, rIdx) => (
|
|
||||||
<tr key={rIdx} className="hover:bg-neutral-light transition-colors">
|
|
||||||
<td className="px-3 py-3 text-sm font-medium text-text-primary sticky left-0 bg-white z-10 whitespace-nowrap">
|
|
||||||
{row.configuration}
|
|
||||||
</td>
|
|
||||||
{row.cells.map((cell, cellIdx) => (
|
|
||||||
<td key={cellIdx} className="px-3 py-3 text-sm text-text-secondary whitespace-nowrap">
|
|
||||||
{cell}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</dl>
|
||||||
</table>
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
||||||
|
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
|
||||||
|
Config.
|
||||||
|
</th>
|
||||||
|
{table.columns.map((col, cIdx) => (
|
||||||
|
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-dark/5">
|
||||||
|
{table.rows.map((row, rIdx) => (
|
||||||
|
<tr key={rIdx} className="hover:bg-neutral-light/50 transition-colors group">
|
||||||
|
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
|
||||||
|
{row.configuration}
|
||||||
|
</td>
|
||||||
|
{row.cells.map((cell, cellIdx) => (
|
||||||
|
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isExpanded && hasManyRows && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white via-white/80 to-transparent z-20 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasManyRows && (
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTable(idx)}
|
||||||
|
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
||||||
|
>
|
||||||
|
{isExpanded ? t('showLess') : t('showMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
components/RelatedProducts.tsx
Normal file
101
components/RelatedProducts.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface RelatedProductsProps {
|
||||||
|
currentSlug: string;
|
||||||
|
categories: string[];
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
||||||
|
const allProducts = 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))
|
||||||
|
)
|
||||||
|
.slice(0, 3); // Limit to 3 for better spacing
|
||||||
|
|
||||||
|
if (related.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<div className="flex items-end justify-between mb-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold text-primary tracking-tight mb-4">
|
||||||
|
{t('relatedProductsTitle') || 'Related Products'}
|
||||||
|
</h2>
|
||||||
|
<div className="h-1.5 w-20 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{related.map(async (product) => {
|
||||||
|
// Find the category slug for the link
|
||||||
|
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||||
|
const 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={product.slug}
|
||||||
|
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
||||||
|
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">
|
||||||
|
{product.frontmatter.images?.[0] ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={product.frontmatter.images[0]}
|
||||||
|
alt={product.frontmatter.title}
|
||||||
|
fill
|
||||||
|
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-2/3 h-3 bg-black/5 blur-lg rounded-full" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-text-secondary/30 font-bold uppercase tracking-widest text-xs">
|
||||||
|
No Image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-text-primary group-hover:text-primary transition-colors leading-tight">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-6 flex items-center text-primary text-sm font-bold group-hover:text-accent-dark transition-colors">
|
||||||
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
components/RequestQuoteForm.tsx
Normal file
208
components/RequestQuoteForm.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Input, Textarea, Button } from '@/components/ui';
|
||||||
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
|
||||||
|
interface RequestQuoteFormProps {
|
||||||
|
productName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) {
|
||||||
|
const t = useTranslations('Products.form');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [request, setRequest] = useState('');
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus('submitting');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', 'Product Inquiry'); // Default name for product inquiries
|
||||||
|
formData.append('email', email);
|
||||||
|
formData.append('message', request);
|
||||||
|
formData.append('productName', productName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendContactFormAction(formData);
|
||||||
|
if (result.success) {
|
||||||
|
trackEvent('contact_form_submission', {
|
||||||
|
form_type: 'product_quote',
|
||||||
|
product_name: productName,
|
||||||
|
email: email,
|
||||||
|
});
|
||||||
|
setStatus('success');
|
||||||
|
setEmail('');
|
||||||
|
setRequest('');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Form submission error:', 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 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 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
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-primary hover:text-accent transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="border-b-2 border-primary/10 group-hover:border-accent transition-colors pb-1">
|
||||||
|
{t('sendAnother')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="space-y-1 !mt-0">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={t('email')}
|
||||||
|
className="h-9 text-xs !mt-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 !mt-0">
|
||||||
|
<Textarea
|
||||||
|
id="request"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={request}
|
||||||
|
onChange={(e) => setRequest(e.target.value)}
|
||||||
|
placeholder={t('message')}
|
||||||
|
className="text-xs !mt-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 !mt-0">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
className="w-full py-2 rounded-lg flex items-center justify-center gap-2 group !mt-0"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/Reveal.tsx
Normal file
49
components/Reveal.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
|
interface RevealProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
threshold?: number;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Reveal({ children, className, threshold = 0.1, delay = 0 }: RevealProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold }
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentRef = ref.current;
|
||||||
|
if (currentRef) {
|
||||||
|
observer.observe(currentRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (currentRef) {
|
||||||
|
observer.unobserve(currentRef);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('reveal-on-scroll', isVisible && 'is-visible', className)}
|
||||||
|
style={{ transitionDelay: `${delay}ms` }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { motion, Variants } from 'framer-motion';
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface ScribbleProps {
|
interface ScribbleProps {
|
||||||
@@ -8,6 +11,18 @@ interface ScribbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
||||||
|
const pathVariants: Variants = {
|
||||||
|
hidden: { pathLength: 0, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 1.8,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -16,12 +31,14 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
viewBox="0 0 800 350"
|
viewBox="0 0 800 350"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<path
|
<motion.path
|
||||||
style={{ animationDuration: '1.8s' }}
|
variants={pathVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true }}
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
strokeLinejoin="miter"
|
strokeLinejoin="miter"
|
||||||
fillOpacity="0"
|
fillOpacity="0"
|
||||||
pathLength="1"
|
|
||||||
strokeMiterlimit="4"
|
strokeMiterlimit="4"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeOpacity="1"
|
strokeOpacity="1"
|
||||||
@@ -40,11 +57,13 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
viewBox="-400 -55 730 60"
|
viewBox="-400 -55 730 60"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<path
|
<motion.path
|
||||||
style={{ animationDuration: '1.8s' }}
|
variants={pathVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true }}
|
||||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
pathLength="1"
|
|
||||||
strokeWidth="20"
|
strokeWidth="20"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
35
components/analytics/AnalyticsProvider.tsx
Normal file
35
components/analytics/AnalyticsProvider.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnalyticsProvider Component
|
||||||
|
*
|
||||||
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
|
* This component handles navigation events for the Umami analytics service.
|
||||||
|
*
|
||||||
|
* 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();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Services like logger are already sub-initialized in getAppServices()
|
||||||
|
// so we don't need to log here manually.
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
1201
components/analytics/EXAMPLES.md
Normal file
1201
components/analytics/EXAMPLES.md
Normal file
File diff suppressed because it is too large
Load Diff
166
components/analytics/QUICK_REFERENCE.md
Normal file
166
components/analytics/QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Umami Analytics Quick Reference
|
||||||
|
|
||||||
|
## Setup Checklist
|
||||||
|
|
||||||
|
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
||||||
|
- [ ] Verify `UmamiScript` is in your layout
|
||||||
|
- [ ] Verify `AnalyticsProvider` is in your layout
|
||||||
|
- [ ] Test in development mode
|
||||||
|
- [ ] Check Umami dashboard for data
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
|
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Usage Examples
|
||||||
|
|
||||||
|
### Track an Event
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
button_id: 'my-button',
|
||||||
|
page: 'homepage',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={handleClick}>Click Me</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track a Pageview
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { trackPageview } = useAnalytics();
|
||||||
|
|
||||||
|
const navigate = () => {
|
||||||
|
trackPageview('/custom-path');
|
||||||
|
// Navigate...
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={navigate}>Go to Page</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track E-commerce Events
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ProductCard({ product }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const addToCart = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={addToCart}>Add to Cart</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Events
|
||||||
|
|
||||||
|
| Event | When to Use | Example Properties |
|
||||||
|
|-------|-------------|-------------------|
|
||||||
|
| `pageview` | Page loads | `{ url: '/products/123' }` |
|
||||||
|
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
|
||||||
|
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
|
||||||
|
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
|
||||||
|
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
|
||||||
|
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
|
||||||
|
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||||
|
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
In development, you'll see console logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Umami] Tracked event: button_click { button_id: 'my-button' }
|
||||||
|
[Umami] Tracked pageview: /products/123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Analytics (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Analytics Not Working?
|
||||||
|
|
||||||
|
1. **Check environment variables:**
|
||||||
|
```bash
|
||||||
|
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify script is loading:**
|
||||||
|
- Open DevTools → Network tab
|
||||||
|
- Look for `script.js` request
|
||||||
|
- Check Console for errors
|
||||||
|
|
||||||
|
3. **Check Umami dashboard:**
|
||||||
|
- Log into Umami
|
||||||
|
- Verify website ID matches
|
||||||
|
- Check if data is being received
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| No data in Umami | Check website ID and script URL |
|
||||||
|
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||||
|
| Script not loading | Check network connection, CORS |
|
||||||
|
| Wrong data | Verify event properties are correct |
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Use `useCallback` for event handlers** to prevent unnecessary re-renders
|
||||||
|
2. **Debounce high-frequency events** (like search input)
|
||||||
|
3. **Don't track every interaction** - focus on meaningful events
|
||||||
|
4. **Use environment variables** to disable analytics in development
|
||||||
|
|
||||||
|
## Privacy & Compliance
|
||||||
|
|
||||||
|
- ✅ Don't track PII (personally identifiable information)
|
||||||
|
- ✅ Don't track sensitive data (passwords, credit cards)
|
||||||
|
- ✅ Follow GDPR and other privacy regulations
|
||||||
|
- ✅ Use anonymized IDs where possible
|
||||||
|
- ✅ Provide cookie consent if required
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Read [`README.md`](README.md) for detailed documentation
|
||||||
|
2. Check [`EXAMPLES.md`](EXAMPLES.md) for more use cases
|
||||||
|
3. Review [`analytics-events.ts`](analytics-events.ts) for event definitions
|
||||||
|
4. Explore [`useAnalytics.ts`](useAnalytics.ts) for the hook implementation
|
||||||
443
components/analytics/README.md
Normal file
443
components/analytics/README.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# Umami Analytics Integration
|
||||||
|
|
||||||
|
This project uses [Umami Analytics](https://umami.is/) for privacy-focused website analytics. The implementation is modern, clean, and follows Next.js best practices.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The analytics system consists of:
|
||||||
|
|
||||||
|
1. **`UmamiScript`** - Loads the Umami tracking script
|
||||||
|
2. **`AnalyticsProvider`** - Tracks pageviews on route changes
|
||||||
|
3. **`useAnalytics`** - Custom hook for tracking custom events
|
||||||
|
4. **`analytics-events.ts`** - Centralized event definitions
|
||||||
|
5. **`UmamiAnalyticsService`** - Service layer for analytics operations
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Add these to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required: Your Umami website ID
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
|
||||||
|
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
The `docker-compose.yml` already includes the environment variables:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Automatic Pageview Tracking
|
||||||
|
|
||||||
|
The `AnalyticsProvider` component automatically tracks pageviews on client-side route changes. It's already included in your layout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/[locale]/layout.tsx
|
||||||
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
<UmamiScript />
|
||||||
|
<Header />
|
||||||
|
<main>{children}</main>
|
||||||
|
<Footer />
|
||||||
|
<AnalyticsProvider />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tracking Custom Events
|
||||||
|
|
||||||
|
Use the `useAnalytics` hook to track custom events:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ProductCard({ product }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
product_category: product.category,
|
||||||
|
price: product.price,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleAddToCart}>
|
||||||
|
Add to Cart
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Tracking Pageviews Manually
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
|
||||||
|
function CustomNavigation() {
|
||||||
|
const { trackPageview } = useAnalytics();
|
||||||
|
|
||||||
|
const navigateToCustomPage = () => {
|
||||||
|
// Track a custom pageview
|
||||||
|
trackPageview('/custom-path?param=value');
|
||||||
|
|
||||||
|
// Then perform navigation
|
||||||
|
window.location.href = '/custom-path?param=value';
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={navigateToCustomPage}>Go to Custom Page</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Using Predefined Events
|
||||||
|
|
||||||
|
The `analytics-events.ts` file provides a centralized list of events:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents, AnalyticsEventProperties } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ContactForm() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleSubmit = (formData: FormData) => {
|
||||||
|
// Track form submission
|
||||||
|
trackEvent(AnalyticsEvents.CONTACT_FORM_SUBMIT, {
|
||||||
|
form_id: 'contact-form',
|
||||||
|
form_name: 'Contact Us',
|
||||||
|
form_fields: {
|
||||||
|
name: formData.get('name'),
|
||||||
|
email: formData.get('email'),
|
||||||
|
message: formData.get('message'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <form onSubmit={handleSubmit}>{/* form fields */}</form>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. E-commerce Tracking Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ProductPage({ product }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
// Track product view on page load
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
product_category: product.category,
|
||||||
|
price: product.price,
|
||||||
|
});
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
product_category: product.category,
|
||||||
|
price: product.price,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePurchase = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_PURCHASE, {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
product_category: product.category,
|
||||||
|
price: product.price,
|
||||||
|
transaction_id: 'TXN-12345',
|
||||||
|
currency: 'EUR',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{product.name}</h1>
|
||||||
|
<p>{product.description}</p>
|
||||||
|
<button onClick={handleAddToCart}>Add to Cart</button>
|
||||||
|
<button onClick={handlePurchase}>Buy Now</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Search & Filter Tracking
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ProductFilter() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleFilterChange = (filters: Record<string, unknown>) => {
|
||||||
|
trackEvent(AnalyticsEvents.FILTER_APPLY, {
|
||||||
|
filter_type: 'category',
|
||||||
|
filter_value: filters.category,
|
||||||
|
filter_count: Object.keys(filters).length,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
trackEvent(AnalyticsEvents.SEARCH, {
|
||||||
|
search_query: query,
|
||||||
|
search_results_count: 42, // You'd get this from your search results
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||||
|
<select onChange={(e) => handleFilterChange({ category: e.target.value })}>
|
||||||
|
{/* filter options */}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. User Account Events
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleLogin = (email: string) => {
|
||||||
|
trackEvent(AnalyticsEvents.USER_LOGIN, {
|
||||||
|
user_email: email,
|
||||||
|
login_method: 'email',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
trackEvent(AnalyticsEvents.USER_LOGOUT, {
|
||||||
|
user_id: 'user-123',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => handleLogin('user@example.com')}>Login</button>
|
||||||
|
<button onClick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Error Tracking
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ErrorBoundary({ children }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleError = (error: Error, errorInfo: React.ErrorInfo) => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
error_message: error.message,
|
||||||
|
error_stack: error.stack,
|
||||||
|
error_component: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary onError={handleError}>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Reference
|
||||||
|
|
||||||
|
### Common Events
|
||||||
|
|
||||||
|
| Event Name | Description | Example Properties |
|
||||||
|
|------------|-------------|-------------------|
|
||||||
|
| `pageview` | Page view | `{ url: '/products/123' }` |
|
||||||
|
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
|
||||||
|
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
|
||||||
|
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
|
||||||
|
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
|
||||||
|
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
|
||||||
|
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
|
||||||
|
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
|
||||||
|
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
|
||||||
|
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
|
||||||
|
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
|
||||||
|
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
|
||||||
|
|
||||||
|
### Custom Events
|
||||||
|
|
||||||
|
You can create any custom event by passing a string name:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
trackEvent('custom_event_name', {
|
||||||
|
custom_property: 'value',
|
||||||
|
another_property: 123,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Centralized Event Definitions
|
||||||
|
|
||||||
|
Always use the `AnalyticsEvents` constant for consistency:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Good
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, { ... });
|
||||||
|
|
||||||
|
// ❌ Avoid
|
||||||
|
trackEvent('product_add_to_cart', { ... }); // Typo risk!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Include Relevant Context
|
||||||
|
|
||||||
|
Add context to your events to make them more useful:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Good
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
button_id: 'cta-primary',
|
||||||
|
page: 'homepage',
|
||||||
|
section: 'hero',
|
||||||
|
user_type: 'guest',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Less useful
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
button_id: 'cta-primary',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Track Meaningful Events
|
||||||
|
|
||||||
|
Focus on business-critical events:
|
||||||
|
|
||||||
|
- ✅ Product views, add to cart, purchases
|
||||||
|
- ✅ Form submissions (contact, newsletter, quote requests)
|
||||||
|
- ✅ Search queries and filter usage
|
||||||
|
- ✅ User authentication events
|
||||||
|
- ✅ Error occurrences
|
||||||
|
|
||||||
|
- ❌ Every mouse move
|
||||||
|
- ❌ Every scroll event (unless specifically needed)
|
||||||
|
- ❌ Every hover state change
|
||||||
|
|
||||||
|
### 4. Respect Privacy
|
||||||
|
|
||||||
|
- Don't track personally identifiable information (PII)
|
||||||
|
- Don't track sensitive data (passwords, credit cards, etc.)
|
||||||
|
- Use anonymized IDs where possible
|
||||||
|
- Follow GDPR and other privacy regulations
|
||||||
|
|
||||||
|
### 5. Test in Development
|
||||||
|
|
||||||
|
The analytics system includes development mode logging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In development, you'll see console logs:
|
||||||
|
[Umami] Tracked event: product_add_to_cart { product_id: '123' }
|
||||||
|
[Umami] Tracked pageview: /products/123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Analytics Not Working
|
||||||
|
|
||||||
|
1. **Check environment variables:**
|
||||||
|
```bash
|
||||||
|
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify the script is loading:**
|
||||||
|
- Open browser DevTools
|
||||||
|
- Check Network tab for `script.js` request
|
||||||
|
- Check Console for any errors
|
||||||
|
|
||||||
|
3. **Check Umami dashboard:**
|
||||||
|
- Log into your Umami instance
|
||||||
|
- Verify the website ID matches
|
||||||
|
- Check if data is being received
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
In development mode, you'll see console logs for all tracked events. This helps you verify that events are being tracked correctly without affecting your production analytics.
|
||||||
|
|
||||||
|
### Disabling Analytics
|
||||||
|
|
||||||
|
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local (not committed to git)
|
||||||
|
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
The analytics implementation is optimized for performance:
|
||||||
|
|
||||||
|
- ✅ Uses Next.js `Script` component with `afterInteractive` strategy
|
||||||
|
- ✅ Script loads after page is interactive
|
||||||
|
- ✅ No blocking of critical rendering path
|
||||||
|
- ✅ Minimal JavaScript bundle size
|
||||||
|
- ✅ Automatic cleanup on route changes
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- ✅ Environment variables are not exposed to the client (except `NEXT_PUBLIC_` prefixed ones)
|
||||||
|
- ✅ Script URL can be customized for self-hosted Umami instances
|
||||||
|
- ✅ Error handling prevents analytics from breaking your app
|
||||||
|
- ✅ Type-safe event tracking with TypeScript
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Umami Documentation](https://umami.is/docs)
|
||||||
|
- [Next.js Script Component](https://nextjs.org/docs/app/api-reference/components/script)
|
||||||
|
- [Analytics Best Practices](https://umami.is/docs/best-practices)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions about the analytics implementation, check:
|
||||||
|
1. This README for usage examples
|
||||||
|
2. The component source code for implementation details
|
||||||
|
3. The Umami documentation for platform-specific questions
|
||||||
268
components/analytics/SUMMARY.md
Normal file
268
components/analytics/SUMMARY.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Umami Analytics Implementation Summary
|
||||||
|
|
||||||
|
## ✅ Implementation Status: COMPLETE
|
||||||
|
|
||||||
|
Your project now has a **modern, clean, and comprehensive** Umami analytics implementation.
|
||||||
|
|
||||||
|
## What Was Already Implemented
|
||||||
|
|
||||||
|
The project already had a solid foundation:
|
||||||
|
|
||||||
|
1. **`UmamiScript.tsx`** - Next.js Script component for loading analytics
|
||||||
|
2. **`AnalyticsProvider.tsx`** - Automatic pageview tracking on route changes
|
||||||
|
3. **`UmamiAnalyticsService.ts`** - Service layer for event tracking
|
||||||
|
4. **Environment variables** in `docker-compose.yml`
|
||||||
|
|
||||||
|
## What Was Enhanced
|
||||||
|
|
||||||
|
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
|
||||||
|
- ✅ Added TypeScript props interface for customization
|
||||||
|
- ✅ Added JSDoc documentation with usage examples
|
||||||
|
- ✅ Added error handling for script loading failures
|
||||||
|
- ✅ Added development mode warnings
|
||||||
|
- ✅ Improved type safety and comments
|
||||||
|
|
||||||
|
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
|
||||||
|
- ✅ Added comprehensive JSDoc documentation
|
||||||
|
- ✅ Added development mode logging
|
||||||
|
- ✅ Improved code comments
|
||||||
|
|
||||||
|
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
|
||||||
|
- ✅ Type-safe `useAnalytics` hook for easy event tracking
|
||||||
|
- ✅ `trackEvent()` method for custom events
|
||||||
|
- ✅ `trackPageview()` method for manual pageview tracking
|
||||||
|
- ✅ `useCallback` optimization for performance
|
||||||
|
- ✅ Development mode logging
|
||||||
|
|
||||||
|
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
|
||||||
|
- ✅ Centralized event constants for consistency
|
||||||
|
- ✅ Type-safe event names
|
||||||
|
- ✅ Helper functions for common event properties
|
||||||
|
- ✅ 30+ predefined events for various use cases
|
||||||
|
|
||||||
|
### 5. **Comprehensive Documentation**
|
||||||
|
- ✅ **README.md** - Full documentation with setup, usage, and best practices
|
||||||
|
- ✅ **EXAMPLES.md** - 20+ practical examples for different scenarios
|
||||||
|
- ✅ **QUICK_REFERENCE.md** - Quick start guide and troubleshooting
|
||||||
|
- ✅ **SUMMARY.md** - This file
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
components/analytics/
|
||||||
|
├── UmamiScript.tsx # Script loader component
|
||||||
|
├── AnalyticsProvider.tsx # Route change tracker
|
||||||
|
├── useAnalytics.ts # Custom hook for event tracking
|
||||||
|
├── analytics-events.ts # Event definitions and helpers
|
||||||
|
├── README.md # Full documentation
|
||||||
|
├── EXAMPLES.md # Practical examples
|
||||||
|
├── QUICK_REFERENCE.md # Quick start guide
|
||||||
|
└── SUMMARY.md # This summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 🚀 Modern Implementation
|
||||||
|
- Uses Next.js `Script` component (not old-school `<script>` tags)
|
||||||
|
- TypeScript for type safety
|
||||||
|
- React hooks for clean API
|
||||||
|
- Environment variable configuration
|
||||||
|
|
||||||
|
### 📊 Comprehensive Tracking
|
||||||
|
- Automatic pageview tracking on route changes
|
||||||
|
- Custom event tracking with properties
|
||||||
|
- E-commerce events (products, cart, purchases)
|
||||||
|
- User authentication events
|
||||||
|
- Search and filter tracking
|
||||||
|
- Error and performance tracking
|
||||||
|
|
||||||
|
### 🎯 Developer Experience
|
||||||
|
- Type-safe event tracking
|
||||||
|
- Centralized event definitions
|
||||||
|
- Development mode logging
|
||||||
|
- Comprehensive documentation
|
||||||
|
- 20+ practical examples
|
||||||
|
|
||||||
|
### 🔒 Privacy & Performance
|
||||||
|
- No PII tracking by default
|
||||||
|
- Script loads after page is interactive
|
||||||
|
- Minimal performance impact
|
||||||
|
- Easy to disable in development
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The project is already configured in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
|
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Setup
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Event Tracking
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
button_id: 'my-button',
|
||||||
|
page: 'homepage',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={handleClick}>Click Me</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E-commerce Tracking
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
|
function ProductCard({ product }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const addToCart = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={addToCart}>Add to Cart</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Pageview Tracking
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
|
||||||
|
function CustomNavigation() {
|
||||||
|
const { trackPageview } = useAnalytics();
|
||||||
|
|
||||||
|
const navigate = () => {
|
||||||
|
trackPageview('/custom-path');
|
||||||
|
// Navigate...
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={navigate}>Go to Page</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing & Development
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
In development, you'll see console logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Umami] Tracked event: button_click { button_id: 'my-button' }
|
||||||
|
[Umami] Tracked pageview: /products/123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Analytics (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Analytics Not Working?
|
||||||
|
|
||||||
|
1. **Check environment variables:**
|
||||||
|
```bash
|
||||||
|
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify script is loading:**
|
||||||
|
- Open DevTools → Network tab
|
||||||
|
- Look for `script.js` request
|
||||||
|
- Check Console for errors
|
||||||
|
|
||||||
|
3. **Check Umami dashboard:**
|
||||||
|
- Log into Umami
|
||||||
|
- Verify website ID matches
|
||||||
|
- Check if data is being received
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| No data in Umami | Check website ID and script URL |
|
||||||
|
| Events not tracking | Verify `useAnalytics` hook is used |
|
||||||
|
| Script not loading | Check network connection, CORS |
|
||||||
|
| Wrong data | Verify event properties are correct |
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Use `useCallback`** - The hook is already optimized
|
||||||
|
2. **Debounce high-frequency events** - See EXAMPLES.md
|
||||||
|
3. **Don't track every interaction** - Focus on meaningful events
|
||||||
|
4. **Use environment variables** - Disable in development
|
||||||
|
|
||||||
|
## Privacy & Compliance
|
||||||
|
|
||||||
|
- ✅ Don't track PII (personally identifiable information)
|
||||||
|
- ✅ Don't track sensitive data (passwords, credit cards)
|
||||||
|
- ✅ Follow GDPR and other privacy regulations
|
||||||
|
- ✅ Use anonymized IDs where possible
|
||||||
|
- ✅ Provide cookie consent if required
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ **Setup complete** - All files are in place
|
||||||
|
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
|
||||||
|
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
|
||||||
|
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
||||||
|
5. 🧪 **Test in development** - Verify events are tracked
|
||||||
|
6. 🚀 **Deploy** - Analytics will work in production
|
||||||
|
|
||||||
|
## Quick Start Checklist
|
||||||
|
|
||||||
|
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
|
||||||
|
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
|
||||||
|
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
|
||||||
|
- [ ] Test in development mode (check console logs)
|
||||||
|
- [ ] Check Umami dashboard for data
|
||||||
|
- [ ] Review EXAMPLES.md for specific use cases
|
||||||
|
- [ ] Start tracking custom events with `useAnalytics`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Your Umami analytics implementation is now **production-ready** with:
|
||||||
|
|
||||||
|
- ✅ **Modern Next.js approach** (Script component, not old-school tags)
|
||||||
|
- ✅ **Type-safe API** (TypeScript throughout)
|
||||||
|
- ✅ **Comprehensive tracking** (pageviews, events, e-commerce, errors)
|
||||||
|
- ✅ **Excellent documentation** (README, examples, quick reference)
|
||||||
|
- ✅ **Developer-friendly** (hooks, helpers, development mode)
|
||||||
|
- ✅ **Performance optimized** (async loading, minimal impact)
|
||||||
|
- ✅ **Privacy conscious** (no PII, easy to disable)
|
||||||
|
|
||||||
|
The implementation is clean, maintainable, and follows Next.js best practices. You can now track any user interaction or business event with just a few lines of code.
|
||||||
130
components/analytics/analytics-events.ts
Normal file
130
components/analytics/analytics-events.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
* product_name: productName,
|
||||||
|
* page: 'product-detail'
|
||||||
|
* });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AnalyticsEvents = {
|
||||||
|
// Page & Navigation Events
|
||||||
|
PAGE_VIEW: 'pageview',
|
||||||
|
PAGE_SCROLL: 'page_scroll',
|
||||||
|
PAGE_EXIT: 'page_exit',
|
||||||
|
|
||||||
|
// User Interaction Events
|
||||||
|
BUTTON_CLICK: 'button_click',
|
||||||
|
LINK_CLICK: 'link_click',
|
||||||
|
FORM_SUBMIT: 'form_submit',
|
||||||
|
FORM_START: 'form_start',
|
||||||
|
FORM_ERROR: 'form_error',
|
||||||
|
|
||||||
|
// E-commerce Events
|
||||||
|
PRODUCT_VIEW: 'product_view',
|
||||||
|
PRODUCT_ADD_TO_CART: 'product_add_to_cart',
|
||||||
|
PRODUCT_REMOVE_FROM_CART: 'product_remove_from_cart',
|
||||||
|
PRODUCT_PURCHASE: 'product_purchase',
|
||||||
|
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
|
||||||
|
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
|
||||||
|
|
||||||
|
// Search & Filter Events
|
||||||
|
SEARCH: 'search',
|
||||||
|
FILTER_APPLY: 'filter_apply',
|
||||||
|
FILTER_CLEAR: 'filter_clear',
|
||||||
|
|
||||||
|
// User Account Events
|
||||||
|
USER_SIGNUP: 'user_signup',
|
||||||
|
USER_LOGIN: 'user_login',
|
||||||
|
USER_LOGOUT: 'user_logout',
|
||||||
|
USER_PROFILE_UPDATE: 'user_profile_update',
|
||||||
|
|
||||||
|
// Content Events
|
||||||
|
BLOG_POST_VIEW: 'blog_post_view',
|
||||||
|
VIDEO_PLAY: 'video_play',
|
||||||
|
VIDEO_PAUSE: 'video_pause',
|
||||||
|
VIDEO_COMPLETE: 'video_complete',
|
||||||
|
DOWNLOAD: 'download',
|
||||||
|
|
||||||
|
// UI Interaction Events
|
||||||
|
MODAL_OPEN: 'modal_open',
|
||||||
|
MODAL_CLOSE: 'modal_close',
|
||||||
|
TOGGLE_SWITCH: 'toggle_switch',
|
||||||
|
ACCORDION_TOGGLE: 'accordion_toggle',
|
||||||
|
TAB_SWITCH: 'tab_switch',
|
||||||
|
|
||||||
|
// Error & Performance Events
|
||||||
|
ERROR: 'error',
|
||||||
|
PERFORMANCE: 'performance',
|
||||||
|
API_ERROR: 'api_error',
|
||||||
|
API_SUCCESS: 'api_success',
|
||||||
|
|
||||||
|
// Custom Business Events
|
||||||
|
QUOTE_REQUEST: 'quote_request',
|
||||||
|
CONTACT_FORM_SUBMIT: 'contact_form_submit',
|
||||||
|
NEWSLETTER_SUBSCRIBE: 'newsletter_subscribe',
|
||||||
|
BROCHURE_DOWNLOAD: 'brochure_download',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe event properties for common events
|
||||||
|
*/
|
||||||
|
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common event property helpers
|
||||||
|
*/
|
||||||
|
export const AnalyticsEventProperties = {
|
||||||
|
/**
|
||||||
|
* Create properties for a product event
|
||||||
|
*/
|
||||||
|
product: (productId: string, productName: string, category?: string) => ({
|
||||||
|
product_id: productId,
|
||||||
|
product_name: productName,
|
||||||
|
product_category: category,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create properties for a form event
|
||||||
|
*/
|
||||||
|
form: (formId: string, formName: string, fields?: Record<string, unknown>) => ({
|
||||||
|
form_id: formId,
|
||||||
|
form_name: formName,
|
||||||
|
form_fields: fields,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create properties for a search event
|
||||||
|
*/
|
||||||
|
search: (query: string, filters?: Record<string, unknown>) => ({
|
||||||
|
search_query: query,
|
||||||
|
search_filters: filters,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create properties for a navigation event
|
||||||
|
*/
|
||||||
|
navigation: (from: string, to: string) => ({
|
||||||
|
from_page: from,
|
||||||
|
to_page: to,
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
77
components/analytics/useAnalytics.ts
Normal file
77
components/analytics/useAnalytics.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for tracking analytics events with Umami.
|
||||||
|
*
|
||||||
|
* Provides a convenient way to track custom events throughout your application.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { trackEvent, trackPageview } = useAnalytics();
|
||||||
|
*
|
||||||
|
* const handleButtonClick = () => {
|
||||||
|
* trackEvent('button_click', {
|
||||||
|
* button_id: 'cta-primary',
|
||||||
|
* page: 'homepage'
|
||||||
|
* });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return <button onClick={handleButtonClick}>Click me</button>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Track a custom pageview
|
||||||
|
* const { trackPageview } = useAnalytics();
|
||||||
|
* trackPageview('/custom-path?param=value');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useAnalytics() {
|
||||||
|
const services = getAppServices();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a custom event with optional properties.
|
||||||
|
*
|
||||||
|
* @param eventName - The name of the event to track
|
||||||
|
* @param properties - Optional event properties (metadata)
|
||||||
|
*/
|
||||||
|
const trackEvent = useCallback(
|
||||||
|
(eventName: string, properties?: AnalyticsEventProperties) => {
|
||||||
|
services.analytics.track(eventName, properties);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[Umami] Tracked event:', eventName, properties);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a pageview (useful for custom navigation or SPA routing).
|
||||||
|
*
|
||||||
|
* @param url - The URL to track (defaults to current location)
|
||||||
|
*/
|
||||||
|
const trackPageview = useCallback(
|
||||||
|
(url?: string) => {
|
||||||
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[Umami] Tracked pageview:', url ?? 'current location');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackEvent,
|
||||||
|
trackPageview,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
components/blog/AnimatedImage.tsx
Normal file
83
components/blog/AnimatedImage.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface AnimatedImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnimatedImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
width = 800,
|
||||||
|
height = 600,
|
||||||
|
className = '',
|
||||||
|
priority = false,
|
||||||
|
}: AnimatedImageProps) {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [isInView, setIsInView] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsInView(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
|
||||||
|
>
|
||||||
|
<div className={`
|
||||||
|
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
|
||||||
|
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
|
||||||
|
`} />
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={`
|
||||||
|
duration-[1.5s] ease-out w-full h-auto transition-all
|
||||||
|
${isLoaded && isInView ? 'scale-100 blur-0 opacity-100' : 'scale-110 blur-2xl opacity-0'}
|
||||||
|
group-hover:scale-105
|
||||||
|
`}
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtle reflection overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
|
||||||
|
|
||||||
|
{alt && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/60 to-transparent translate-y-full group-hover:translate-y-0 transition-transform duration-500">
|
||||||
|
<p className="text-sm text-white font-medium italic">
|
||||||
|
{alt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
components/blog/ChatBubble.tsx
Normal file
65
components/blog/ChatBubble.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface ChatBubbleProps {
|
||||||
|
author?: string;
|
||||||
|
avatar?: string;
|
||||||
|
role?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatBubble({
|
||||||
|
author = 'KLZ Team',
|
||||||
|
avatar = '/uploads/2024/11/cropped-favicon-3-192x192.png', // Default fallback
|
||||||
|
role = 'Assistant',
|
||||||
|
children,
|
||||||
|
align = 'left',
|
||||||
|
}: ChatBubbleProps) {
|
||||||
|
const isRight = align === 'right';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex w-full gap-4 my-10 ${isRight ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex-shrink-0 flex flex-col items-center gap-1">
|
||||||
|
<div className="w-10 h-10 rounded-full overflow-hidden border border-neutral-200 shadow-sm relative">
|
||||||
|
{/* Use a simple div placeholder if image fails or isn't provided, but here we assume a path */}
|
||||||
|
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-xs font-bold text-neutral-400">
|
||||||
|
{author.charAt(0)}
|
||||||
|
</div>
|
||||||
|
{avatar && (
|
||||||
|
<Image
|
||||||
|
src={avatar}
|
||||||
|
alt={author}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Bubble */}
|
||||||
|
<div className={`flex flex-col max-w-[85%] ${isRight ? 'items-end' : 'items-start'}`}>
|
||||||
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
|
<span className="text-sm md:text-base font-bold text-text-primary">{author}</span>
|
||||||
|
{role && <span className="text-xs md:text-sm text-text-secondary">{role}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`
|
||||||
|
relative px-8 py-6 rounded-3xl shadow-sm border transition-all duration-300 hover:shadow-md
|
||||||
|
${isRight
|
||||||
|
? 'bg-neutral-dark text-white rounded-tr-none border-neutral-dark'
|
||||||
|
: 'bg-white text-text-primary rounded-tl-none border-neutral-200'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
<div className={`prose prose-base md:prose-lg max-w-none ${isRight ? 'prose-invert' : ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Industrial accent dot */}
|
||||||
|
<div className={`absolute top-4 ${isRight ? 'left-4' : 'right-4'} w-1.5 h-1.5 rounded-full ${isRight ? 'bg-primary' : 'bg-primary/30'}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/blog/ComparisonGrid.tsx
Normal file
50
components/blog/ComparisonGrid.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ComparisonGridItem {
|
||||||
|
label: string;
|
||||||
|
leftValue: string;
|
||||||
|
rightValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComparisonGridProps {
|
||||||
|
title?: string;
|
||||||
|
leftLabel: string;
|
||||||
|
rightLabel: string;
|
||||||
|
items: ComparisonGridItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComparisonGrid({ title, leftLabel, rightLabel, items }: ComparisonGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="my-16 not-prose">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-2xl font-bold text-text-primary mb-8">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div className="border border-neutral-200 rounded-3xl overflow-hidden shadow-sm bg-white">
|
||||||
|
<div className="grid grid-cols-3 bg-neutral-50 border-b border-neutral-200">
|
||||||
|
<div className="p-4 md:p-6"></div>
|
||||||
|
<div className="p-4 md:p-6 text-center font-bold text-primary uppercase tracking-widest border-l border-neutral-200 text-xs md:text-sm">
|
||||||
|
{leftLabel}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 md:p-6 text-center font-bold text-primary uppercase tracking-widest border-l border-neutral-200 text-xs md:text-sm">
|
||||||
|
{rightLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-3 border-b border-neutral-200 last:border-0 hover:bg-neutral-50 transition-colors group">
|
||||||
|
<div className="p-4 md:p-6 font-bold text-text-primary flex items-center text-sm md:text-base">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 md:p-6 text-center text-text-secondary border-l border-neutral-200 flex items-center justify-center group-hover:text-text-primary transition-colors text-sm md:text-base">
|
||||||
|
{item.leftValue}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 md:p-6 text-center text-text-secondary border-l border-neutral-200 flex items-center justify-center group-hover:text-text-primary transition-colors text-sm md:text-base">
|
||||||
|
{item.rightValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Scribble from '@/components/Scribble';
|
||||||
|
|
||||||
interface HighlightBoxProps {
|
interface HighlightBoxProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -7,21 +8,31 @@ interface HighlightBoxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const colorStyles = {
|
const colorStyles = {
|
||||||
primary: 'bg-gradient-to-br from-primary/10 to-primary/5 border-primary/30',
|
primary: 'bg-gradient-to-br from-primary/5 to-transparent border-primary/20',
|
||||||
secondary: 'bg-gradient-to-br from-blue-50 to-blue-100/50 border-blue-200',
|
secondary: 'bg-gradient-to-br from-blue-50/50 to-transparent border-blue-200/50',
|
||||||
accent: 'bg-gradient-to-br from-green-50 to-green-100/50 border-green-200',
|
accent: 'bg-gradient-to-br from-accent/5 to-transparent border-accent/20',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HighlightBox({ title, children, color = 'primary' }: HighlightBoxProps) {
|
export default function HighlightBox({ title, children, color = 'primary' }: HighlightBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`my-10 p-8 rounded-2xl border-2 ${colorStyles[color]} shadow-sm`}>
|
<div className={`my-12 p-8 md:p-10 rounded-3xl border ${colorStyles[color]} shadow-sm relative overflow-hidden group`}>
|
||||||
|
{/* Industrial accent corner */}
|
||||||
|
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
|
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="text-2xl font-bold text-text-primary mb-4 flex items-center gap-3">
|
<h3 className="text-xl md:text-2xl font-bold text-text-primary mb-6 flex items-center gap-4 relative">
|
||||||
<span className="w-1.5 h-8 bg-primary rounded-full"></span>
|
<span className="relative">
|
||||||
{title}
|
{title}
|
||||||
|
{color === 'accent' && (
|
||||||
|
<Scribble
|
||||||
|
variant="underline"
|
||||||
|
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="prose prose-lg max-w-none">
|
<div className="prose prose-base md:prose-lg max-w-none relative z-10">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
97
components/blog/PostNavigation.tsx
Normal file
97
components/blog/PostNavigation.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PostMdx } from '@/lib/blog';
|
||||||
|
|
||||||
|
interface PostNavigationProps {
|
||||||
|
prev: PostMdx | null;
|
||||||
|
next: PostMdx | null;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
|
||||||
|
if (!prev && !next) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 w-full mt-16">
|
||||||
|
{/* Previous Post (Older) */}
|
||||||
|
{prev ? (
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog/${prev.slug}`}
|
||||||
|
className="group relative h-64 md:h-80 overflow-hidden block"
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
{prev.frontmatter.featuredImage ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||||
|
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-neutral-100" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
||||||
|
{locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
|
{prev.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow Icon */}
|
||||||
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:-translate-x-2 transition-all duration-300">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="hidden md:block bg-neutral-50" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next Post (Newer) */}
|
||||||
|
{next ? (
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog/${next.slug}`}
|
||||||
|
className="group relative h-64 md:h-80 overflow-hidden block"
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
{next.frontmatter.featuredImage ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||||
|
style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-neutral-100" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
||||||
|
{locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
|
{next.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow Icon */}
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:translate-x-2 transition-all duration-300">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="hidden md:block bg-neutral-50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/blog/PowerCTA.tsx
Normal file
73
components/blog/PowerCTA.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface PowerCTAProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PowerCTA({ locale }: PowerCTAProps) {
|
||||||
|
const isDe = locale === 'de';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-16 p-10 md:p-16 bg-neutral-dark rounded-[2rem] shadow-2xl relative overflow-hidden group">
|
||||||
|
{/* Industrial background pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative accent */}
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
|
||||||
|
{isDe ? 'Lösungen' : 'Solutions'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
|
||||||
|
{isDe ? 'Bereit für die' : 'Ready for the'}
|
||||||
|
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
|
||||||
|
{isDe
|
||||||
|
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
|
||||||
|
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||||
|
{[
|
||||||
|
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
||||||
|
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
||||||
|
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
|
||||||
|
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 text-white/80">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
||||||
|
>
|
||||||
|
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
||||||
|
<svg className="w-5 h-5 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<p className="text-white/50 text-sm font-medium">
|
||||||
|
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/blog/ReadingProgressBar.tsx
Normal file
34
components/blog/ReadingProgressBar.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function ReadingProgressBar() {
|
||||||
|
const [completion, setCompletion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateScrollCompletion = () => {
|
||||||
|
const currentProgress = window.scrollY;
|
||||||
|
const scrollHeight = document.body.scrollHeight - window.innerHeight;
|
||||||
|
if (scrollHeight) {
|
||||||
|
setCompletion(
|
||||||
|
Number((currentProgress / scrollHeight).toFixed(2)) * 100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateScrollCompletion);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updateScrollCompletion);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 w-full h-1 z-50 bg-neutral-100">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-150 ease-out"
|
||||||
|
style={{ width: `${completion}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/blog/ShareButton.tsx
Normal file
49
components/blog/ShareButton.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
|
interface ShareButtonProps {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareButton({ title, text, url, locale }: ShareButtonProps) {
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sharing:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to copy link
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
alert(locale === 'de' ? 'Link kopiert!' : 'Link copied!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying link:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleShare}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 rounded-full px-6"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 100 5.368 3 3 0 000-5.368z" />
|
||||||
|
</svg>
|
||||||
|
{locale === 'de' ? 'Teilen' : 'Share'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
components/blog/SplitHeading.tsx
Normal file
22
components/blog/SplitHeading.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SplitHeadingProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SplitHeading({ children, className = '', id }: SplitHeadingProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<h2 className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,23 +12,28 @@ interface StatsProps {
|
|||||||
|
|
||||||
export default function Stats({ stats }: StatsProps) {
|
export default function Stats({ stats }: StatsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="my-12 grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="my-16 grid grid-cols-1 md:grid-cols-3 border border-neutral-200 rounded-2xl overflow-hidden shadow-sm">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-gradient-to-br from-primary/5 to-primary/10 p-6 rounded-xl border border-primary/20 text-center hover:shadow-md transition-shadow"
|
className={`p-8 flex flex-col items-center text-center transition-all duration-500 hover:bg-neutral-50 group ${
|
||||||
|
index !== stats.length - 1 ? 'border-b md:border-b-0 md:border-r border-neutral-200' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{stat.icon && (
|
{stat.icon && (
|
||||||
<div className="text-primary mb-3 flex justify-center">
|
<div className="text-primary mb-4 transform transition-transform group-hover:scale-110 duration-500">
|
||||||
{stat.icon}
|
{stat.icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-4xl font-bold text-primary mb-2">
|
<div className="text-5xl font-bold text-text-primary mb-3 tracking-tight">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-text-secondary font-medium">
|
<div className="text-xs font-bold text-text-secondary uppercase tracking-[0.2em]">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Industrial accent line */}
|
||||||
|
<div className="w-8 h-[2px] bg-primary/20 mt-6 transition-all group-hover:w-16 group-hover:bg-primary duration-500" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
36
components/blog/StickyNarrative.tsx
Normal file
36
components/blog/StickyNarrative.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface StickyNarrativeItem {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StickyNarrativeProps {
|
||||||
|
title: string;
|
||||||
|
items: StickyNarrativeItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StickyNarrative({ title, items }: StickyNarrativeProps) {
|
||||||
|
return (
|
||||||
|
<div className="my-24 grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20 items-start not-prose">
|
||||||
|
<div className="lg:col-span-4 lg:sticky lg:top-32">
|
||||||
|
<h3 className="text-2xl md:text-3xl font-bold text-primary leading-tight">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="w-16 h-1.5 bg-accent mt-8 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-8 space-y-12 md:space-y-16">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="group border-b border-neutral-200 pb-12 last:border-0 last:pb-0">
|
||||||
|
<h4 className="text-xl md:text-2xl font-bold text-text-primary mb-4 group-hover:text-primary transition-colors">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
<div className="text-lg text-text-secondary leading-relaxed">
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
components/blog/TableOfContents.tsx
Normal file
92
components/blog/TableOfContents.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
|
||||||
|
interface TocItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableOfContentsProps {
|
||||||
|
headings: TocItem[];
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
|
||||||
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observerOptions = {
|
||||||
|
rootMargin: '-10% 0% -70% 0%',
|
||||||
|
threshold: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setActiveId(entry.target.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [headings]);
|
||||||
|
|
||||||
|
if (headings.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="hidden lg:block w-full ml-12">
|
||||||
|
<div className="relative pl-6 border-l border-neutral-200">
|
||||||
|
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/50 mb-6">
|
||||||
|
{locale === 'de' ? 'Inhalt' : 'Table of Contents'}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{headings.map((heading) => (
|
||||||
|
<li
|
||||||
|
key={heading.id}
|
||||||
|
style={{ paddingLeft: `${(heading.level - 2) * 1}rem` }}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
{activeId === heading.id && (
|
||||||
|
<div className="absolute -left-[25px] top-0 w-[2px] h-full bg-primary transition-all duration-300" />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`#${heading.id}`}
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const element = document.getElementById(heading.id);
|
||||||
|
if (element) {
|
||||||
|
const yOffset = -100;
|
||||||
|
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||||
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heading.text.replace(/\*\*(.*?)\*\*/g, '$1')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/blog/TechnicalGrid.tsx
Normal file
43
components/blog/TechnicalGrid.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Scribble from '@/components/Scribble';
|
||||||
|
|
||||||
|
interface TechnicalGridItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TechnicalGridProps {
|
||||||
|
title?: string;
|
||||||
|
items: TechnicalGridItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="my-16 not-prose">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||||
|
<span className="relative inline-block">
|
||||||
|
{title}
|
||||||
|
<Scribble
|
||||||
|
variant="underline"
|
||||||
|
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
|
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,30 +10,64 @@ interface VisualLinkPreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VisualLinkPreview({ url, title, summary, image }: VisualLinkPreviewProps) {
|
export default function VisualLinkPreview({ url, title, summary, image }: VisualLinkPreviewProps) {
|
||||||
|
const hostname = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-8 no-underline group">
|
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
|
||||||
<div className="flex flex-col md:flex-row border border-neutral-dark rounded-lg overflow-hidden bg-white hover:shadow-md transition-shadow">
|
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
||||||
<div className="relative w-full md:w-48 h-48 md:h-auto flex-shrink-0 bg-neutral-light flex items-center justify-center">
|
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-full h-full object-cover"
|
fill
|
||||||
|
unoptimized
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-neutral-dark">No Image</div>
|
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
||||||
|
<svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Industrial overlay */}
|
||||||
|
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col justify-center">
|
|
||||||
<h3 className="text-lg font-bold text-primary mb-2 group-hover:underline line-clamp-2">
|
<div className="p-8 flex flex-col justify-center relative">
|
||||||
|
{/* Industrial accent corner */}
|
||||||
|
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
|
||||||
|
External Link
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
|
||||||
|
{hostname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm line-clamp-3">
|
|
||||||
|
<p className="text-text-secondary text-base line-clamp-2 leading-relaxed mb-4">
|
||||||
{summary}
|
{summary}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-text-secondary mt-2 opacity-70">
|
|
||||||
{new URL(url).hostname}
|
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
||||||
</span>
|
<span>Read more</span>
|
||||||
|
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
115
components/emails/ContactEmail.tsx
Normal file
115
components/emails/ContactEmail.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface ContactEmailProps {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
subject?: string;
|
||||||
|
productName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContactEmail = ({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
subject = "New Contact Form Submission",
|
||||||
|
productName,
|
||||||
|
}: ContactEmailProps) => (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{subject}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>{subject}</Heading>
|
||||||
|
{productName && (
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>Product Inquiry:</strong> {productName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Section style={section}>
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>Name:</strong> {name}
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>Email:</strong> {email}
|
||||||
|
</Text>
|
||||||
|
<Hr style={hr} />
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>Message:</strong>
|
||||||
|
</Text>
|
||||||
|
<Text style={messageText}>{message}</Text>
|
||||||
|
</Section>
|
||||||
|
<Hr style={hr} />
|
||||||
|
<Text style={footer}>
|
||||||
|
This email was sent from the contact form on klz-cables.com
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ContactEmail;
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: "#f6f9fc",
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "20px 0 48px",
|
||||||
|
marginBottom: "64px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const section = {
|
||||||
|
padding: "0 48px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
color: "#333",
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
padding: "0 48px",
|
||||||
|
margin: "30px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
color: "#333",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
textAlign: "left" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageText = {
|
||||||
|
...text,
|
||||||
|
backgroundColor: "#f4f4f4",
|
||||||
|
padding: "15px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
whiteSpace: "pre-wrap" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: "#e6ebf1",
|
||||||
|
margin: "20px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = {
|
||||||
|
color: "#8898aa",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "16px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginTop: "20px",
|
||||||
|
};
|
||||||
@@ -1,21 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Section, Container } from '../../components/ui';
|
import { Section, Container, Button, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function CTA() {
|
export default function CTA() {
|
||||||
|
const t = useTranslations('Home.cta');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-primary text-white py-24">
|
<Section className="bg-primary text-white py-32 relative overflow-hidden">
|
||||||
<Container>
|
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
|
||||||
<h2 className="text-3xl md:text-4xl font-bold max-w-2xl">
|
|
||||||
Enough information, let's build something together!
|
<Container className="relative z-10">
|
||||||
</h2>
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-16">
|
||||||
<Link href="/contact" className="group flex items-center gap-4 text-xl font-bold text-[#82ed20]">
|
<div className="max-w-3xl text-center lg:text-left">
|
||||||
Reach out now
|
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
|
||||||
<span className="w-10 h-10 border-2 border-[#82ed20] rounded-full flex items-center justify-center group-hover:bg-[#82ed20] group-hover:text-primary transition-all">
|
<span className="text-white">{t('title')}</span>
|
||||||
→
|
</Heading>
|
||||||
</span>
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
|
||||||
</Link>
|
{t('description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12">
|
||||||
|
{t('button')}
|
||||||
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
57
components/home/Experience.tsx
Normal file
57
components/home/Experience.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
|
export default function Experience() {
|
||||||
|
const t = useTranslations('Home.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')}
|
||||||
|
fill
|
||||||
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
|
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">
|
||||||
|
<span className="text-white">{t('title')}</span>
|
||||||
|
</Heading>
|
||||||
|
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
|
||||||
|
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
|
||||||
|
{t('p1')}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Section, Container } from '../../components/ui';
|
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() {
|
export default function GallerySection() {
|
||||||
|
const t = useTranslations('Home.gallery');
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const images = [
|
const images = [
|
||||||
'/uploads/2024/12/DSC07433-Large-600x400.webp',
|
'/uploads/2024/12/DSC07433-Large-600x400.webp',
|
||||||
'/uploads/2024/12/DSC07460-Large-600x400.webp',
|
'/uploads/2024/12/DSC07460-Large-600x400.webp',
|
||||||
@@ -12,28 +18,53 @@ export default function GallerySection() {
|
|||||||
'/uploads/2024/12/DSC07768-Large.webp',
|
'/uploads/2024/12/DSC07768-Large.webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const photoParam = searchParams.get('photo');
|
||||||
|
const lightboxOpen = photoParam !== null;
|
||||||
|
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-neutral-dark py-24">
|
<Section className="bg-white text-white py-32">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="text-center mb-16">
|
<Heading level={2} subtitle={t('subtitle')} align="center">
|
||||||
<h2 className="text-4xl md:text-5xl font-bold">Strong Connections for a Sustainable World.</h2>
|
{t('title')}
|
||||||
</div>
|
</Heading>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{images.map((src, idx) => (
|
{images.map((src, idx) => (
|
||||||
<div key={idx} className="relative aspect-[4/3] overflow-hidden rounded-lg group">
|
<button
|
||||||
<Image
|
key={idx}
|
||||||
src={src}
|
onClick={() => {
|
||||||
alt={`Gallery image ${idx + 1}`}
|
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"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={`${t('alt')} ${idx + 1}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
unoptimized
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-transparent transition-colors" />
|
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
||||||
</div>
|
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<Lightbox
|
||||||
|
isOpen={lightboxOpen}
|
||||||
|
images={images}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
onClose={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('photo');
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,182 @@
|
|||||||
import React from 'react';
|
'use client';
|
||||||
import Link from 'next/link';
|
|
||||||
import { Container } from '@/components/ui';
|
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
|
const t = useTranslations('Home.hero');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[80vh] flex items-center justify-center overflow-hidden bg-neutral-dark">
|
<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">
|
||||||
<div className="absolute inset-0 z-0">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<video
|
<motion.div
|
||||||
className="w-full h-full object-cover"
|
className="max-w-5xl mx-auto md:mx-0"
|
||||||
autoPlay
|
initial="hidden"
|
||||||
muted
|
animate="visible"
|
||||||
loop
|
variants={containerVariants}
|
||||||
playsInline
|
|
||||||
poster="/uploads/2025/02/Still-2025-02-10-104337_1.1.1.webp"
|
|
||||||
>
|
>
|
||||||
<source src="/uploads/2025/02/header.webm" type="video/webm" />
|
<motion.div variants={headingVariants}>
|
||||||
<source src="/uploads/2025/02/header.mp4" type="video/mp4" />
|
<Heading
|
||||||
</video>
|
level={1}
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-black/10 to-black/40" />
|
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]"
|
||||||
</div>
|
>
|
||||||
|
{t.rich('title', {
|
||||||
<Container className="relative z-10 text-left text-white w-full">
|
green: (chunks) => (
|
||||||
<div className="max-w-4xl">
|
<span className="relative inline-block">
|
||||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold mb-6 tracking-tight leading-tight">
|
<motion.span
|
||||||
We are helping to expand the energy cable networks for a
|
className="relative z-10 text-accent italic"
|
||||||
<span className="relative inline-block ml-4 mr-2">
|
variants={accentVariants}
|
||||||
<span className="relative z-10 text-white italic">green</span>
|
>
|
||||||
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%]" />
|
{chunks}
|
||||||
</span>
|
</motion.span>
|
||||||
future
|
<motion.div
|
||||||
</h1>
|
variants={scribbleVariants}
|
||||||
<div className="mt-8">
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
||||||
<Link href="/contact" className="inline-flex items-center text-white text-xl font-medium group">
|
>
|
||||||
Let's talk
|
<Scribble variant="circle" />
|
||||||
<span className="ml-2 w-8 h-8 border-2 border-white rounded-full flex items-center justify-center group-hover:bg-white group-hover:text-black transition-all">
|
</motion.div>
|
||||||
→
|
</span>
|
||||||
</span>
|
),
|
||||||
</Link>
|
})}
|
||||||
</div>
|
</Heading>
|
||||||
</div>
|
</motion.div>
|
||||||
|
<motion.div variants={subtitleVariants}>
|
||||||
|
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
||||||
|
variants={buttonContainerVariants}
|
||||||
|
>
|
||||||
|
<motion.div variants={buttonVariants}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{t('cta')}
|
||||||
|
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={buttonVariants}>
|
||||||
|
<Button
|
||||||
|
href="/products"
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
||||||
|
>
|
||||||
|
{t('exploreProducts')}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
|
<motion.div
|
||||||
|
className="w-1 h-2 bg-white rounded-full"
|
||||||
|
animate={{ y: [0, -10, 0] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 1 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.12,
|
||||||
|
delayChildren: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const headingVariants = {
|
||||||
|
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const accentVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
rotate: 0,
|
||||||
|
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const scribbleVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0, rotate: 180 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
rotate: 0,
|
||||||
|
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const subtitleVariants = {
|
||||||
|
hidden: { opacity: 0, y: 40, scale: 0.95 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const buttonContainerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.15,
|
||||||
|
delayChildren: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const buttonVariants = {
|
||||||
|
hidden: { opacity: 0, y: 30, scale: 0.9 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { type: 'spring', stiffness: 400, damping: 20 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|||||||
408
components/home/HeroIllustration.tsx
Normal file
408
components/home/HeroIllustration.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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
|
||||||
|
function gridToScreen(col: number, row: number): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: (col - row) * (CELL_WIDTH / 2),
|
||||||
|
y: (col + row) * (CELL_HEIGHT / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid layout (10 columns x 8 rows)
|
||||||
|
const GRID = {
|
||||||
|
cols: 10,
|
||||||
|
rows: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Infrastructure positions
|
||||||
|
const INFRASTRUCTURE = {
|
||||||
|
solar: [
|
||||||
|
{ col: 0, row: 5 },
|
||||||
|
{ col: 1, row: 5 },
|
||||||
|
{ col: 0, row: 6 },
|
||||||
|
{ col: 1, row: 6 },
|
||||||
|
{ col: 2, row: 7 },
|
||||||
|
{ col: 3, row: 7 },
|
||||||
|
{ col: 2, row: 8 },
|
||||||
|
{ col: 3, row: 8 },
|
||||||
|
],
|
||||||
|
wind: [
|
||||||
|
{ col: 0, row: 1 },
|
||||||
|
{ col: 1, row: 2 },
|
||||||
|
{ col: 2, row: 1 },
|
||||||
|
{ col: 3, row: 0 },
|
||||||
|
{ col: 4, row: 1 },
|
||||||
|
{ col: 5, row: 0 },
|
||||||
|
],
|
||||||
|
substations: [
|
||||||
|
{ col: 3, row: 3, type: 'collection' },
|
||||||
|
{ col: 6, row: 4, type: 'distribution' },
|
||||||
|
{ col: 5, row: 7, type: 'distribution' },
|
||||||
|
],
|
||||||
|
towers: [
|
||||||
|
{ col: 4, row: 3 },
|
||||||
|
{ col: 5, row: 4 },
|
||||||
|
{ col: 4, row: 5 },
|
||||||
|
{ col: 5, row: 6 },
|
||||||
|
],
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
city2: [
|
||||||
|
{ col: 6, row: 8, type: 'medium' },
|
||||||
|
{ col: 7, row: 7, type: 'tall' },
|
||||||
|
{ col: 7, row: 8, type: 'small' },
|
||||||
|
],
|
||||||
|
trees: [
|
||||||
|
{ col: 0, row: 3 },
|
||||||
|
{ col: 2, row: 6 },
|
||||||
|
{ col: 3, row: 1 },
|
||||||
|
{ col: 6, row: 2 },
|
||||||
|
{ col: 6, row: 6 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const POWER_LINES = [
|
||||||
|
{ from: { col: 0, row: 1 }, to: { col: 1, row: 1 } },
|
||||||
|
{ from: { col: 1, row: 2 }, to: { col: 1, row: 1 } },
|
||||||
|
{ from: { col: 2, row: 1 }, to: { col: 1, row: 1 } },
|
||||||
|
{ from: { col: 1, row: 1 }, to: { col: 1, row: 3 } },
|
||||||
|
{ from: { col: 1, row: 3 }, to: { col: 3, row: 3 } },
|
||||||
|
{ from: { col: 3, row: 0 }, to: { col: 4, row: 0 } },
|
||||||
|
{ from: { col: 4, row: 0 }, to: { col: 4, row: 1 } },
|
||||||
|
{ from: { col: 5, row: 0 }, to: { col: 5, row: 1 } },
|
||||||
|
{ from: { col: 5, row: 1 }, to: { col: 4, row: 1 } },
|
||||||
|
{ from: { col: 4, row: 1 }, to: { col: 4, row: 3 } },
|
||||||
|
{ from: { col: 4, row: 3 }, to: { col: 3, row: 3 } },
|
||||||
|
{ 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 } },
|
||||||
|
{ from: { col: 1, row: 5 }, to: { col: 1, row: 3 } },
|
||||||
|
{ 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 } },
|
||||||
|
{ from: { col: 3, row: 7 }, to: { col: 3, row: 5 } },
|
||||||
|
{ from: { col: 3, row: 5 }, to: { col: 3, row: 3 } },
|
||||||
|
{ from: { col: 3, row: 3 }, to: { col: 4, row: 3 } },
|
||||||
|
{ from: { col: 4, row: 3 }, to: { col: 5, row: 3 } },
|
||||||
|
{ from: { col: 5, row: 3 }, to: { col: 5, row: 4 } },
|
||||||
|
{ from: { col: 5, row: 4 }, to: { col: 6, row: 4 } },
|
||||||
|
{ from: { col: 6, row: 4 }, to: { col: 7, row: 4 } },
|
||||||
|
{ from: { col: 7, row: 4 }, to: { col: 8, row: 4 } },
|
||||||
|
{ 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 } },
|
||||||
|
{ from: { col: 3, row: 3 }, to: { col: 3, row: 5 } },
|
||||||
|
{ from: { col: 3, row: 5 }, to: { col: 4, row: 5 } },
|
||||||
|
{ from: { col: 4, row: 5 }, to: { col: 5, row: 5 } },
|
||||||
|
{ from: { col: 5, row: 5 }, to: { col: 5, row: 6 } },
|
||||||
|
{ from: { col: 5, row: 6 }, to: { col: 5, row: 7 } },
|
||||||
|
{ 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 } },
|
||||||
|
{ from: { col: 7, row: 7 }, to: { col: 7, row: 8 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
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-visible bg-primary w-full h-full">
|
||||||
|
<svg
|
||||||
|
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>
|
||||||
|
<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" />
|
||||||
|
<stop offset="50%" stopColor="#9bf14d" stopOpacity="1" />
|
||||||
|
<stop offset="70%" stopColor="#82ed20" stopOpacity="0.6" />
|
||||||
|
<stop offset="100%" stopColor="#82ed20" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<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" />
|
||||||
|
<stop offset="50%" stopColor="white" stopOpacity="0.6" />
|
||||||
|
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
|
||||||
|
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g transform="translate(900, 100)">
|
||||||
|
{/* ISOMETRIC GRID */}
|
||||||
|
<g opacity="0.1">
|
||||||
|
{[...Array(GRID.rows + 1)].map((_, row) => {
|
||||||
|
const start = gridToScreen(0, row);
|
||||||
|
const end = gridToScreen(GRID.cols, row);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`h-${row}`}
|
||||||
|
x1={start.x}
|
||||||
|
y1={start.y}
|
||||||
|
x2={end.x}
|
||||||
|
y2={end.y}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{[...Array(GRID.cols + 1)].map((_, col) => {
|
||||||
|
const start = gridToScreen(col, 0);
|
||||||
|
const end = gridToScreen(col, GRID.rows);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`v-${col}`}
|
||||||
|
x1={start.x}
|
||||||
|
y1={start.y}
|
||||||
|
x2={end.x}
|
||||||
|
y2={end.y}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 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} />;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 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));
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`flow-${i}`}
|
||||||
|
x1={from.x}
|
||||||
|
y1={from.y}
|
||||||
|
x2={to.x}
|
||||||
|
y2={to.y}
|
||||||
|
stroke="url(#energy-pulse)"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
from={length}
|
||||||
|
to={0}
|
||||||
|
dur={`${1.5 + (i % 3) * 0.5}s`}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</line>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 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})`}>
|
||||||
|
<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.3"
|
||||||
|
/>
|
||||||
|
<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 */}
|
||||||
|
{INFRASTRUCTURE.wind.map((turbine, i) => {
|
||||||
|
const pos = gridToScreen(turbine.col, turbine.row);
|
||||||
|
return (
|
||||||
|
<g key={`wind-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||||
|
<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
|
||||||
|
key={`blade-${i}-${j}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="-30"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeOpacity="0.4"
|
||||||
|
transform={`rotate(${angle})`}
|
||||||
|
>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
from={`${angle} 0 0`}
|
||||||
|
to={`${angle + 360} 0 0`}
|
||||||
|
dur={`${3 + i}s`}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</line>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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})`}>
|
||||||
|
<path
|
||||||
|
d="M -25 0 L 0 -12 L 25 0 L 0 12 Z"
|
||||||
|
fill="white"
|
||||||
|
fillOpacity="0.05"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeOpacity="0.2"
|
||||||
|
/>
|
||||||
|
<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'
|
||||||
|
}
|
||||||
|
fill="white"
|
||||||
|
fillOpacity="0.08"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeOpacity="0.3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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})`}>
|
||||||
|
<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, ...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] || 45;
|
||||||
|
return (
|
||||||
|
<g key={`building-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||||
|
<path
|
||||||
|
d={`M -12 0 L -12 -${height} L 0 -${height + 6} L 0 -6 Z`}
|
||||||
|
fill="white"
|
||||||
|
fillOpacity="0.08"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeOpacity="0.2"
|
||||||
|
/>
|
||||||
|
<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.15"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-primary/9 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +1,70 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Section, Container, Button } from '../../components/ui';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { Section, Container, Button, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function MeetTheTeam() {
|
export default function MeetTheTeam() {
|
||||||
|
const t = useTranslations('Home.meetTheTeam');
|
||||||
|
const teamT = useTranslations('Team');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative py-24 md:py-32 overflow-hidden">
|
<Section className="relative py-32 md:py-48 overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/DSC07655-Large.webp"
|
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||||
alt="KLZ Team"
|
alt={t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
unoptimized
|
sizes="100vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-neutral-dark/90 to-neutral-dark/40" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-2xl text-white">
|
<div className="max-w-3xl text-white animate-slide-up">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
|
||||||
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-white/20">
|
<span className="text-white">{t('title')}</span>
|
||||||
{/* Placeholder for avatar if needed, or just icon */}
|
</Heading>
|
||||||
<div className="w-full h-full bg-primary flex items-center justify-center">
|
|
||||||
<span className="font-bold">KLZ</span>
|
<div className="relative mb-12">
|
||||||
</div>
|
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
||||||
</div>
|
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
||||||
<h2 className="text-3xl font-bold">Meet the team behind KLZ</h2>
|
"{t('description')}"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-6 mb-8 border border-white/10">
|
|
||||||
<p className="text-lg leading-relaxed">
|
|
||||||
At KLZ, our team is the power behind the cables. From seasoned experts like Michael and Klaus to a dedicated group of planners, logistics specialists, and customer support professionals, every member plays a vital role. Together, we combine decades of experience, innovative thinking, and a shared commitment to delivering reliable energy solutions.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button href="/team" variant="primary" size="lg">
|
<div className="flex flex-wrap gap-8 items-center">
|
||||||
Checkout our team
|
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
|
||||||
</Button>
|
{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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
|
||||||
|
{t('andNetwork')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,59 +1,83 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Section } from '../../components/ui';
|
import { Section } from '../../components/ui';
|
||||||
|
|
||||||
export default function ProductCategories() {
|
export default function ProductCategories() {
|
||||||
|
const t = useTranslations('Products');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white py-0">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{[
|
{categories.map((category, idx) => (
|
||||||
{
|
<Link
|
||||||
title: 'Low Voltage Cables',
|
key={idx}
|
||||||
desc: 'Powering everyday essentials with reliability and safety.',
|
href={category.href}
|
||||||
img: '/uploads/2024/12/low-voltage-scaled.webp',
|
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"
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
>
|
||||||
href: '/products/low-voltage-cables'
|
<Image
|
||||||
},
|
src={category.img}
|
||||||
{
|
|
||||||
title: 'Medium Voltage Cables',
|
|
||||||
desc: 'The perfect balance between power and performance for industrial and urban grids.',
|
|
||||||
img: '/uploads/2024/12/medium-voltage-scaled.webp',
|
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
|
||||||
href: '/products/medium-voltage-cables'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'High Voltage Cables',
|
|
||||||
desc: 'Delivering maximum power over long distances—without compromise.',
|
|
||||||
img: '/uploads/2025/06/na2xsfl2y-rendered.webp',
|
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
|
||||||
href: '/products/high-voltage-cables'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Solar Cables',
|
|
||||||
desc: 'Connecting the sun’s energy to your sustainable future.',
|
|
||||||
img: '/uploads/2025/04/3.webp',
|
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
|
||||||
href: '/products/solar-cables'
|
|
||||||
}
|
|
||||||
].map((category, idx) => (
|
|
||||||
<Link key={idx} href={category.href} className="group block relative h-[400px] md:h-[500px] overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={category.img}
|
|
||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
unoptimized
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors" />
|
<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 flex flex-col justify-center items-center text-center text-white">
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
<div className="mb-4 transform transition-transform group-hover:-translate-y-2">
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
<img src={category.icon} alt="" className="w-16 h-16 mx-auto" />
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold mb-2 transform transition-transform group-hover:-translate-y-2">{category.title}</h3>
|
|
||||||
<p className="text-white/90 opacity-0 group-hover:opacity-100 transform translate-y-4 group-hover:translate-y-0 transition-all duration-300">
|
|
||||||
{category.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
92
components/home/RecentPosts.tsx
Normal file
92
components/home/RecentPosts.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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';
|
||||||
|
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
||||||
|
|
||||||
|
interface RecentPostsProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||||
|
const t = await getTranslations('Blog');
|
||||||
|
const posts = await getAllPosts(locale);
|
||||||
|
const recentPosts = posts.slice(0, 3);
|
||||||
|
|
||||||
|
if (recentPosts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section className="bg-neutral py-16 md:py-24">
|
||||||
|
<Container>
|
||||||
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
||||||
|
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
||||||
|
{t('allArticles')}
|
||||||
|
</Heading>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
||||||
|
{recentPosts.map((post) => (
|
||||||
|
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||||
|
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
||||||
|
{post.frontmatter.featuredImage && (
|
||||||
|
<div className="relative h-64 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={post.frontmatter.featuredImage}
|
||||||
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
{post.frontmatter.category && (
|
||||||
|
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||||
|
{post.frontmatter.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||||
|
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||||
|
{post.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||||
|
{t('readMore')}
|
||||||
|
<svg
|
||||||
|
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection() {
|
export default function VideoSection() {
|
||||||
|
const t = useTranslations('Home.video');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[60vh] overflow-hidden">
|
<section className="relative h-[70vh] overflow-hidden bg-primary">
|
||||||
<video
|
<video
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover opacity-60"
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
@@ -13,15 +15,19 @@ export default function VideoSection() {
|
|||||||
>
|
>
|
||||||
<source src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm" type="video/mp4" />
|
<source src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-white text-center max-w-4xl px-4 leading-tight">
|
<div className="max-w-5xl px-6 text-center animate-slide-up">
|
||||||
From a single strand to infinite power – the
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
<span className="relative inline-block mx-2">
|
{t.rich('title', {
|
||||||
<span className="relative z-10 italic">future</span>
|
future: (chunks) => (
|
||||||
<Scribble variant="underline" className="w-full h-full top-full left-0" />
|
<span className="relative inline-block mx-2">
|
||||||
</span>
|
<span className="relative z-10 italic text-accent">{chunks}</span>
|
||||||
starts here.
|
<Scribble variant="underline" className="w-full h-4 -bottom-2 left-0 text-accent/40" />
|
||||||
</h2>
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,46 +1,40 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Section, Container } from '../../components/ui';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function WhatWeDo() {
|
export default function WhatWeDo() {
|
||||||
|
const t = useTranslations('Home.whatWeDo');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-neutral-dark">
|
<Section className="bg-white">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
<div className="sticky-narrative-container">
|
||||||
<div className="lg:col-span-4">
|
<div className="sticky-narrative-sidebar">
|
||||||
<div className="sticky top-24">
|
<div className="lg:sticky lg:top-32">
|
||||||
<h2 className="text-5xl font-bold text-primary mb-6">What we do</h2>
|
<Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
|
||||||
<p className="text-xl text-text-secondary">
|
{t('title')}
|
||||||
We ensure that the electricity flows – with quality-tested cables. From low voltage up to high voltage.
|
</Heading>
|
||||||
|
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||||
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
|
||||||
|
<p className="text-saturated font-bold text-base md:text-base italic">
|
||||||
|
"{t('quote')}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-16">
|
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20">
|
||||||
{[
|
{[0, 1, 2, 3].map((idx) => (
|
||||||
{
|
<div key={idx} className="group">
|
||||||
num: '01',
|
<div className="flex items-center gap-4 mb-4 md:mb-6">
|
||||||
title: 'Supply to energy suppliers, wind and solar parks, industry and trade',
|
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
|
||||||
desc: 'We support your projects from 1 to 220 kV, from simple NYY to high-voltage cables with segment conductors and aluminum sheaths, with a particular focus on medium-voltage cables.'
|
{idx + 1}
|
||||||
},
|
</span>
|
||||||
{
|
<div className="h-px flex-grow bg-neutral-medium" />
|
||||||
num: '02',
|
</div>
|
||||||
title: 'Supply of cables whose quality is certified',
|
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
|
||||||
desc: 'Cables are products that have to function 100%. For decades, often 80 to 100 years. Our cables are not only approved by VDE. The most well-known energy suppliers trust us.'
|
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
||||||
},
|
|
||||||
{
|
|
||||||
num: '03',
|
|
||||||
title: 'We deliver on time because we know the consequences for you',
|
|
||||||
desc: 'Wind farm North Germany, coordinates XYZ, delivery Wednesday 2-4 p.m., no unloading option. Yes, we know that. We organize the logistics with a back office team that has up to 20 years of cable experience.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: '04',
|
|
||||||
title: 'The cable alone is not the solution',
|
|
||||||
desc: 'Stony ground? Perhaps a thicker outer sheath would be better? Damp ground? Can there be transverse watertight protection? We think for you and ask questions.'
|
|
||||||
}
|
|
||||||
].map((item, idx) => (
|
|
||||||
<div key={idx} className="space-y-4">
|
|
||||||
<span className="text-sm font-mono text-primary/60 border-b border-primary/20 pb-2 block w-fit">{item.num}</span>
|
|
||||||
<h3 className="text-2xl font-bold">{item.title}</h3>
|
|
||||||
<p className="text-text-secondary leading-relaxed">{item.desc}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,46 +1,45 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Section, Container } from '../../components/ui';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
export default function WhyChooseUs() {
|
export default function WhyChooseUs() {
|
||||||
|
const t = useTranslations('Home.whyChooseUs');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-neutral-dark">
|
<Section className="bg-neutral-light">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4 order-1 lg:order-2">
|
||||||
<div className="sticky top-24">
|
<div className="sticky top-32">
|
||||||
<h2 className="text-5xl font-bold text-primary mb-6">Why choose us</h2>
|
<Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
|
||||||
<p className="text-xl text-text-secondary">
|
{t('title')}
|
||||||
Experience prevents many mistakes, but we learn something new every day
|
</Heading>
|
||||||
|
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||||
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-12 space-y-6">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 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>
|
||||||
|
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-16">
|
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
|
||||||
{[
|
{[0, 1, 2, 3].map((idx) => (
|
||||||
{
|
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
|
||||||
num: '01',
|
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
||||||
title: 'Expertise with depth',
|
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
|
||||||
desc: 'Our team has decades of experience – far beyond the founding of KLZ in 2009. The entire team has over 100 years of cable experience, gained in a wide variety of plants, from low voltage to medium voltage to high voltage. We know what cables smell like, what the colleague at the shielding machine is responsible for how testing is carried out.'
|
</div>
|
||||||
},
|
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
|
||||||
{
|
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
||||||
num: '02',
|
|
||||||
title: 'Tailor-made solutions for your project',
|
|
||||||
desc: 'When things get more complex, we involve our technical consultants. That’s where you need experts who haven’t just started their careers. You need people who read and understand standards and have sometimes been involved. We have them, and with their and our experience we differentiate ourselves from simple cable trading'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: '03',
|
|
||||||
title: 'Reliability that keeps your projects on track',
|
|
||||||
desc: 'Accessibility, quick response in a fast-moving world. Do you still have questions after 5 p.m.? Or at the weekend? We are always there. And that is how we have developed our partners so that as a team we can realize what you have paid for. And if something does not go well, no one hides.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
num: '04',
|
|
||||||
title: 'Sustainability without compromise',
|
|
||||||
desc: 'We are convinced that we will leave the world better than we found it. With initiatives such as our drum return service and a clear focus on recycling, we ensure that every connection is as environmentally friendly as possible. Each of our partners has the appropriate certificates, which are increasingly expected by all customers.'
|
|
||||||
}
|
|
||||||
].map((item, idx) => (
|
|
||||||
<div key={idx} className="space-y-4">
|
|
||||||
<span className="text-sm font-mono text-primary/60 border-b border-primary/20 pb-2 block w-fit">{item.num}</span>
|
|
||||||
<h3 className="text-2xl font-bold">{item.title}</h3>
|
|
||||||
<p className="text-text-secondary leading-relaxed">{item.desc}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
components/team/Gallery.tsx
Normal file
55
components/team/Gallery.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Lightbox from '@/components/Lightbox';
|
||||||
|
import { Section, Container, Heading } from '@/components/ui';
|
||||||
|
|
||||||
|
export default function Gallery() {
|
||||||
|
const t = useTranslations('Team');
|
||||||
|
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
|
|
||||||
|
const teamGalleryImages = [
|
||||||
|
'/uploads/2024/12/DSC07539-Large-600x400.webp',
|
||||||
|
'/uploads/2024/12/DSC07460-Large-600x400.webp',
|
||||||
|
'/uploads/2024/12/DSC07469-Large-600x400.webp',
|
||||||
|
'/uploads/2024/12/DSC07433-Large-600x400.webp',
|
||||||
|
'/uploads/2024/12/DSC07387-Large-600x400.webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section className="bg-primary-dark py-16 md:py-32">
|
||||||
|
<Container>
|
||||||
|
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
|
||||||
|
<span className="text-white">{t('gallery.title')}</span>
|
||||||
|
</Heading>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 md:gap-6">
|
||||||
|
{teamGalleryImages.map((src, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxIndex(idx);
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}}
|
||||||
|
className="relative aspect-[3/4] rounded-2xl md:rounded-[32px] overflow-hidden group shadow-2xl cursor-pointer"
|
||||||
|
>
|
||||||
|
<Image src={src} alt={t('gallery.title')} fill className="object-cover transition-transform duration-1000 group-hover:scale-110" />
|
||||||
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-transparent transition-all duration-500" />
|
||||||
|
<div className="absolute inset-0 border-0 group-hover:border-[8px] md:group-hover:border-[12px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Lightbox
|
||||||
|
isOpen={lightboxOpen}
|
||||||
|
images={teamGalleryImages}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,71 +1 @@
|
|||||||
import React from 'react';
|
export * from './ui/index';
|
||||||
import Link from 'next/link';
|
|
||||||
import { clsx, type ClassValue } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
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-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
primary: 'bg-primary text-white hover:bg-primary-dark',
|
|
||||||
secondary: 'bg-secondary text-white hover:bg-secondary-light',
|
|
||||||
outline: 'border border-neutral-dark bg-transparent hover:bg-neutral-light text-text-primary',
|
|
||||||
ghost: 'hover:bg-neutral-light text-text-primary',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
sm: 'h-9 px-3 text-sm',
|
|
||||||
md: 'h-10 px-4 py-2',
|
|
||||||
lg: 'h-11 px-8 text-lg',
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = cn(baseStyles, variants[variant], sizes[size], className);
|
|
||||||
|
|
||||||
if (href) {
|
|
||||||
return (
|
|
||||||
<Link href={href} className={styles}>
|
|
||||||
{props.children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className={styles} {...props} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
|
||||||
return (
|
|
||||||
<section className={cn('py-12 md:py-16 lg:py-24', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Container({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div className={cn('container mx-auto px-4 md:px-6', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Card({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div className={cn('bg-white rounded-lg border border-neutral-dark shadow-sm overflow-hidden', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
17
components/ui/Badge.tsx
Normal file
17
components/ui/Badge.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from './utils';
|
||||||
|
|
||||||
|
export function Badge({ children, className, variant = 'primary' }: { children: React.ReactNode, className?: string, variant?: 'primary' | 'accent' | 'neutral' | 'saturated' }) {
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary-light text-primary',
|
||||||
|
accent: 'bg-accent-light text-accent-dark',
|
||||||
|
neutral: 'bg-neutral-medium text-text-secondary',
|
||||||
|
saturated: 'bg-saturated text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider', variants[variant], className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user