Compare commits
376 Commits
06bbed8c21
...
v2.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f766589c4 | |||
| 56a7613e85 | |||
| c7c345eaad | |||
| ec99dc0317 | |||
| a6dd7913a7 | |||
| 388b90ddb0 | |||
| d57700d322 | |||
| f7aa880d9f | |||
| 2bac8d6e8a | |||
| 5bd95bca4f | |||
| 6f8d63200a | |||
| 4742630260 | |||
| a5d77fc69b | |||
| 41cfe19cbf | |||
| e4f68713e7 | |||
| 2fbcce0990 | |||
| c414a7614b | |||
| 6a0269facc | |||
| 477a3bb8ce | |||
| b1859c15ce | |||
| 6085cc05dc | |||
| bcf2d60da6 | |||
| f4fdb89ba4 | |||
| 9de3931e33 | |||
| b10dbcb23f | |||
| 65bb9c620a | |||
| 63853ffa89 | |||
| 9694c77ef7 | |||
| 2c11b5026a | |||
| eaa90c65f1 | |||
| 2a47d22e26 | |||
| 33d2d67774 | |||
| 3de62dba04 | |||
| fb2354d2cc | |||
| 70984b9021 | |||
| e1b441e8e7 | |||
| 470e532d2c | |||
| 1d24a8fb7a | |||
| 73c4988eb2 | |||
| 4a75db5f54 | |||
| d76fadd6e8 | |||
| 4b2638caed | |||
| a6dcc64833 | |||
| a55680ed41 | |||
| 1a39e9c0e4 | |||
| 16723a04b7 | |||
| 639e25276f | |||
| ad2936bf93 | |||
| f0522ff3b7 | |||
| d6c799078c | |||
| d11dae5f85 | |||
| dd7e800ec4 | |||
| 046ad4475e | |||
| b29e08e954 | |||
| 36d193f8ec | |||
| b8f04d3595 | |||
| 5f7dd838ac | |||
| 8c9f51b74a | |||
| cef86717d9 | |||
| a97a00b7fd | |||
| f696e55600 | |||
| 36455ef479 | |||
| a5384134e7 | |||
| 4965e4ae26 | |||
| 1153a79eb6 | |||
| 678c803408 | |||
| 21288a4a45 | |||
| b514125e0d | |||
| 55a084e762 | |||
| 2b09cfc5d9 | |||
| 927ce977f2 | |||
| 85bc03b9d2 | |||
| c4bc10ef76 | |||
| e95f7c6dd2 | |||
| 17a91e48e6 | |||
| 4d0a94d288 | |||
| 3568c13941 | |||
| d538d7b9ec | |||
| 8c08b552cf | |||
| 1dd74a3861 | |||
| 8d77ca45f7 | |||
| c646815a3a | |||
| 23bf327670 | |||
| c77f99ef37 | |||
| bffcc98820 | |||
| 7519e17280 | |||
| 5bd7421764 | |||
| d7aba218d9 | |||
| e20d7f42c0 | |||
| 16d06d3275 | |||
| 7542f42568 | |||
| 474fa4f3df | |||
| f1d49416d1 | |||
| e3e0a7670c | |||
| 8a87318b12 | |||
| 93cb12d7d9 | |||
| 44f0c430a9 | |||
| 1478909a73 | |||
| 837abd4921 | |||
| 75c6d363c0 | |||
| a2b7f28b9f | |||
| 52ecd1b052 | |||
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 | |||
| 02bd1dcd7f | |||
| 4b0433394f | |||
| d9bddae20e | |||
| e7c482dabf | |||
| 8974d89b33 | |||
| f99ca4d35d | |||
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c | |||
| 1d074ba6d2 | |||
| 0e972983bc | |||
| c979582193 | |||
| e47ba31763 | |||
| 28072908f7 | |||
| 7e6b4a3ed7 | |||
| d7e5a57344 | |||
| c859d5e677 | |||
| e036dea089 | |||
| 39088ca868 | |||
| 18f9104623 | |||
| 76f745cc87 | |||
| 848d58010f | |||
| c0f5799667 | |||
| 0e089f9471 | |||
| 52b17423dd | |||
| bfd3c8164b | |||
| b091175b89 | |||
| 1baf03a84e | |||
| 483dfabe10 | |||
| 65f8b2c485 | |||
| 90cdd7e713 | |||
| 40fa2a7721 | |||
| a136e7b4a7 | |||
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 | |||
| 319b2b3e0c | |||
| d7f5504149 | |||
| 0f705b474b | |||
| 67046b9301 | |||
| 0b6211cf5f | |||
| c7f2c3fdfe | |||
| f30c93ffce | |||
| 3e6bbe9a93 | |||
| c6cbb02dfa | |||
| bec1916ccc | |||
| ab17e9e758 | |||
| f257e5428f | |||
| 797411ccc3 | |||
| 94a609e438 | |||
| 409ac3fea7 | |||
| b3876666c8 | |||
| bd1a61e9cd | |||
| f2ce9ec262 | |||
| eddfa3a1f1 | |||
| 1e77914314 | |||
| 52dfbb3870 | |||
| 72e85b99ee | |||
| c7807610f6 | |||
| 81f0dd88a6 | |||
| 458e467a14 | |||
| 060118202f | |||
| 64af78a984 | |||
| f6d7584613 | |||
| 2192f37fee | |||
| 8a9cd7ef3e | |||
| 406cf22050 | |||
| 5e82d6edc9 | |||
| 85375eefb0 | |||
| fe829b0c4c | |||
| 9ed08004af | |||
| fa6f27114b | |||
| a60e8af26b | |||
| c111efae1a | |||
| a12759d507 | |||
| eefabfa3ff | |||
| 86d28796a7 | |||
| bb9424d482 | |||
| b1515155b7 | |||
| 65d54ae789 | |||
| dc21d480ab | |||
| 51043da882 | |||
| 4a31cddf11 | |||
| 1b999510db | |||
| 0d852db651 | |||
| f3ff9cd364 | |||
| f15957847c | |||
| 55fc63fed5 | |||
| dac719efd2 | |||
| ec3f9d5c8e | |||
| 7ad5b5696d | |||
| 9bcf946752 | |||
| 1fefb794c1 | |||
| 1c1aebb804 | |||
| 30d8645f74 | |||
| 365cd50402 | |||
| a9f03b24c8 | |||
| 79a2a5121e | |||
| b2f26208ad | |||
| 6c739e2726 | |||
| 0ec830f5c6 | |||
| 713908ef95 | |||
| c3f41a24d5 | |||
| 013fbc5d66 | |||
| fd65b19f1d | |||
| 340c145863 | |||
| 2da182ec47 | |||
| 33a0877a6d | |||
| fdd1d5afb7 | |||
| bf996934af | |||
| 3e724f74fa | |||
| cfd5cbda55 | |||
| 0032da1562 | |||
| 7965e9c01a | |||
| f5df62c297 | |||
| 87ef5798d2 | |||
| 90e992636c | |||
| 44dbfdb3a8 | |||
| f60288a06c | |||
| 5906fc3375 | |||
| f36c6731e8 | |||
| 65ce8adc5d | |||
| 1d7c52fbca | |||
| 16f0e9b4e5 | |||
| 8dc41d52ed | |||
| 169b25ea12 | |||
| 205880b41a | |||
| 84555d11ed | |||
| 1dce82b74e | |||
| 3be4939ff5 | |||
| e054bb3490 | |||
| 75234095b7 | |||
| 4bdd4efdc3 | |||
| 47ca58a85a | |||
| d5d39a218a | |||
| ae7a45a911 | |||
| cb51c37207 | |||
| 8872d2424a | |||
| eb388610de | |||
| 6451a9e28e | |||
| 7ec826dae3 | |||
| 453a603392 | |||
| 5cfcc16dc2 | |||
| 5b43349205 | |||
| 96b296da12 | |||
| d5eb20a341 | |||
| 333111f03b | |||
| 698141f70b | |||
| e179e8162c | |||
| 259d712105 | |||
| 0178e828d6 | |||
| 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 |
@@ -1,5 +1,16 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
public/uploads
|
||||||
|
directus/uploads
|
||||||
|
.turbo
|
||||||
|
reference/
|
||||||
|
.next
|
||||||
|
!.next/cache
|
||||||
.git
|
.git
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
@@ -7,3 +18,5 @@ node_modules
|
|||||||
docs
|
docs
|
||||||
reference
|
reference
|
||||||
public/datasheets/*.pdf
|
public/datasheets/*.pdf
|
||||||
|
.pnpm-store
|
||||||
|
.gitea
|
||||||
|
|||||||
30
.env
30
.env
@@ -1,16 +1,12 @@
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
# WooCommerce & WordPress
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
WOOCOMMERCE_URL=https://klz-cables.com
|
|
||||||
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
|
||||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
|
||||||
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
|
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -20,12 +16,12 @@ MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
|||||||
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
# Strapi
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
STRAPI_DATABASE_NAME=strapi
|
# Payload Infrastructure (Dockerized)
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
STRAPI_DATABASE_PASSWORD=strapi_password_change_me
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
APP_KEYS=toBeModified1,toBeModified2,toBeModified3,toBeModified4
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
API_TOKEN_SALT=tobemodified
|
# manually write the connection strings here.
|
||||||
ADMIN_JWT_SECRET=tobemodified
|
PAYLOAD_DB_NAME=payload
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
PAYLOAD_DB_USER=payload
|
||||||
JWT_SECRET=tobemodified
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|||||||
50
.env.example
50
.env.example
@@ -10,13 +10,18 @@
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||||
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
|
TARGET=development
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable analytics
|
# Optional: Leave empty to disable analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
@@ -39,24 +44,35 @@ MAIL_RECIPIENTS=info@klz-cables.com
|
|||||||
# Logging
|
# Logging
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
SENTRY_DSN=
|
||||||
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Varnish Cache (Docker only)
|
# Payload Infrastructure (Dockerized)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
VARNISH_CACHE_SIZE=256m
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
|
# manually write the connection strings here.
|
||||||
|
PAYLOAD_DB_NAME=payload
|
||||||
|
PAYLOAD_DB_USER=payload
|
||||||
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Strapi CMS
|
# Deployment Configuration (CI/CD only)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
STRAPI_DATABASE_NAME=strapi
|
# These are typically set by the CI/CD workflow
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
IMAGE_TAG=latest
|
||||||
STRAPI_DATABASE_PASSWORD=strapi
|
TRAEFIK_HOST=klz-cables.com
|
||||||
STRAPI_URL=http://localhost:1337
|
ENV_FILE=.env
|
||||||
APP_KEYS=toBeModified1,toBeModified2
|
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
||||||
API_TOKEN_SALT=tobemodified
|
# Next.js will proxy requests from /_img to this URL.
|
||||||
ADMIN_JWT_SECRET=tobemodified
|
IMGPROXY_URL=https://img.infra.mintel.me
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
|
||||||
JWT_SECRET=tobemodified
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Varnish Configuration
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
VARNISH_CACHE_SIZE=256M
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IMPORTANT NOTES
|
# IMPORTANT NOTES
|
||||||
@@ -74,7 +90,11 @@ JWT_SECRET=tobemodified
|
|||||||
# ──────────────────
|
# ──────────────────
|
||||||
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
||||||
# 2. Runtime: All vars are loaded from .env file on the server
|
# 2. Runtime: All vars are loaded from .env file on the server
|
||||||
# 3. The .env file should exist at: /home/deploy/sites/klz-cables.com/.env
|
# 3. Branch Deployments:
|
||||||
|
# - main branch uses .env.prod
|
||||||
|
# - staging branch uses .env.staging
|
||||||
|
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
|
||||||
|
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
|
||||||
#
|
#
|
||||||
# Security:
|
# Security:
|
||||||
# ─────────
|
# ─────────
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
|
|||||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||||
MAIL_RECIPIENTS=info@klz-cables.com
|
MAIL_RECIPIENTS=info@klz-cables.com
|
||||||
|
|
||||||
# Strapi
|
|
||||||
STRAPI_DATABASE_NAME=strapi
|
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
|
||||||
STRAPI_DATABASE_PASSWORD=
|
|
||||||
APP_KEYS=
|
|
||||||
API_TOKEN_SALT=
|
|
||||||
ADMIN_JWT_SECRET=
|
|
||||||
TRANSFER_TOKEN_SALT=
|
|
||||||
JWT_SECRET=
|
|
||||||
|
|
||||||
# Varnish Cache Size (optional)
|
# Varnish Cache Size (optional)
|
||||||
VARNISH_CACHE_SIZE=256m
|
VARNISH_CACHE_SIZE=256m
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
.next/
|
|
||||||
node_modules/
|
|
||||||
reference/
|
|
||||||
public/
|
|
||||||
dist/
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
"prefer-const": "warn",
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"@next/next/no-img-element": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
52
.gitea/workflows/ci.yml
Normal file
52
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: CI - Lint, Typecheck & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-pipeline
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-assurance:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
|
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
|
- name: 🧪 QA Checks
|
||||||
|
env:
|
||||||
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
|
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
|
- name: 🏗️ Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: ♿ Accessibility Check
|
||||||
|
run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000"
|
||||||
|
|
||||||
|
- name: ♿ WCAG Sitemap Audit
|
||||||
|
run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000"
|
||||||
|
# monitor trigger
|
||||||
@@ -1,185 +1,614 @@
|
|||||||
name: Build & Deploy KLZ Cables
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_checks:
|
||||||
|
description: 'Skip tests? (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-pipeline
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 1: Prepare Environment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
prepare:
|
||||||
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
outputs:
|
||||||
|
target: ${{ steps.determine.outputs.target }}
|
||||||
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
# LOGGING: Workflow Start - Full Transparency
|
shell: bash
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
- name: 📋 Log Workflow Start
|
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
|
echo "Purging old build layers and dangling images..."
|
||||||
echo " • Commit: ${{ github.sha }}"
|
docker image prune -f
|
||||||
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
docker builder prune -f --filter "until=24h"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🔍 Environment ermitteln
|
||||||
# LOGGING: Registry Login Phase
|
id: determine
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
shell: bash
|
||||||
- name: 🔐 Login to private registry
|
|
||||||
run: |
|
run: |
|
||||||
echo "🔐 Authenticating with registry.infra.mintel.me..."
|
REF="${{ github.ref_name }}"
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
DOMAIN="klz-cables.com"
|
||||||
|
PRJ="klz"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||||
# LOGGING: Build Phase
|
TARGET="testing"
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
- name: 🏗️ Build Docker image
|
ENV_FILE=".env.testing"
|
||||||
run: |
|
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||||
echo "🏗️ Building Docker image (linux/arm64)..."
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
docker buildx build \
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
--pull \
|
TARGET="production"
|
||||||
--platform linux/arm64 \
|
IMAGE_TAG="$REF"
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
ENV_FILE=".env.prod"
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
|
else
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
TARGET="staging"
|
||||||
--push .
|
IMAGE_TAG="$REF"
|
||||||
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
TARGET="branch"
|
||||||
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
|
fi
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# Standardize Traefik Rule
|
||||||
# LOGGING: Deployment Phase
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
||||||
- name: 🚀 Deploy to production server
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
|
else
|
||||||
|
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||||
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "target=$TARGET"
|
||||||
|
echo "image_tag=$IMAGE_TAG"
|
||||||
|
echo "env_file=$ENV_FILE"
|
||||||
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
echo "project_name=klz-cablescom"
|
||||||
|
elif [[ "$TARGET" == "branch" ]]; then
|
||||||
|
echo "project_name=$PRJ-branch-$SLUG"
|
||||||
|
else
|
||||||
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
fi
|
||||||
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
|
# 1. Discovery (Works without token for public repositories)
|
||||||
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
|
||||||
|
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
if [[ -n "$POLL_TOKEN" ]]; then
|
||||||
|
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||||
|
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||||
|
chmod +x wait-for-upstream.sh
|
||||||
|
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||||
|
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
qa:
|
||||||
|
name: 🧪 QA
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Deploying to alpha.mintel.me..."
|
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: 🔒 Security Audit
|
||||||
|
run: pnpm audit --audit-level high
|
||||||
|
- name: 🧪 QA Checks
|
||||||
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
|
env:
|
||||||
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
|
run: npx turbo run lint check:spell typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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
|
||||||
|
provenance: false
|
||||||
|
platforms: linux/arm64
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
|
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||||
|
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 }}
|
||||||
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
|
||||||
|
# Secrets mapping (Payload CMS)
|
||||||
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
|
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
||||||
|
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
||||||
|
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
|
||||||
|
|
||||||
|
# Secrets mapping (Mail)
|
||||||
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
|
||||||
|
# Gatekeeper
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 📝 Generate Environment
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
|
# Middleware Selection Logic
|
||||||
|
# Regular app routes get auth on non-production
|
||||||
|
# Unprotected routes (/stats, /errors) never get auth
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||||
|
|
||||||
# Setup SSH
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
|
COMPOSE_PROFILES=""
|
||||||
|
else
|
||||||
|
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||||
|
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||||
|
COMPOSE_PROFILES="gatekeeper"
|
||||||
|
fi
|
||||||
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
|
# Gatekeeper Origin
|
||||||
|
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Generated by CI - $TARGET"
|
||||||
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
|
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||||
|
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||||
|
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||||
|
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||||
|
echo "MAIL_HOST=$MAIL_HOST"
|
||||||
|
echo "MAIL_PORT=$MAIL_PORT"
|
||||||
|
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||||
|
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||||
|
echo "MAIL_FROM=$MAIL_FROM"
|
||||||
|
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||||
|
echo ""
|
||||||
|
echo "# Payload CMS"
|
||||||
|
echo "PAYLOAD_SECRET=$PAYLOAD_SECRET"
|
||||||
|
echo "PAYLOAD_DB_NAME=$PAYLOAD_DB_NAME"
|
||||||
|
echo "PAYLOAD_DB_USER=$PAYLOAD_DB_USER"
|
||||||
|
echo "PAYLOAD_DB_PASSWORD=$PAYLOAD_DB_PASSWORD"
|
||||||
|
echo ""
|
||||||
|
echo "# Gatekeeper"
|
||||||
|
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||||
|
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||||
|
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
echo "# Analytics"
|
||||||
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
|
echo ""
|
||||||
|
echo "TARGET=$TARGET"
|
||||||
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
|
echo "TRAEFIK_TLS=true"
|
||||||
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
|
echo "ENV_FILE=$ENV_FILE"
|
||||||
|
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||||
|
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||||
|
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||||
|
} > .env.deploy
|
||||||
|
|
||||||
|
echo "--- Generated .env.deploy ---"
|
||||||
|
cat .env.deploy
|
||||||
|
echo "----------------------------"
|
||||||
|
|
||||||
|
- name: 🚀 SSH Deploy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
# Create .env file content
|
# Transfer and Restart
|
||||||
cat > /tmp/klz-cables.env << EOF
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
# ============================================================================
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
# KLZ Cables - Production Environment Configuration
|
elif [[ "$TARGET" == "testing" ]]; then
|
||||||
# ============================================================================
|
SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
|
||||||
# Auto-generated by CI/CD workflow
|
else
|
||||||
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
|
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Application
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
|
|
||||||
# Analytics (Umami)
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
|
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
|
||||||
|
|
||||||
# Email Configuration (Mailgun)
|
|
||||||
MAIL_HOST=${{ secrets.MAIL_HOST }}
|
|
||||||
MAIL_PORT=${{ secrets.MAIL_PORT }}
|
|
||||||
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
|
|
||||||
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
|
|
||||||
MAIL_FROM=${{ secrets.MAIL_FROM }}
|
|
||||||
MAIL_RECIPIENTS=${{ secrets.MAIL_RECIPIENTS }}
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Upload .env and deploy
|
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
|
|
||||||
set -e
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
|
|
||||||
chmod 600 .env
|
|
||||||
chown deploy:deploy .env
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
|
|
||||||
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
echo "🚀 Starting containers..."
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
echo "⏳ Giving the app a few seconds to warm up..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo "🔍 Checking container status..."
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
if ! docker-compose ps | grep -q "Up"; then
|
|
||||||
echo "❌ Container failed to start"
|
|
||||||
docker-compose logs --tail=100
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||||
echo "✅ Deployment complete!"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
rm -f /tmp/klz-cables.env
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
# LOGGING: Workflow Summary
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
- name: 📊 Workflow Summary
|
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: docker builder prune -f --filter "until=1h"
|
||||||
echo "📊 Status: ${{ job.status }}"
|
|
||||||
echo "🎯 Target: alpha.mintel.me"
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# NOTIFICATION: Gotify
|
# JOB 5: Smoke Test (OG Images)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
- name: 🔔 Gotify Notification (Success)
|
smoke_test:
|
||||||
if: success()
|
name: 🧪 Smoke Test
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
continue-on-error: true
|
||||||
|
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "Sending success notification to Gotify..."
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
- name: Install dependencies
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🚀 Run OG Image Check
|
||||||
Commit: ${{ github.sha }}
|
env:
|
||||||
Actor: ${{ github.actor }}
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
Run ID: ${{ github.run_id }}" \
|
run: pnpm run check:og
|
||||||
-F "priority=5")
|
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
|
||||||
|
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
|
||||||
echo "Response Body: $BODY"
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
|
||||||
echo "Failed to send Gotify notification"
|
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 🔔 Gotify Notification (Failure)
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
if: failure()
|
# JOB 6: Lighthouse (Performance & Accessibility)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: ⚡ Lighthouse
|
||||||
|
needs: [prepare, deploy, smoke_test]
|
||||||
|
continue-on-error: true
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "Sending failure notification to Gotify..."
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
- name: Install dependencies
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
echo "🎯 Debian detected - installing native chromium"
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
Commit: ${{ github.sha }}
|
# Fetch PPA key
|
||||||
Actor: ${{ github.actor }}
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
Run ID: ${{ github.run_id }}
|
|
||||||
|
|
||||||
Please check the logs for details." \
|
# Add PPA repository
|
||||||
-F "priority=8")
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
apt-get update
|
||||||
echo "Response Body: $BODY"
|
apt-get install -y --allow-downgrades chromium
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
|
||||||
echo "Failed to send Gotify notification"
|
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Standardize binary paths
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
- name: ⚡ Run Lighthouse CI
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
CHROME_PATH: /usr/bin/chromium
|
||||||
|
PAGESPEED_LIMIT: 8
|
||||||
|
run: pnpm run pagespeed:test
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 7: WCAG Audit
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
wcag:
|
||||||
|
name: ♿ WCAG
|
||||||
|
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||||
|
continue-on-error: true
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
echo "🎯 Debian detected - installing native chromium"
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
|
# Fetch PPA key
|
||||||
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
|
|
||||||
|
# Add PPA repository
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
|
||||||
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --allow-downgrades chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Standardize binary paths
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
- name: ♿ Run WCAG Audit
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
CHROME_PATH: /usr/bin/chromium
|
||||||
|
PAGESPEED_LIMIT: 8
|
||||||
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 9: Quality Assertions
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
quality_assertions:
|
||||||
|
name: 🛡️ Quality Gates
|
||||||
|
needs: [prepare, deploy, smoke_test, lighthouse, wcag]
|
||||||
|
continue-on-error: true
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🌐 HTML DOM Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:html
|
||||||
|
- name: 🔒 Security Headers Scan
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:security
|
||||||
|
- name: 🔗 Lychee Deep Link Crawl
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:links
|
||||||
|
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:assets
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 10: Notifications
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
notifications:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [prepare, deploy, smoke_test, lighthouse, wcag, quality_assertions]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- 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
|
||||||
|
|||||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -1,2 +1,30 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
.DS_Store
|
||||||
|
.pnpm-store
|
||||||
|
public/uploads
|
||||||
|
public/media
|
||||||
|
|
||||||
|
# Lighthouse CI
|
||||||
|
.lighthouseci/
|
||||||
|
lighthouserc.cjs
|
||||||
|
.lighthouserc.json
|
||||||
|
|
||||||
|
# Legacy (Directus) cleanup
|
||||||
|
directus/uploads
|
||||||
|
|
||||||
|
.next-docker
|
||||||
|
|
||||||
|
# Pa11y CI
|
||||||
|
.pa11yci/
|
||||||
|
|
||||||
|
.htmlvalidate-tmp
|
||||||
|
|
||||||
|
# Turborepo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Test Outputs
|
||||||
|
html-errors*.json
|
||||||
|
reference/
|
||||||
|
# Database backups
|
||||||
|
backups/
|
||||||
|
|||||||
22
.htmlvalidate.json
Normal file
22
.htmlvalidate.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": ["html-validate:recommended", "html-validate:document"],
|
||||||
|
"rules": {
|
||||||
|
"require-sri": "off",
|
||||||
|
"meta-refresh": "off",
|
||||||
|
"heading-level": "warn",
|
||||||
|
"no-trailing-whitespace": "off",
|
||||||
|
"wcag/h37": "warn",
|
||||||
|
"no-inline-style": "off",
|
||||||
|
"svg-focusable": "off",
|
||||||
|
"attribute-boolean-style": "off",
|
||||||
|
"attr-case": "off",
|
||||||
|
"void-style": "off",
|
||||||
|
"no-implicit-button-type": "off",
|
||||||
|
"unique-landmark": "off",
|
||||||
|
"long-title": "off",
|
||||||
|
"valid-id": "off",
|
||||||
|
"element-required-attributes": "off",
|
||||||
|
"attribute-empty-style": "off",
|
||||||
|
"element-permitted-content": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
32
.husky/pre-push
Executable file
32
.husky/pre-push
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Husky pre-push hook to validate tags
|
||||||
|
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
|
||||||
|
|
||||||
|
z40=0000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
while read local_ref local_sha remote_ref remote_sha
|
||||||
|
do
|
||||||
|
# Check if we are pushing a tag
|
||||||
|
case "$local_ref" in
|
||||||
|
refs/tags/*)
|
||||||
|
tag_name="${local_ref#refs/tags/}"
|
||||||
|
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ ERROR: Invalid tag name '$tag_name'"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo "Consistency check failed: All tags MUST start with 'v'."
|
||||||
|
echo "Example: v1.0.10"
|
||||||
|
echo ""
|
||||||
|
echo "Please delete the invalid tag and create a new one:"
|
||||||
|
echo " git tag -d $tag_name"
|
||||||
|
echo " git tag v$tag_name"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
10
.lintstagedrc.cjs
Normal file
10
.lintstagedrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
const path = require('path'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||||
|
|
||||||
|
const buildEslintCommand = (filenames) =>
|
||||||
|
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||||
|
'*.{json,md,css,scss}': ['prettier --write'],
|
||||||
|
};
|
||||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@mintel:registry=https://npm.infra.mintel.me/
|
||||||
|
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||||
|
always-auth=true
|
||||||
26
.pa11yci.json
Normal file
26
.pa11yci.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"standard": "WCAG2AA",
|
||||||
|
"runners": ["axe", "htmlcs"],
|
||||||
|
"ignore": [],
|
||||||
|
"timeout": 50000,
|
||||||
|
"wait": 1000,
|
||||||
|
"chromeLaunchConfig": {
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
},
|
||||||
|
"threshold": 25
|
||||||
|
},
|
||||||
|
"urls": [
|
||||||
|
"http://localhost:3000/en",
|
||||||
|
"http://localhost:3000/en/blog",
|
||||||
|
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
|
||||||
|
"http://localhost:3000/en/contact",
|
||||||
|
"http://localhost:3000/en/team",
|
||||||
|
"http://localhost:3000/en/products",
|
||||||
|
"http://localhost:3000/en/products/medium-voltage-cables",
|
||||||
|
"http://localhost:3000/en/products/low-voltage-cables",
|
||||||
|
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
|
||||||
|
"http://localhost:3000/en/legal-notice",
|
||||||
|
"http://localhost:3000/en/privacy-policy"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Ignore Next.js auto-generated environment file
|
||||||
|
# It often uses different quote styles than our project config
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
|
||||||
|
# Ignore other potentially generated files
|
||||||
|
pnpm-lock.yaml
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
Sheet 1
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,237 +0,0 @@
|
|||||||
# Analytics Migration Complete ✅
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Successfully migrated analytics data from Independent Analytics (WordPress) to Umami.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. Migration Script
|
|
||||||
**Location:** `scripts/migrate-analytics-to-umami.py`
|
|
||||||
- Converts Independent Analytics CSV to Umami format
|
|
||||||
- Supports 3 output formats: JSON (API), SQL (database), API payload
|
|
||||||
- Preserves page view counts and average duration data
|
|
||||||
|
|
||||||
### 2. Deployment Script
|
|
||||||
**Location:** `scripts/deploy-analytics-to-umami.sh`
|
|
||||||
- Tailored for your server setup (`deploy@alpha.mintel.me`)
|
|
||||||
- Copies files to your Umami server
|
|
||||||
- Provides import instructions for your specific environment
|
|
||||||
|
|
||||||
### 3. Output Files
|
|
||||||
|
|
||||||
#### JSON Import File
|
|
||||||
**Location:** `data/umami-import.json`
|
|
||||||
- **Size:** 2.1 MB
|
|
||||||
- **Records:** 7,634 page view events
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
- **Use:** Import via Umami API
|
|
||||||
|
|
||||||
#### SQL Import File
|
|
||||||
**Location:** `data/umami-import.sql`
|
|
||||||
- **Size:** 1.8 MB
|
|
||||||
- **Records:** 5,250 SQL statements
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
- **Use:** Direct database import
|
|
||||||
|
|
||||||
### 4. Documentation
|
|
||||||
|
|
||||||
**Location:** `scripts/README-migration.md`
|
|
||||||
- Step-by-step migration guide
|
|
||||||
- Prerequisites and setup instructions
|
|
||||||
- Import methods (API and database)
|
|
||||||
- Troubleshooting tips
|
|
||||||
|
|
||||||
**Location:** `MIGRATION_SUMMARY.md`
|
|
||||||
- Complete migration overview
|
|
||||||
- Data summary and limitations
|
|
||||||
- Verification steps
|
|
||||||
- Next steps
|
|
||||||
|
|
||||||
**Location:** `ANALYTICS_MIGRATION_COMPLETE.md` (this file)
|
|
||||||
- Quick reference guide
|
|
||||||
- Deployment instructions
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Option 1: Automated Deployment (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the deployment script
|
|
||||||
./scripts/deploy-analytics-to-umami.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This script will:
|
|
||||||
1. Copy files to your server
|
|
||||||
2. Provide import instructions
|
|
||||||
3. Show you the exact commands to run
|
|
||||||
|
|
||||||
### Option 2: Manual Deployment
|
|
||||||
|
|
||||||
#### Step 1: Copy files to server
|
|
||||||
```bash
|
|
||||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 2: SSH into server
|
|
||||||
```bash
|
|
||||||
ssh deploy@alpha.mintel.me
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 3: Import data
|
|
||||||
|
|
||||||
**Method A: API Import (if API key is available)**
|
|
||||||
```bash
|
|
||||||
# Get your API key from Umami dashboard
|
|
||||||
# Add to .env: UMAMI_API_KEY=your-api-key
|
|
||||||
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
http://localhost:3000/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method B: Database Import (direct)**
|
|
||||||
```bash
|
|
||||||
# Import SQL file into PostgreSQL
|
|
||||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method C: Manual via Umami Dashboard**
|
|
||||||
1. Access Umami dashboard: https://analytics.infra.mintel.me
|
|
||||||
2. Go to Settings → Import
|
|
||||||
3. Upload `data/umami-import.json`
|
|
||||||
4. Select website ID: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
5. Click Import
|
|
||||||
|
|
||||||
## Your Umami Configuration
|
|
||||||
|
|
||||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
**Environment Variables** (from docker-compose.yml):
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server Details:**
|
|
||||||
- **Host:** alpha.mintel.me
|
|
||||||
- **User:** deploy
|
|
||||||
- **Path:** /home/deploy/sites/klz-cables.com
|
|
||||||
- **Umami API:** http://localhost:3000/api/import
|
|
||||||
|
|
||||||
## Data Summary
|
|
||||||
|
|
||||||
### What Was Migrated
|
|
||||||
- **Source:** Independent Analytics CSV (220 unique pages)
|
|
||||||
- **Migrated:** 7,634 simulated page view events
|
|
||||||
- **Metrics:** Page views, visitor counts, average duration
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
### What Was NOT Migrated
|
|
||||||
- Individual user sessions
|
|
||||||
- Real-time data
|
|
||||||
- Geographic data
|
|
||||||
- Referrer data
|
|
||||||
- Device/browser data
|
|
||||||
- Custom events
|
|
||||||
|
|
||||||
**Note:** The CSV contains aggregated data, not raw event data. The migration creates simulated historical data for reference only.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### After Import
|
|
||||||
1. **Check Umami dashboard:** https://analytics.infra.mintel.me
|
|
||||||
2. **Verify page view counts** match your expectations
|
|
||||||
3. **Check top pages** appear correctly
|
|
||||||
4. **Monitor for a few days** to ensure new data is being collected
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
- ✅ 7,634 events imported
|
|
||||||
- ✅ 220 unique pages
|
|
||||||
- ✅ Historical view counts preserved
|
|
||||||
- ✅ Duration data maintained
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "SSH connection failed"
|
|
||||||
**Solution:** Check your SSH key and ensure `deploy@alpha.mintel.me` has access
|
|
||||||
|
|
||||||
### Issue: "API import failed"
|
|
||||||
**Solution:**
|
|
||||||
1. Check if Umami API is running: `docker compose ps`
|
|
||||||
2. Verify API key in `.env`: `UMAMI_API_KEY=your-key`
|
|
||||||
3. Try database import instead
|
|
||||||
|
|
||||||
### Issue: "Database import failed"
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure PostgreSQL is running: `docker compose ps`
|
|
||||||
2. Check database credentials
|
|
||||||
3. Run migrations first: `docker exec -it $(docker compose ps -q postgres) psql -U umami -d umami -c "SELECT 1;"`
|
|
||||||
|
|
||||||
### Issue: "No data appears in dashboard"
|
|
||||||
**Solution:**
|
|
||||||
1. Verify import completed successfully
|
|
||||||
2. Check Umami logs: `docker compose logs app`
|
|
||||||
3. Ensure website ID matches: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### 1. Import the Data
|
|
||||||
Choose one of the import methods above and run it.
|
|
||||||
|
|
||||||
### 2. Verify the Migration
|
|
||||||
- Check Umami dashboard
|
|
||||||
- Verify page view counts
|
|
||||||
- Confirm data appears correctly
|
|
||||||
|
|
||||||
### 3. Update Your Website
|
|
||||||
Your website is already configured with:
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Monitor for a Few Days
|
|
||||||
- Ensure Umami is collecting new data
|
|
||||||
- Compare with any remaining Independent Analytics data
|
|
||||||
- Verify tracking code is working
|
|
||||||
|
|
||||||
### 5. Clean Up
|
|
||||||
- Keep the original CSV as backup: `data/pages(1).csv`
|
|
||||||
- Store migration files for future reference
|
|
||||||
- Remove old Independent Analytics plugin from WordPress
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Migration Details
|
|
||||||
|
|
||||||
**Migration Date:** 2026-01-25
|
|
||||||
**Source Plugin:** Independent Analytics v2.9.7
|
|
||||||
**Target Platform:** Umami Analytics
|
|
||||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
**Server:** alpha.mintel.me (deploy user)
|
|
||||||
**Status:** ✅ Ready for import
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Quick Command Reference:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Deploy to server
|
|
||||||
./scripts/deploy-analytics-to-umami.sh
|
|
||||||
|
|
||||||
# Or manually:
|
|
||||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
|
||||||
ssh deploy@alpha.mintel.me
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Need help?** Check `scripts/README-migration.md` for detailed instructions.
|
|
||||||
110
Dockerfile
110
Dockerfile
@@ -1,75 +1,65 @@
|
|||||||
FROM node:20-alpine AS base
|
# Stage 1: Builder
|
||||||
|
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS deps
|
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
|
||||||
RUN apk add --no-cache libc6-compat curl
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Arguments for build-time configuration
|
||||||
COPY package.json package-lock.json* ./
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
RUN npm ci
|
ARG NEXT_PUBLIC_TARGET
|
||||||
|
ARG DIRECTUS_URL
|
||||||
|
ARG UMAMI_WEBSITE_ID
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
|
# Environment variables for Next.js build
|
||||||
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
FROM base AS builder
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
# Configure private registry and install dependencies
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
|
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||||
|
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
|
pnpm install --frozen-lockfile && \
|
||||||
|
rm .npmrc
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Stage 2: Development (Hot-Reloading)
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
FROM base AS development
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
ENV NODE_ENV=development
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
# Build-time environment variables for Next.js
|
# Build application
|
||||||
# These are baked into the client bundle during build
|
# Stage 3: Builder (Production)
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
FROM base AS builder
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
RUN pnpm build
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
# Stage 3: Runner
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
|
|
||||||
# Validate environment variables during build
|
|
||||||
RUN npx tsx scripts/validate-env.ts
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install curl for health checks
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
RUN apk add --no-cache curl
|
USER root
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
ENV NODE_ENV=production
|
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV PORT=3000
|
|
||||||
# set hostname to localhost
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Copy standalone output and static files
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
# server.js is created by next build from the standalone output
|
|
||||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install essential build tools if needed (e.g., for node-gyp)
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Enable corepack for pnpm
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Pre-set the pnpm store directory
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
|
# Set up pnpm store configuration
|
||||||
|
RUN pnpm config set store-dir /pnpm/store
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
# Environment Variables Cleanup - Summary
|
|
||||||
|
|
||||||
## What Was Done
|
|
||||||
|
|
||||||
Cleaned up the fragile, overkill environment variable mess and replaced it with a simple, clean, robust **fully automated** system.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Dockerfile ✅
|
|
||||||
**Before**: 4 build args including runtime-only variables (SENTRY_DSN)
|
|
||||||
**After**: 3 build args - only `NEXT_PUBLIC_*` variables that need to be baked into the client bundle
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Only these build args now:
|
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. docker-compose.yml ✅
|
|
||||||
**Before**: 12+ individual environment variables listed
|
|
||||||
**After**: Single `env_file: .env` directive
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
app:
|
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
|
||||||
env_file:
|
|
||||||
- .env # All runtime vars loaded from here
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. .gitea/workflows/deploy.yml ✅
|
|
||||||
**Before**: Passing 12+ environment variables individually via SSH command (fragile!)
|
|
||||||
**After**: **Fully automated** - workflow creates `.env` file from Gitea secrets and uploads it
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Before (FRAGILE):
|
|
||||||
ssh root@alpha.mintel.me \
|
|
||||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
|
||||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
|
||||||
... (12+ variables) \
|
|
||||||
/home/deploy/deploy.sh"
|
|
||||||
|
|
||||||
# After (AUTOMATED):
|
|
||||||
# 1. Create .env from secrets
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
# ... all other vars from secrets
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. Upload to server
|
|
||||||
scp /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
|
||||||
|
|
||||||
# 3. Deploy
|
|
||||||
ssh root@alpha.mintel.me "cd /home/deploy/sites/klz-cables.com && docker-compose up -d"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. New Files Created ✅
|
|
||||||
|
|
||||||
- **`.env.production`** - Template for reference (not used in automation)
|
|
||||||
- **`docs/DEPLOYMENT.md`** - Complete deployment guide
|
|
||||||
- **`docs/SERVER_SETUP.md`** - Server setup instructions
|
|
||||||
- **`docs/ENV_MIGRATION.md`** - Migration guide from old to new system
|
|
||||||
|
|
||||||
### 5. Updated Files ✅
|
|
||||||
|
|
||||||
- **`.env.example`** - Clear documentation of all variables with build-time vs runtime notes
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Build Time (CI/CD)
|
|
||||||
```
|
|
||||||
Gitea Workflow
|
|
||||||
↓
|
|
||||||
Only passes NEXT_PUBLIC_* as --build-arg
|
|
||||||
↓
|
|
||||||
Docker Build
|
|
||||||
↓
|
|
||||||
Validates env vars
|
|
||||||
↓
|
|
||||||
Bakes NEXT_PUBLIC_* into client bundle
|
|
||||||
↓
|
|
||||||
Push to Registry
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime (Production Server) - FULLY AUTOMATED
|
|
||||||
```
|
|
||||||
Gitea Secrets
|
|
||||||
↓
|
|
||||||
Workflow creates .env file
|
|
||||||
↓
|
|
||||||
SCP uploads to server
|
|
||||||
↓
|
|
||||||
Secured (chmod 600, chown deploy:deploy)
|
|
||||||
↓
|
|
||||||
docker-compose.yml (env_file: .env)
|
|
||||||
↓
|
|
||||||
Loads .env into container
|
|
||||||
↓
|
|
||||||
Application runs with full config
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Benefits
|
|
||||||
|
|
||||||
### 1. Simplicity
|
|
||||||
- **Before**: 15+ Gitea secrets, variables in 3+ places
|
|
||||||
- **After**: All secrets in Gitea, automatically deployed
|
|
||||||
|
|
||||||
### 2. Clarity
|
|
||||||
- **Before**: Confusing duplication, unclear which vars go where
|
|
||||||
- **After**: Clear separation - build args vs runtime env file
|
|
||||||
|
|
||||||
### 3. Robustness
|
|
||||||
- **Before**: Fragile SSH command with 12+ inline variables
|
|
||||||
- **After**: Robust automated file generation and upload
|
|
||||||
|
|
||||||
### 4. Security
|
|
||||||
- **Before**: Secrets potentially exposed in CI logs
|
|
||||||
- **After**: Secrets masked in logs, .env auto-secured on server
|
|
||||||
|
|
||||||
### 5. Maintainability
|
|
||||||
- **Before**: Update in 3 places (Dockerfile, docker-compose.yml, deploy.yml)
|
|
||||||
- **After**: Update Gitea secrets only - deployment is automatic
|
|
||||||
|
|
||||||
### 6. **Zero Manual Steps** 🎉
|
|
||||||
- **Before**: Manual .env file creation on server (error-prone, can be forgotten)
|
|
||||||
- **After**: **Fully automated** - .env file created and uploaded on every deployment
|
|
||||||
|
|
||||||
## What You Need to Do
|
|
||||||
|
|
||||||
### Required Gitea Secrets
|
|
||||||
|
|
||||||
Ensure these secrets are configured in your Gitea repository:
|
|
||||||
|
|
||||||
**Build-Time (NEXT_PUBLIC_*):**
|
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
|
||||||
|
|
||||||
**Runtime:**
|
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
|
||||||
- `MAIL_HOST` - SMTP server
|
|
||||||
- `MAIL_PORT` - SMTP port (e.g., `587`)
|
|
||||||
- `MAIL_USERNAME` - SMTP username
|
|
||||||
- `MAIL_PASSWORD` - SMTP password
|
|
||||||
- `MAIL_FROM` - Sender email
|
|
||||||
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
|
|
||||||
|
|
||||||
**Infrastructure:**
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment server
|
|
||||||
|
|
||||||
**Notifications:**
|
|
||||||
- `GOTIFY_URL` - Gotify notification server URL
|
|
||||||
- `GOTIFY_TOKEN` - Gotify application token
|
|
||||||
|
|
||||||
### That's It!
|
|
||||||
|
|
||||||
**No manual steps required.** Just push to main branch and the workflow will:
|
|
||||||
1. ✅ Build Docker image with NEXT_PUBLIC_* build args
|
|
||||||
2. ✅ Create .env file from all secrets
|
|
||||||
3. ✅ Upload .env to server
|
|
||||||
4. ✅ Secure .env file (600 permissions, deploy:deploy ownership)
|
|
||||||
5. ✅ Pull latest image
|
|
||||||
6. ✅ Deploy with docker-compose
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
```
|
|
||||||
Modified:
|
|
||||||
├── Dockerfile (removed redundant build args)
|
|
||||||
├── docker-compose.yml (use env_file instead of individual vars)
|
|
||||||
├── .gitea/workflows/deploy.yml (automated .env creation & upload)
|
|
||||||
├── .env.example (clear documentation)
|
|
||||||
├── lib/services/create-services.ts (removed redundant dotenv usage)
|
|
||||||
└── scripts/migrate-*.ts (removed redundant dotenv usage)
|
|
||||||
|
|
||||||
Created:
|
|
||||||
├── .env.production (reference template)
|
|
||||||
├── docs/DEPLOYMENT.md (deployment guide)
|
|
||||||
├── docs/SERVER_SETUP.md (server setup guide)
|
|
||||||
├── docs/ENV_MIGRATION.md (migration guide)
|
|
||||||
└── ENV_CLEANUP_SUMMARY.md (this file)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Developer pushes to main branch │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Gitea Workflow Triggered │
|
|
||||||
│ │
|
|
||||||
│ 1. Build Docker image (NEXT_PUBLIC_* build args) │
|
|
||||||
│ 2. Push to registry │
|
|
||||||
│ 3. Generate .env from secrets │
|
|
||||||
│ 4. Upload .env to server via SCP │
|
|
||||||
│ 5. SSH to server and deploy │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Production Server │
|
|
||||||
│ │
|
|
||||||
│ 1. .env file secured (600, deploy:deploy) │
|
|
||||||
│ 2. Docker login to registry │
|
|
||||||
│ 3. Pull latest image │
|
|
||||||
│ 4. docker-compose down │
|
|
||||||
│ 5. docker-compose up -d (loads .env) │
|
|
||||||
│ 6. Health checks pass │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ✅ Deployment Complete - Gotify Notification Sent │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comparison: Before vs After
|
|
||||||
|
|
||||||
| Aspect | Before | After |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| **Gitea Secrets** | 15+ secrets | Same secrets, better organized |
|
|
||||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
|
||||||
| **Runtime Vars** | Passed via SSH command | Auto-generated .env file |
|
|
||||||
| **Manual Steps** | ❌ Manual .env creation | ✅ Fully automated |
|
|
||||||
| **Maintenance** | Update in 3 places | Update Gitea secrets only |
|
|
||||||
| **Security** | Secrets in CI logs | Secrets masked, .env secured |
|
|
||||||
| **Clarity** | Confusing duplication | Clear separation |
|
|
||||||
| **Robustness** | Fragile SSH command | Robust automation |
|
|
||||||
| **Error-Prone** | ❌ Can forget .env | ✅ Impossible to forget |
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Complete deployment guide
|
|
||||||
- **[SERVER_SETUP.md](docs/SERVER_SETUP.md)** - Server setup instructions (mostly automated now)
|
|
||||||
- **[ENV_MIGRATION.md](docs/ENV_MIGRATION.md)** - Migration from old to new system
|
|
||||||
- **[.env.example](.env.example)** - Environment variables reference
|
|
||||||
- **[.env.production](.env.production)** - Production template (for reference)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Deployment Fails
|
|
||||||
|
|
||||||
1. **Check Gitea secrets** - Ensure all required secrets are set
|
|
||||||
2. **Check workflow logs** - Look for specific error messages
|
|
||||||
3. **SSH to server** - Verify .env file exists and has correct permissions
|
|
||||||
4. **Check container logs** - `docker-compose logs -f app`
|
|
||||||
|
|
||||||
### .env File Issues
|
|
||||||
|
|
||||||
The workflow automatically:
|
|
||||||
- Creates .env from secrets
|
|
||||||
- Uploads to server
|
|
||||||
- Sets 600 permissions
|
|
||||||
- Sets deploy:deploy ownership
|
|
||||||
|
|
||||||
If there are issues, check the workflow logs for the "📝 Preparing environment configuration" step.
|
|
||||||
|
|
||||||
### Missing Environment Variables
|
|
||||||
|
|
||||||
If a variable is missing:
|
|
||||||
1. Add it to Gitea secrets
|
|
||||||
2. Update `.gitea/workflows/deploy.yml` to include it in the .env generation
|
|
||||||
3. Push to trigger new deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Result**: Environment variable management is now simple, clean, robust, and **fully automated**! 🎉
|
|
||||||
|
|
||||||
No more manual .env file creation. No more forgotten configuration. No more fragile SSH commands. Just push and deploy!
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Analytics Migration Summary: Independent Analytics → Umami
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Successfully migrated analytics data from Independent Analytics WordPress plugin to Umami format.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. Migration Script
|
|
||||||
- **Location:** `scripts/migrate-analytics-to-umami.py`
|
|
||||||
- **Purpose:** Converts Independent Analytics CSV data to Umami format
|
|
||||||
- **Features:**
|
|
||||||
- JSON format (for API import)
|
|
||||||
- SQL format (for direct database import)
|
|
||||||
- API payload format (for manual import)
|
|
||||||
|
|
||||||
### 2. Migration Documentation
|
|
||||||
- **Location:** `scripts/README-migration.md`
|
|
||||||
- **Purpose:** Step-by-step guide for migration
|
|
||||||
- **Contents:**
|
|
||||||
- Prerequisites
|
|
||||||
- Migration options
|
|
||||||
- Import instructions
|
|
||||||
- Troubleshooting guide
|
|
||||||
|
|
||||||
### 3. Output Files
|
|
||||||
|
|
||||||
#### JSON Import File
|
|
||||||
- **Location:** `data/umami-import.json`
|
|
||||||
- **Size:** 2.1 MB
|
|
||||||
- **Records:** 7,634 simulated page view events
|
|
||||||
- **Format:** JSON array of Umami-compatible events
|
|
||||||
- **Use Case:** Import via Umami API
|
|
||||||
|
|
||||||
#### SQL Import File
|
|
||||||
- **Location:** `data/umami-import.sql`
|
|
||||||
- **Size:** 1.8 MB
|
|
||||||
- **Records:** 5,250 SQL INSERT statements
|
|
||||||
- **Format:** PostgreSQL-compatible SQL
|
|
||||||
- **Use Case:** Direct database import
|
|
||||||
|
|
||||||
## Data Migrated
|
|
||||||
|
|
||||||
### Source Data
|
|
||||||
- **File:** `data/pages(1).csv`
|
|
||||||
- **Records:** 220 unique pages
|
|
||||||
- **Metrics:**
|
|
||||||
- Page titles
|
|
||||||
- Visitor counts
|
|
||||||
- View counts
|
|
||||||
- Average view duration
|
|
||||||
- Bounce rates
|
|
||||||
- URLs
|
|
||||||
- Page types (Page, Post, Product, Category, etc.)
|
|
||||||
|
|
||||||
### Migrated Data
|
|
||||||
- **Total Events:** 7,634 simulated page views
|
|
||||||
- **Unique Pages:** 220
|
|
||||||
- **Data Points:**
|
|
||||||
- Website ID: `klz-cables`
|
|
||||||
- Path: Page URLs
|
|
||||||
- Duration: Preserved from average view duration
|
|
||||||
- Timestamp: Current time (for historical reference)
|
|
||||||
|
|
||||||
## Migration Process
|
|
||||||
|
|
||||||
### Step 1: Run Migration Script
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import.json \
|
|
||||||
--format json \
|
|
||||||
--site-id klz-cables
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Choose Import Method
|
|
||||||
|
|
||||||
#### Option A: API Import (Recommended)
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
https://your-umami-instance.com/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Database Import
|
|
||||||
```bash
|
|
||||||
psql -U umami -d umami -f data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Verify Migration
|
|
||||||
1. Check Umami dashboard
|
|
||||||
2. Verify page view counts
|
|
||||||
3. Confirm data appears correctly
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### Data Limitations
|
|
||||||
The CSV export contains **aggregated data**, not raw event data:
|
|
||||||
- ✅ Page views (total counts)
|
|
||||||
- ✅ Visitor counts
|
|
||||||
- ✅ Average view duration
|
|
||||||
- ❌ Individual user sessions
|
|
||||||
- ❌ Real-time data
|
|
||||||
- ❌ Geographic data
|
|
||||||
- ❌ Referrer data
|
|
||||||
- ❌ Device/browser data
|
|
||||||
|
|
||||||
### What Gets Imported
|
|
||||||
The migration creates **simulated historical data**:
|
|
||||||
- Each page view becomes a separate event
|
|
||||||
- Timestamps are set to current time
|
|
||||||
- Duration is preserved from average view duration
|
|
||||||
- No session tracking (each view is independent)
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
1. **Start fresh with Umami** - Let Umami collect new data going forward
|
|
||||||
2. **Keep the original CSV** - Store as backup for future reference
|
|
||||||
3. **Update your website** - Replace Independent Analytics tracking with Umami tracking
|
|
||||||
4. **Monitor for a few days** - Verify Umami is collecting data correctly
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Check Generated Files
|
|
||||||
```bash
|
|
||||||
# Verify JSON file
|
|
||||||
ls -lh data/umami-import.json
|
|
||||||
head -20 data/umami-import.json
|
|
||||||
|
|
||||||
# Verify SQL file
|
|
||||||
ls -lh data/umami-import.sql
|
|
||||||
head -20 data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
- ✅ JSON file: ~2.1 MB, 7,634 records
|
|
||||||
- ✅ SQL file: ~1.8 MB, 5,250 statements
|
|
||||||
- ✅ Both files contain valid data for Umami import
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Set up Umami instance** (if not already done)
|
|
||||||
2. **Create a website** in Umami dashboard
|
|
||||||
3. **Get your Website ID** and API key
|
|
||||||
4. **Run the migration script** with your credentials
|
|
||||||
5. **Import the data** using your preferred method
|
|
||||||
6. **Verify the migration** in Umami dashboard
|
|
||||||
7. **Update your website** to use Umami tracking code
|
|
||||||
8. **Monitor for a few days** to ensure data collection works
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "ModuleNotFoundError"
|
|
||||||
**Solution:** Ensure Python 3 is installed: `python3 --version`
|
|
||||||
|
|
||||||
### Issue: "Permission denied"
|
|
||||||
**Solution:** Make script executable: `chmod +x scripts/migrate-analytics-to-umami.py`
|
|
||||||
|
|
||||||
### Issue: API import fails
|
|
||||||
**Solution:** Check API key, website ID, and Umami instance accessibility
|
|
||||||
|
|
||||||
### Issue: SQL import fails
|
|
||||||
**Solution:** Verify database credentials and run migrations first
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
✅ **Completed:**
|
|
||||||
- Created migration script with 3 output formats
|
|
||||||
- Generated JSON import file (2.1 MB, 7,634 events)
|
|
||||||
- Generated SQL import file (1.8 MB, 5,250 statements)
|
|
||||||
- Created comprehensive documentation
|
|
||||||
|
|
||||||
📊 **Data Migrated:**
|
|
||||||
- 220 unique pages
|
|
||||||
- 7,634 simulated page view events
|
|
||||||
- Historical view counts and durations
|
|
||||||
|
|
||||||
🎯 **Ready for Import:**
|
|
||||||
- Choose API or SQL import method
|
|
||||||
- Follow instructions in `scripts/README-migration.md`
|
|
||||||
- Verify data in Umami dashboard
|
|
||||||
|
|
||||||
**Migration Date:** 2026-01-25
|
|
||||||
**Source:** Independent Analytics v2.9.7
|
|
||||||
**Target:** Umami Analytics
|
|
||||||
**Site ID:** klz-cables
|
|
||||||
76
README.md
76
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
|||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
|
||||||
|
- Node.js 18+
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
```bash
|
|
||||||
|
````bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
@@ -42,11 +44,12 @@ npm run cms:logs
|
|||||||
|
|
||||||
# Stop the CMS
|
# Stop the CMS
|
||||||
npm run cms:stop
|
npm run cms:stop
|
||||||
```
|
````
|
||||||
|
|
||||||
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||||
|
|
||||||
### 🔄 Data & Migration
|
### 🔄 Data & Migration
|
||||||
|
|
||||||
To sync data or migrate existing content:
|
To sync data or migrate existing content:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -61,6 +64,7 @@ npm run cms:migrate
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
SITE_URL=https://klz-cables.com
|
SITE_URL=https://klz-cables.com
|
||||||
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
|||||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||||
|
|
||||||
# Umami
|
# Umami
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# GlitchTip (Sentry compatible)
|
# GlitchTip (Sentry compatible)
|
||||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||||
@@ -81,6 +85,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
## 📊 Project Overview
|
## 📊 Project Overview
|
||||||
|
|
||||||
### Migration Statistics
|
### Migration Statistics
|
||||||
|
|
||||||
- **Content Exported**: 141 items
|
- **Content Exported**: 141 items
|
||||||
- 18 pages (9 EN + 9 DE)
|
- 18 pages (9 EN + 9 DE)
|
||||||
- 59 posts (29 EN + 30 DE)
|
- 59 posts (29 EN + 30 DE)
|
||||||
@@ -91,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
- **Translation Pairs**: 16
|
- **Translation Pairs**: 16
|
||||||
|
|
||||||
### Performance Benefits
|
### Performance Benefits
|
||||||
|
|
||||||
- **Before**: Dynamic WordPress with database queries
|
- **Before**: Dynamic WordPress with database queries
|
||||||
- **After**: Static HTML with CDN delivery
|
- **After**: Static HTML with CDN delivery
|
||||||
- **Load Time**: <100ms (vs 500ms+)
|
- **Load Time**: <100ms (vs 500ms+)
|
||||||
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 14 (App Router)
|
- **Framework**: Next.js 14 (App Router)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Styling**: SCSS
|
- **Styling**: SCSS
|
||||||
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
- **CAPTCHA**: Cloudflare Turnstile
|
- **CAPTCHA**: Cloudflare Turnstile
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
@@ -133,7 +141,7 @@ app/
|
|||||||
├── api/
|
├── api/
|
||||||
│ └── contact/route.ts # Contact API
|
│ └── contact/route.ts # Contact API
|
||||||
├── sitemap.ts # Sitemap generator
|
├── sitemap.ts # Sitemap generator
|
||||||
└── robots.ts # Robots.txt generator
|
├── robots.ts # Robots.txt generator
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
├── data.ts # Data access
|
├── data.ts # Data access
|
||||||
@@ -144,7 +152,7 @@ components/
|
|||||||
├── LocaleSwitcher.tsx # Language switcher
|
├── LocaleSwitcher.tsx # Language switcher
|
||||||
├── ContactForm.tsx # Contact form
|
├── ContactForm.tsx # Contact form
|
||||||
├── CookieConsent.tsx # GDPR banner
|
├── CookieConsent.tsx # GDPR banner
|
||||||
└── SEO.tsx # SEO utilities
|
├── SEO.tsx # SEO utilities
|
||||||
|
|
||||||
data/
|
data/
|
||||||
├── raw/ # WordPress export
|
├── raw/ # WordPress export
|
||||||
@@ -163,6 +171,7 @@ scripts/
|
|||||||
## 🎯 Features
|
## 🎯 Features
|
||||||
|
|
||||||
### ✅ Implemented
|
### ✅ Implemented
|
||||||
|
|
||||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||||
- **Contact Forms**: Resend integration with validation
|
- **Contact Forms**: Resend integration with validation
|
||||||
- **GDPR Compliance**: Cookie consent banner
|
- **GDPR Compliance**: Cookie consent banner
|
||||||
@@ -175,12 +184,14 @@ scripts/
|
|||||||
- **Asset Management**: WordPress → local path mapping
|
- **Asset Management**: WordPress → local path mapping
|
||||||
|
|
||||||
### 🔄 In Progress
|
### 🔄 In Progress
|
||||||
|
|
||||||
- Analytics integration (consent-based)
|
- Analytics integration (consent-based)
|
||||||
- Turnstile CAPTCHA
|
- Turnstile CAPTCHA
|
||||||
- Build testing
|
- Build testing
|
||||||
- Deployment configuration
|
- Deployment configuration
|
||||||
|
|
||||||
### 📝 Remaining
|
### 📝 Remaining
|
||||||
|
|
||||||
- Performance optimization
|
- Performance optimization
|
||||||
- Final QA testing
|
- Final QA testing
|
||||||
- Documentation updates
|
- Documentation updates
|
||||||
@@ -188,6 +199,7 @@ scripts/
|
|||||||
## 📝 Content Management
|
## 📝 Content Management
|
||||||
|
|
||||||
### Data Export
|
### Data Export
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Export from WordPress
|
# Export from WordPress
|
||||||
npm run data:export
|
npm run data:export
|
||||||
@@ -203,6 +215,7 @@ npm run data:improve-mapping
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Content
|
### Adding New Content
|
||||||
|
|
||||||
1. Export new content from WordPress
|
1. Export new content from WordPress
|
||||||
2. Process the data
|
2. Process the data
|
||||||
3. Rebuild the site
|
3. Rebuild the site
|
||||||
@@ -210,17 +223,20 @@ npm run data:improve-mapping
|
|||||||
## 🎨 Design System
|
## 🎨 Design System
|
||||||
|
|
||||||
### Colors
|
### Colors
|
||||||
|
|
||||||
- Primary: `#0066cc` (KLZ Blue)
|
- Primary: `#0066cc` (KLZ Blue)
|
||||||
- Secondary: `#00a896` (Teal)
|
- Secondary: `#00a896` (Teal)
|
||||||
- Text: `#1a1a1a`
|
- Text: `#1a1a1a`
|
||||||
- Background: `#f8f9fa`
|
- Background: `#f8f9fa`
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- Font: Inter
|
- Font: Inter
|
||||||
- Base: 16px
|
- Base: 16px
|
||||||
- Scale: 1.25 (Major Third)
|
- Scale: 1.25 (Major Third)
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
- Max width: 1200px
|
- Max width: 1200px
|
||||||
- Responsive grid
|
- Responsive grid
|
||||||
- Mobile-first
|
- Mobile-first
|
||||||
@@ -228,6 +244,7 @@ npm run data:improve-mapping
|
|||||||
## 🔧 API Endpoints
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
### Contact Form
|
### Contact Form
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/contact
|
POST /api/contact
|
||||||
{
|
{
|
||||||
@@ -239,11 +256,13 @@ POST /api/contact
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sitemap
|
### Sitemap
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /sitemap.xml
|
GET /sitemap.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Robots
|
### Robots
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /robots.txt
|
GET /robots.txt
|
||||||
```
|
```
|
||||||
@@ -252,21 +271,32 @@ GET /robots.txt
|
|||||||
|
|
||||||
### Automatic Deployment (Current Setup)
|
### Automatic Deployment (Current Setup)
|
||||||
|
|
||||||
The project uses **Gitea Actions** for CI/CD. Every push to `main` triggers:
|
The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
|
||||||
|
|
||||||
1. **Build**: Docker image built for `linux/arm64`
|
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
|
||||||
2. **Push**: Image pushed to `registry.infra.mintel.me`
|
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
|
||||||
3. **Deploy**: SSH to production server, pull and restart containers
|
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
|
||||||
|
|
||||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
**Branch Deployments**:
|
||||||
|
|
||||||
|
- `main` branch: Deploys to production using `.env.prod`
|
||||||
|
- `staging` branch: Deploys to staging using `.env.staging`
|
||||||
|
|
||||||
|
**Environment Overrides**:
|
||||||
|
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
||||||
|
|
||||||
**Required Secrets** (configure in Gitea repository settings):
|
**Required Secrets** (configure in Gitea repository settings):
|
||||||
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
- `REGISTRY_USER` - Docker registry username
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
- `REGISTRY_PASS` - Docker registry password
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||||
|
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
- `SENTRY_DSN` - Error tracking DSN
|
||||||
|
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||||
|
|
||||||
### Manual Deployment
|
### Manual Deployment
|
||||||
|
|
||||||
@@ -284,6 +314,7 @@ docker image prune -f
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or use the convenience script:
|
Or use the convenience script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/deploy-webhook.sh
|
bash scripts/deploy-webhook.sh
|
||||||
```
|
```
|
||||||
@@ -295,11 +326,13 @@ Client → Traefik (TLS) → Next.js App
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Domains**:
|
**Domains**:
|
||||||
|
|
||||||
- `klz-cables.com` - Production
|
- `klz-cables.com` - Production
|
||||||
- `www.klz-cables.com` - Production (www)
|
- `www.klz-cables.com` - Production (www)
|
||||||
- `staging.klz-cables.com` - Staging
|
- `staging.klz-cables.com` - Staging
|
||||||
|
|
||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
- `app`: Next.js application (port 3000)
|
- `app`: Next.js application (port 3000)
|
||||||
- `traefik`: Reverse proxy (external)
|
- `traefik`: Reverse proxy (external)
|
||||||
|
|
||||||
@@ -308,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
## 📈 Performance
|
## 📈 Performance
|
||||||
|
|
||||||
### Build Time
|
### Build Time
|
||||||
|
|
||||||
- **Target**: < 2 minutes
|
- **Target**: < 2 minutes
|
||||||
- **Current**: ~1-2 minutes
|
- **Current**: ~1-2 minutes
|
||||||
|
|
||||||
### Page Load
|
### Page Load
|
||||||
|
|
||||||
- **Target**: < 100ms
|
- **Target**: < 100ms
|
||||||
- **Current**: Static HTML from CDN
|
- **Current**: Static HTML from CDN
|
||||||
|
|
||||||
### Bundle Size
|
### Bundle Size
|
||||||
|
|
||||||
- **Target**: < 100KB gzipped
|
- **Target**: < 100KB gzipped
|
||||||
- **Current**: Optimized with code splitting
|
- **Current**: Optimized with code splitting
|
||||||
|
|
||||||
## 🔒 Security
|
## 🔒 Security
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- Never commit `.env` file
|
- Never commit `.env` file
|
||||||
- Rotate keys regularly
|
- Rotate keys regularly
|
||||||
- Use secrets in deployment platform
|
- Use secrets in deployment platform
|
||||||
|
|
||||||
### Form Security
|
### Form Security
|
||||||
|
|
||||||
- Email validation
|
- Email validation
|
||||||
- Rate limiting (recommended)
|
- Rate limiting (recommended)
|
||||||
- Turnstile CAPTCHA (pending)
|
- Turnstile CAPTCHA (pending)
|
||||||
@@ -334,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
## 🎓 WordPress Specifics
|
## 🎓 WordPress Specifics
|
||||||
|
|
||||||
### WPBakery Shortcodes Removed
|
### WPBakery Shortcodes Removed
|
||||||
|
|
||||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||||
- `[nectar_*]` (Salient theme)
|
- `[nectar_*]` (Salient theme)
|
||||||
- `[image_with_animation]`
|
- `[image_with_animation]`
|
||||||
@@ -341,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
- `[divider]`
|
- `[divider]`
|
||||||
|
|
||||||
### HTML Sanitization
|
### HTML Sanitization
|
||||||
|
|
||||||
- Removes inline event handlers
|
- Removes inline event handlers
|
||||||
- Strips scripts
|
- Strips scripts
|
||||||
- Normalizes classes
|
- Normalizes classes
|
||||||
- Preserves structure
|
- Preserves structure
|
||||||
|
|
||||||
### Asset Mapping
|
### Asset Mapping
|
||||||
|
|
||||||
WordPress URLs → Local paths:
|
WordPress URLs → Local paths:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||||
```
|
```
|
||||||
@@ -355,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||||
- `FINAL_SUMMARY.md` - Complete overview
|
- `FINAL_SUMMARY.md` - Complete overview
|
||||||
|
|
||||||
### External
|
### External
|
||||||
|
|
||||||
- [Next.js Docs](https://nextjs.org/docs)
|
- [Next.js Docs](https://nextjs.org/docs)
|
||||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||||
- [Resend Docs](https://resend.com/docs)
|
- [Resend Docs](https://resend.com/docs)
|
||||||
@@ -370,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
**TypeScript Errors**
|
**TypeScript Errors**
|
||||||
|
|
||||||
- The TypeScript errors shown in the editor are expected
|
- The TypeScript errors shown in the editor are expected
|
||||||
- They occur because modules reference each other
|
- They occur because modules reference each other
|
||||||
- The build process resolves these correctly
|
- The build process resolves these correctly
|
||||||
- Run `npm run build` to verify
|
- Run `npm run build` to verify
|
||||||
|
|
||||||
**Build Failures**
|
**Build Failures**
|
||||||
|
|
||||||
- Check environment variables
|
- Check environment variables
|
||||||
- Verify data files exist
|
- Verify data files exist
|
||||||
- Clear `.next` cache: `rm -rf .next`
|
- Clear `.next` cache: `rm -rf .next`
|
||||||
|
|
||||||
**Missing Modules**
|
**Missing Modules**
|
||||||
|
|
||||||
- Run `npm install --legacy-peer-deps`
|
- Run `npm install --legacy-peer-deps`
|
||||||
- Check `package.json` dependencies
|
- Check `package.json` dependencies
|
||||||
|
|
||||||
@@ -395,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
✅ **i18n**: Multi-language support
|
✅ **i18n**: Multi-language support
|
||||||
✅ **SEO**: Metadata and sitemaps
|
✅ **SEO**: Metadata and sitemaps
|
||||||
✅ **Compatibility**: WPBakery content handled
|
✅ **Compatibility**: WPBakery content handled
|
||||||
✅ **Media**: All images downloaded
|
✅ **Media**: All images downloaded
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
For issues or questions:
|
For issues or questions:
|
||||||
|
|
||||||
1. Check the documentation
|
1. Check the documentation
|
||||||
2. Review the troubleshooting section
|
2. Review the troubleshooting section
|
||||||
3. Check environment variables
|
3. Check environment variables
|
||||||
|
|||||||
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { RootPage } from '@payloadcms/next/views';
|
||||||
|
import { importMap } from '../importMap';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
segments: string[];
|
||||||
|
}>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
[key: string]: string | string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = ({ params, searchParams }: Args) =>
|
||||||
|
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||||
|
|
||||||
|
export default Page;
|
||||||
53
app/(payload)/admin/importMap.js
Normal file
53
app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
|
export const importMap = {
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
|
}
|
||||||
14
app/(payload)/api/[...slug]/route.ts
Normal file
14
app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import config from '@payload-config';
|
||||||
|
import {
|
||||||
|
REST_GET,
|
||||||
|
REST_OPTIONS,
|
||||||
|
REST_PATCH,
|
||||||
|
REST_POST,
|
||||||
|
REST_DELETE,
|
||||||
|
} from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const GET = REST_GET(config);
|
||||||
|
export const POST = REST_POST(config);
|
||||||
|
export const DELETE = REST_DELETE(config);
|
||||||
|
export const PATCH = REST_PATCH(config);
|
||||||
|
export const OPTIONS = REST_OPTIONS(config);
|
||||||
4
app/(payload)/api/graphql/route.ts
Normal file
4
app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import config from '@payload-config';
|
||||||
|
import { GRAPHQL_POST } from '@payloadcms/next/routes';
|
||||||
|
|
||||||
|
export const POST = GRAPHQL_POST(config);
|
||||||
1
app/(payload)/custom.scss
Normal file
1
app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* Custom Payload CMS admin styles can go here. Do not import payloadcms/ui/scss/app.scss as it is handled by @payloadcms/next/css */
|
||||||
31
app/(payload)/layout.tsx
Normal file
31
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { RootLayout } from '@payloadcms/next/layouts';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import '@payloadcms/next/css';
|
||||||
|
import './custom.scss';
|
||||||
|
import { handleServerFunctions } from '@payloadcms/next/layouts';
|
||||||
|
import { importMap } from './admin/importMap';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverFunction: any = async function (args: any) {
|
||||||
|
'use server';
|
||||||
|
return handleServerFunctions({
|
||||||
|
...args,
|
||||||
|
config: configPromise,
|
||||||
|
importMap,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const Layout = ({ children }: Args) => {
|
||||||
|
return (
|
||||||
|
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
|
||||||
|
{children}
|
||||||
|
</RootLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPageBySlug } from '@/lib/pages';
|
import { getPageBySlug } from '@/lib/pages';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
export default async function Image({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
return new ImageResponse(
|
return new Response('Page not found', { status: 404 });
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={pageData.frontmatter.title}
|
||||||
title={pageData.frontmatter.title}
|
description={pageData.frontmatter.excerpt}
|
||||||
description={pageData.frontmatter.excerpt}
|
label="Information"
|
||||||
label="Information"
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,40 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
const locales = ['en', 'de'];
|
const { locale, slug } = await params;
|
||||||
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: { locale, slug } }: PageProps): Promise<Metadata> {
|
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/${slug}`,
|
de: `${SITE_URL}/de/${slug}`,
|
||||||
'en': `/en/${slug}`,
|
en: `${SITE_URL}/en/${slug}`,
|
||||||
'x-default': `/en/${slug}`,
|
'x-default': `${SITE_URL}/en/${slug}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
url: `https://klz-cables.com/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${slug}`,
|
||||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -58,7 +44,9 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
export default async function StandardPage({ params }: PageProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
@@ -74,8 +62,10 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||||
|
{t('badge')}
|
||||||
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-0">
|
<Heading level={1} className="text-white mb-0">
|
||||||
{pageData.frontmatter.title}
|
{pageData.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -88,7 +78,7 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{pageData.frontmatter.excerpt && (
|
{pageData.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom">
|
<div className="mb-16">
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
{pageData.frontmatter.excerpt}
|
{pageData.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
@@ -96,8 +86,8 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with shared blog components */}
|
{/* Main content with shared blog components */}
|
||||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom">
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||||
<MDXRemote source={pageData.content} components={mdxComponents} />
|
<PayloadRichText data={pageData.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
@@ -106,14 +96,23 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||||
<a href={`/${locale}/contact`} className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link">
|
<TrackedLink
|
||||||
{t('contactUs')}
|
href={`/${locale}/contact`}
|
||||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">→</span>
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
</a>
|
eventProperties={{
|
||||||
|
location: 'generic_page_support_cta',
|
||||||
|
page_slug: slug,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('contactUs')}
|
||||||
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</TrackedLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,72 +3,76 @@ import { getProductBySlug } from '@/lib/mdx';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { locale: string } }
|
{ params }: { params: Promise<{ locale: string }> },
|
||||||
) {
|
) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get('slug');
|
const slug = searchParams.get('slug');
|
||||||
const locale = params.locale || 'en';
|
const { locale } = await params;
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return new Response('Missing slug', { status: 400 });
|
return new Response('Missing slug', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fonts = await getOgFonts();
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
|
||||||
// Check if it's a category page
|
// Check if it's a category page
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
if (categories.includes(slug)) {
|
if (categories.includes(slug)) {
|
||||||
const categoryKey = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = slug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : slug;
|
.replace(/-cables$/, '')
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: slug;
|
||||||
|
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||||
|
? t(`categories.${categoryKey}.description`)
|
||||||
|
: '';
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={categoryTitle}
|
|
||||||
description={categoryDesc}
|
|
||||||
label="Product Category"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await getProductBySlug(slug, locale);
|
const product = await getProductBySlug(slug, locale);
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return new ImageResponse(
|
return new Response('Product not found', { status: 404 });
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { origin } = new URL(request.url);
|
|
||||||
const featuredImage = product.frontmatter.images?.[0]
|
const featuredImage = product.frontmatter.images?.[0]
|
||||||
? (product.frontmatter.images[0].startsWith('http')
|
? product.frontmatter.images[0].startsWith('http')
|
||||||
? product.frontmatter.images[0]
|
? product.frontmatter.images[0]
|
||||||
: `${origin}${product.frontmatter.images[0]}`)
|
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={product.frontmatter.title}
|
||||||
title={product.frontmatter.title}
|
description={product.frontmatter.description}
|
||||||
description={product.frontmatter.description}
|
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
image={featuredImage}
|
||||||
image={featuredImage}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,47 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPostBySlug } from '@/lib/blog';
|
import { getPostBySlug } from '@/lib/blog';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
export default async function Image({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return new ImageResponse(
|
return new Response('Post not found', { status: 404 });
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
|
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||||
|
// For local images during dev, relative paths in <img> might not work in Satori
|
||||||
|
// but if we are in nodejs runtime, we could potentially read from disk.
|
||||||
|
// For now, let's just make sure it's absolute.
|
||||||
const featuredImage = post.frontmatter.featuredImage
|
const featuredImage = post.frontmatter.featuredImage
|
||||||
? (post.frontmatter.featuredImage.startsWith('http')
|
? post.frontmatter.featuredImage.startsWith('http')
|
||||||
? post.frontmatter.featuredImage
|
? post.frontmatter.featuredImage
|
||||||
: `https://klz-cables.com${post.frontmatter.featuredImage}`)
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={post.frontmatter.title}
|
||||||
title={post.frontmatter.title}
|
description={post.frontmatter.excerpt}
|
||||||
description={post.frontmatter.excerpt}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
image={featuredImage}
|
||||||
image={featuredImage}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import PostNavigation from '@/components/blog/PostNavigation';
|
import PostNavigation from '@/components/blog/PostNavigation';
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
import PowerCTA from '@/components/blog/PowerCTA';
|
||||||
import TableOfContents from '@/components/blog/TableOfContents';
|
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
|
|
||||||
|
// Payload CMS Imports
|
||||||
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale, slug } }: BlogPostProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
|
|
||||||
const description = post.frontmatter.excerpt || '';
|
const description = post.frontmatter.excerpt || '';
|
||||||
@@ -30,12 +32,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
languages: {
|
|
||||||
'de': `/de/blog/${slug}`,
|
|
||||||
'en': `/en/blog/${slug}`,
|
|
||||||
'x-default': `/en/blog/${slug}`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
@@ -43,8 +40,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `https://klz-cables.com/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -54,52 +50,79 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
export default async function BlogPost({ params }: BlogPostProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const headings = getHeadings(post.content);
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||||
|
<BlogEngagementTracker
|
||||||
|
title={post.frontmatter.title}
|
||||||
|
slug={slug}
|
||||||
|
category={post.frontmatter.category}
|
||||||
|
readingTime={getReadingTime(rawTextContent)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Featured Image Header */}
|
{/* Featured Image Header */}
|
||||||
{post.frontmatter.featuredImage ? (
|
{post.frontmatter.featuredImage ? (
|
||||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||||
<div
|
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
<Image
|
||||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
/>
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<div className="overflow-hidden mb-6">
|
<div className="overflow-hidden mb-6">
|
||||||
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
|
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date}>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,16 +141,25 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
<Heading level={1} className="mb-8">
|
<Heading level={1} className="mb-8">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date}>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
|
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -140,16 +172,16 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
<div className="sticky-narrative-content">
|
<div className="sticky-narrative-content">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{post.frontmatter.excerpt && (
|
{post.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
|
<div className="mb-16">
|
||||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with enhanced styling */}
|
{/* Main content with enhanced styling rendering Payload Lexical */}
|
||||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
|
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||||
<MDXRemote source={post.content} components={mdxComponents} />
|
<PayloadRichText data={post.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Power CTA */}
|
{/* Power CTA */}
|
||||||
@@ -159,7 +191,13 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
|
|
||||||
{/* Post Navigation */}
|
{/* Post Navigation */}
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<PostNavigation prev={prev} next={next} locale={locale} />
|
<PostNavigation
|
||||||
|
prev={prev}
|
||||||
|
next={next}
|
||||||
|
isPrevRandom={isPrevRandom}
|
||||||
|
isNextRandom={isNextRandom}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to blog link */}
|
{/* Back to blog link */}
|
||||||
@@ -168,18 +206,28 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
href={`/${locale}/blog`}
|
href={`/${locale}/blog`}
|
||||||
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 transition-transform group-hover:-translate-x-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
className="w-5 h-5 transition-transform group-hover:-translate-x-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sticky Sidebar */}
|
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
||||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<TableOfContents headings={headings} locale={locale} />
|
{/* Future Payload Table of Contents Implementation */}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,57 +236,63 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{/* Structured Data */}
|
{/* Structured Data */}
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`jsonld-${slug}`}
|
id={`jsonld-${slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'BlogPosting',
|
'@context': 'https://schema.org',
|
||||||
headline: post.frontmatter.title,
|
'@type': 'BlogPosting',
|
||||||
datePublished: post.frontmatter.date,
|
headline: post.frontmatter.title,
|
||||||
dateModified: post.frontmatter.date,
|
datePublished: post.frontmatter.date,
|
||||||
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
|
dateModified: post.frontmatter.date,
|
||||||
author: {
|
image: post.frontmatter.featuredImage
|
||||||
'@type': 'Organization',
|
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
name: 'KLZ Cables',
|
: undefined,
|
||||||
url: 'https://klz-cables.com',
|
author: {
|
||||||
logo: 'https://klz-cables.com/logo-blue.svg'
|
'@type': 'Organization',
|
||||||
},
|
name: 'KLZ Cables',
|
||||||
publisher: {
|
url: SITE_URL,
|
||||||
'@type': 'Organization',
|
logo: `${SITE_URL}/logo-blue.svg`,
|
||||||
name: 'KLZ Cables',
|
|
||||||
logo: {
|
|
||||||
'@type': 'ImageObject',
|
|
||||||
url: 'https://klz-cables.com/logo-blue.svg',
|
|
||||||
},
|
},
|
||||||
},
|
publisher: {
|
||||||
description: post.frontmatter.excerpt,
|
'@type': 'Organization',
|
||||||
mainEntityOfPage: {
|
name: 'KLZ Cables',
|
||||||
'@type': 'WebPage',
|
logo: {
|
||||||
'@id': `https://klz-cables.com/${locale}/blog/${slug}`,
|
'@type': 'ImageObject',
|
||||||
},
|
url: `${SITE_URL}/logo-blue.svg`,
|
||||||
articleSection: post.frontmatter.category,
|
},
|
||||||
wordCount: post.content.split(/\s+/).length,
|
},
|
||||||
timeRequired: `PT${getReadingTime(post.content)}M`
|
description: post.frontmatter.excerpt,
|
||||||
} as any}
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
|
},
|
||||||
|
articleSection: post.frontmatter.category,
|
||||||
|
wordCount: rawTextContent.split(/\s+/).length,
|
||||||
|
timeRequired: `PT${getReadingTime(rawTextContent)}M`,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`breadcrumb-${slug}`}
|
id={`breadcrumb-${slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'BreadcrumbList',
|
'@context': 'https://schema.org',
|
||||||
itemListElement: [
|
'@type': 'BreadcrumbList',
|
||||||
{
|
itemListElement: [
|
||||||
'@type': 'ListItem',
|
{
|
||||||
position: 1,
|
'@type': 'ListItem',
|
||||||
name: 'Blog',
|
position: 1,
|
||||||
item: `https://klz-cables.com/${locale}/blog`,
|
name: 'Blog',
|
||||||
},
|
item: `${SITE_URL}/${locale}/blog`,
|
||||||
{
|
},
|
||||||
'@type': 'ListItem',
|
{
|
||||||
position: 2,
|
'@type': 'ListItem',
|
||||||
name: post.frontmatter.title,
|
position: 2,
|
||||||
item: `https://klz-cables.com/${locale}/blog/${slug}`,
|
name: post.frontmatter.title,
|
||||||
},
|
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
],
|
},
|
||||||
} as any}
|
],
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
const title = t('title');
|
const fonts = await getOgFonts();
|
||||||
const description = t('description');
|
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Blog"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,37 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
return {
|
return {
|
||||||
title: t('title'),
|
title: t('title'),
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog`,
|
canonical: `${SITE_URL}/${locale}/blog`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de/blog',
|
de: `${SITE_URL}/de/blog`,
|
||||||
'en': '/en/blog',
|
en: `${SITE_URL}/en/blog`,
|
||||||
'x-default': '/en/blog',
|
'x-default': `${SITE_URL}/en/blog`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${t('title')} | KLZ Cables`,
|
title: `${t('title')} | KLZ Cables`,
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
url: `https://klz-cables.com/${locale}/blog`,
|
url: `${SITE_URL}/${locale}/blog`,
|
||||||
images: getOGImageMetadata('blog', t('title'), locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -38,13 +41,15 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
|
|
||||||
// Sort posts by date descending
|
// Sort posts by date descending
|
||||||
const sortedPosts = [...posts].sort((a, b) =>
|
const sortedPosts = [...posts].sort(
|
||||||
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const featuredPost = sortedPosts[0];
|
const featuredPost = sortedPosts[0];
|
||||||
@@ -54,38 +59,60 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
<div className="bg-neutral-light min-h-screen">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
{/* Hero Section - Immersive Magazine Feel */}
|
{/* Hero Section - Immersive Magazine Feel */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<>
|
<>
|
||||||
<img
|
<Image
|
||||||
src={featuredPost.frontmatter.featuredImage}
|
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={featuredPost.frontmatter.title}
|
alt={featuredPost.frontmatter.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
fill
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 image-overlay-gradient" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||||
|
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||||
|
{featuredPost &&
|
||||||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
|
featuredPost.frontmatter.public === false) && (
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="border border-white/30 bg-transparent text-white/80 shadow-none"
|
||||||
|
>
|
||||||
|
Draft Preview
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
<>
|
<>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{featuredPost.frontmatter.title}
|
{featuredPost.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl">
|
||||||
{featuredPost.frontmatter.excerpt}
|
{featuredPost.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<Button href={`/${locale}/blog/${featuredPost.slug}`} variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
<Button
|
||||||
|
href={`/${locale}/blog/${featuredPost.slug}`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
>
|
||||||
{t('readFullArticle')}
|
{t('readFullArticle')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</article>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Section className="bg-neutral-light py-12 md:py-28">
|
<Section className="bg-neutral-light py-12 md:py-28">
|
||||||
@@ -97,10 +124,30 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||||
{/* Category filters could go here */}
|
{/* Category filters could go here */}
|
||||||
<Badge variant="primary" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.all')}</Badge>
|
<Badge
|
||||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.industry')}</Badge>
|
variant="primary"
|
||||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.technical')}</Badge>
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.sustainability')}</Badge>
|
>
|
||||||
|
{t('categories.all')}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
|
>
|
||||||
|
{t('categories.industry')}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
|
>
|
||||||
|
{t('categories.technical')}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||||
|
>
|
||||||
|
{t('categories.sustainability')}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -110,34 +157,50 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{remainingPosts.map((post, idx) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 100}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
<Card
|
||||||
|
tag="article"
|
||||||
|
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
||||||
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src={post.frontmatter.featuredImage}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<Badge variant="accent" className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg">
|
<Badge
|
||||||
|
variant="accent"
|
||||||
|
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
||||||
|
>
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
<div className="p-5 md:p-10 flex flex-col flex-1">
|
||||||
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
<div className="flex items-center gap-3 text-[10px] md:text-sm font-bold text-primary/70 mb-2 md:mb-4 tracking-widest uppercase">
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
<span>
|
||||||
year: 'numeric',
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
month: 'long',
|
year: 'numeric',
|
||||||
day: 'numeric'
|
month: 'long',
|
||||||
})}
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
|
<p className="text-text-secondary text-sm md:text-lg line-clamp-3 md:line-clamp-4 mb-4 md:mb-8 leading-relaxed">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
||||||
@@ -145,8 +208,18 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,14 +229,48 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Placeholder */}
|
{/* Pagination */}
|
||||||
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>{t('prev')}</Button>
|
<Button
|
||||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">1</Button>
|
href="#"
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">2</Button>
|
variant="outline"
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">{t('next')}</Button>
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base pointer-events-none opacity-50"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-keyshortcuts="ArrowLeft"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{t('prev')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/blog?page=1`}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
aria-current="page"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/blog?page=2`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/blog?page=2`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="md:h-11 md:px-6 md:text-base"
|
||||||
|
aria-keyshortcuts="ArrowRight"
|
||||||
|
>
|
||||||
|
{t('next')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<BlogPaginationKeyboardObserver currentPage={1} totalPages={2} locale={locale} />
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Contact"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,20 @@ import JsonLd from '@/components/JsonLd';
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { Container, Heading, Section } from '@/components/ui';
|
import { Container, Heading, Section } from '@/components/ui';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import ContactMap from '@/components/ContactMap';
|
||||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
|
||||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: ContactPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
@@ -31,18 +24,18 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `https://klz-cables.com/${locale}/contact`,
|
canonical: `${SITE_URL}/${locale}/contact`,
|
||||||
languages: {
|
languages: {
|
||||||
'de-DE': '/de/contact',
|
de: `${SITE_URL}/de/contact`,
|
||||||
'en-US': '/en/contact',
|
en: `${SITE_URL}/en/contact`,
|
||||||
|
'x-default': `${SITE_URL}/en/contact`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${locale}/contact`,
|
url: `${SITE_URL}/${locale}/contact`,
|
||||||
siteName: 'KLZ Cables',
|
siteName: 'KLZ Cables',
|
||||||
images: getOGImageMetadata('contact', title, locale),
|
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
@@ -50,7 +43,6 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
|||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -64,7 +56,8 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage({ params }: ContactPageProps) {
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
const { locale } = params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
@@ -78,7 +71,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
'@type': 'ListItem',
|
'@type': 'ListItem',
|
||||||
position: 1,
|
position: 1,
|
||||||
name: t('title'),
|
name: t('title'),
|
||||||
item: `https://klz-cables.com/${locale}/contact`,
|
item: `${SITE_URL}/${locale}/contact`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
@@ -89,9 +82,9 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'LocalBusiness',
|
'@type': 'LocalBusiness',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
image: 'https://klz-cables.com/logo.png',
|
image: `${SITE_URL}/logo.png`,
|
||||||
'@id': 'https://klz-cables.com',
|
'@id': SITE_URL,
|
||||||
url: 'https://klz-cables.com',
|
url: SITE_URL,
|
||||||
address: {
|
address: {
|
||||||
'@type': 'PostalAddress',
|
'@type': 'PostalAddress',
|
||||||
streetAddress: 'Raiffeisenstraße 22',
|
streetAddress: 'Raiffeisenstraße 22',
|
||||||
@@ -107,20 +100,12 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
openingHoursSpecification: [
|
openingHoursSpecification: [
|
||||||
{
|
{
|
||||||
'@type': 'OpeningHoursSpecification',
|
'@type': 'OpeningHoursSpecification',
|
||||||
dayOfWeek: [
|
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||||
'Monday',
|
|
||||||
'Tuesday',
|
|
||||||
'Wednesday',
|
|
||||||
'Thursday',
|
|
||||||
'Friday'
|
|
||||||
],
|
|
||||||
opens: '08:00',
|
opens: '08:00',
|
||||||
closes: '17:00'
|
closes: '17:00',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||||
'https://www.linkedin.com/company/klz-cables'
|
|
||||||
]
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -151,39 +136,74 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||||
{t('info.howToReachUs')}
|
{t('info.howToReachUs')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="space-y-4 md:space-y-8">
|
<address className="space-y-4 md:space-y-8 not-italic">
|
||||||
<div className="flex items-start gap-4 md:gap-6 group">
|
<div className="flex items-start gap-4 md:gap-6 group">
|
||||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
className="w-5 h-5 md:w-7 md:h-7"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.office')}</h4>
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
|
{t('info.office')}
|
||||||
|
</h4>
|
||||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
|
||||||
{t('info.address')}
|
{t('info.address')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-start gap-4 md:gap-6 group">
|
<div className="flex items-start gap-4 md:gap-6 group">
|
||||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
className="w-5 h-5 md:w-7 md:h-7"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.email')}</h4>
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
<a href="mailto:info@klz-cables.com" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">info@klz-cables.com</a>
|
{t('info.email')}
|
||||||
|
</h4>
|
||||||
|
<a
|
||||||
|
href="mailto:info@klz-cables.com"
|
||||||
|
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||||
|
>
|
||||||
|
info@klz-cables.com
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||||
<Heading level={4} className="mb-4 md:mb-6">{t('hours.title')}</Heading>
|
<Heading level={4} className="mb-4 md:mb-6">
|
||||||
|
{t('hours.title')}
|
||||||
|
</Heading>
|
||||||
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
||||||
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
||||||
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
||||||
@@ -199,24 +219,28 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
|
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<Suspense fallback={<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Map Section */}
|
{/* Map Section */}
|
||||||
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
||||||
<Suspense fallback={<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
<Suspense
|
||||||
<div className="text-primary font-medium">Loading Map...</div>
|
fallback={
|
||||||
</div>}>
|
<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
||||||
<LeafletMap
|
<div className="text-primary font-medium">Loading Map...</div>
|
||||||
address={t('info.address')}
|
</div>
|
||||||
lat={48.8144}
|
}
|
||||||
lng={9.4144}
|
>
|
||||||
/>
|
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ export default function Error({
|
|||||||
const t = useTranslations('Error');
|
const t = useTranslations('Error');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Treat "Failed to find Server Action" as a deployment sync issue and reload
|
||||||
|
if (error?.message?.includes('Failed to find Server Action')) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
services.errors.captureException(error);
|
services.errors.captureException(error);
|
||||||
services.logger.error('Application error caught by boundary', {
|
services.logger.error('Application error caught by boundary', {
|
||||||
message: error.message,
|
message: error?.message || 'Unknown error',
|
||||||
stack: error.stack,
|
stack: error?.stack,
|
||||||
digest: error.digest
|
digest: error?.digest,
|
||||||
});
|
});
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
@@ -36,19 +42,14 @@ export default function Error({
|
|||||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
||||||
500
|
500
|
||||||
</Heading>
|
</Heading>
|
||||||
<Scribble
|
<Scribble variant="underline" className="w-full h-6 -bottom-2 left-0 text-saturated/40" />
|
||||||
variant="underline"
|
|
||||||
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button onClick={() => reset()} variant="saturated" size="lg">
|
<Button onClick={() => reset()} variant="saturated" size="lg">
|
||||||
|
|||||||
@@ -1,50 +1,165 @@
|
|||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import SkipLink from '@/components/SkipLink';
|
||||||
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
|
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const inter = Inter({
|
||||||
metadataBase: new URL(SITE_URL),
|
subsets: ['latin'],
|
||||||
};
|
display: 'swap',
|
||||||
|
variable: '--font-inter',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
|
||||||
|
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
template: '%s | KLZ Cables',
|
||||||
|
default: 'KLZ Cables | Ihr Partner für Kabel & Leitungen',
|
||||||
|
},
|
||||||
|
metadataBase: new URL(baseUrl),
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}/${locale}`,
|
||||||
|
languages: {
|
||||||
|
de: `${baseUrl}/de`,
|
||||||
|
en: `${baseUrl}/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
|
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 5,
|
||||||
userScalable: false,
|
userScalable: true,
|
||||||
viewportFit: 'cover',
|
viewportFit: 'cover',
|
||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function Layout(props: {
|
||||||
children,
|
|
||||||
params: {locale}
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: {locale: string};
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Providing all messages to the client
|
const params = await props.params;
|
||||||
// side is the easiest way to get started
|
const { locale } = params;
|
||||||
const messages = await getMessages();
|
const { children } = props;
|
||||||
|
const supportedLocales = ['en', 'de'];
|
||||||
|
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||||
|
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||||
|
|
||||||
|
setRequestLocale(safeLocale);
|
||||||
|
|
||||||
|
let messages: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
messages = await getMessages();
|
||||||
|
} catch (error) {
|
||||||
|
messages = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||||
|
const clientKeys = [
|
||||||
|
'Footer',
|
||||||
|
'Navigation',
|
||||||
|
'Contact',
|
||||||
|
'Products',
|
||||||
|
'Team',
|
||||||
|
'Home',
|
||||||
|
'Error',
|
||||||
|
'StandardPage',
|
||||||
|
];
|
||||||
|
const clientMessages: Record<string, any> = {};
|
||||||
|
for (const key of clientKeys) {
|
||||||
|
if (messages[key]) {
|
||||||
|
clientMessages[key] = messages[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
// Skip setting server context for analytics in CI
|
||||||
|
} else if ('setServerContext' in serverServices.analytics) {
|
||||||
|
(serverServices.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||||
|
// Client-side AnalyticsProvider handles all pageviews.
|
||||||
|
} catch {
|
||||||
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
|
console.warn(
|
||||||
|
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||||
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
<html
|
||||||
|
lang={safeLocale}
|
||||||
|
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
||||||
|
</head>
|
||||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
|
<SkipLink />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="flex-grow animate-fade-in overflow-visible"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{/* Sends pageviews for client-side navigations */}
|
<CMSConnectivityNotice />
|
||||||
<AnalyticsProvider />
|
|
||||||
|
<AnalyticsShell />
|
||||||
|
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
|
'use client';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Container, Button, Heading } from '@/components/ui';
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = useTranslations('Error.notFound');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown';
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path: errorUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explicitly send the 404 to Sentry so we have visibility into broken links
|
||||||
|
import('@sentry/nextjs').then((Sentry) => {
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag('status_code', '404');
|
||||||
|
scope.setTag('path', errorUrl);
|
||||||
|
Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [trackEvent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
@@ -16,19 +38,17 @@ export default function NotFound() {
|
|||||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||||
404
|
404
|
||||||
</Heading>
|
</Heading>
|
||||||
<Scribble
|
<Scribble
|
||||||
variant="circle"
|
variant="circle"
|
||||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant="accent" size="lg">
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={t('title')}
|
||||||
title={t('title')}
|
description={t('description')}
|
||||||
description={t('description')}
|
label="Reliable Energy Infrastructure"
|
||||||
label="Reliable Energy Infrastructure"
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,111 @@
|
|||||||
import Hero from '@/components/home/Hero';
|
import Hero from '@/components/home/Hero';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import dynamic from 'next/dynamic';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
|
||||||
import RecentPosts from '@/components/home/RecentPosts';
|
|
||||||
import Experience from '@/components/home/Experience';
|
|
||||||
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
|
||||||
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|
||||||
import GallerySection from '@/components/home/GallerySection';
|
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
|
||||||
import CTA from '@/components/home/CTA';
|
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
|
||||||
|
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
|
||||||
|
|
||||||
|
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||||
|
const Experience = dynamic(() => import('@/components/home/Experience'));
|
||||||
|
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
||||||
|
const MeetTheTeam = dynamic(() => import('@/components/home/MeetTheTeam'));
|
||||||
|
const GallerySection = dynamic(() => import('@/components/home/GallerySection'));
|
||||||
|
const VideoSection = dynamic(() => import('@/components/home/VideoSection'));
|
||||||
|
const CTA = dynamic(() => import('@/components/home/CTA'));
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="breadcrumb-home"
|
id="breadcrumb-home"
|
||||||
data={getBreadcrumbSchema([
|
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||||
{ name: 'Home', item: `/${locale}` },
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
The instruction refers to changing a class within the Hero component's paragraph.
|
||||||
|
Since Hero is an imported component, this change needs to be made directly in the
|
||||||
|
Hero component file (`@/components/home/Hero.tsx`) itself, not in this page file.
|
||||||
|
This file (`app/[locale]/page.tsx`) only renders the Hero component.
|
||||||
|
Therefore, no change is applied here.
|
||||||
|
*/}
|
||||||
<Hero />
|
<Hero />
|
||||||
<Reveal><ProductCategories /></Reveal>
|
<Reveal>
|
||||||
<Reveal><WhatWeDo /></Reveal>
|
<ProductCategories />
|
||||||
<Reveal><RecentPosts locale={locale} /></Reveal>
|
</Reveal>
|
||||||
<Reveal><Experience /></Reveal>
|
<Reveal>
|
||||||
<Reveal><WhyChooseUs /></Reveal>
|
<WhatWeDo />
|
||||||
<Reveal><MeetTheTeam /></Reveal>
|
</Reveal>
|
||||||
<Reveal><GallerySection /></Reveal>
|
<Reveal>
|
||||||
<Reveal><VideoSection /></Reveal>
|
<RecentPosts locale={locale} />
|
||||||
<Reveal><CTA /></Reveal>
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<Experience />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<WhyChooseUs />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<MeetTheTeam />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<GallerySection />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<VideoSection />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal className="content-visibility-auto">
|
||||||
|
<CTA />
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }): Promise<Metadata> {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
// Use translations for meta where available (namespace: Index.meta)
|
// Use translations for meta where available (namespace: Index.meta)
|
||||||
// Fallback to a sensible default if translation keys are missing.
|
// Fallback to a sensible default if translation keys are missing.
|
||||||
let t;
|
let t;
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If translations for Index.meta are not present, try generic Index namespace
|
// If translations for Index.meta are not present, try generic Index namespace
|
||||||
try {
|
try {
|
||||||
t = await getTranslations({ locale, namespace: 'Index' });
|
t = await getTranslations({ locale, namespace: 'Index' });
|
||||||
} catch (e) {
|
} catch {
|
||||||
t = (key: string) => '';
|
t = () => '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = t('title') || 'KLZ Cables';
|
const title = t('title') || 'KLZ Cables';
|
||||||
const description = t('description') || '';
|
const description =
|
||||||
|
t('description') ||
|
||||||
|
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}`,
|
canonical: `${SITE_URL}/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de',
|
de: `${SITE_URL}/de`,
|
||||||
'en': '/en',
|
en: `${SITE_URL}/en`,
|
||||||
'x-default': '/en',
|
'x-default': `${SITE_URL}/en`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${locale}`,
|
url: `${SITE_URL}/${locale}`,
|
||||||
images: getOGImageMetadata('', title, locale),
|
images: getOGImageMetadata('', title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
|
|||||||
@@ -1,64 +1,65 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import ProductSidebar from '@/components/ProductSidebar';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
import ProductTabs from '@/components/ProductTabs';
|
import ProductTabs from '@/components/ProductTabs';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
import RelatedProducts from '@/components/RelatedProducts';
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
|
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getDatasheetPath } from '@/lib/datasheets';
|
import { getDatasheetPath } from '@/lib/datasheets';
|
||||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string[];
|
slug: string[];
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Check if it's a category page
|
// Check if it's a category page
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = fileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
.replace(/-cables$/, '')
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: fileSlug;
|
||||||
|
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||||
|
? t(`categories.${categoryKey}.description`)
|
||||||
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
'en': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
|
||||||
title: `${categoryTitle} | KLZ Cables`,
|
|
||||||
description: categoryDesc,
|
|
||||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
|
||||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
title: `${categoryTitle} | KLZ Cables`,
|
|
||||||
description: categoryDesc,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,92 +70,63 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
'en': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const components = {
|
|
||||||
ProductTechnicalData,
|
|
||||||
ProductTabs,
|
|
||||||
p: (props: any) => <p {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium" />,
|
|
||||||
h2: (props: any) => (
|
|
||||||
<div className="relative mb-16">
|
|
||||||
<h2 {...props} className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6" />
|
|
||||||
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
h3: (props: any) => <h3 {...props} className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase" />,
|
|
||||||
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
|
||||||
section: (props: any) => <div {...props} className="block" />,
|
|
||||||
li: (props: any) => (
|
|
||||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
|
||||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
|
||||||
<span {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium" />
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
|
||||||
table: (props: any) => (
|
|
||||||
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
|
|
||||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60" />,
|
|
||||||
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium" />,
|
|
||||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
|
||||||
blockquote: (props: any) => (
|
|
||||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
|
||||||
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight" {...props} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
// Check if it's a category page
|
const categories = [
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = fileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
// Filter products for this category
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
const filteredProducts = allProducts.filter(p =>
|
? t(`categories.${categoryKey}.title`)
|
||||||
p.frontmatter.categories.some(cat =>
|
: fileSlug;
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === fileSlug ||
|
|
||||||
cat === categoryTitle
|
const filteredProducts = allProducts.filter((p) =>
|
||||||
)
|
p.frontmatter.categories.some(
|
||||||
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get translated product slugs
|
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
...p,
|
...p,
|
||||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale)
|
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +135,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
|
<Link
|
||||||
|
href={`/${locale}/${productsSlug}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
|
</Link>
|
||||||
<span className="mx-3 opacity-30">/</span>
|
<span className="mx-3 opacity-30">/</span>
|
||||||
<span className="text-white/90">{categoryTitle}</span>
|
<span className="text-white/90">{categoryTitle}</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -181,46 +158,61 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{productsWithTranslatedSlugs.map((product) => (
|
{productsWithTranslatedSlugs.map((product) => (
|
||||||
<Link
|
<Link
|
||||||
key={product.slug}
|
key={product.slug}
|
||||||
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
href={`/${locale}/${productsSlug}/${productSlug}/${product.translatedSlug}`}
|
||||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||||
>
|
>
|
||||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
<Card tag="article" className="premium-card-reset">
|
||||||
{product.frontmatter.images?.[0] && (
|
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||||
<>
|
{product.frontmatter.images?.[0] && (
|
||||||
<Image
|
<>
|
||||||
src={product.frontmatter.images[0]}
|
<Image
|
||||||
alt={product.frontmatter.title}
|
src={product.frontmatter.images[0]}
|
||||||
fill
|
alt={product.frontmatter.title}
|
||||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
fill
|
||||||
/>
|
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||||
{/* Subtle reflection/shadow effect */}
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
/>
|
||||||
</>
|
<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>
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="p-8 md:p-10">
|
||||||
{product.frontmatter.categories.map((cat, i) => (
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<span key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
{product.frontmatter.categories.map((cat, i) => (
|
||||||
{cat}
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||||
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||||
|
{t('details')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
<svg
|
||||||
|
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
</Card>
|
||||||
{product.frontmatter.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
|
||||||
{product.frontmatter.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
|
||||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
|
||||||
{t('details')}
|
|
||||||
</span>
|
|
||||||
<svg className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,15 +228,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract technical data for schema
|
// Extract technical data natively from the Lexical AST for Schema.org
|
||||||
const technicalDataMatch = product.content.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
|
|
||||||
let technicalItems = [];
|
let technicalItems = [];
|
||||||
if (technicalDataMatch) {
|
if (product.content?.root?.children) {
|
||||||
try {
|
const productTabsBlock = product.content.root.children.find(
|
||||||
const data = JSON.parse(technicalDataMatch[1]);
|
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||||
technicalItems = data.technicalItems || [];
|
);
|
||||||
} catch (e) {
|
if (productTabsBlock && productTabsBlock.fields?.technicalItems) {
|
||||||
console.error('Failed to parse technical data for schema', e);
|
technicalItems = productTabsBlock.fields.technicalItems;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,51 +243,86 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
const isFallback = (product.frontmatter as any).isFallback;
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
const categorySlug = slug[0];
|
const categorySlug = slug[0];
|
||||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||||
const categoryKey = categoryFileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = categoryFileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : categoryFileSlug;
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: categoryFileSlug;
|
||||||
|
|
||||||
|
// Split content into Description and Technical Data
|
||||||
|
const rootChildren = product.content?.root?.children || [];
|
||||||
|
const technicalBlocks = rootChildren.filter(
|
||||||
|
(node: any) =>
|
||||||
|
node.type === 'block' &&
|
||||||
|
(node.fields?.blockType === 'productTabs' ||
|
||||||
|
node.fields?.blockType === 'productTechnicalData'),
|
||||||
|
);
|
||||||
|
const descriptionChildren = rootChildren.filter(
|
||||||
|
(node: any) =>
|
||||||
|
!(
|
||||||
|
node.type === 'block' &&
|
||||||
|
(node.fields?.blockType === 'productTabs' ||
|
||||||
|
node.fields?.blockType === 'productTechnicalData')
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptionContent = {
|
||||||
|
root: {
|
||||||
|
...product.content.root,
|
||||||
|
children: descriptionChildren,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const technicalContent = {
|
||||||
|
root: {
|
||||||
|
...product.content.root,
|
||||||
|
children: technicalBlocks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<ProductSidebar
|
<ProductSidebar
|
||||||
productName={product.frontmatter.title}
|
productName={product.frontmatter.title}
|
||||||
productImage={product.frontmatter.images?.[0]}
|
productImage={product.frontmatter.images?.[0]}
|
||||||
datasheetPath={datasheetPath}
|
datasheetPath={datasheetPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const productComponents = {
|
|
||||||
...components,
|
|
||||||
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
|
|
||||||
const processedContent = product.content
|
|
||||||
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
|
|
||||||
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
|
|
||||||
.replace(/<p[^>]*>(.*?)<\/p>/g, '\n$1\n')
|
|
||||||
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
|
|
||||||
.replace(/<li[^>]*>(.*?)<\/li>/g, '\n- $1\n')
|
|
||||||
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
|
|
||||||
.replace(/<section[^>]*>/g, '')
|
|
||||||
.replace(/<\/section>/g, '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-white relative">
|
<div className="flex flex-col min-h-screen bg-white relative">
|
||||||
{/* Product Hero */}
|
{/* Product Hero */}
|
||||||
|
<ProductEngagementTracker
|
||||||
|
productName={product.frontmatter.title}
|
||||||
|
productSlug={productSlug}
|
||||||
|
categories={product.frontmatter.categories}
|
||||||
|
sku={product.frontmatter.sku}
|
||||||
|
/>
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
|
<Link
|
||||||
|
href={`/${locale}/${productsSlug}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<Link href={`/${locale}/products/${categorySlug}`} className="hover:text-accent transition-colors">{categoryTitle}</Link>
|
<Link
|
||||||
|
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{categoryTitle}
|
||||||
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{isFallback && (
|
{isFallback && (
|
||||||
@@ -307,7 +333,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-3 mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge key={idx} variant="accent" className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]">
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant="accent"
|
||||||
|
className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]"
|
||||||
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -328,25 +358,38 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative">
|
<Container className="relative">
|
||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div className="relative -mt-32 mb-32 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
<div
|
||||||
|
className="relative -mt-32 mb-32 animate-slide-up"
|
||||||
|
style={{ animationDelay: '200ms' }}
|
||||||
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
{/* Subtle reflection/shadow effect */}
|
{/* Subtle reflection/shadow effect */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.frontmatter.images.length > 1 && (
|
{product.frontmatter.images.length > 1 && (
|
||||||
<div className="flex justify-center gap-8 mt-20">
|
<div className="flex justify-center gap-8 mt-20">
|
||||||
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
||||||
<div key={idx} className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4">
|
<div
|
||||||
<Image src={img} alt="" fill className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" />
|
key={idx}
|
||||||
|
className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="128px"
|
||||||
|
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -355,23 +398,50 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
||||||
<div className="w-full">
|
{/* Description Area Next to Sidebar */}
|
||||||
{/* Main Content Area */}
|
<div className="lg:col-span-8">
|
||||||
<div className="max-w-none">
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
||||||
<MDXRemote source={processedContent} components={productComponents} />
|
<PayloadRichText data={descriptionContent} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Structured Data */}
|
{/* Sidebar Column */}
|
||||||
<JsonLd
|
<div className="lg:col-span-4 lg:sticky lg:top-32 h-fit">{sidebar}</div>
|
||||||
id={`jsonld-${product.slug}`}
|
</div>
|
||||||
data={{
|
|
||||||
|
{/* Full-width Technical Data Below */}
|
||||||
|
<div className="mt-16 pt-16 border-t-0">
|
||||||
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||||
|
<PayloadRichText data={technicalContent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datasheet Download Section */}
|
||||||
|
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
||||||
|
<div className="mt-16 pt-16 border-t-2 border-neutral-dark/5">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||||
|
{t('downloadDatasheet')}
|
||||||
|
</h2>
|
||||||
|
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Structured Data (Hidden) */}
|
||||||
|
<JsonLd
|
||||||
|
id={`jsonld-${product.slug}`}
|
||||||
|
data={
|
||||||
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Product',
|
'@type': 'Product',
|
||||||
name: product.frontmatter.title,
|
name: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||||
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
|
image: product.frontmatter.images?.[0]
|
||||||
|
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||||
|
: undefined,
|
||||||
brand: {
|
brand: {
|
||||||
'@type': 'Brand',
|
'@type': 'Brand',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
@@ -380,7 +450,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
availability: 'https://schema.org/InStock',
|
availability: 'https://schema.org/InStock',
|
||||||
priceCurrency: 'EUR',
|
priceCurrency: 'EUR',
|
||||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
itemCondition: 'https://schema.org/NewCondition',
|
itemCondition: 'https://schema.org/NewCondition',
|
||||||
},
|
},
|
||||||
additionalProperty: technicalItems.map((item: any) => ({
|
additionalProperty: technicalItems.map((item: any) => ({
|
||||||
@@ -391,19 +461,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
category: product.frontmatter.categories.join(', '),
|
category: product.frontmatter.categories.join(', '),
|
||||||
mainEntityOfPage: {
|
mainEntityOfPage: {
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
'@id': `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
},
|
},
|
||||||
} as any}
|
} as any
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,83 +1,29 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getProductBySlug } from '@/lib/mdx';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const t = await getTranslations('Products');
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
// If no slug, it's the main products page
|
const title = t.has('meta.title')
|
||||||
if (!slug || slug.length === 0) {
|
? t('meta.title')
|
||||||
const title = t('meta.title') || t('title');
|
: t.has('breadcrumb')
|
||||||
const description = t('meta.description') || t('subtitle');
|
? t('breadcrumb')
|
||||||
|
: 'Products';
|
||||||
return new ImageResponse(
|
const description = t('meta.description') || t('subtitle');
|
||||||
(
|
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Products"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const productSlug = slug[slug.length - 1];
|
|
||||||
|
|
||||||
// Check if it's a category page
|
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
|
||||||
if (categories.includes(productSlug)) {
|
|
||||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
|
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<OGImageTemplate
|
|
||||||
title={categoryTitle}
|
|
||||||
description={categoryDesc}
|
|
||||||
label="Product Category"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return new ImageResponse(
|
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const featuredImage = product.frontmatter.images?.[0]
|
|
||||||
? (product.frontmatter.images[0].startsWith('http')
|
|
||||||
? product.frontmatter.images[0]
|
|
||||||
: `https://klz-cables.com${product.frontmatter.images[0]}`)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={product.frontmatter.title}
|
|
||||||
description={product.frontmatter.description}
|
|
||||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
|
||||||
image={featuredImage}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: ProductsPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t.has('meta.title')
|
||||||
|
? t('meta.title')
|
||||||
|
: t.has('breadcrumb')
|
||||||
|
? t('breadcrumb')
|
||||||
|
: 'Products';
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de/products',
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||||
'en': '/en/products',
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
'x-default': '/en/products',
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${locale}/products`,
|
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
images: getOGImageMetadata('products', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -44,13 +48,17 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Get translated category slugs
|
// Get translated category slugs
|
||||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
|
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
|
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
|
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
@@ -58,29 +66,29 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${lowVoltageSlug}`
|
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${mediumVoltageSlug}`
|
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${highVoltageSlug}`
|
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${params.locale}/products/${solarSlug}`
|
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,7 +97,10 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5">
|
<Badge
|
||||||
|
variant="saturated"
|
||||||
|
className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5"
|
||||||
|
>
|
||||||
{t('heroSubtitle')}
|
{t('heroSubtitle')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
@@ -97,16 +108,24 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="relative inline-block">
|
||||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||||
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
|
<Scribble
|
||||||
|
variant="circle"
|
||||||
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-4 md:gap-6">
|
<div className="flex flex-wrap gap-4 md:gap-6">
|
||||||
<Button href="#categories" variant="accent" size="lg" className="group w-full md:w-auto">
|
<Button
|
||||||
|
href="#categories"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto"
|
||||||
|
>
|
||||||
{t('viewProducts')}
|
{t('viewProducts')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,25 +139,41 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Reveal key={idx} delay={idx * 100}>
|
<Reveal key={idx} delay={idx * 100}>
|
||||||
<Link key={idx} href={category.href} className="group block">
|
<TrackedLink
|
||||||
|
key={idx}
|
||||||
|
href={category.href}
|
||||||
|
className="group block"
|
||||||
|
eventProperties={{
|
||||||
|
category_title: category.title,
|
||||||
|
location: 'products_index',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
||||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={category.img}
|
src={category.img}
|
||||||
alt={category.title}
|
alt={category.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
||||||
|
|
||||||
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
||||||
<Image src={category.icon} alt="" width={24} height={24} className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
|
<Image
|
||||||
|
src={category.icon}
|
||||||
|
alt=""
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
||||||
<Badge variant="accent" className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs">
|
<Badge
|
||||||
|
variant="accent"
|
||||||
|
className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs"
|
||||||
|
>
|
||||||
{t('categoryLabel')}
|
{t('categoryLabel')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
||||||
@@ -155,20 +190,30 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
{t('viewProducts')}
|
{t('viewProducts')}
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</TrackedLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Technical Support CTA */}
|
{/* Technical Support CTA */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<Section className="bg-white py-12 md:py-28">
|
<Section className="bg-white py-12 md:py-28">
|
||||||
@@ -177,14 +222,23 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{t('cta.title')}</h2>
|
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
|
{t('cta.title')}
|
||||||
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
{t('cta.description')}
|
{t('cta.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button href={`/${params.locale}/contact`} variant="accent" size="lg" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
<Button
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
>
|
||||||
{t('cta.button')}
|
{t('cta.button')}
|
||||||
<span className="ml-4 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
const title = t('meta.title') || t('hero.subtitle');
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Our Team"
|
|
||||||
image="https://klz-cables.com/uploads/2024/12/DSC07655-Large.webp"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||||
|
|
||||||
interface TeamPageProps {
|
interface TeamPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const title = t('meta.title') || t('hero.subtitle');
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
@@ -22,18 +23,17 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/team`,
|
canonical: `${SITE_URL}/${locale}/team`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de/team',
|
de: `${SITE_URL}/de/team`,
|
||||||
'en': '/en/team',
|
en: `${SITE_URL}/en/team`,
|
||||||
'x-default': '/en/team',
|
'x-default': `${SITE_URL}/en/team`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${locale}/team`,
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
images: getOGImageMetadata('team', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -43,16 +43,16 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
export default async function TeamPage({ params }: TeamPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="breadcrumb-team"
|
id="breadcrumb-team"
|
||||||
data={getBreadcrumbSchema([
|
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||||
{ name: t('hero.subtitle'), item: `/team` },
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="person-michael"
|
id="person-michael"
|
||||||
@@ -65,10 +65,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||||
'https://www.linkedin.com/in/michael-bodemer-33b493122/'
|
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||||
],
|
|
||||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -82,10 +80,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||||
'https://www.linkedin.com/in/klaus-mintel-b80a8b193/'
|
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||||
],
|
|
||||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -97,13 +93,16 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
alt="KLZ Team"
|
alt="KLZ Team"
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||||
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10 text-center text-white max-w-5xl">
|
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">{t('hero.badge')}</Badge>
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||||
|
{t('hero.badge')}
|
||||||
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t('hero.subtitle')}
|
{t('hero.subtitle')}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -115,12 +114,14 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||||
<section className="relative bg-white overflow-hidden">
|
<article className="relative bg-white overflow-hidden">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-8">{t('michael.role')}</Badge>
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
|
{t('michael.role')}
|
||||||
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -133,15 +134,20 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||||
{t('michael.description')}
|
{t('michael.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Michael Bodemer',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('michael.linkedin')}
|
{t('michael.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||||
@@ -155,7 +161,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Legacy Section - Immersive Background */}
|
{/* Legacy Section - Immersive Background */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -173,26 +179,36 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||||
<div className="lg:col-span-6">
|
<div className="lg:col-span-6">
|
||||||
<Heading level={2} subtitle={t('legacy.subtitle')} className="text-white mb-6 md:mb-10">
|
<Heading
|
||||||
|
level={2}
|
||||||
|
subtitle={t('legacy.subtitle')}
|
||||||
|
className="text-white mb-6 md:mb-10"
|
||||||
|
>
|
||||||
<span className="text-white">{t('legacy.title')}</span>
|
<span className="text-white">{t('legacy.title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
||||||
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
||||||
{t('legacy.p1')}
|
{t('legacy.p1')}
|
||||||
</p>
|
</p>
|
||||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">
|
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
|
||||||
{t('legacy.p2')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
||||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.expertise')}</div>
|
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.expertiseDesc')}</div>
|
{t('legacy.expertise')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||||
|
{t('legacy.expertiseDesc')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.network')}</div>
|
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.networkDesc')}</div>
|
{t('legacy.network')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||||
|
{t('legacy.networkDesc')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +217,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||||
<section className="relative bg-white overflow-hidden">
|
<article className="relative bg-white overflow-hidden">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||||
<Image
|
<Image
|
||||||
@@ -216,7 +232,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
||||||
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8">{t('klaus.role')}</Badge>
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
|
{t('klaus.role')}
|
||||||
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -229,19 +247,24 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||||
{t('klaus.description')}
|
{t('klaus.description')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<TrackedButton
|
||||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Klaus Mintel',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('klaus.linkedin')}
|
{t('klaus.linkedin')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Button>
|
</TrackedButton>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Manifesto Section - Modern Grid */}
|
{/* Manifesto Section - Modern Grid */}
|
||||||
<Section className="bg-white text-primary py-16 md:py-28">
|
<Section className="bg-white text-primary py-16 md:py-28">
|
||||||
@@ -255,28 +278,40 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
||||||
{t('manifesto.tagline')}
|
{t('manifesto.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Mobile-only progress indicator */}
|
{/* Mobile-only progress indicator */}
|
||||||
<div className="flex lg:hidden mt-8 gap-2">
|
<div className="flex lg:hidden mt-8 gap-2">
|
||||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
<div key={i} className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
||||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
<div key={idx} className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none">
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||||
|
>
|
||||||
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
||||||
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
|
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
||||||
|
0{idx + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{t(`manifesto.items.${idx}.title`)}</h3>
|
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
||||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{t(`manifesto.items.${idx}.description`)}</p>
|
{t(`manifesto.items.${idx}.title`)}
|
||||||
</div>
|
</h3>
|
||||||
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,46 +1,150 @@
|
|||||||
"use server";
|
'use server';
|
||||||
|
|
||||||
import { sendEmail } from "@/lib/mail/mailer";
|
import { sendEmail } from '@/lib/mail/mailer';
|
||||||
import ContactEmail from "@/components/emails/ContactEmail";
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
export async function sendContactFormAction(formData: FormData) {
|
export async function sendContactFormAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||||
const name = formData.get("name") as string;
|
|
||||||
const email = formData.get("email") as string;
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
const message = formData.get("message") as string;
|
const { headers } = await import('next/headers');
|
||||||
const productName = formData.get("productName") as string | null;
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ('setServerContext' in services.analytics) {
|
||||||
|
(services.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track attempt
|
||||||
|
services.analytics.track('contact-form-attempt');
|
||||||
|
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const message = formData.get('message') as string;
|
||||||
|
const productName = formData.get('productName') as string | null;
|
||||||
|
|
||||||
if (!name || !email || !message) {
|
if (!name || !email || !message) {
|
||||||
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
|
logger.warn('Missing required fields in contact form', {
|
||||||
return { success: false, error: "Missing required fields" };
|
name: !!name,
|
||||||
|
email: !!email,
|
||||||
|
message: !!message,
|
||||||
|
});
|
||||||
|
return { success: false, error: 'Missing required fields' };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Sending contact form email', { email, productName });
|
// 1. Save to CMS
|
||||||
|
try {
|
||||||
|
const { getPayload } = await import('payload');
|
||||||
|
const configPromise = (await import('@payload-config')).default;
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
const subject = productName
|
await payload.create({
|
||||||
? `Product Inquiry: ${productName}`
|
collection: 'form-submissions',
|
||||||
: "New Contact Form Submission";
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
type: productName ? 'product_quote' : 'contact',
|
||||||
|
productName: productName || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const result = await sendEmail({
|
logger.info('Successfully saved form submission to Payload CMS', {
|
||||||
subject,
|
type: productName ? 'product_quote' : 'contact',
|
||||||
template: React.createElement(ContactEmail, {
|
|
||||||
name,
|
|
||||||
email,
|
email,
|
||||||
message,
|
});
|
||||||
productName: productName || undefined,
|
} catch (error) {
|
||||||
subject,
|
logger.error('Failed to store submission in Payload CMS', { error });
|
||||||
}),
|
services.errors.captureException(error, { action: 'payload_store_submission' });
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
|
||||||
} else {
|
|
||||||
logger.error('Failed to send contact form email', { error: result.error });
|
|
||||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
// 2. Send Emails
|
||||||
|
logger.info('Sending branded emails', { email, productName });
|
||||||
|
|
||||||
|
const notificationSubject = productName
|
||||||
|
? `Product Inquiry: ${productName}`
|
||||||
|
: 'New Contact Form Submission';
|
||||||
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2a. Send notification to Mintel/Client
|
||||||
|
const notificationHtml = await render(
|
||||||
|
React.createElement(ContactFormNotification, {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
productName: productName || undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationResult = await sendEmail({
|
||||||
|
replyTo: email,
|
||||||
|
subject: notificationSubject,
|
||||||
|
html: notificationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notificationResult.success) {
|
||||||
|
logger.info('Notification email sent successfully', {
|
||||||
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
|
const confirmationHtml = await render(
|
||||||
|
React.createElement(ConfirmationMessage, {
|
||||||
|
name,
|
||||||
|
clientName: 'KLZ Cables',
|
||||||
|
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmationResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmationResult.success) {
|
||||||
|
logger.info('Confirmation email sent successfully', {
|
||||||
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify via Gotify (Internal)
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: `📩 ${notificationSubject}`,
|
||||||
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track('contact-form-success', {
|
||||||
|
is_product_request: !!productName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to send branded emails', {
|
||||||
|
error: errorMsg,
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||||
|
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '🚨 Contact Form Error',
|
||||||
|
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
9
app/api/health/cms/route.ts
Normal file
9
app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
|
||||||
|
// Further DB health checks can be implemented via Payload Local API later.
|
||||||
|
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
|
||||||
|
}
|
||||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Only allow in development
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Ensure we are in the project root by using process.cwd()
|
||||||
|
// Path: <project-root>/remotion/session.json
|
||||||
|
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||||
|
const filePath = path.join(remotionDir, 'session.json');
|
||||||
|
|
||||||
|
// Create remotion directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(remotionDir)) {
|
||||||
|
fs.mkdirSync(remotionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the JSON file
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, path: filePath });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save session:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||||
|
}
|
||||||
78
app/errors/api/relay/route.ts
Normal file
78
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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 {
|
||||||
|
// Prevent 403 Forbidden console noise in local dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = await request.text();
|
||||||
|
|
||||||
|
// Sentry envelopes can contain multiple parts separated by newlines
|
||||||
|
const lines = envelope.split('\n');
|
||||||
|
if (lines.length < 1) {
|
||||||
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON.parse(lines[0]);
|
||||||
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
|
if (!realDsn) {
|
||||||
|
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dsnUrl = new URL(realDsn);
|
||||||
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||||
|
|
||||||
|
logger.debug('Relaying Sentry envelope', {
|
||||||
|
projectId,
|
||||||
|
host: dsnUrl.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(relayUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: envelope,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-sentry-envelope',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (!process.env.CI) {
|
||||||
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
if (!process.env.CI) {
|
||||||
|
logger.error('Failed to relay Sentry request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
sizes: 'any',
|
sizes: 'any',
|
||||||
type: 'image/x-icon',
|
type: 'image/x-icon',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: '/logo.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: '/apple-touch-icon.png',
|
src: '/apple-touch-icon.png',
|
||||||
sizes: '180x180',
|
sizes: '180x180',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
@@ -11,8 +13,8 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
{
|
{
|
||||||
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||||
allow: '/',
|
allow: '/',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
sitemap: 'https://klz-cables.com/sitemap.xml',
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/sitemap.ts
101
app/sitemap.ts
@@ -1,46 +1,72 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPostsMetadata } from '@/lib/blog';
|
||||||
import { getAllPages } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
|
|
||||||
|
export const revalidate = 3600; // Revalidate every hour
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = 'https://klz-cables.com';
|
const baseUrl = process.env.CI
|
||||||
|
? 'http://klz.localhost'
|
||||||
|
: config.baseUrl || 'https://klz-cables.com';
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const routes = [
|
|
||||||
'',
|
|
||||||
'/blog',
|
|
||||||
'/contact',
|
|
||||||
'/team',
|
|
||||||
'/products',
|
|
||||||
'/products/low-voltage-cables',
|
|
||||||
'/products/medium-voltage-cables',
|
|
||||||
'/products/high-voltage-cables',
|
|
||||||
'/products/solar-cables',
|
|
||||||
];
|
|
||||||
|
|
||||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
|
// Helper to generate localized URL Segment
|
||||||
|
const getLocalizedRoute = async (pageKey: string) => {
|
||||||
|
if (pageKey === '') return '';
|
||||||
|
const translated = await mapFileSlugToTranslated(pageKey, locale);
|
||||||
|
return `/${translated}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Static routes
|
// Static routes
|
||||||
for (const route of routes) {
|
const staticPages = ['', 'blog', 'contact', 'team', 'products'];
|
||||||
|
for (const page of staticPages) {
|
||||||
|
const localizedRoute = await getLocalizedRoute(page);
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}${route}`,
|
url: `${baseUrl}/${locale}${localizedRoute}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: route === '' ? 'daily' : 'weekly',
|
changeFrequency: page === '' ? 'daily' : 'weekly',
|
||||||
priority: route === '' ? 1 : 0.8,
|
priority: page === '' ? 1 : 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories routes
|
||||||
|
const productCategories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
|
|
||||||
|
const translatedProducts = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
|
for (const category of productCategories) {
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
||||||
|
sitemapEntries.push({
|
||||||
|
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 0.8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
const products = await getAllProducts(locale);
|
const productsMetadata = await getAllProductsMetadata(locale);
|
||||||
for (const product of products) {
|
for (const product of productsMetadata) {
|
||||||
// We need to find the category for the product to build the URL
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
// In this project, products are under /products/[category]/[slug]
|
|
||||||
// The category is in product.frontmatter.categories
|
const category =
|
||||||
const category = product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
||||||
|
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
@@ -48,10 +74,15 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blog posts
|
// Blog posts
|
||||||
const posts = await getAllPosts(locale);
|
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
|
||||||
for (const post of posts) {
|
const postsMetadata = await getAllPostsMetadata(locale);
|
||||||
|
for (const post of postsMetadata) {
|
||||||
|
if (!post.frontmatter || !post.slug) continue;
|
||||||
|
|
||||||
|
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`,
|
||||||
lastModified: new Date(post.frontmatter.date),
|
lastModified: new Date(post.frontmatter.date),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
@@ -59,10 +90,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
const pages = await getAllPages(locale);
|
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||||
for (const page of pages) {
|
for (const page of pagesMetadata) {
|
||||||
|
if (!page.slug) continue;
|
||||||
|
|
||||||
|
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
url: `${baseUrl}/${locale}/${translatedSlug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
|
|||||||
101
app/stats/api/send/route.ts
Normal file
101
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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 {
|
||||||
|
// Prevent 400 Bad Request console noise in local dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
// Inject the secret websiteId from server config
|
||||||
|
const websiteId = config.analytics.umami.websiteId;
|
||||||
|
if (!websiteId) {
|
||||||
|
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the enhanced payload with the secret ID
|
||||||
|
const enhancedPayload = {
|
||||||
|
...payload,
|
||||||
|
website: websiteId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||||
|
|
||||||
|
// Log the event (internal only)
|
||||||
|
logger.debug('Forwarding analytics event', {
|
||||||
|
type,
|
||||||
|
url: payload.url,
|
||||||
|
website: websiteId.slice(0, 8) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||||
|
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (!process.env.CI) {
|
||||||
|
logger.error('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|
||||||
|
// Console error to ensure it appears in logs even if logger fails
|
||||||
|
if (!process.env.CI) {
|
||||||
|
console.error('CRITICAL PROXY ERROR:', {
|
||||||
|
message: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
endpoint: config.analytics.umami.apiEndpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error('Failed to proxy analytics request', {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
details: errorMessage, // Expose error for debugging
|
||||||
|
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
check-data.ts
Normal file
39
check-data.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
|
async function checkData() {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const { docs: posts } = await payload.find({ collection: 'posts', limit: 3 });
|
||||||
|
const { docs: products } = await payload.find({ collection: 'products', limit: 3 });
|
||||||
|
const { docs: pages } = await payload.find({ collection: 'pages', limit: 3 });
|
||||||
|
|
||||||
|
const checkDocs = (name: string, docs: any[]) => {
|
||||||
|
console.log(`\n----- ${name.toUpperCase()} -----`);
|
||||||
|
docs.forEach((p) => {
|
||||||
|
console.log(`ID: ${p.id}, Slug: ${p.slug}`);
|
||||||
|
if (Array.isArray(p.content)) {
|
||||||
|
console.log(
|
||||||
|
'Content is ARRAY (Slate format!)',
|
||||||
|
JSON.stringify(p.content).substring(0, 100),
|
||||||
|
);
|
||||||
|
} else if (p.content && p.content.root) {
|
||||||
|
console.log('Content is Lexical format.');
|
||||||
|
} else {
|
||||||
|
console.log('Content is UNKNOWN format.');
|
||||||
|
console.log(JSON.stringify(p.content).substring(0, 100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDocs('posts', posts);
|
||||||
|
checkDocs('products', products);
|
||||||
|
checkDocs('pages', pages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkData();
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.tmp
|
|
||||||
.cache
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
.env
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
# Installing libvips-dev for sharp Compatibility
|
|
||||||
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev vips-dev git > /dev/null 2>&1
|
|
||||||
|
|
||||||
WORKDIR /opt/
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm install -g node-gyp
|
|
||||||
RUN npm config set fetch-retry-maxtimeout 600000 -g && npm ci
|
|
||||||
ENV PATH=/opt/node_modules/.bin:$PATH
|
|
||||||
|
|
||||||
WORKDIR /opt/app
|
|
||||||
COPY . .
|
|
||||||
RUN NODE_ENV=production npm run build
|
|
||||||
EXPOSE 1337
|
|
||||||
CMD ["npm", "run", "develop"]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export default ({ env }) => ({
|
|
||||||
auth: {
|
|
||||||
secret: env('ADMIN_JWT_SECRET'),
|
|
||||||
},
|
|
||||||
apiToken: {
|
|
||||||
salt: env('API_TOKEN_SALT'),
|
|
||||||
},
|
|
||||||
transfer: {
|
|
||||||
token: {
|
|
||||||
salt: env('TRANSFER_TOKEN_SALT'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export default {
|
|
||||||
rest: {
|
|
||||||
defaultLimit: 25,
|
|
||||||
maxLimit: 100,
|
|
||||||
withCount: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export default ({ env }) => ({
|
|
||||||
connection: {
|
|
||||||
client: 'postgres',
|
|
||||||
connection: {
|
|
||||||
host: env('DATABASE_HOST', '127.0.0.1'),
|
|
||||||
port: env.int('DATABASE_PORT', 5432),
|
|
||||||
database: env('DATABASE_NAME', 'strapi'),
|
|
||||||
user: env('DATABASE_USERNAME', 'strapi'),
|
|
||||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
|
||||||
ssl: env.bool('DATABASE_SSL', false),
|
|
||||||
},
|
|
||||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export default ({ env }) => ({
|
|
||||||
host: env('HOST', '0.0.0.0'),
|
|
||||||
port: env.int('PORT', 1337),
|
|
||||||
app: {
|
|
||||||
keys: env.array('APP_KEYS'),
|
|
||||||
},
|
|
||||||
webhooks: {
|
|
||||||
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
19637
cms/package-lock.json
generated
19637
cms/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "klz-cms",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Strapi CMS for KLZ Cables",
|
|
||||||
"scripts": {
|
|
||||||
"develop": "strapi develop",
|
|
||||||
"start": "strapi start",
|
|
||||||
"build": "strapi build",
|
|
||||||
"strapi": "strapi"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@strapi/strapi": "4.25.11",
|
|
||||||
"@strapi/plugin-users-permissions": "4.25.11",
|
|
||||||
"@strapi/plugin-i18n": "4.25.11",
|
|
||||||
"pg": "8.11.3",
|
|
||||||
"react": "^18.0.0",
|
|
||||||
"react-dom": "^18.0.0",
|
|
||||||
"react-router-dom": "^5.2.0",
|
|
||||||
"styled-components": "^5.2.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^18.0.0",
|
|
||||||
"@types/react": "^18.0.0",
|
|
||||||
"@types/react-dom": "^18.0.0",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"name": "A Strapi developer"
|
|
||||||
},
|
|
||||||
"strapi": {
|
|
||||||
"uuid": "klz-cms"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0 <=20.x.x",
|
|
||||||
"npm": ">=6.0.0"
|
|
||||||
},
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "applications",
|
|
||||||
"info": {
|
|
||||||
"singularName": "application",
|
|
||||||
"pluralName": "applications",
|
|
||||||
"displayName": "Application"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": false
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"job": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "manyToOne",
|
|
||||||
"target": "api::job.job"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"type": "email",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"resume": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": false,
|
|
||||||
"required": true,
|
|
||||||
"allowedTypes": [
|
|
||||||
"files"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreController('api::application.application');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreRouter('api::application.application');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreService('api::application.application');
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "categories",
|
|
||||||
"info": {
|
|
||||||
"singularName": "category",
|
|
||||||
"pluralName": "categories",
|
|
||||||
"displayName": "Category"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": false
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"type": "uid",
|
|
||||||
"targetField": "name",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"products": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "manyToMany",
|
|
||||||
"target": "api::product.product",
|
|
||||||
"mappedBy": "categories"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreController('api::category.category');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreRouter('api::category.category');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreService('api::category.category');
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "contact_messages",
|
|
||||||
"info": {
|
|
||||||
"singularName": "contact-message",
|
|
||||||
"pluralName": "contact-messages",
|
|
||||||
"displayName": "Contact Message"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": false
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"type": "email",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"subject": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreController('api::contact-message.contact-message');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreRouter('api::contact-message.contact-message');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreService('api::contact-message.contact-message');
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "jobs",
|
|
||||||
"info": {
|
|
||||||
"singularName": "job",
|
|
||||||
"pluralName": "jobs",
|
|
||||||
"displayName": "Job"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": true
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "richtext",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"type": "string",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreController('api::job.job');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreRouter('api::job.job');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreService('api::job.job');
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "products",
|
|
||||||
"info": {
|
|
||||||
"singularName": "product",
|
|
||||||
"pluralName": "products",
|
|
||||||
"displayName": "Product",
|
|
||||||
"description": ""
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": true
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sku": {
|
|
||||||
"type": "uid",
|
|
||||||
"targetField": "title",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "text",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application": {
|
|
||||||
"type": "text",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "richtext",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"images": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": true,
|
|
||||||
"required": false,
|
|
||||||
"allowedTypes": [
|
|
||||||
"images"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "manyToMany",
|
|
||||||
"target": "api::category.category",
|
|
||||||
"inversedBy": "products"
|
|
||||||
},
|
|
||||||
"technicalData": {
|
|
||||||
"type": "json",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreController('api::product.product');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreRouter('api::product.product');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreService('api::product.product');
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "singleType",
|
|
||||||
"collectionName": "settings",
|
|
||||||
"info": {
|
|
||||||
"singularName": "setting",
|
|
||||||
"pluralName": "settings",
|
|
||||||
"displayName": "Setting"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": true
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"siteName": {
|
|
||||||
"type": "string",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"siteDescription": {
|
|
||||||
"type": "text",
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"logo": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": false,
|
|
||||||
"allowedTypes": [
|
|
||||||
"images"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreController('api::setting.setting');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreRouter('api::setting.setting');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
export default factories.createCoreService('api::setting.setting');
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export default {
|
|
||||||
/**
|
|
||||||
* An asynchronous register function that runs before
|
|
||||||
* your application is initialized.
|
|
||||||
*
|
|
||||||
* This gives you an opportunity to extend code.
|
|
||||||
*/
|
|
||||||
register(/*{ strapi }*/) {},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An asynchronous bootstrap function that runs before
|
|
||||||
* your application gets started.
|
|
||||||
*
|
|
||||||
* This gives you an opportunity to set up your data model,
|
|
||||||
* run jobs, or perform some special logic.
|
|
||||||
*/
|
|
||||||
bootstrap(/*{ strapi }*/) {},
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@strapi/typescript-utils/tsconfigs/server",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "."
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"config/**/*.ts",
|
|
||||||
"src/**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules/",
|
|
||||||
"build/",
|
|
||||||
"dist/",
|
|
||||||
".cache/",
|
|
||||||
".tmp/",
|
|
||||||
"src/admin/",
|
|
||||||
"**/*.test.ts",
|
|
||||||
"src/plugins/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
9
commitlint.config.cjs
Normal file
9
commitlint.config.cjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'header-max-length': [2, 'always', 500],
|
||||||
|
'subject-case': [0],
|
||||||
|
'subject-full-stop': [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
84
components/CMSConnectivityNotice.tsx
Normal file
84
components/CMSConnectivityNotice.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
|
export default function CMSConnectivityNotice() {
|
||||||
|
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only show if we've detected an issue AND we are in a context where we want to see it
|
||||||
|
const checkCMS = async () => {
|
||||||
|
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||||
|
const isLocal = config.isDevelopment;
|
||||||
|
const isTesting = config.isTesting;
|
||||||
|
|
||||||
|
// Only proceed with check if it's developer context (Local or Testing)
|
||||||
|
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||||
|
if (!isLocal && !isTesting && !isDebug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health/cms');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'ok') {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg(data.message);
|
||||||
|
setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
setStatus('ok');
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If it's a connection error, only show if we are really debugging
|
||||||
|
if (isDebug || isLocal) {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMsg('Could not connect to CMS health endpoint');
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkCMS();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
|
||||||
|
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="bg-white/20 p-2 rounded-lg">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
||||||
|
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
||||||
|
{errorMsg === 'relation "products" does not exist'
|
||||||
|
? 'The database schema is missing. Please sync your local data to this environment.'
|
||||||
|
: errorMsg || 'The application cannot connect to the Directus CMS.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const t = useTranslations('Contact');
|
const t = useTranslations('Contact');
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = (fieldId: string) => {
|
||||||
|
// Initial form start
|
||||||
|
if (!hasStarted) {
|
||||||
|
setHasStarted(true);
|
||||||
|
trackEvent(AnalyticsEvents.FORM_START, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
form_name: 'Contact',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level transparency
|
||||||
|
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
field_id: fieldId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -17,10 +36,10 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendContactFormAction(formData);
|
const result = await sendContactFormAction(formData);
|
||||||
if (result.success) {
|
if (result?.success) {
|
||||||
trackEvent('contact_form_submission', {
|
trackEvent('contact_form_submission', {
|
||||||
form_type: 'general',
|
form_type: 'general',
|
||||||
email,
|
email,
|
||||||
@@ -29,19 +48,36 @@ export default function ContactForm() {
|
|||||||
(e.target as HTMLFormElement).reset();
|
(e.target as HTMLFormElement).reset();
|
||||||
} else {
|
} else {
|
||||||
console.error('Contact form submission failed:', { email, error: result.error });
|
console.error('Contact form submission failed:', { email, error: result.error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: result.error || 'submission_failed',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Contact form submission error:', { email, error });
|
console.error('Contact form submission error:', { email, error });
|
||||||
|
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||||
|
form_id: 'contact_form',
|
||||||
|
error: (error as Error).message || 'unexpected_error',
|
||||||
|
});
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
<Card
|
||||||
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||||
<svg className="w-10 h-10 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
className="w-10 h-10 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +85,8 @@ export default function ContactForm() {
|
|||||||
{t('form.successTitle') || 'Message Sent!'}
|
{t('form.successTitle') || 'Message Sent!'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-text-secondary text-lg mb-8">
|
<p className="text-text-secondary text-lg mb-8">
|
||||||
{t('form.successDesc') || 'Thank you for your message. We will get back to you as soon as possible.'}
|
{t('form.successDesc') ||
|
||||||
|
'Thank you for your message. We will get back to you as soon as possible.'}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||||
{t('form.sendAnother') || 'Send another message'}
|
{t('form.sendAnother') || 'Send another message'}
|
||||||
@@ -60,9 +97,19 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
<Card
|
||||||
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||||
<svg className="w-10 h-10 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
|
className="w-10 h-10 text-destructive-foreground"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
@@ -74,7 +121,12 @@ export default function ContactForm() {
|
|||||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||||
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setStatus('idle')} variant="saturated" size="lg" className="w-full bg-destructive hover:bg-destructive/90 text-destructive-foreground border-destructive shadow-lg shadow-destructive/20">
|
<Button
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
{t('form.tryAgain') || 'Try Again'}
|
{t('form.tryAgain') || 'Try Again'}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -88,58 +140,78 @@ export default function ContactForm() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="contact-name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.namePlaceholder')}
|
onFocus={() => handleFocus('contact-name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="email">{t('form.email')}</Label>
|
<Label htmlFor="contact-email">{t('form.email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="contact-email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.emailPlaceholder')}
|
placeholder={t('form.emailPlaceholder')}
|
||||||
|
onFocus={() => handleFocus('contact-email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="message">{t('form.message')}</Label>
|
<Label htmlFor="contact-message">{t('form.message')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="contact-message"
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
|
onFocus={() => handleFocus('contact-message')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
{status === 'submitting' ? (
|
{status === 'submitting' ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-5 w-5 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('form.submitting') || 'Sending...'}
|
{t('form.submitting') || 'Sending...'}
|
||||||
</span>
|
</span>
|
||||||
) : t('form.submit')}
|
) : (
|
||||||
|
t('form.submit')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||||
|
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ContactMapProps {
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||||
|
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user