Compare commits
117 Commits
v1.0.0-rc1
...
feature/fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 06bbed8c21 | |||
| f5a879fa60 | |||
| be9f9cf483 |
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
!.next/cache
|
||||||
.git
|
.git
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|||||||
30
.env
30
.env
@@ -1,16 +1,10 @@
|
|||||||
# 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_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
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=true
|
||||||
# WooCommerce & WordPress
|
|
||||||
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
|
||||||
@@ -19,3 +13,23 @@ MAIL_USERNAME=postmaster@mg.mintel.me
|
|||||||
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
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
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=https://cms.klz-cables.com
|
||||||
|
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||||
|
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
|
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
DIRECTUS_DB_NAME=directus
|
||||||
|
DIRECTUS_DB_USER=directus
|
||||||
|
DIRECTUS_DB_PASSWORD=directus
|
||||||
|
# Local Development
|
||||||
|
PROJECT_NAME=klz-cables
|
||||||
|
GATEKEEPER_BYPASS_ENABLED=true
|
||||||
|
TRAEFIK_HOST=klz.localhost
|
||||||
|
DIRECTUS_HOST=cms.klz.localhost
|
||||||
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
COOKIE_DOMAIN=localhost
|
||||||
|
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||||
|
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
|||||||
17
.env.example
17
.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
|
||||||
|
DIRECTUS_PORT=8055
|
||||||
|
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||||
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
|
TARGET=development
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# 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,6 +44,10 @@ MAIL_RECIPIENTS=info@klz-cables.com
|
|||||||
# Logging
|
# Logging
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
SENTRY_DSN=
|
||||||
|
# For Directus Error Tracking
|
||||||
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Deployment Configuration (CI/CD only)
|
# Deployment Configuration (CI/CD only)
|
||||||
@@ -49,9 +58,9 @@ TRAEFIK_HOST=klz-cables.com
|
|||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Varnish Cache (Docker only)
|
# Varnish Configuration
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
VARNISH_CACHE_SIZE=256m
|
VARNISH_CACHE_SIZE=256M
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IMPORTANT NOTES
|
# IMPORTANT NOTES
|
||||||
|
|||||||
@@ -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,6 +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
|
||||||
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
42
.gitea/workflows/ci.yml
Normal file
42
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI - Lint, Typecheck & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-assurance:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
|
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
|
- name: 🔍 Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: 🏗️ Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: 🧪 Test
|
||||||
|
run: npm run test
|
||||||
@@ -2,218 +2,567 @@ name: Build & Deploy KLZ Cables
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, staging]
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_long_checks:
|
||||||
|
description: 'Skip tests? (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || (github.ref_name == 'main' && 'testing' || github.ref_name)) }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 1: Prepare & Determine Environment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
prepare:
|
||||||
|
name: 🔍 Prepare Environment
|
||||||
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_host_rule: ${{ steps.determine.outputs.traefik_host_rule }}
|
||||||
|
primary_host: ${{ steps.determine.outputs.primary_host }}
|
||||||
|
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
||||||
|
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||||
|
directus_host: ${{ steps.determine.outputs.directus_host }}
|
||||||
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
|
is_prod: ${{ steps.determine.outputs.is_prod }}
|
||||||
|
gotify_title: ${{ steps.determine.outputs.gotify_title }}
|
||||||
|
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
||||||
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
||||||
|
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 " • Branch: ${{ github.ref_name }}"
|
docker image prune -f
|
||||||
echo " • Commit: ${{ github.sha }}"
|
docker builder prune -f --filter "until=6h"
|
||||||
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# LOGGING: Registry Login Phase
|
- name: 🔍 Environment & Version ermitteln
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
id: determine
|
||||||
- name: 🔐 Login to private registry
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
|
||||||
|
IMAGE_TAG="sha-${SHORT_SHA}"
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
||||||
|
|
||||||
|
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
||||||
|
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
|
||||||
|
TARGET="skip"
|
||||||
|
GOTIFY_TITLE="ℹ️ Skip Deploy (Chore)"
|
||||||
|
GOTIFY_PRIORITY=2
|
||||||
|
else
|
||||||
|
TARGET="testing"
|
||||||
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.testing"
|
||||||
|
TRAEFIK_HOST="testing.klz-cables.com"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
|
||||||
|
DIRECTUS_URL="https://cms.testing.klz-cables.com"
|
||||||
|
DIRECTUS_HOST="cms.testing.klz-cables.com"
|
||||||
|
PROJECT_NAME="klz-cables-testing"
|
||||||
|
IS_PROD="false"
|
||||||
|
GOTIFY_TITLE="🧪 Testing-Deploy"
|
||||||
|
GOTIFY_PRIORITY=4
|
||||||
|
fi
|
||||||
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
TARGET="production"
|
||||||
|
IMAGE_TAG="$TAG"
|
||||||
|
ENV_FILE=".env.prod"
|
||||||
|
TRAEFIK_HOST="klz-cables.com, www.klz-cables.com"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
|
||||||
|
DIRECTUS_URL="https://cms.klz-cables.com"
|
||||||
|
DIRECTUS_HOST="cms.klz-cables.com"
|
||||||
|
PROJECT_NAME="klz-cables-prod"
|
||||||
|
IS_PROD="true"
|
||||||
|
GOTIFY_TITLE="🚀 Production-Release"
|
||||||
|
GOTIFY_PRIORITY=6
|
||||||
|
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
|
||||||
|
TARGET="staging"
|
||||||
|
IMAGE_TAG="$TAG"
|
||||||
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.klz-cables.com"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
|
||||||
|
DIRECTUS_URL="https://cms.staging.klz-cables.com"
|
||||||
|
DIRECTUS_HOST="cms.staging.klz-cables.com"
|
||||||
|
PROJECT_NAME="klz-cables-staging"
|
||||||
|
IS_PROD="false"
|
||||||
|
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
||||||
|
GOTIFY_PRIORITY=5
|
||||||
|
else
|
||||||
|
TARGET="skip"
|
||||||
|
GOTIFY_TITLE="❓ Unbekannter Tag"
|
||||||
|
GOTIFY_PRIORITY=3
|
||||||
|
fi
|
||||||
|
elif [[ "${{ github.ref_type }}" == "branch" ]]; then
|
||||||
|
TARGET="branch"
|
||||||
|
# Slugify branch name: lowercase, replace non-alphanumeric with -, remove leading/trailing -
|
||||||
|
SLUG=$(echo "$TAG" | 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"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://${SLUG}.branch.mintel.me"
|
||||||
|
DIRECTUS_URL="https://cms.${SLUG}.branch.mintel.me"
|
||||||
|
DIRECTUS_HOST="cms.${SLUG}.branch.mintel.me"
|
||||||
|
PROJECT_NAME="klz-cables-br-${SLUG}"
|
||||||
|
IS_PROD="false"
|
||||||
|
GOTIFY_TITLE="🌿 Branch-Deploy ($TAG)"
|
||||||
|
GOTIFY_PRIORITY=4
|
||||||
|
else
|
||||||
|
TARGET="skip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
|
# Multi-domain: Host(`a.com`) || Host(`b.com`)
|
||||||
|
TRAEFIK_HOST_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||||
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
|
else
|
||||||
|
# Single domain: Host(`domain.com`)
|
||||||
|
TRAEFIK_HOST_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=$TRAEFIK_HOST"
|
||||||
|
echo "traefik_host_rule=$TRAEFIK_HOST_RULE"
|
||||||
|
echo "primary_host=$PRIMARY_HOST"
|
||||||
|
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
||||||
|
echo "directus_url=$DIRECTUS_URL"
|
||||||
|
echo "directus_host=$DIRECTUS_HOST"
|
||||||
|
echo "project_name=$PROJECT_NAME"
|
||||||
|
echo "is_prod=$IS_PROD"
|
||||||
|
echo "gotify_title=$GOTIFY_TITLE"
|
||||||
|
echo "gotify_priority=$GOTIFY_PRIORITY"
|
||||||
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
echo "commit_msg=$COMMIT_MSG"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 2: Quality Assurance (Lint & Test)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
qa:
|
||||||
|
name: 🧪 Quality Assurance
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
|
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
|
- name: 🧪 Run Checks in Parallel
|
||||||
|
if: github.event.inputs.skip_long_checks != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm lint &
|
||||||
|
LINT_PID=$!
|
||||||
|
pnpm typecheck &
|
||||||
|
TYPE_PID=$!
|
||||||
|
pnpm test &
|
||||||
|
TEST_PID=$!
|
||||||
|
|
||||||
|
# Wait for all and fail if any fail
|
||||||
|
wait $LINT_PID || exit 1
|
||||||
|
wait $TYPE_PID || exit 1
|
||||||
|
wait $TEST_PID || exit 1
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 3: Build & Push Docker Image
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
build-app:
|
||||||
|
name: 🏗️ Build App
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: 🔐 Registry Login
|
||||||
run: |
|
run: |
|
||||||
echo "🔐 Authenticating with registry.infra.mintel.me..."
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🏗️ App bauen & pushen
|
||||||
# LOGGING: Build Phase
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
- name: 🏗️ Build Docker image
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
run: |
|
run: |
|
||||||
echo "🏗️ Building Docker image (linux/arm64) for branch ${{ github.ref_name }}..."
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--pull \
|
--pull \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:${{ github.sha }} \
|
--build-arg REGISTRY_HOST="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" \
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
--build-arg NPM_TOKEN="${{ secrets.REGISTRY_PASS }}" \
|
||||||
|
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
||||||
|
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
|
||||||
|
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# LOGGING: Deployment Phase
|
# JOB 4: Deploy via SSH
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
- name: 🚀 Deploy to server
|
deploy:
|
||||||
env:
|
name: 🚀 Deploy
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
needs: [prepare, build-app, qa]
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
runs-on: docker
|
||||||
SENTRY_DSN: ${{ github.ref_name == 'main' && secrets.SENTRY_DSN || (secrets.STAGING_SENTRY_DSN || secrets.SENTRY_DSN) }}
|
container:
|
||||||
MAIL_HOST: ${{ github.ref_name == 'main' && secrets.MAIL_HOST || (secrets.STAGING_MAIL_HOST || secrets.MAIL_HOST) }}
|
image: catthehacker/ubuntu:act-latest
|
||||||
MAIL_PORT: ${{ github.ref_name == 'main' && secrets.MAIL_PORT || (secrets.STAGING_MAIL_PORT || secrets.MAIL_PORT) }}
|
env:
|
||||||
MAIL_USERNAME: ${{ github.ref_name == 'main' && secrets.MAIL_USERNAME || (secrets.STAGING_MAIL_USERNAME || secrets.MAIL_USERNAME) }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
MAIL_PASSWORD: ${{ github.ref_name == 'main' && secrets.MAIL_PASSWORD || (secrets.STAGING_MAIL_PASSWORD || secrets.MAIL_PASSWORD) }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
MAIL_FROM: ${{ github.ref_name == 'main' && secrets.MAIL_FROM || (secrets.STAGING_MAIL_FROM || secrets.MAIL_FROM) }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
MAIL_RECIPIENTS: ${{ github.ref_name == 'main' && secrets.MAIL_RECIPIENTS || (secrets.STAGING_MAIL_RECIPIENTS || secrets.MAIL_RECIPIENTS) }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
|
UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
||||||
|
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
||||||
|
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
|
||||||
|
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
|
||||||
|
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
|
||||||
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
|
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
||||||
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
|
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
|
||||||
|
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
|
||||||
|
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
||||||
|
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
||||||
|
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
|
||||||
|
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: 🚀 Deploy to ${{ env.TARGET }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
BRANCH=${{ github.ref_name }}
|
echo "Deploying $TARGET → $IMAGE_TAG"
|
||||||
|
|
||||||
# Derive domain from NEXT_PUBLIC_BASE_URL (strip https:// and trailing slash)
|
|
||||||
DOMAIN=$(echo "$NEXT_PUBLIC_BASE_URL" | sed -E 's|https?://||' | sed -E 's|/.*||')
|
|
||||||
|
|
||||||
if [ "$BRANCH" = "main" ]; then
|
|
||||||
ENV_FILE=.env.prod
|
|
||||||
# For production, we want both root and www
|
|
||||||
TRAEFIK_HOST="\`$DOMAIN\`, \`www.$DOMAIN\`"
|
|
||||||
else
|
|
||||||
ENV_FILE=.env.staging
|
|
||||||
TRAEFIK_HOST="\`$DOMAIN\`"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🚀 Deploying branch $BRANCH to $ENV_FILE..."
|
|
||||||
echo "🌐 Domain: $DOMAIN"
|
|
||||||
|
|
||||||
# Setup SSH
|
|
||||||
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
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
|
# Determine dynamic values before writing the file
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
cat > /tmp/klz-cables.env << EOF
|
||||||
# ============================================================================
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
# KLZ Cables - Environment Configuration ($BRANCH)
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
# ============================================================================
|
|
||||||
# Auto-generated by CI/CD workflow
|
|
||||||
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
|
LOG_LEVEL=$LOG_LEVEL
|
||||||
MAIL_HOST=$MAIL_HOST
|
MAIL_HOST=$MAIL_HOST
|
||||||
MAIL_PORT=$MAIL_PORT
|
MAIL_PORT=$MAIL_PORT
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||||
MAIL_FROM=$MAIL_FROM
|
MAIL_FROM=$MAIL_FROM
|
||||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||||
|
|
||||||
# Deployment variables for docker-compose
|
# Directus
|
||||||
IMAGE_TAG=${{ github.sha }}
|
DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
DIRECTUS_HOST=$DIRECTUS_HOST
|
||||||
|
DIRECTUS_KEY=$DIRECTUS_KEY
|
||||||
|
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
||||||
|
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
||||||
|
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||||
|
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||||
|
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||||
|
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||||
|
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||||
|
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||||
|
|
||||||
|
TARGET=$TARGET
|
||||||
|
SENTRY_ENVIRONMENT=$TARGET
|
||||||
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
|
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
ENV_FILE=$ENV_FILE
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Upload .env and docker-compose.yml
|
# Append complex variables that contain backticks using printf to avoid shell expansion hits
|
||||||
|
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> /tmp/klz-cables.env
|
||||||
|
printf "TRAEFIK_HOST_RULE='%s'\n" '${{ needs.prepare.outputs.traefik_host_rule }}' >> /tmp/klz-cables.env
|
||||||
|
|
||||||
|
# 1. Cleanup and Create Directories on server BEFORE SCP
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
||||||
|
set -e
|
||||||
|
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
||||||
|
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads \
|
||||||
|
/home/deploy/sites/klz-cables.com/directus/extensions \
|
||||||
|
/home/deploy/sites/klz-cables.com/directus/schema
|
||||||
|
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
||||||
|
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
||||||
|
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
||||||
|
fi
|
||||||
|
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. Transfer files
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
||||||
|
scp -r -o StrictHostKeyChecking=accept-new directus/schema root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/directus/
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
|
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
||||||
set -e
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
|
|
||||||
chmod 600 $ENV_FILE
|
|
||||||
chown deploy:deploy $ENV_FILE
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
|
|
||||||
echo "📥 Pulling images..."
|
|
||||||
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE pull
|
|
||||||
|
|
||||||
echo "🚀 Starting containers..."
|
|
||||||
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE up -d
|
|
||||||
|
|
||||||
echo "🧹 Cleaning up old images..."
|
|
||||||
docker system prune -f
|
|
||||||
|
|
||||||
echo "⏳ Giving the app a few seconds to warm up..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo "🔍 Checking container status..."
|
|
||||||
docker compose --env-file $ENV_FILE ps
|
|
||||||
|
|
||||||
if ! docker compose --env-file $ENV_FILE ps | grep -q "Up"; then
|
|
||||||
echo "❌ Container failed to start"
|
|
||||||
docker compose --env-file $ENV_FILE logs --tail=100
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Deployment complete!"
|
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
||||||
EOF
|
set -e
|
||||||
|
cd /home/deploy/sites/klz-cables.com
|
||||||
rm -f /tmp/klz-cables.env
|
chmod 600 "$ENV_FILE"
|
||||||
|
chown deploy:deploy "$ENV_FILE"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
# LOGGING: Workflow Summary
|
echo "→ Pulling image: $IMAGE_TAG"
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||||
- name: 📊 Workflow Summary
|
echo "→ Starting containers..."
|
||||||
if: always()
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||||
run: |
|
docker system prune -f --filter "until=24h"
|
||||||
echo "📊 Status: ${{ job.status }}"
|
echo "→ Waiting 15s for warmup..."
|
||||||
echo "🎯 Target: alpha.mintel.me"
|
sleep 15
|
||||||
echo "🌿 Branch: ${{ github.ref_name }}"
|
echo "→ Container status:"
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps
|
||||||
|
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps | grep -q "Up"; then
|
||||||
|
echo "❌ Fehler: Container nicht Up!"
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
echo "→ Applying Directus Schema Snapshot..."
|
||||||
# NOTIFICATION: Gotify
|
# Note: We check if snapshot exists first to avoid failing if no snapshot is committed yet
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
if docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then
|
||||||
- name: 🔔 Gotify Notification (Success)
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes
|
||||||
if: success()
|
else
|
||||||
run: |
|
echo "ℹ️ No snapshot.yaml found, skipping schema apply."
|
||||||
echo "Sending success notification to Gotify..."
|
fi
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
|
||||||
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) was successful.
|
|
||||||
|
|
||||||
Commit: ${{ github.sha }}
|
echo "→ Verifying Varnish Backend Health..."
|
||||||
Actor: ${{ github.actor }}
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
||||||
Run ID: ${{ github.run_id }}" \
|
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
|
||||||
-F "priority=5")
|
echo "❌ Fehler: Varnish Backend ist SICK!"
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
exit 1
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
fi
|
||||||
|
echo "✅ Varnish Backend ist Healthy."
|
||||||
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 5: PageSpeed Test
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
pagespeed:
|
||||||
|
name: ⚡ PageSpeed
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
needs.prepare.outputs.target != 'skip' &&
|
||||||
|
needs.deploy.result == 'success' &&
|
||||||
|
github.event.inputs.skip_long_checks != 'true'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
# outputs:
|
||||||
|
# report_url: ${{ steps.save.outputs.report_url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: 🔐 Configure Private Registry
|
||||||
run: |
|
run: |
|
||||||
echo "Sending failure notification to Gotify..."
|
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||||
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) failed!
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
echo "🎯 Debian detected - installing native chromium"
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
Commit: ${{ github.sha }}
|
# Multi-method Key Fetch
|
||||||
Actor: ${{ github.actor }}
|
SUCCESS=false
|
||||||
Run ID: ${{ github.run_id }}
|
echo "Fetching key $KEY_ID..."
|
||||||
|
|
||||||
Please check the logs for details." \
|
# Method 1: gpg --recv-keys (standard)
|
||||||
-F "priority=8")
|
for server in "hkp://keyserver.ubuntu.com:80" "hkp://keyserver.ubuntu.com:11371"; do
|
||||||
|
if gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --keyserver "$server" --recv-keys "$KEY_ID"; then
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --export > /etc/apt/keyrings/xtradeb.gpg
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
SUCCESS=true && break
|
||||||
|
fi
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
done
|
||||||
echo "Response Body: $BODY"
|
|
||||||
|
# Method 2: Direct wget (fallback)
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
if [ "$SUCCESS" = false ]; then
|
||||||
echo "Failed to send Gotify notification"
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg && SUCCESS=true
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
fi
|
||||||
|
|
||||||
|
if [ "$SUCCESS" = true ]; then
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
else
|
||||||
|
echo "⚠️ GPG fetch failed, using legacy apt-key as last resort..."
|
||||||
|
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$KEY_ID" || true
|
||||||
|
echo "deb http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
|
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 || apt-get install -y chromium-browser
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Force clean paths (remove existing dead links/files if they are snap wrappers)
|
||||||
|
rm -f /usr/bin/google-chrome /usr/bin/chromium-browser
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
|
echo "✅ Binary check:"
|
||||||
|
ls -l /usr/bin/chromium* /usr/bin/google-chrome || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: 🧪 Run PageSpeed (Lighthouse)
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
PAGESPEED_LIMIT: 8
|
||||||
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
|
CHROME_PATH: /usr/bin/chromium
|
||||||
|
run: pnpm pagespeed:test
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 6: Notifications
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
notifications:
|
||||||
|
name: 🔔 Notifications
|
||||||
|
needs: [prepare, qa, build-app, deploy, pagespeed]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 📊 Deployment Summary
|
||||||
|
run: |
|
||||||
|
echo "┌──────────────────────────────┐"
|
||||||
|
echo "│ Deployment Summary │"
|
||||||
|
echo "├──────────────────────────────┤"
|
||||||
|
echo "│ Status: ${{ needs.deploy.result }} │"
|
||||||
|
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
|
||||||
|
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
|
||||||
|
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
|
||||||
|
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
|
||||||
|
echo "└──────────────────────────────┘"
|
||||||
|
|
||||||
|
- name: 🔔 Gotify - Success
|
||||||
|
if: needs.deploy.result == 'success'
|
||||||
|
run: |
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
||||||
|
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
||||||
|
-F "priority=4" || true
|
||||||
|
|
||||||
|
- name: 🔔 Gotify - Failure
|
||||||
|
if: |
|
||||||
|
needs.prepare.result == 'failure' ||
|
||||||
|
needs.qa.result == 'failure' ||
|
||||||
|
needs.build-app.result == 'failure' ||
|
||||||
|
needs.deploy.result == 'failure' ||
|
||||||
|
needs.pagespeed.result == 'failure'
|
||||||
|
run: |
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
||||||
|
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
|
||||||
|
-F "priority=8" || true
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,2 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
directus/uploads
|
||||||
|
!directus/extensions/
|
||||||
|
!directus/schema/
|
||||||
|
!directus/migrations/
|
||||||
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
9
.lintstagedrc.js
Normal file
9
.lintstagedrc.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
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'],
|
||||||
|
};
|
||||||
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.
|
|
||||||
27
Dockerfile
27
Dockerfile
@@ -2,13 +2,22 @@ FROM node:20-alpine AS base
|
|||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
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
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
RUN npm ci
|
ARG REGISTRY_HOST
|
||||||
|
ARG NPM_TOKEN
|
||||||
|
RUN if [ -n "$NPM_TOKEN" ]; then \
|
||||||
|
REGISTRY="${REGISTRY_HOST:-npm.infra.mintel.me}" && \
|
||||||
|
echo "@mintel:registry=https://$REGISTRY" > .npmrc && \
|
||||||
|
echo "//$REGISTRY/:_authToken=$NPM_TOKEN" >> .npmrc; \
|
||||||
|
fi
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
@@ -25,17 +34,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# Build-time environment variables for Next.js
|
# Build-time environment variables for Next.js
|
||||||
# These are baked into the client bundle during build
|
# These are baked into the client bundle during build
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
ARG DIRECTUS_URL
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
|
||||||
# Validate environment variables during build
|
# Validate environment variables during build
|
||||||
RUN npx tsx scripts/validate-env.ts
|
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
|
||||||
|
|
||||||
RUN npm run build
|
RUN --mount=type=cache,target=/app/.next/cache pnpm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
@@ -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
|
|
||||||
85
README.md
85
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
|||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
|
||||||
|
- Node.js 18+
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
```bash
|
|
||||||
|
````bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
@@ -29,9 +31,40 @@ npm run export
|
|||||||
|
|
||||||
# Or run development server
|
# Or run development server
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
### 🏗️ CMS (Strapi)
|
||||||
|
The CMS runs in Docker. Use the following npm scripts for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start Strapi and its database
|
||||||
|
npm run cms:dev
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
npm run cms:logs
|
||||||
|
|
||||||
|
# Stop the CMS
|
||||||
|
npm run cms:stop
|
||||||
|
````
|
||||||
|
|
||||||
|
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||||
|
|
||||||
|
### 🔄 Data & Migration
|
||||||
|
|
||||||
|
To sync data or migrate existing content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export local data
|
||||||
|
npm run cms:export -- my-data.tar.gz
|
||||||
|
|
||||||
|
# Import data
|
||||||
|
npm run cms:import -- my-data.tar.gz
|
||||||
|
|
||||||
|
# Migrate existing MDX data to Strapi
|
||||||
|
npm run cms:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
SITE_URL=https://klz-cables.com
|
SITE_URL=https://klz-cables.com
|
||||||
@@ -40,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
|
||||||
@@ -52,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)
|
||||||
@@ -62,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+)
|
||||||
@@ -70,15 +105,18 @@ 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
|
||||||
- **Data**: Static JSON (WordPress export)
|
- **CMS**: Strapi (Source of Truth)
|
||||||
|
- **Data**: Static JSON (WordPress export) & Strapi API
|
||||||
- **Email**: Resend
|
- **Email**: Resend
|
||||||
- **Analytics**: Vercel (consent-based)
|
- **Analytics**: Vercel (consent-based)
|
||||||
- **CAPTCHA**: Cloudflare Turnstile
|
- **CAPTCHA**: Cloudflare Turnstile
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
@@ -133,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
|
||||||
@@ -145,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
|
||||||
@@ -158,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
|
||||||
@@ -173,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
|
||||||
@@ -180,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
|
||||||
@@ -198,6 +244,7 @@ npm run data:improve-mapping
|
|||||||
## 🔧 API Endpoints
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
### Contact Form
|
### Contact Form
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/contact
|
POST /api/contact
|
||||||
{
|
{
|
||||||
@@ -209,11 +256,13 @@ POST /api/contact
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sitemap
|
### Sitemap
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /sitemap.xml
|
GET /sitemap.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Robots
|
### Robots
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /robots.txt
|
GET /robots.txt
|
||||||
```
|
```
|
||||||
@@ -231,6 +280,7 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
|||||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
**Branch Deployments**:
|
**Branch Deployments**:
|
||||||
|
|
||||||
- `main` branch: Deploys to production using `.env.prod`
|
- `main` branch: Deploys to production using `.env.prod`
|
||||||
- `staging` branch: Deploys to staging using `.env.staging`
|
- `staging` branch: Deploys to staging using `.env.staging`
|
||||||
|
|
||||||
@@ -238,12 +288,13 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
|||||||
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
||||||
|
|
||||||
**Required Secrets** (configure in Gitea repository settings):
|
**Required Secrets** (configure in Gitea repository settings):
|
||||||
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
- `REGISTRY_USER` - Docker registry username
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
- `REGISTRY_PASS` - Docker registry password
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
- `SENTRY_DSN` - Error tracking DSN
|
||||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||||
|
|
||||||
@@ -263,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
|
||||||
```
|
```
|
||||||
@@ -274,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)
|
||||||
|
|
||||||
@@ -287,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)
|
||||||
@@ -313,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]`
|
||||||
@@ -320,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/...
|
||||||
```
|
```
|
||||||
@@ -334,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)
|
||||||
@@ -349,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
|
||||||
|
|
||||||
@@ -374,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
✅ **i18n**: Multi-language support
|
✅ **i18n**: Multi-language support
|
||||||
✅ **SEO**: Metadata and sitemaps
|
✅ **SEO**: Metadata and sitemaps
|
||||||
✅ **Compatibility**: WPBakery content handled
|
✅ **Compatibility**: WPBakery content handled
|
||||||
✅ **Media**: All images downloaded
|
✅ **Media**: All images downloaded
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
For issues or questions:
|
For issues or questions:
|
||||||
|
|
||||||
1. Check the documentation
|
1. Check the documentation
|
||||||
2. Review the troubleshooting section
|
2. Review the troubleshooting section
|
||||||
3. Check environment variables
|
3. Check environment variables
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -8,11 +9,11 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
|||||||
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
|
||||||
@@ -22,8 +23,9 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { Metadata } from 'next';
|
|||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@@ -28,9 +29,10 @@ export async function generateStaticParams() {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -39,15 +41,15 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/${slug}`,
|
canonical: `/${locale}/${slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/${slug}`,
|
de: `/de/${slug}`,
|
||||||
'en': `/en/${slug}`,
|
en: `/en/${slug}`,
|
||||||
'x-default': `/en/${slug}`,
|
'x-default': `/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),
|
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -58,7 +60,8 @@ 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;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
@@ -75,7 +78,9 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
</div>
|
</div>
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||||
|
{t('badge')}
|
||||||
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-0">
|
<Heading level={1} className="text-white mb-0">
|
||||||
{pageData.frontmatter.title}
|
{pageData.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -106,9 +111,14 @@ 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">
|
<a
|
||||||
{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"
|
||||||
|
>
|
||||||
|
{t('contactUs')}
|
||||||
|
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,4 +126,4 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,72 +3,74 @@ 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';
|
||||||
|
|
||||||
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, origin } = 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]}`)
|
: `${origin}${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?.startsWith('http') ? featuredImage : undefined}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
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 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: { locale, slug },
|
||||||
|
}: {
|
||||||
|
params: { locale: string; slug: string };
|
||||||
|
}) {
|
||||||
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?.startsWith('http') ? featuredImage : undefined}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,16 @@ import { Heading } from '@/components/ui';
|
|||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
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 || '';
|
||||||
@@ -32,8 +33,8 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog/${slug}`,
|
canonical: `/${locale}/blog/${slug}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/blog/${slug}`,
|
de: `/de/blog/${slug}`,
|
||||||
'en': `/en/blog/${slug}`,
|
en: `/en/blog/${slug}`,
|
||||||
'x-default': `/en/blog/${slug}`,
|
'x-default': `/en/blog/${slug}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,7 +44,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),
|
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -54,7 +55,8 @@ 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;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
@@ -66,16 +68,15 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||||
|
|
||||||
{/* 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 bg-cover bg-center 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"
|
||||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
@@ -87,7 +88,10 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
<Heading
|
||||||
|
level={1}
|
||||||
|
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
|
||||||
|
>
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
||||||
@@ -95,7 +99,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
@@ -123,7 +127,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
@@ -168,8 +172,18 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
href={`/${locale}/blog`}
|
href={`/${locale}/blog`}
|
||||||
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 transition-transform group-hover:-translate-x-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
className="w-5 h-5 transition-transform group-hover:-translate-x-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -188,57 +202,63 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
|||||||
{/* Structured Data */}
|
{/* Structured Data */}
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`jsonld-${slug}`}
|
id={`jsonld-${slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'BlogPosting',
|
'@context': 'https://schema.org',
|
||||||
headline: post.frontmatter.title,
|
'@type': 'BlogPosting',
|
||||||
datePublished: post.frontmatter.date,
|
headline: post.frontmatter.title,
|
||||||
dateModified: post.frontmatter.date,
|
datePublished: post.frontmatter.date,
|
||||||
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
|
dateModified: post.frontmatter.date,
|
||||||
author: {
|
image: post.frontmatter.featuredImage
|
||||||
'@type': 'Organization',
|
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
name: 'KLZ Cables',
|
: undefined,
|
||||||
url: 'https://klz-cables.com',
|
author: {
|
||||||
logo: 'https://klz-cables.com/logo-blue.svg'
|
'@type': 'Organization',
|
||||||
},
|
name: 'KLZ Cables',
|
||||||
publisher: {
|
url: SITE_URL,
|
||||||
'@type': 'Organization',
|
logo: `${SITE_URL}/logo-blue.svg`,
|
||||||
name: 'KLZ Cables',
|
|
||||||
logo: {
|
|
||||||
'@type': 'ImageObject',
|
|
||||||
url: 'https://klz-cables.com/logo-blue.svg',
|
|
||||||
},
|
},
|
||||||
},
|
publisher: {
|
||||||
description: post.frontmatter.excerpt,
|
'@type': 'Organization',
|
||||||
mainEntityOfPage: {
|
name: 'KLZ Cables',
|
||||||
'@type': 'WebPage',
|
logo: {
|
||||||
'@id': `https://klz-cables.com/${locale}/blog/${slug}`,
|
'@type': 'ImageObject',
|
||||||
},
|
url: `${SITE_URL}/logo-blue.svg`,
|
||||||
articleSection: post.frontmatter.category,
|
},
|
||||||
wordCount: post.content.split(/\s+/).length,
|
},
|
||||||
timeRequired: `PT${getReadingTime(post.content)}M`
|
description: post.frontmatter.excerpt,
|
||||||
} as any}
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
|
},
|
||||||
|
articleSection: post.frontmatter.category,
|
||||||
|
wordCount: post.content.split(/\s+/).length,
|
||||||
|
timeRequired: `PT${getReadingTime(post.content)}M`,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`breadcrumb-${slug}`}
|
id={`breadcrumb-${slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'BreadcrumbList',
|
'@context': 'https://schema.org',
|
||||||
itemListElement: [
|
'@type': 'BreadcrumbList',
|
||||||
{
|
itemListElement: [
|
||||||
'@type': 'ListItem',
|
{
|
||||||
position: 1,
|
'@type': 'ListItem',
|
||||||
name: 'Blog',
|
position: 1,
|
||||||
item: `https://klz-cables.com/${locale}/blog`,
|
name: 'Blog',
|
||||||
},
|
item: `${SITE_URL}/${locale}/blog`,
|
||||||
{
|
},
|
||||||
'@type': 'ListItem',
|
{
|
||||||
position: 2,
|
'@type': 'ListItem',
|
||||||
name: post.frontmatter.title,
|
position: 2,
|
||||||
item: `https://klz-cables.com/${locale}/blog/${slug}`,
|
name: post.frontmatter.title,
|
||||||
},
|
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
],
|
},
|
||||||
} as any}
|
],
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,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 runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
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
|
<OGImageTemplate
|
||||||
title={title}
|
title={t('title')}
|
||||||
description={description}
|
description={t('description')}
|
||||||
label="Blog"
|
label="Blog"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import { Section, Container, Heading, Card, Badge, Button } from '@/components/u
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||||
|
const { 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'),
|
||||||
@@ -19,15 +21,15 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/blog`,
|
canonical: `/${locale}/blog`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de/blog',
|
de: '/de/blog',
|
||||||
'en': '/en/blog',
|
en: '/en/blog',
|
||||||
'x-default': '/en/blog',
|
'x-default': '/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),
|
images: getOGImageMetadata('blog', t('title'), locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -38,13 +40,14 @@ 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;
|
||||||
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];
|
||||||
@@ -65,10 +68,12 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 image-overlay-gradient" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
<Badge variant="saturated" className="mb-4 md:mb-6">
|
||||||
|
{t('featuredPost')}
|
||||||
|
</Badge>
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
<>
|
<>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
@@ -77,9 +82,16 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||||
{featuredPost.frontmatter.excerpt}
|
{featuredPost.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<Button href={`/${locale}/blog/${featuredPost.slug}`} variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
<Button
|
||||||
|
href={`/${locale}/blog/${featuredPost.slug}`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
>
|
||||||
{t('readFullArticle')}
|
{t('readFullArticle')}
|
||||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -97,10 +109,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>
|
||||||
@@ -120,7 +152,10 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
/>
|
/>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
@@ -131,7 +166,7 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
||||||
@@ -145,8 +180,18 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,13 +201,21 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Placeholder */}
|
{/* Pagination Placeholder */}
|
||||||
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>{t('prev')}</Button>
|
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
|
||||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">1</Button>
|
{t('prev')}
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">2</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">{t('next')}</Button>
|
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||||
|
2
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||||
|
{t('next')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
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 runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
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');
|
||||||
|
|
||||||
@@ -18,8 +21,8 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,16 @@ import { getTranslations } 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,7 +24,7 @@ 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-DE': '/de/contact',
|
||||||
'en-US': '/en/contact',
|
'en-US': '/en/contact',
|
||||||
@@ -40,7 +33,7 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
|||||||
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),
|
images: getOGImageMetadata('contact', title, locale),
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
@@ -64,7 +57,7 @@ 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;
|
||||||
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 */}
|
||||||
@@ -154,36 +139,71 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<div className="space-y-4 md:space-y-8">
|
<div className="space-y-4 md:space-y-8">
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
<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>
|
||||||
|
|||||||
@@ -2,14 +2,24 @@ 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 AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||||
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
|
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||||
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 '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
|
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -20,31 +30,60 @@ export const viewport: Viewport = {
|
|||||||
viewportFit: 'cover',
|
viewportFit: 'cover',
|
||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params: {locale}
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: {locale: string};
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Providing all messages to the client
|
const { locale } = await params;
|
||||||
// side is the easiest way to get started
|
|
||||||
const messages = await getMessages();
|
// Ensure locale is a valid string, fallback to 'en'
|
||||||
|
const supportedLocales = ['en', 'de'];
|
||||||
|
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||||
|
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||||
|
|
||||||
|
let messages = {};
|
||||||
|
try {
|
||||||
|
messages = await getMessages();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||||
|
messages = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pageview on the server with high-fidelity header context
|
||||||
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ('setServerContext' in serverServices.analytics) {
|
||||||
|
(serverServices.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track initial server-side pageview
|
||||||
|
serverServices.analytics.trackPageview();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
<html lang={safeLocale} className="scroll-smooth overflow-x-hidden">
|
||||||
<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={messages} locale={safeLocale}>
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">
|
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<CMSConnectivityNotice />
|
||||||
{/* Sends pageviews for client-side navigations */}
|
|
||||||
<AnalyticsProvider />
|
<AnalyticsProvider />
|
||||||
|
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
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 runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
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(
|
||||||
(
|
(
|
||||||
@@ -16,8 +18,9 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,30 +15,56 @@ import { getTranslations } 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;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id="breadcrumb-home"
|
id="breadcrumb-home"
|
||||||
data={getBreadcrumbSchema([
|
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||||
{ name: 'Home', item: `/${locale}` },
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Reveal><ProductCategories /></Reveal>
|
<Reveal>
|
||||||
<Reveal><WhatWeDo /></Reveal>
|
<ProductCategories />
|
||||||
<Reveal><RecentPosts locale={locale} /></Reveal>
|
</Reveal>
|
||||||
<Reveal><Experience /></Reveal>
|
<Reveal>
|
||||||
<Reveal><WhyChooseUs /></Reveal>
|
<WhatWeDo />
|
||||||
<Reveal><MeetTheTeam /></Reveal>
|
</Reveal>
|
||||||
<Reveal><GallerySection /></Reveal>
|
<Reveal>
|
||||||
<Reveal><VideoSection /></Reveal>
|
<RecentPosts locale={locale} />
|
||||||
<Reveal><CTA /></Reveal>
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<Experience />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<WhyChooseUs />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<MeetTheTeam />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<GallerySection />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<VideoSection />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal>
|
||||||
|
<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;
|
||||||
@@ -62,15 +88,15 @@ export async function generateMetadata({ params: { locale } }: { params: { local
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}`,
|
canonical: `/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de',
|
de: '/de',
|
||||||
'en': '/en',
|
en: '/en',
|
||||||
'x-default': '/en',
|
'x-default': '/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: {
|
||||||
|
|||||||
@@ -19,24 +19,35 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string[];
|
slug: string[];
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
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,
|
||||||
@@ -44,15 +55,15 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${productSlug}`,
|
canonical: `/${locale}/products/${productSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
'en': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${categoryTitle} | KLZ Cables`,
|
title: `${categoryTitle} | KLZ Cables`,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
url: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -72,8 +83,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
canonical: `/${locale}/products/${slug.join('/')}`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||||
'en': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -81,7 +92,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -95,20 +106,36 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
const components = {
|
const components = {
|
||||||
ProductTechnicalData,
|
ProductTechnicalData,
|
||||||
ProductTabs,
|
ProductTabs,
|
||||||
p: (props: any) => <p {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium" />,
|
p: (props: any) => (
|
||||||
|
<p
|
||||||
|
{...props}
|
||||||
|
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
|
||||||
|
/>
|
||||||
|
),
|
||||||
h2: (props: any) => (
|
h2: (props: any) => (
|
||||||
<div className="relative mb-16">
|
<div className="relative mb-16">
|
||||||
<h2 {...props} className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6" />
|
<h2
|
||||||
|
{...props}
|
||||||
|
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
|
||||||
|
/>
|
||||||
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
h3: (props: any) => <h3 {...props} className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase" />,
|
h3: (props: any) => (
|
||||||
|
<h3
|
||||||
|
{...props}
|
||||||
|
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
|
||||||
|
/>
|
||||||
|
),
|
||||||
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
||||||
section: (props: any) => <div {...props} className="block" />,
|
section: (props: any) => <div {...props} className="block" />,
|
||||||
li: (props: any) => (
|
li: (props: any) => (
|
||||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
||||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
||||||
<span {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium" />
|
<span
|
||||||
|
{...props}
|
||||||
|
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
||||||
@@ -117,45 +144,66 @@ const components = {
|
|||||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60" />,
|
th: (props: any) => (
|
||||||
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium" />,
|
<th
|
||||||
|
{...props}
|
||||||
|
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
td: (props: any) => (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
||||||
|
/>
|
||||||
|
),
|
||||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
||||||
blockquote: (props: any) => (
|
blockquote: (props: any) => (
|
||||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||||
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight" {...props} />
|
<div
|
||||||
|
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Check if it's a category page
|
// Check if it's a category page
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = fileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: fileSlug;
|
||||||
|
|
||||||
// Filter products for this category
|
// Filter products for this category
|
||||||
const filteredProducts = allProducts.filter(p =>
|
const filteredProducts = allProducts.filter((p) =>
|
||||||
p.frontmatter.categories.some(cat =>
|
p.frontmatter.categories.some(
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === fileSlug ||
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||||
cat === categoryTitle
|
),
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get translated product slugs
|
// Get translated product slugs
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
...p,
|
...p,
|
||||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale)
|
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,7 +212,9 @@ 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}/products`} className="hover:text-accent transition-colors">
|
||||||
|
{t('title')}
|
||||||
|
</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>
|
||||||
@@ -202,7 +252,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<div className="p-8 md:p-10">
|
<div className="p-8 md:p-10">
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{product.frontmatter.categories.map((cat, i) => (
|
{product.frontmatter.categories.map((cat, i) => (
|
||||||
<span key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -217,8 +270,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||||
{t('details')}
|
{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">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +301,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract technical data for schema
|
// Extract technical data for schema
|
||||||
const technicalDataMatch = product.content.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
|
const technicalDataMatch = product.content.match(
|
||||||
|
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
||||||
|
);
|
||||||
let technicalItems = [];
|
let technicalItems = [];
|
||||||
if (technicalDataMatch) {
|
if (technicalDataMatch) {
|
||||||
try {
|
try {
|
||||||
@@ -253,11 +318,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
const isFallback = (product.frontmatter as any).isFallback;
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
const categorySlug = slug[0];
|
const categorySlug = slug[0];
|
||||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||||
const categoryKey = categoryFileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = categoryFileSlug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : categoryFileSlug;
|
.replace(/-cables$/, '')
|
||||||
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: categoryFileSlug;
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<ProductSidebar
|
<ProductSidebar
|
||||||
productName={product.frontmatter.title}
|
productName={product.frontmatter.title}
|
||||||
productImage={product.frontmatter.images?.[0]}
|
productImage={product.frontmatter.images?.[0]}
|
||||||
datasheetPath={datasheetPath}
|
datasheetPath={datasheetPath}
|
||||||
@@ -287,17 +356,24 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* 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}/products`} className="hover:text-accent transition-colors">
|
||||||
|
{t('title')}
|
||||||
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<Link href={`/${locale}/products/${categorySlug}`} className="hover:text-accent transition-colors">{categoryTitle}</Link>
|
<Link
|
||||||
|
href={`/${locale}/products/${categorySlug}`}
|
||||||
|
className="hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
{categoryTitle}
|
||||||
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{isFallback && (
|
{isFallback && (
|
||||||
@@ -308,7 +384,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>
|
||||||
))}
|
))}
|
||||||
@@ -329,11 +409,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative">
|
<Container className="relative">
|
||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div className="relative -mt-32 mb-32 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
<div
|
||||||
|
className="relative -mt-32 mb-32 animate-slide-up"
|
||||||
|
style={{ animationDelay: '200ms' }}
|
||||||
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||||
@@ -342,12 +425,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Subtle reflection/shadow effect */}
|
{/* Subtle reflection/shadow effect */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.frontmatter.images.length > 1 && (
|
{product.frontmatter.images.length > 1 && (
|
||||||
<div className="flex justify-center gap-8 mt-20">
|
<div className="flex justify-center gap-8 mt-20">
|
||||||
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
||||||
<div key={idx} className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4">
|
<div
|
||||||
<Image src={img} alt="" fill className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" />
|
key={idx}
|
||||||
|
className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,7 +451,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="max-w-none">
|
<div className="max-w-none">
|
||||||
<MDXRemote source={processedContent} components={productComponents} />
|
<MDXRemote source={processedContent} components={productComponents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
||||||
@@ -379,45 +470,49 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Structured Data */}
|
{/* Structured Data */}
|
||||||
<JsonLd
|
<JsonLd
|
||||||
id={`jsonld-${product.slug}`}
|
id={`jsonld-${product.slug}`}
|
||||||
data={{
|
data={
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'Product',
|
'@context': 'https://schema.org',
|
||||||
name: product.frontmatter.title,
|
'@type': 'Product',
|
||||||
description: product.frontmatter.description,
|
name: product.frontmatter.title,
|
||||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
description: product.frontmatter.description,
|
||||||
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
|
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||||
brand: {
|
image: product.frontmatter.images?.[0]
|
||||||
'@type': 'Brand',
|
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||||
name: 'KLZ Cables',
|
: undefined,
|
||||||
},
|
brand: {
|
||||||
offers: {
|
'@type': 'Brand',
|
||||||
'@type': 'Offer',
|
name: 'KLZ Cables',
|
||||||
availability: 'https://schema.org/InStock',
|
},
|
||||||
priceCurrency: 'EUR',
|
offers: {
|
||||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
'@type': 'Offer',
|
||||||
itemCondition: 'https://schema.org/NewCondition',
|
availability: 'https://schema.org/InStock',
|
||||||
},
|
priceCurrency: 'EUR',
|
||||||
additionalProperty: technicalItems.map((item: any) => ({
|
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
'@type': 'PropertyValue',
|
itemCondition: 'https://schema.org/NewCondition',
|
||||||
name: item.label,
|
},
|
||||||
value: item.value,
|
additionalProperty: technicalItems.map((item: any) => ({
|
||||||
})),
|
'@type': 'PropertyValue',
|
||||||
category: product.frontmatter.categories.join(', '),
|
name: item.label,
|
||||||
mainEntityOfPage: {
|
value: item.value,
|
||||||
'@type': 'WebPage',
|
})),
|
||||||
'@id': `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
category: product.frontmatter.categories.join(', '),
|
||||||
},
|
mainEntityOfPage: {
|
||||||
} as any}
|
'@type': 'WebPage',
|
||||||
|
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,83 +1,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 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: { locale } }: { params: { locale: string } }) {
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
// If no slug, it's the main products page
|
const title = t('meta.title') || t('title');
|
||||||
if (!slug || slug.length === 0) {
|
const description = t('meta.description') || t('subtitle');
|
||||||
const title = t('meta.title') || t('title');
|
|
||||||
const description = t('meta.description') || t('subtitle');
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<OGImageTemplate
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
label="Products"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const productSlug = slug[slug.length - 1];
|
|
||||||
|
|
||||||
// Check if it's a category page
|
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
|
||||||
if (categories.includes(productSlug)) {
|
|
||||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
|
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<OGImageTemplate
|
|
||||||
title={categoryTitle}
|
|
||||||
description={categoryDesc}
|
|
||||||
label="Product Category"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return new ImageResponse(
|
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const featuredImage = product.frontmatter.images?.[0]
|
|
||||||
? (product.frontmatter.images[0].startsWith('http')
|
|
||||||
? product.frontmatter.images[0]
|
|
||||||
: `https://klz-cables.com${product.frontmatter.images[0]}`)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={product.frontmatter.title}
|
title={title}
|
||||||
description={product.frontmatter.description}
|
description={description}
|
||||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
label="Products"
|
||||||
image={featuredImage?.startsWith('http') ? featuredImage : undefined}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: ProductsPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
@@ -24,15 +26,15 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products`,
|
canonical: `/${locale}/products`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de/products',
|
de: '/de/products',
|
||||||
'en': '/en/products',
|
en: '/en/products',
|
||||||
'x-default': '/en/products',
|
'x-default': '/en/products',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `https://klz-cables.com/${locale}/products`,
|
url: `${SITE_URL}/${locale}/products`,
|
||||||
images: getOGImageMetadata('products', title, locale),
|
images: getOGImageMetadata('products', title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -44,13 +46,14 @@ 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;
|
||||||
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 categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
@@ -58,29 +61,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}/products/${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}/products/${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}/products/${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}/products/${solarSlug}`,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,7 +92,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 +103,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>
|
||||||
@@ -123,8 +137,8 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<Link key={idx} href={category.href} className="group block">
|
<Link key={idx} href={category.href} className="group block">
|
||||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
<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"
|
||||||
@@ -132,13 +146,22 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
unoptimized
|
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,8 +178,18 @@ 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>
|
||||||
@@ -168,7 +201,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</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 +210,23 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{t('cta.title')}</h2>
|
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
|
{t('cta.title')}
|
||||||
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
{t('cta.description')}
|
{t('cta.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button href={`/${params.locale}/contact`} variant="accent" size="lg" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
<Button
|
||||||
|
href={`/${locale}/contact`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
>
|
||||||
{t('cta.button')}
|
{t('cta.button')}
|
||||||
<span className="ml-4 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-4 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
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 runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||||
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');
|
||||||
|
|
||||||
@@ -18,8 +21,9 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
width: 1200,
|
...OG_IMAGE_SIZE,
|
||||||
height: 630,
|
fonts,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
|
|||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
|
||||||
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');
|
||||||
@@ -24,15 +25,15 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/team`,
|
canonical: `/${locale}/team`,
|
||||||
languages: {
|
languages: {
|
||||||
'de': '/de/team',
|
de: '/de/team',
|
||||||
'en': '/en/team',
|
en: '/en/team',
|
||||||
'x-default': '/en/team',
|
'x-default': '/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),
|
images: getOGImageMetadata('team', title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -43,16 +44,15 @@ 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;
|
||||||
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 */}
|
||||||
@@ -101,9 +97,11 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
/>
|
/>
|
||||||
<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>
|
||||||
@@ -120,7 +118,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
<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,9 +133,9 @@ 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
|
<Button
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -173,26 +173,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>
|
||||||
@@ -216,7 +226,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,9 +241,9 @@ 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
|
<Button
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -255,11 +267,14 @@ 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>
|
||||||
))}
|
))}
|
||||||
@@ -268,12 +283,21 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
||||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
<div key={idx} className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||||
|
>
|
||||||
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
<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`)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,46 +1,153 @@
|
|||||||
"use server";
|
'use server';
|
||||||
|
|
||||||
import { sendEmail } from "@/lib/mail/mailer";
|
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||||
import ContactEmail from "@/components/emails/ContactEmail";
|
import { createItem } from '@directus/sdk';
|
||||||
import React from "react";
|
import { sendEmail } from '@/lib/mail/mailer';
|
||||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
|
import React from 'react';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
export async function sendContactFormAction(formData: FormData) {
|
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 Directus
|
||||||
|
try {
|
||||||
const subject = productName
|
await ensureAuthenticated();
|
||||||
? `Product Inquiry: ${productName}`
|
if (productName) {
|
||||||
: "New Contact Form Submission";
|
await client.request(
|
||||||
|
createItem('product_requests', {
|
||||||
const result = await sendEmail({
|
product_name: productName,
|
||||||
subject,
|
email,
|
||||||
template: React.createElement(ContactEmail, {
|
message,
|
||||||
name,
|
}),
|
||||||
email,
|
);
|
||||||
message,
|
logger.info('Product request stored in Directus');
|
||||||
productName: productName || undefined,
|
} else {
|
||||||
subject,
|
await client.request(
|
||||||
}),
|
createItem('contact_submissions', {
|
||||||
});
|
name,
|
||||||
|
email,
|
||||||
if (result.success) {
|
message,
|
||||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
}),
|
||||||
} else {
|
);
|
||||||
logger.error('Failed to send contact form email', { error: result.error });
|
logger.info('Contact submission stored in Directus');
|
||||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to store submission in Directus', { error });
|
||||||
|
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
// 2. Send Emails
|
||||||
|
logger.info('Sending branded emails', { email, productName });
|
||||||
|
|
||||||
|
const notificationSubject = productName
|
||||||
|
? `Product Inquiry: ${productName}`
|
||||||
|
: 'New Contact Form Submission';
|
||||||
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2a. Send notification to Mintel/Client
|
||||||
|
const notificationHtml = await render(
|
||||||
|
React.createElement(ContactFormNotification, {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
productName: productName || undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationResult = await sendEmail({
|
||||||
|
replyTo: email,
|
||||||
|
subject: notificationSubject,
|
||||||
|
html: notificationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notificationResult.success) {
|
||||||
|
logger.info('Notification email sent successfully', {
|
||||||
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
|
const confirmationHtml = await render(
|
||||||
|
React.createElement(ConfirmationMessage, {
|
||||||
|
name,
|
||||||
|
clientName: 'KLZ Cables',
|
||||||
|
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmationResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmationResult.success) {
|
||||||
|
logger.info('Confirmation email sent successfully', {
|
||||||
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify via Gotify (Internal)
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: `📩 ${notificationSubject}`,
|
||||||
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track('contact-form-success', {
|
||||||
|
is_product_request: !!productName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to send branded emails', {
|
||||||
|
error: errorMsg,
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||||
|
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '🚨 Contact Form Error',
|
||||||
|
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
9
app/api/health/cms/route.ts
Normal file
9
app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { checkHealth } from '@/lib/directus';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const health = await checkHealth();
|
||||||
|
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
|
||||||
|
}
|
||||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||||
|
}
|
||||||
69
app/errors/api/relay/route.ts
Normal file
69
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||||
|
*
|
||||||
|
* This Route Handler receives Sentry envelopes from the client,
|
||||||
|
* injects the correct DSN if needed, and forwards them to the
|
||||||
|
* internal GlitchTip/Sentry instance.
|
||||||
|
*
|
||||||
|
* This hides the real DSN from the client and bypasses ad-blockers
|
||||||
|
* that target Sentry's default ingest endpoints.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envelope = await request.text();
|
||||||
|
|
||||||
|
// Sentry envelopes can contain multiple parts separated by newlines
|
||||||
|
const lines = envelope.split('\n');
|
||||||
|
if (lines.length < 1) {
|
||||||
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = JSON.parse(lines[0]);
|
||||||
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
|
if (!realDsn) {
|
||||||
|
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dsnUrl = new URL(realDsn);
|
||||||
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||||
|
|
||||||
|
logger.debug('Relaying Sentry envelope', {
|
||||||
|
projectId,
|
||||||
|
host: dsnUrl.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(relayUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: envelope,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-sentry-envelope',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to relay Sentry request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
sizes: 'any',
|
sizes: 'any',
|
||||||
type: 'image/x-icon',
|
type: 'image/x-icon',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: '/logo.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: '/apple-touch-icon.png',
|
src: '/apple-touch-icon.png',
|
||||||
sizes: '180x180',
|
sizes: '180x180',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
@@ -11,8 +13,8 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
{
|
{
|
||||||
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||||
allow: '/',
|
allow: '/',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
sitemap: 'https://klz-cables.com/sitemap.xml',
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPostsMetadata } from '@/lib/blog';
|
||||||
import { getAllPages } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
|
|
||||||
|
export const revalidate = 3600; // Revalidate every hour
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = 'https://klz-cables.com';
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -33,12 +36,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
const products = await getAllProducts(locale);
|
const productsMetadata = await getAllProductsMetadata(locale);
|
||||||
for (const product of products) {
|
for (const product of productsMetadata) {
|
||||||
// We need to find the category for the product to build the URL
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
// In this project, products are under /products/[category]/[slug]
|
|
||||||
// The category is in product.frontmatter.categories
|
const category =
|
||||||
const category = product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
@@ -48,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blog posts
|
// Blog posts
|
||||||
const posts = await getAllPosts(locale);
|
const postsMetadata = await getAllPostsMetadata(locale);
|
||||||
for (const post of posts) {
|
for (const post of postsMetadata) {
|
||||||
|
if (!post.frontmatter || !post.slug) continue;
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||||
lastModified: new Date(post.frontmatter.date),
|
lastModified: new Date(post.frontmatter.date),
|
||||||
@@ -59,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
const pages = await getAllPages(locale);
|
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||||
for (const page of pages) {
|
for (const page of pagesMetadata) {
|
||||||
|
if (!page.slug) continue;
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|||||||
73
app/stats/api/send/route.ts
Normal file
73
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy for Umami Analytics.
|
||||||
|
*
|
||||||
|
* This Route Handler receives tracking events from the browser,
|
||||||
|
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||||
|
* internal Umami API endpoint.
|
||||||
|
*
|
||||||
|
* This ensures:
|
||||||
|
* 1. The Website ID is NOT leaked to the client bundle.
|
||||||
|
* 2. The Umami API endpoint is hidden behind our domain.
|
||||||
|
* 3. We have full control over the tracking data.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
// Inject the secret websiteId from server config
|
||||||
|
const websiteId = config.analytics.umami.websiteId;
|
||||||
|
if (!websiteId) {
|
||||||
|
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the enhanced payload with the secret ID
|
||||||
|
const enhancedPayload = {
|
||||||
|
...payload,
|
||||||
|
website: websiteId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||||
|
|
||||||
|
// Log the event (internal only)
|
||||||
|
logger.debug('Forwarding analytics event', {
|
||||||
|
type,
|
||||||
|
url: payload.url,
|
||||||
|
website: websiteId.slice(0, 8) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||||
|
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to proxy analytics request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
commitlint.config.js
Normal file
8
commitlint.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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, Database } from 'lucide-react';
|
||||||
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
|
export default function CMSConnectivityNotice() {
|
||||||
|
const [status, 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 (err) {
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,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,
|
||||||
@@ -41,7 +41,12 @@ export default function ContactForm() {
|
|||||||
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">
|
||||||
<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 +54,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'}
|
||||||
@@ -62,7 +68,13 @@ export default function ContactForm() {
|
|||||||
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">
|
||||||
<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 +86,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>
|
||||||
@@ -89,9 +106,9 @@ export default function ContactForm() {
|
|||||||
<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="name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
@@ -101,9 +118,9 @@ export default function ContactForm() {
|
|||||||
</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="email">{t('form.email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
@@ -114,32 +131,50 @@ export default function ContactForm() {
|
|||||||
</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="message">{t('form.message')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="message"
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
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} />;
|
||||||
|
}
|
||||||
@@ -14,10 +14,10 @@ export default function Header() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
const currentLocale = pathname.split('/')[1] || 'en';
|
const currentLocale = pathname.split('/')[1] || 'en';
|
||||||
|
|
||||||
// Check if homepage
|
// Check if homepage
|
||||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||||
|
|
||||||
@@ -32,8 +32,10 @@ export default function Header() {
|
|||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMobileMenuOpen(false);
|
if (isMobileMenuOpen) {
|
||||||
}, [pathname]);
|
setIsMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
}, [pathname, isMobileMenuOpen]);
|
||||||
|
|
||||||
// Prevent scroll when mobile menu is open
|
// Prevent scroll when mobile menu is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +45,7 @@ export default function Header() {
|
|||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
}
|
}
|
||||||
}, [isMobileMenuOpen]);
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
// Function to get path for a different locale
|
// Function to get path for a different locale
|
||||||
const getPathForLocale = (newLocale: string) => {
|
const getPathForLocale = (newLocale: string) => {
|
||||||
const segments = pathname.split('/');
|
const segments = pathname.split('/');
|
||||||
@@ -59,15 +61,15 @@ export default function Header() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const headerClass = cn(
|
const headerClass = cn(
|
||||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu",
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
|
||||||
{
|
{
|
||||||
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
|
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen,
|
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const textColorClass = "text-white";
|
const textColorClass = 'text-white';
|
||||||
const logoSrc = "/logo-white.svg";
|
const logoSrc = '/logo-white.svg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -75,14 +77,14 @@ export default function Header() {
|
|||||||
className={headerClass}
|
className={headerClass}
|
||||||
initial={{ y: -100, opacity: 0 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex-shrink-0 group touch-target"
|
className="flex-shrink-0 group touch-target"
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<Link href={`/${currentLocale}`}>
|
<Link href={`/${currentLocale}`}>
|
||||||
<Image
|
<Image
|
||||||
@@ -105,25 +107,19 @@ export default function Header() {
|
|||||||
visible: {
|
visible: {
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.08,
|
staggerChildren: 0.08,
|
||||||
delayChildren: 0.3
|
delayChildren: 0.3,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.nav
|
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||||
className="hidden lg:flex items-center space-x-10"
|
|
||||||
variants={navVariants}
|
|
||||||
>
|
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<motion.div
|
<motion.div key={item.href} variants={navLinkVariants}>
|
||||||
key={item.href}
|
|
||||||
variants={navLinkVariants}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
textColorClass,
|
textColorClass,
|
||||||
"hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5"
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -134,7 +130,7 @@ export default function Header() {
|
|||||||
</motion.nav>
|
</motion.nav>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn("hidden lg:flex items-center space-x-8", textColorClass)}
|
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||||
variants={headerRightVariants}
|
variants={headerRightVariants}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -174,11 +170,11 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }}
|
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/contact`}
|
||||||
@@ -193,11 +189,20 @@ export default function Header() {
|
|||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<motion.button
|
<motion.button
|
||||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)}
|
className={cn(
|
||||||
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||||
|
textColorClass,
|
||||||
|
)}
|
||||||
aria-label={t('toggleMenu')}
|
aria-label={t('toggleMenu')}
|
||||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||||
transition={{ duration: 0.6, type: "spring", stiffness: 300, damping: 20, delay: 0.5 }}
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
delay: 0.5,
|
||||||
|
}}
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
>
|
>
|
||||||
<motion.svg
|
<motion.svg
|
||||||
@@ -236,21 +241,25 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div className={cn(
|
<div
|
||||||
"fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col",
|
className={cn(
|
||||||
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
)}>
|
isMobileMenuOpen
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||||
initial="closed"
|
initial="closed"
|
||||||
animate={isMobileMenuOpen ? "open" : "closed"}
|
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||||
variants={{
|
variants={{
|
||||||
open: {
|
open: {
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.1,
|
staggerChildren: 0.1,
|
||||||
delayChildren: 0.2
|
delayChildren: 0.2,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
@@ -264,10 +273,10 @@ export default function Header() {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
ease: "easeOut",
|
ease: 'easeOut',
|
||||||
delay: idx * 0.08
|
delay: idx * 0.08,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
@@ -278,7 +287,7 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@@ -322,11 +331,11 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 20, delay: 1.2 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/contact`}
|
||||||
@@ -338,23 +347,23 @@ export default function Header() {
|
|||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
{/* Bottom Branding */}
|
||||||
<motion.div
|
|
||||||
className="p-12 flex justify-center opacity-20"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.5, delay: 1.4 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.5 }}
|
className="p-12 flex justify-center opacity-20"
|
||||||
animate={{ scale: 1 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
transition={{ type: "spring", stiffness: 300, delay: 1.5 }}
|
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.5, delay: 1.4 }}
|
||||||
>
|
>
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
<motion.div
|
||||||
|
initial={{ scale: 0.5 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||||
|
>
|
||||||
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.header>
|
</motion.header>
|
||||||
</>
|
</>
|
||||||
@@ -367,9 +376,9 @@ const navVariants = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.06,
|
staggerChildren: 0.06,
|
||||||
delayChildren: 0.1
|
delayChildren: 0.1,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const navLinkVariants = {
|
const navLinkVariants = {
|
||||||
@@ -380,9 +389,9 @@ const navLinkVariants = {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: "easeOut"
|
ease: 'easeOut',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const headerRightVariants = {
|
const headerRightVariants = {
|
||||||
@@ -390,6 +399,6 @@ const headerRightVariants = {
|
|||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
x: 0,
|
x: 0,
|
||||||
transition: { duration: 0.6, ease: "easeOut" }
|
transition: { duration: 0.6, ease: 'easeOut' },
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
return () => setMounted(false);
|
return () => setMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateUrl = useCallback((index: number | null) => {
|
const updateUrl = useCallback(
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
(index: number | null) => {
|
||||||
if (index !== null) {
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('photo', index.toString());
|
if (index !== null) {
|
||||||
} else {
|
params.set('photo', index.toString());
|
||||||
params.delete('photo');
|
} else {
|
||||||
}
|
params.delete('photo');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
}
|
||||||
}, [pathname, router, searchParams]);
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[pathname, router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const prevImage = useCallback(() => {
|
const prevImage = useCallback(() => {
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
@@ -61,6 +64,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
}
|
}
|
||||||
}, [searchParams, images.length]);
|
}, [searchParams, images.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
updateUrl(null);
|
||||||
|
onClose();
|
||||||
|
}, [updateUrl, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updateUrl(currentIndex);
|
updateUrl(currentIndex);
|
||||||
@@ -79,22 +87,17 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
// Lock scroll
|
// Lock scroll
|
||||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = originalStyle;
|
document.body.style.overflow = originalStyle;
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpen, prevImage, nextImage]);
|
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
updateUrl(null);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -121,7 +124,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
@@ -131,9 +134,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||||
aria-label="Previous image"
|
aria-label="Previous image"
|
||||||
>
|
>
|
||||||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">‹</span>
|
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||||
|
‹
|
||||||
|
</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
@@ -143,10 +148,12 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||||
aria-label="Next image"
|
aria-label="Next image"
|
||||||
>
|
>
|
||||||
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">›</span>
|
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
@@ -173,15 +180,15 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
||||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
||||||
|
|
||||||
{/* Premium Reflection: Subtle gradient to give material feel */}
|
{/* Premium Reflection: Subtle gradient to give material feel */}
|
||||||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
exit={{ opacity: 0, y: 10 }}
|
||||||
@@ -199,6 +206,6 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>,
|
</AnimatePresence>,
|
||||||
document.body
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function OGImageTemplate({
|
|||||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||||
padding: '80px',
|
padding: '80px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
fontFamily: 'Inter',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,22 +64,22 @@ export function OGImageTemplate({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
background: 'linear-gradient(to right, rgba(0,26,77,0.9), rgba(0,26,77,0.4))',
|
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Decorative Scribble Circle (Simplified for Satori) */}
|
{/* Decorative Brand Accent (Top Right) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '-100px',
|
top: '-150px',
|
||||||
right: '-100px',
|
right: '-150px',
|
||||||
width: '600px',
|
width: '600px',
|
||||||
height: '600px',
|
height: '600px',
|
||||||
borderRadius: '300px',
|
borderRadius: '300px',
|
||||||
backgroundColor: `${accentGreen}1a`,
|
backgroundColor: `${accentGreen}15`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -89,11 +90,11 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 700,
|
||||||
color: accentGreen,
|
color: accentGreen,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.2em',
|
letterSpacing: '0.3em',
|
||||||
marginBottom: '24px',
|
marginBottom: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -104,13 +105,14 @@ export function OGImageTemplate({
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '72px',
|
fontSize: title.length > 40 ? '64px' : '82px',
|
||||||
fontWeight: '900',
|
fontWeight: 700,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
lineHeight: '1.1',
|
lineHeight: '1.05',
|
||||||
maxWidth: '900px',
|
maxWidth: '950px',
|
||||||
marginBottom: '32px',
|
marginBottom: '40px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -121,13 +123,14 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '32px',
|
fontSize: '32px',
|
||||||
color: 'rgba(255,255,255,0.8)',
|
color: 'rgba(255,255,255,0.7)',
|
||||||
maxWidth: '800px',
|
maxWidth: '850px',
|
||||||
lineHeight: '1.4',
|
lineHeight: '1.4',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
fontWeight: 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,33 +147,34 @@ export function OGImageTemplate({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '120px',
|
width: '80px',
|
||||||
height: '8px',
|
height: '6px',
|
||||||
backgroundColor: accentGreen,
|
backgroundColor: accentGreen,
|
||||||
borderRadius: '4px',
|
borderRadius: '3px',
|
||||||
marginRight: '24px',
|
marginRight: '24px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 700,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.15em',
|
||||||
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
KLZ Cables
|
KLZ Cables
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saturated Blue Accent */}
|
{/* Saturated Blue Brand Strip */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
width: '10px',
|
width: '12px',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
backgroundColor: saturatedBlue,
|
backgroundColor: saturatedBlue,
|
||||||
}}
|
}}
|
||||||
@@ -178,3 +182,4 @@ export function OGImageTemplate({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,14 +49,28 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0">
|
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-accent/20">
|
<div className="flex justify-center mb-3">
|
||||||
<svg className="w-5 h-5 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<svg
|
||||||
</svg>
|
className="w-5 h-5 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
|
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
|
{t('successTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||||
{t('successDesc', { productName })}
|
{t('successDesc', { productName })}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -73,26 +87,36 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0">
|
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-destructive/20">
|
<div className="flex justify-center mb-3">
|
||||||
<svg className="w-5 h-5 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<svg
|
||||||
<line x1="15" y1="9" x2="9" y2="15" />
|
className="w-5 h-5 text-destructive-foreground"
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
fill="none"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive">{t('errorTitle') || 'Submission Failed'}</h3>
|
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0">
|
{t('errorTitle') || 'Submission Failed'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||||
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setStatus('idle')}
|
onClick={() => setStatus('idle')}
|
||||||
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-destructive hover:text-destructive/80 transition-colors group"
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<span className="border-b-2 border-destructive/10 group-hover:border-destructive/30 transition-colors pb-1">
|
{t('tryAgain') || 'Try Again'}
|
||||||
{t('tryAgain') || 'Try Again'}
|
</Button>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,22 +157,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
>
|
>
|
||||||
{status === 'submitting' ? (
|
{status === 'submitting' ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-3 w-3 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs">{t('submitting')}</span>
|
<span className="text-xs">{t('submitting')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs">{t('submit')}</span>
|
<span className="text-xs">{t('submit')}</span>
|
||||||
<svg className="w-3 h-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-3 h-3 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||||
{t('privacyNote')}
|
{t('privacyNote')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,25 +3,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AnalyticsProvider Component
|
* AnalyticsProvider Component
|
||||||
*
|
*
|
||||||
* Automatically tracks pageviews on client-side route changes.
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
* This component should be placed inside your layout to handle navigation events.
|
* This component handles navigation events for the Umami analytics service.
|
||||||
*
|
*
|
||||||
* @example
|
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||||
* ```tsx
|
* so it's no longer needed as a prop here.
|
||||||
* // In your layout.tsx
|
|
||||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
|
||||||
* <UmamiScript />
|
|
||||||
* <Header />
|
|
||||||
* <main>{children}</main>
|
|
||||||
* <Footer />
|
|
||||||
* <AnalyticsProvider />
|
|
||||||
* </NextIntlClientProvider>
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export default function AnalyticsProvider() {
|
export default function AnalyticsProvider() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -29,31 +19,17 @@ export default function AnalyticsProvider() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pathname) return;
|
if (!pathname) return;
|
||||||
|
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
// Track pageview with the full URL
|
// Track pageview with the full URL
|
||||||
|
// The service will relay this to our internal proxy which injects the Website ID
|
||||||
services.analytics.trackPageview(url);
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
// Services like logger are already sub-initialized in getAppServices()
|
||||||
console.log('[Umami] Tracked pageview:', url);
|
// so we don't need to log here manually.
|
||||||
}
|
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
return null;
|
||||||
if (!websiteId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Script
|
|
||||||
id="umami-analytics"
|
|
||||||
src="/stats/script.js"
|
|
||||||
data-website-id={websiteId}
|
|
||||||
data-host-url="/stats"
|
|
||||||
strategy="afterInteractive"
|
|
||||||
data-domains="klz-cables.com"
|
|
||||||
defer
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ export default function GallerySection() {
|
|||||||
if (photoParam !== null) {
|
if (photoParam !== null) {
|
||||||
const index = parseInt(photoParam, 10);
|
const index = parseInt(photoParam, 10);
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||||
setLightboxIndex(index);
|
if (lightboxIndex !== index) setLightboxIndex(index);
|
||||||
setLightboxOpen(true);
|
if (!lightboxOpen) setLightboxOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, images.length]);
|
}, [searchParams, images.length, lightboxIndex, lightboxOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-white py-32">
|
<Section className="bg-white text-white py-32">
|
||||||
@@ -39,7 +39,7 @@ export default function GallerySection() {
|
|||||||
<Heading level={2} subtitle={t('subtitle')} align="center">
|
<Heading level={2} subtitle={t('subtitle')} align="center">
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{images.map((src, idx) => (
|
{images.map((src, idx) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function Hero() {
|
|||||||
>
|
>
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
|||||||
@@ -3,24 +3,43 @@ import Link from 'next/link';
|
|||||||
import { cn } from './utils';
|
import { cn } from './utils';
|
||||||
|
|
||||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'accent' | 'saturated' | 'outline' | 'ghost' | 'white';
|
variant?:
|
||||||
|
| 'primary'
|
||||||
|
| 'secondary'
|
||||||
|
| 'accent'
|
||||||
|
| 'saturated'
|
||||||
|
| 'outline'
|
||||||
|
| 'ghost'
|
||||||
|
| 'white'
|
||||||
|
| 'destructive';
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
href?: string;
|
href?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
|
export function Button({
|
||||||
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
href,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||||
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
||||||
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
||||||
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||||
outline: 'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
outline:
|
||||||
|
'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||||
ghost: 'text-primary hover:shadow-lg',
|
ghost: 'text-primary hover:shadow-lg',
|
||||||
white: 'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
white:
|
||||||
|
'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-md hover:shadow-destructive/30 hover:shadow-2xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
|
|||||||
outline: 'bg-primary',
|
outline: 'bg-primary',
|
||||||
ghost: 'bg-primary-light/10',
|
ghost: 'bg-primary-light/10',
|
||||||
white: 'bg-primary-light',
|
white: 'bg-primary-light',
|
||||||
|
destructive: 'bg-destructive/90',
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<span className={cn(
|
<span
|
||||||
"relative z-10 flex items-center justify-center gap-2 transition-colors duration-500",
|
className={cn(
|
||||||
variant === 'white' ? "group-hover/btn:text-primary-dark" : ""
|
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
|
||||||
)}>
|
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span
|
||||||
"absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out",
|
className={cn(
|
||||||
overlayColors[variant]
|
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
|
||||||
)} />
|
overlayColors[variant],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -398,6 +398,24 @@ locale: de
|
|||||||
"55",
|
"55",
|
||||||
"4195"
|
"4195"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"0,95",
|
||||||
|
"48,5",
|
||||||
|
"0,0247",
|
||||||
|
"3,4",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"113",
|
||||||
|
"2,4",
|
||||||
|
"885",
|
||||||
|
"59",
|
||||||
|
"4800"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -737,6 +755,24 @@ locale: de
|
|||||||
"60",
|
"60",
|
||||||
"4634"
|
"4634"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1,05",
|
||||||
|
"52,3",
|
||||||
|
"0,0247",
|
||||||
|
"5,5",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"113",
|
||||||
|
"2,4",
|
||||||
|
"990",
|
||||||
|
"66",
|
||||||
|
"5200"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1076,6 +1112,24 @@ locale: de
|
|||||||
"65",
|
"65",
|
||||||
"5093"
|
"5093"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1,15",
|
||||||
|
"57,5",
|
||||||
|
"0,0247",
|
||||||
|
"8,0",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"113",
|
||||||
|
"2,4",
|
||||||
|
"1065",
|
||||||
|
"71",
|
||||||
|
"5900"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,6 +398,24 @@ locale: en
|
|||||||
"55",
|
"55",
|
||||||
"4195"
|
"4195"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"0.95",
|
||||||
|
"48.5",
|
||||||
|
"0.0247",
|
||||||
|
"3.4",
|
||||||
|
"On Request",
|
||||||
|
"On Request",
|
||||||
|
"113",
|
||||||
|
"2.4",
|
||||||
|
"885",
|
||||||
|
"59",
|
||||||
|
"4800"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -737,6 +755,24 @@ locale: en
|
|||||||
"60",
|
"60",
|
||||||
"4634"
|
"4634"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1.05",
|
||||||
|
"52.3",
|
||||||
|
"0.0247",
|
||||||
|
"5.5",
|
||||||
|
"On Request",
|
||||||
|
"On Request",
|
||||||
|
"113",
|
||||||
|
"2.4",
|
||||||
|
"990",
|
||||||
|
"66",
|
||||||
|
"5200"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1076,6 +1112,24 @@ locale: en
|
|||||||
"65",
|
"65",
|
||||||
"5093"
|
"5093"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1.15",
|
||||||
|
"57.5",
|
||||||
|
"0.0247",
|
||||||
|
"8",
|
||||||
|
"On Request",
|
||||||
|
"On Request",
|
||||||
|
"113",
|
||||||
|
"2.4",
|
||||||
|
"1065",
|
||||||
|
"71",
|
||||||
|
"5900"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
0
directus/migrations/.keep
Normal file
0
directus/migrations/.keep
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.14.1
|
||||||
|
vendor: postgres
|
||||||
|
collections:
|
||||||
|
- collection: contact_submissions
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: contact_submissions
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: contact_mail
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: contact_submissions
|
||||||
|
- collection: product_requests
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: product_requests
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: null
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: inventory
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: product_requests
|
||||||
|
- collection: visual_feedback
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: feedback
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: comment
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback_comments
|
||||||
|
fields:
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: null
|
||||||
|
display_options: null
|
||||||
|
field: id
|
||||||
|
group: null
|
||||||
|
hidden: true
|
||||||
|
interface: null
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 1
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: uuid
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: false
|
||||||
|
is_unique: true
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: true
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: status
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#e1f5fe'
|
||||||
|
color: '#01579b'
|
||||||
|
text: Open
|
||||||
|
value: open
|
||||||
|
- background: '#e8f5e9'
|
||||||
|
color: '#1b5e20'
|
||||||
|
text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- background: '#fafafa'
|
||||||
|
color: '#212121'
|
||||||
|
text: Closed
|
||||||
|
value: closed
|
||||||
|
show_as_dot: true
|
||||||
|
field: status
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Open
|
||||||
|
value: open
|
||||||
|
- text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- text: Closed
|
||||||
|
value: closed
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 2
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: status
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: open
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#fff9c4'
|
||||||
|
color: '#fbc02d'
|
||||||
|
text: Design
|
||||||
|
value: design
|
||||||
|
- background: '#f3e5f5'
|
||||||
|
color: '#7b1fa2'
|
||||||
|
text: Content
|
||||||
|
value: content
|
||||||
|
show_as_dot: true
|
||||||
|
field: type
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Design
|
||||||
|
value: design
|
||||||
|
- text: Content
|
||||||
|
value: content
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 3
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: type
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: formatted-text
|
||||||
|
display_options:
|
||||||
|
soft_limit: 100
|
||||||
|
field: text
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input-multiline
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 4
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: text
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: url
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: link
|
||||||
|
display_options:
|
||||||
|
url: '{{url}}'
|
||||||
|
target: _blank
|
||||||
|
icon: open_in_new
|
||||||
|
field: url
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 5
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: url
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: person
|
||||||
|
header_text: User Information
|
||||||
|
sort: 6
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 1
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_identity
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: psychology
|
||||||
|
header_text: Technical Context
|
||||||
|
sort: 7
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 1
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: selector
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: x
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: 'y'
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: datetime
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 8
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: null
|
||||||
|
field: feedback_id
|
||||||
|
interface: select-relational
|
||||||
|
sort: 2
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: feedback_id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: character varying
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
interface: input-multiline
|
||||||
|
sort: 4
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: text
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
systemFields:
|
||||||
|
- collection: directus_activity
|
||||||
|
field: timestamp
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: activity
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: parent
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
relations:
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
related_collection: visual_feedback
|
||||||
|
schema:
|
||||||
|
column: feedback_id
|
||||||
|
foreign_key_column: id
|
||||||
|
foreign_key_table: visual_feedback
|
||||||
|
table: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
junction_field: null
|
||||||
|
many_collection: visual_feedback_comments
|
||||||
|
many_field: feedback_id
|
||||||
|
one_allowed_m2m: false
|
||||||
|
one_collection: visual_feedback
|
||||||
|
one_deselect_action: nullify
|
||||||
|
one_field: null
|
||||||
|
sort_field: null
|
||||||
590
directus/schema/snapshot.yaml
Normal file
590
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.14.1
|
||||||
|
vendor: postgres
|
||||||
|
collections:
|
||||||
|
- collection: contact_submissions
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: contact_submissions
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: contact_mail
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: contact_submissions
|
||||||
|
- collection: product_requests
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: product_requests
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: null
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: inventory
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: product_requests
|
||||||
|
- collection: visual_feedback
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: feedback
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: comment
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback_comments
|
||||||
|
fields:
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: null
|
||||||
|
display_options: null
|
||||||
|
field: id
|
||||||
|
group: null
|
||||||
|
hidden: true
|
||||||
|
interface: null
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 1
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: uuid
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: false
|
||||||
|
is_unique: true
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: true
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: status
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#e1f5fe'
|
||||||
|
color: '#01579b'
|
||||||
|
text: Open
|
||||||
|
value: open
|
||||||
|
- background: '#e8f5e9'
|
||||||
|
color: '#1b5e20'
|
||||||
|
text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- background: '#fafafa'
|
||||||
|
color: '#212121'
|
||||||
|
text: Closed
|
||||||
|
value: closed
|
||||||
|
show_as_dot: true
|
||||||
|
field: status
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Open
|
||||||
|
value: open
|
||||||
|
- text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- text: Closed
|
||||||
|
value: closed
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 2
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: status
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: open
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#fff9c4'
|
||||||
|
color: '#fbc02d'
|
||||||
|
text: Design
|
||||||
|
value: design
|
||||||
|
- background: '#f3e5f5'
|
||||||
|
color: '#7b1fa2'
|
||||||
|
text: Content
|
||||||
|
value: content
|
||||||
|
show_as_dot: true
|
||||||
|
field: type
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Design
|
||||||
|
value: design
|
||||||
|
- text: Content
|
||||||
|
value: content
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 3
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: type
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: formatted-text
|
||||||
|
display_options:
|
||||||
|
soft_limit: 100
|
||||||
|
field: text
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input-multiline
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 4
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: text
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: url
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: link
|
||||||
|
display_options:
|
||||||
|
url: '{{url}}'
|
||||||
|
target: _blank
|
||||||
|
icon: open_in_new
|
||||||
|
field: url
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 5
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: url
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: person
|
||||||
|
header_text: User Information
|
||||||
|
sort: 6
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 1
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_identity
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: psychology
|
||||||
|
header_text: Technical Context
|
||||||
|
sort: 7
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 1
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: selector
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: x
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: 'y'
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: datetime
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 8
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: null
|
||||||
|
field: feedback_id
|
||||||
|
interface: select-relational
|
||||||
|
sort: 2
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: feedback_id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: character varying
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
interface: input-multiline
|
||||||
|
sort: 4
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: text
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
systemFields:
|
||||||
|
- collection: directus_activity
|
||||||
|
field: timestamp
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: activity
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: parent
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
relations:
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
related_collection: visual_feedback
|
||||||
|
schema:
|
||||||
|
column: feedback_id
|
||||||
|
foreign_key_column: id
|
||||||
|
foreign_key_table: visual_feedback
|
||||||
|
table: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
junction_field: null
|
||||||
|
many_collection: visual_feedback_comments
|
||||||
|
many_field: feedback_id
|
||||||
|
one_allowed_m2m: false
|
||||||
|
one_collection: visual_feedback
|
||||||
|
one_deselect_action: nullify
|
||||||
|
one_field: null
|
||||||
|
sort_field: null
|
||||||
@@ -1,4 +1,83 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
klz-app:
|
||||||
env_file:
|
image: node:20-alpine
|
||||||
- .env
|
working_dir: /app
|
||||||
|
command: sh -c "npm install --legacy-peer-deps && npx next dev"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
# Docker Internal Communication
|
||||||
|
DIRECTUS_URL: http://directus:8055
|
||||||
|
INTERNAL_DIRECTUS_URL: http://directus:8055
|
||||||
|
INFRA_DIRECTUS_URL: http://cms-infra-infra-cms-1:8055
|
||||||
|
GATEKEEPER_URL: http://gatekeeper:3000
|
||||||
|
DIRECTUS_API_TOKEN: ${DIRECTUS_API_TOKEN}
|
||||||
|
INFRA_DIRECTUS_TOKEN: ${INFRA_DIRECTUS_TOKEN}
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED: ${NEXT_PUBLIC_FEEDBACK_ENABLED}
|
||||||
|
GATEKEEPER_BYPASS_ENABLED: ${GATEKEEPER_BYPASS_ENABLED}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Global local settings
|
||||||
|
- "traefik.http.routers.klz-cables-local.entrypoints=web"
|
||||||
|
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-local.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-local.middlewares="
|
||||||
|
- "traefik.http.routers.klz-cables-local.service=klz-cables-local"
|
||||||
|
- "traefik.http.services.klz-cables-local.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
# Web direct router
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.middlewares="
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
|
||||||
|
|
||||||
|
directus:
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
|
||||||
|
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-directus-local.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-directus-local.middlewares="
|
||||||
|
- "traefik.http.routers.klz-cables-directus-local.service=klz-cables-directus-local"
|
||||||
|
- "traefik.http.services.klz-cables-directus-local.loadbalancer.server.port=8055"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
ports:
|
||||||
|
- "${DIRECTUS_PORT:-8055}:8055"
|
||||||
|
environment:
|
||||||
|
PUBLIC_URL: http://cms.klz.localhost
|
||||||
|
|
||||||
|
gatekeeper:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app/packages/gatekeeper
|
||||||
|
command: sh -c "corepack enable && CI=true NPM_TOKEN=dummy pnpm install --no-frozen-lockfile && pnpm dev"
|
||||||
|
volumes:
|
||||||
|
- /Users/marcmintel/Projects/at-mintel:/app
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
environment:
|
||||||
|
DIRECTUS_URL: http://directus:8055
|
||||||
|
NEXT_PUBLIC_BASE_URL: http://gatekeeper.klz.localhost
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
|
COOKIE_DOMAIN: localhost
|
||||||
|
NODE_ENV: development
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.entrypoints=web"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.rule=Host(`gatekeeper.klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.service=klz-cables-gatekeeper-local"
|
||||||
|
- "traefik.http.services.klz-cables-gatekeeper-local.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|||||||
@@ -1,33 +1,156 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
klz-app:
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- default
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
|
||||||
|
varnish:
|
||||||
|
image: varnish:7
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
volumes:
|
||||||
|
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/varnish:exec,mode=1777
|
||||||
|
environment:
|
||||||
|
VARNISH_SIZE: ${VARNISH_CACHE_SIZE:-256M}
|
||||||
|
APP_VERSION: ${IMAGE_TAG:-latest}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
- "traefik.http.routers.klz-cables-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||||
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||||
# HTTPS router
|
# HTTPS router (Protected)
|
||||||
- "traefik.http.routers.klz-cables.rule=Host(${TRAEFIK_HOST})"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||||
- "traefik.http.routers.klz-cables.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.klz-cables.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
||||||
- "traefik.http.routers.klz-cables.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
||||||
- "traefik.http.routers.klz-cables.service=klz-cables"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||||
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-compress}"
|
||||||
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
|
|
||||||
|
# HTTPS router (Unprotected - for Analytics & Errors)
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
|
||||||
|
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
# Middleware Definitions
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
|
||||||
|
|
||||||
|
# Gatekeeper Router (to show the login page)
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST:-klz-cables.com}`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
||||||
|
|
||||||
# Forwarded Headers
|
# Forwarded Headers
|
||||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
# Middlewares
|
|
||||||
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
|
# Middleware Definitions
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
|
gatekeeper:
|
||||||
|
image: registry.infra.mintel.me/mintel/gatekeeper:1.4.0
|
||||||
|
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||||
|
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
||||||
|
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
|
||||||
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
||||||
|
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
directus:
|
||||||
|
image: directus/directus:11
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
KEY: ${DIRECTUS_KEY}
|
||||||
|
SECRET: ${DIRECTUS_SECRET}
|
||||||
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
|
DB_CLIENT: 'pg'
|
||||||
|
DB_HOST: 'directus-db'
|
||||||
|
DB_PORT: '5432'
|
||||||
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||||
|
WEBSOCKETS_ENABLED: 'true'
|
||||||
|
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
|
||||||
|
# Error Tracking
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN}
|
||||||
|
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
||||||
|
LOGGER_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
volumes:
|
||||||
|
- ./directus/uploads:/directus/uploads
|
||||||
|
- ./directus/extensions:/directus/extensions
|
||||||
|
- ./directus/schema:/directus/schema
|
||||||
|
- ./directus/migrations:/directus/migrations
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
directus-db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||||
|
volumes:
|
||||||
|
- directus-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
default:
|
||||||
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
directus-db-data:
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
### Build-Time Variables (NEXT_PUBLIC_*)
|
### Build-Time Variables (NEXT*PUBLIC*\*)
|
||||||
|
|
||||||
These are embedded into the JavaScript bundle during build and are visible to the client:
|
These are embedded into the JavaScript bundle during build and are visible to the client:
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
| ---------------------- | -------- | ------------------------------------------------------------ |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
||||||
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID |
|
| `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
|
||||||
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
|
| `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
|
||||||
|
|
||||||
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
||||||
|
|
||||||
@@ -58,29 +58,40 @@ These are embedded into the JavaScript bundle during build and are visible to th
|
|||||||
|
|
||||||
These are loaded from the `.env` file at runtime and are only available on the server:
|
These are loaded from the `.env` file at runtime and are only available on the server:
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
| -------------------------- | -------- | ------------------------------------------------------ |
|
||||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||||
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
||||||
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
||||||
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
|
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
|
||||||
|
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
|
||||||
|
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
|
||||||
|
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
|
||||||
|
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
|
||||||
|
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
|
||||||
|
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
|
||||||
|
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
|
||||||
|
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
|
||||||
|
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Copy the example environment file:
|
1. Copy the example environment file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Edit `.env` and fill in your local configuration:
|
2. Edit `.env` and fill in your local configuration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
@@ -88,6 +99,7 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Install dependencies:
|
3. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
@@ -103,8 +115,8 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
|||||||
# Build with build-time arguments
|
# Build with build-time arguments
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||||
-t klz-cables:local .
|
-t klz-cables:local .
|
||||||
|
|
||||||
# Run with runtime environment file
|
# Run with runtime environment file
|
||||||
@@ -129,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
|
|||||||
|
|
||||||
**Build-Time Variables:**
|
**Build-Time Variables:**
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
- `UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
- `UMAMI_API_ENDPOINT` - Umami API endpoint
|
||||||
|
|
||||||
**Runtime Variables:**
|
**Runtime Variables:**
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
- `SENTRY_DSN` - Error tracking DSN
|
||||||
@@ -200,11 +212,12 @@ docker-compose logs -f app
|
|||||||
**Problem**: Build fails with "Environment validation failed"
|
**Problem**: Build fails with "Environment validation failed"
|
||||||
|
|
||||||
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||||
-t klz-cables .
|
-t klz-cables .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -213,6 +226,7 @@ docker build \
|
|||||||
**Problem**: Container starts but application crashes
|
**Problem**: Container starts but application crashes
|
||||||
|
|
||||||
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On the server
|
# On the server
|
||||||
cat /home/deploy/sites/klz-cables.com/.env
|
cat /home/deploy/sites/klz-cables.com/.env
|
||||||
@@ -226,9 +240,11 @@ docker-compose logs app
|
|||||||
**Problem**: Features not working (email, analytics, etc.)
|
**Problem**: Features not working (email, analytics, etc.)
|
||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
|
|
||||||
1. Check that the secret is configured in Gitea
|
1. Check that the secret is configured in Gitea
|
||||||
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
||||||
3. Redeploy to regenerate the `.env` file:
|
3. Redeploy to regenerate the `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit --allow-empty -m "Trigger redeploy"
|
git commit --allow-empty -m "Trigger redeploy"
|
||||||
git push origin main
|
git push origin main
|
||||||
@@ -246,6 +262,7 @@ docker-compose logs app
|
|||||||
**Problem**: `docker-compose up` fails with "env file not found"
|
**Problem**: `docker-compose up` fails with "env file not found"
|
||||||
|
|
||||||
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
|
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
|
||||||
|
|
||||||
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
||||||
2. Manually trigger a deployment by pushing to main
|
2. Manually trigger a deployment by pushing to main
|
||||||
3. If still missing, check server permissions and disk space
|
3. If still missing, check server permissions and disk space
|
||||||
@@ -255,6 +272,7 @@ docker-compose logs app
|
|||||||
**Problem**: Container can't connect to Traefik
|
**Problem**: Container can't connect to Traefik
|
||||||
|
|
||||||
**Solution**: Verify the `infra` network exists:
|
**Solution**: Verify the `infra` network exists:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker network ls | grep infra
|
docker network ls | grep infra
|
||||||
docker network inspect infra
|
docker network inspect infra
|
||||||
|
|||||||
@@ -7,29 +7,31 @@ This guide helps you migrate from the old fragile environment variable setup to
|
|||||||
### Before (Fragile & Overkill)
|
### Before (Fragile & Overkill)
|
||||||
|
|
||||||
❌ **Problems:**
|
❌ **Problems:**
|
||||||
|
|
||||||
- Environment variables passed individually via SSH (12+ vars)
|
- Environment variables passed individually via SSH (12+ vars)
|
||||||
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
|
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
|
||||||
- Build args included runtime-only variables (SENTRY_DSN, MAIL_*, REDIS_*)
|
- Build args included runtime-only variables (SENTRY*DSN, MAIL*_, REDIS\__)
|
||||||
- No single source of truth
|
- No single source of truth
|
||||||
- Difficult to maintain and error-prone
|
- Difficult to maintain and error-prone
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Old deploy.yml - FRAGILE!
|
# Old deploy.yml - FRAGILE!
|
||||||
ssh root@alpha.mintel.me \
|
ssh root@alpha.mintel.me \
|
||||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||||
... (12+ variables) \
|
... (12+ variables) \
|
||||||
/home/deploy/deploy.sh"
|
/home/deploy/deploy.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
### After (Clean & Robust)
|
### After (Clean & Robust)
|
||||||
|
|
||||||
✅ **Benefits:**
|
✅ **Benefits:**
|
||||||
|
|
||||||
- Single `.env` file on server contains all runtime variables
|
- Single `.env` file on server contains all runtime variables
|
||||||
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
||||||
- Clear separation: build-time vs runtime
|
- Clear separation: build-time vs runtime
|
||||||
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
|||||||
### Step 1: Update Gitea Secrets
|
### Step 1: Update Gitea Secrets
|
||||||
|
|
||||||
**Remove these secrets** (no longer needed in CI/CD):
|
**Remove these secrets** (no longer needed in CI/CD):
|
||||||
|
|
||||||
- ❌ `MAIL_FROM`
|
- ❌ `MAIL_FROM`
|
||||||
- ❌ `MAIL_HOST`
|
- ❌ `MAIL_HOST`
|
||||||
- ❌ `MAIL_PASSWORD`
|
- ❌ `MAIL_PASSWORD`
|
||||||
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
|||||||
- ❌ `SENTRY_DSN` (from build args)
|
- ❌ `SENTRY_DSN` (from build args)
|
||||||
|
|
||||||
**Keep these secrets** (still needed for build):
|
**Keep these secrets** (still needed for build):
|
||||||
|
|
||||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||||
- ✅ `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||||
- ✅ `NEXT_PUBLIC_UMAMI_SCRIPT_URL`
|
- ✅ `UMAMI_WEBSITE_ID`
|
||||||
|
- ✅ `UMAMI_API_ENDPOINT`
|
||||||
- ✅ `REGISTRY_USER`
|
- ✅ `REGISTRY_USER`
|
||||||
- ✅ `REGISTRY_PASS`
|
- ✅ `REGISTRY_PASS`
|
||||||
- ✅ `ALPHA_SSH_KEY`
|
- ✅ `ALPHA_SSH_KEY`
|
||||||
@@ -81,8 +86,8 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
|
UMAMI_WEBSITE_ID=your-actual-id
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking
|
# Error Tracking
|
||||||
SENTRY_DSN=your-actual-dsn
|
SENTRY_DSN=your-actual-dsn
|
||||||
@@ -168,6 +173,7 @@ git push origin main
|
|||||||
```
|
```
|
||||||
|
|
||||||
The CI/CD workflow will:
|
The CI/CD workflow will:
|
||||||
|
|
||||||
1. Build with only `NEXT_PUBLIC_*` build args
|
1. Build with only `NEXT_PUBLIC_*` build args
|
||||||
2. Push to registry
|
2. Push to registry
|
||||||
3. SSH to server and run deploy.sh
|
3. SSH to server and run deploy.sh
|
||||||
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
|
|||||||
|
|
||||||
## Comparison Table
|
## Comparison Table
|
||||||
|
|
||||||
| Aspect | Before | After |
|
| Aspect | Before | After |
|
||||||
|--------|--------|-------|
|
| ----------------- | ------------------------------- | ---------------------------- |
|
||||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
|
||||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||||
| **Clarity** | Confusing duplication | Clear separation |
|
| **Clarity** | Confusing duplication | Clear separation |
|
||||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||||
|
|
||||||
## Rollback Plan
|
## Rollback Plan
|
||||||
|
|
||||||
If you need to rollback to the old system:
|
If you need to rollback to the old system:
|
||||||
|
|
||||||
1. Revert the changes in git:
|
1. Revert the changes in git:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git revert HEAD
|
git revert HEAD
|
||||||
git push origin main
|
git push origin main
|
||||||
@@ -229,7 +236,8 @@ A: `NEXT_PUBLIC_*` variables are special in Next.js - they're embedded into the
|
|||||||
|
|
||||||
**Q: Can I update environment variables without rebuilding?**
|
**Q: Can I update environment variables without rebuilding?**
|
||||||
|
|
||||||
A: Yes, for runtime-only variables (MAIL_*, REDIS_*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
A: Yes, for runtime-only variables (MAIL*\*, REDIS*\*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nano /home/deploy/sites/klz-cables.com/.env
|
nano /home/deploy/sites/klz-cables.com/.env
|
||||||
docker-compose down && docker-compose up -d
|
docker-compose down && docker-compose up -d
|
||||||
@@ -240,6 +248,7 @@ For `NEXT_PUBLIC_*` variables, you need to rebuild the Docker image since they'r
|
|||||||
**Q: Where should I store the .env file backup?**
|
**Q: Where should I store the .env file backup?**
|
||||||
|
|
||||||
A: Keep a secure backup outside the server:
|
A: Keep a secure backup outside the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download from server
|
# Download from server
|
||||||
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
||||||
@@ -250,7 +259,8 @@ scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
|||||||
|
|
||||||
**Q: What if I accidentally commit .env to git?**
|
**Q: What if I accidentally commit .env to git?**
|
||||||
|
|
||||||
A:
|
A:
|
||||||
|
|
||||||
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
||||||
2. Rotate all credentials in the file
|
2. Rotate all credentials in the file
|
||||||
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
||||||
@@ -267,6 +277,7 @@ If you encounter issues during migration:
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
The new system is:
|
The new system is:
|
||||||
|
|
||||||
- ✅ **Simpler**: One .env file instead of scattered variables
|
- ✅ **Simpler**: One .env file instead of scattered variables
|
||||||
- ✅ **Cleaner**: Clear separation of build vs runtime
|
- ✅ **Cleaner**: Clear separation of build vs runtime
|
||||||
- ✅ **Robust**: File-based config instead of fragile SSH commands
|
- ✅ **Robust**: File-based config instead of fragile SSH commands
|
||||||
|
|||||||
@@ -36,6 +36,31 @@ https://logs.infra.mintel.me
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## SMTP
|
||||||
|
|
||||||
|
# SMTP Config
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM= # muss im projekt gesetzt werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Image Optimization (imgproxy)
|
||||||
|
|
||||||
|
Alle Bilder werden zentral über **imgproxy** optimiert, resized und in moderne Formate (WebP, AVIF) konvertiert.
|
||||||
|
|
||||||
|
**Basis-URL**
|
||||||
|
https://img.infra.mintel.me
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://img.infra.mintel.me/unsafe/rs:800x600/plain/https://example.com/bild.jpg
|
||||||
|
https://img.infra.mintel.me/rs:400x/plain/https://picsum.photos/2000/1333
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Production Platform (Alpha)
|
## Production Platform (Alpha)
|
||||||
|
|
||||||
Alpha runs all customer websites and is publicly reachable.
|
Alpha runs all customer websites and is publicly reachable.
|
||||||
|
|||||||
50
eslint.config.mjs
Normal file
50
eslint.config.mjs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import baseConfig from "@mintel/eslint-config";
|
||||||
|
import { nextConfig } from "@mintel/eslint-config/next";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"**/.next/**",
|
||||||
|
".next/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"dist/**",
|
||||||
|
"**/out/**",
|
||||||
|
"out/**",
|
||||||
|
"**/.pnpm-store/**",
|
||||||
|
"**/at-mintel/**",
|
||||||
|
"at-mintel/**",
|
||||||
|
"**/.git/**",
|
||||||
|
"*.js",
|
||||||
|
"*.mjs",
|
||||||
|
"scripts/**",
|
||||||
|
"tests/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"reference/**",
|
||||||
|
"data/**"
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
...baseConfig,
|
||||||
|
...nextConfig.map((config) => ({
|
||||||
|
...config,
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
...config.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",
|
||||||
|
"react-hooks/set-state-in-effect": "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
})),
|
||||||
|
];
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS-FL-2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446010900000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Cu, blank",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Schichtenmantel": "ja",
|
|
||||||
"Kabel querwasserdicht": "ja",
|
|
||||||
"Kabel längswasserdicht": "ja",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Mantelwanddicke": "2.1 mm",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Cu, blank",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"Flammwidrigkeit": "keine",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform": "rund",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Mantelwanddicke": "2.1 mm",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSF2Y/",
|
|
||||||
"verwendung": "",
|
|
||||||
"technischeDaten": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSY/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446010900000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Cu, blank",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "PVC DMV6",
|
|
||||||
"Mantelfarbe": "rot",
|
|
||||||
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform": "rund",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Aluminium",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"Flammwidrigkeit": "keine",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSF2Y/",
|
|
||||||
"verwendung": "",
|
|
||||||
"technischeDaten": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS-FL-2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Aluminium",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Schichtenmantel": "ja",
|
|
||||||
"Kabel querwasserdicht": "ja",
|
|
||||||
"Kabel längswasserdicht": "ja",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"Flammwidrigkeit": "keine",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform (Faber)": "RMv",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSY/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Aluminium",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "PVC DMV6",
|
|
||||||
"Mantelfarbe": "rot",
|
|
||||||
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform (Faber)": "RMv",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import {getRequestConfig} from 'next-intl/server';
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
export default getRequestConfig(async ({requestLocale}) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
// This typically corresponds to the `[locale]` segment
|
|
||||||
let locale = await requestLocale;
|
let locale = await requestLocale;
|
||||||
|
|
||||||
// Ensure that a valid locale is used
|
// Ensure that a valid locale is used
|
||||||
if (!locale || !['en', 'de'].includes(locale)) {
|
if (!locale || !['en', 'de'].includes(locale)) {
|
||||||
locale = 'en';
|
locale = 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
messages: (await import(`../messages/${locale}.json`)).default,
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
|
|||||||
}
|
}
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
},
|
},
|
||||||
getMessageFallback({namespace, key, error}) {
|
getMessageFallback({ namespace, key, error }) {
|
||||||
const path = [namespace, key].filter((part) => part != null).join('.');
|
const path = [namespace, key].filter((part) => part != null).join('.');
|
||||||
if (error.code === 'MISSING_MESSAGE') {
|
if (error.code === 'MISSING_MESSAGE') {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
return 'fallback';
|
return 'fallback';
|
||||||
}
|
}
|
||||||
};
|
} as any;
|
||||||
});
|
});
|
||||||
|
|||||||
40
lib/blog.ts
40
lib/blog.ts
@@ -41,11 +41,11 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||||
if (!fs.existsSync(postsDir)) return [];
|
if (!fs.existsSync(postsDir)) return [];
|
||||||
|
|
||||||
const files = fs.readdirSync(postsDir);
|
const files = fs.readdirSync(postsDir);
|
||||||
const posts = files
|
const posts = files
|
||||||
.filter(file => file.endsWith('.mdx'))
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
.map(file => {
|
.map((file) => {
|
||||||
const filePath = path.join(postsDir, file);
|
const filePath = path.join(postsDir, file);
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
@@ -55,14 +55,42 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
|
.sort(
|
||||||
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
return posts;
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdjacentPosts(slug: string, locale: string): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
|
||||||
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||||
|
if (!fs.existsSync(postsDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(postsDir);
|
||||||
|
return files
|
||||||
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
|
.map((file) => {
|
||||||
|
const filePath = path.join(postsDir, file);
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
return {
|
||||||
|
slug: file.replace(/\.mdx$/, ''),
|
||||||
|
frontmatter: data as PostFrontmatter,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.frontmatter.date as string).getTime() -
|
||||||
|
new Date(a.frontmatter.date as string).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdjacentPosts(
|
||||||
|
slug: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const currentIndex = posts.findIndex(post => post.slug === slug);
|
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
return { prev: null, next: null };
|
return { prev: null, next: null };
|
||||||
|
|||||||
121
lib/config.ts
121
lib/config.ts
@@ -13,31 +13,35 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
|||||||
function createConfig() {
|
function createConfig() {
|
||||||
const env = envSchema.parse(getRawEnv());
|
const env = envSchema.parse(getRawEnv());
|
||||||
|
|
||||||
|
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
env: env.NODE_ENV,
|
env: env.NODE_ENV,
|
||||||
isProduction: env.NODE_ENV === 'production',
|
target,
|
||||||
isDevelopment: env.NODE_ENV === 'development',
|
isProduction: target === 'production' || !target,
|
||||||
isTest: env.NODE_ENV === 'test',
|
isStaging: target === 'staging',
|
||||||
|
isTesting: target === 'testing',
|
||||||
|
isDevelopment: target === 'development',
|
||||||
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||||
|
|
||||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
websiteId: env.UMAMI_WEBSITE_ID,
|
||||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
// The proxied path used in the frontend
|
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||||
proxyPath: '/stats/script.js',
|
|
||||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
glitchtip: {
|
glitchtip: {
|
||||||
// Use SENTRY_DSN for both server and client (proxied)
|
|
||||||
dsn: env.SENTRY_DSN,
|
dsn: env.SENTRY_DSN,
|
||||||
// The proxied origin used in the frontend
|
|
||||||
proxyPath: '/errors',
|
proxyPath: '/errors',
|
||||||
enabled: Boolean(env.SENTRY_DSN),
|
// On the client, we always enable it (it uses the tunnel / proxy defined in sentry.client.config.ts)
|
||||||
|
// On the server, we only enable it if the DSN is provided.
|
||||||
|
enabled: typeof window !== 'undefined' || Boolean(env.SENTRY_DSN),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -57,6 +61,25 @@ function createConfig() {
|
|||||||
from: env.MAIL_FROM,
|
from: env.MAIL_FROM,
|
||||||
recipients: env.MAIL_RECIPIENTS,
|
recipients: env.MAIL_RECIPIENTS,
|
||||||
},
|
},
|
||||||
|
directus: {
|
||||||
|
url: env.DIRECTUS_URL,
|
||||||
|
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
|
||||||
|
password: env.DIRECTUS_ADMIN_PASSWORD,
|
||||||
|
token: env.DIRECTUS_API_TOKEN,
|
||||||
|
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||||
|
proxyPath: '/cms',
|
||||||
|
},
|
||||||
|
infraCMS: {
|
||||||
|
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
|
||||||
|
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
gotify: {
|
||||||
|
url: env.GOTIFY_URL,
|
||||||
|
token: env.GOTIFY_TOKEN,
|
||||||
|
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,16 +99,57 @@ export function getConfig() {
|
|||||||
* Uses getters to ensure it's only initialized when accessed.
|
* Uses getters to ensure it's only initialized when accessed.
|
||||||
*/
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
get env() { return getConfig().env; },
|
get env() {
|
||||||
get isProduction() { return getConfig().isProduction; },
|
return getConfig().env;
|
||||||
get isDevelopment() { return getConfig().isDevelopment; },
|
},
|
||||||
get isTest() { return getConfig().isTest; },
|
get target() {
|
||||||
get baseUrl() { return getConfig().baseUrl; },
|
return getConfig().target;
|
||||||
get analytics() { return getConfig().analytics; },
|
},
|
||||||
get errors() { return getConfig().errors; },
|
get isProduction() {
|
||||||
get cache() { return getConfig().cache; },
|
return getConfig().isProduction;
|
||||||
get logging() { return getConfig().logging; },
|
},
|
||||||
get mail() { return getConfig().mail; },
|
get isStaging() {
|
||||||
|
return getConfig().isStaging;
|
||||||
|
},
|
||||||
|
get isTesting() {
|
||||||
|
return getConfig().isTesting;
|
||||||
|
},
|
||||||
|
get isDevelopment() {
|
||||||
|
return getConfig().isDevelopment;
|
||||||
|
},
|
||||||
|
get baseUrl() {
|
||||||
|
return getConfig().baseUrl;
|
||||||
|
},
|
||||||
|
get analytics() {
|
||||||
|
return getConfig().analytics;
|
||||||
|
},
|
||||||
|
get errors() {
|
||||||
|
return getConfig().errors;
|
||||||
|
},
|
||||||
|
get cache() {
|
||||||
|
return getConfig().cache;
|
||||||
|
},
|
||||||
|
get logging() {
|
||||||
|
return getConfig().logging;
|
||||||
|
},
|
||||||
|
get mail() {
|
||||||
|
return getConfig().mail;
|
||||||
|
},
|
||||||
|
get directus() {
|
||||||
|
return getConfig().directus;
|
||||||
|
},
|
||||||
|
get notifications() {
|
||||||
|
return getConfig().notifications;
|
||||||
|
},
|
||||||
|
get feedbackEnabled() {
|
||||||
|
return getConfig().feedbackEnabled;
|
||||||
|
},
|
||||||
|
get infraCMS() {
|
||||||
|
return getConfig().infraCMS;
|
||||||
|
},
|
||||||
|
get gatekeeperUrl() {
|
||||||
|
return getConfig().gatekeeperUrl;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,7 +165,7 @@ export function getMaskedConfig() {
|
|||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: mask(c.analytics.umami.websiteId),
|
websiteId: mask(c.analytics.umami.websiteId),
|
||||||
scriptUrl: c.analytics.umami.scriptUrl,
|
apiEndpoint: c.analytics.umami.apiEndpoint,
|
||||||
enabled: c.analytics.umami.enabled,
|
enabled: c.analytics.umami.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -124,5 +188,18 @@ export function getMaskedConfig() {
|
|||||||
from: c.mail.from,
|
from: c.mail.from,
|
||||||
recipients: c.mail.recipients,
|
recipients: c.mail.recipients,
|
||||||
},
|
},
|
||||||
|
directus: {
|
||||||
|
url: c.directus.url,
|
||||||
|
adminEmail: mask(c.directus.adminEmail),
|
||||||
|
password: mask(c.directus.password),
|
||||||
|
token: mask(c.directus.token),
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
gotify: {
|
||||||
|
url: c.notifications.gotify.url,
|
||||||
|
token: mask(c.notifications.gotify.token),
|
||||||
|
enabled: c.notifications.gotify.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
221
lib/directus.ts
Normal file
221
lib/directus.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { createDirectus, rest, authentication, staticToken, readItems, readCollections } from '@directus/sdk';
|
||||||
|
import { config } from './config';
|
||||||
|
import { getServerAppServices } from './services/create-services.server';
|
||||||
|
|
||||||
|
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
|
||||||
|
|
||||||
|
// Use internal URL if on server to bypass Gatekeeper/Auth
|
||||||
|
// Use proxy path in browser to stay on the same origin
|
||||||
|
const effectiveUrl =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? internalUrl || url
|
||||||
|
: typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}${proxyPath}`
|
||||||
|
: proxyPath;
|
||||||
|
|
||||||
|
// Initialize client with authentication plugin
|
||||||
|
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to determine if we should show detailed errors
|
||||||
|
*/
|
||||||
|
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genericizes error messages for production/staging
|
||||||
|
*/
|
||||||
|
function formatError(error: any) {
|
||||||
|
if (shouldShowDevErrors) {
|
||||||
|
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
return 'A system error occurred. Our team has been notified.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let authPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export async function ensureAuthenticated() {
|
||||||
|
if (token) {
|
||||||
|
(client as any).setToken(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have a valid session token in memory (for login flow)
|
||||||
|
const existingToken = await (client as any).getToken();
|
||||||
|
if (existingToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminEmail && password) {
|
||||||
|
if (authPromise) {
|
||||||
|
return authPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
authPromise = (async () => {
|
||||||
|
try {
|
||||||
|
client.setToken(null as any);
|
||||||
|
await client.login(adminEmail, password);
|
||||||
|
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||||
|
}
|
||||||
|
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
|
||||||
|
if (shouldShowDevErrors && e.errors) {
|
||||||
|
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
|
||||||
|
}
|
||||||
|
// Clear the promise on failure (especially on invalid credentials)
|
||||||
|
// so we can retry on next request if credentials were updated
|
||||||
|
authPromise = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return authPromise;
|
||||||
|
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
|
||||||
|
console.warn('Directus: No token or admin credentials provided.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the new translation-based schema back to the application's Product interface
|
||||||
|
*/
|
||||||
|
function mapDirectusProduct(item: any, locale: string): any {
|
||||||
|
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||||
|
const translation =
|
||||||
|
item.translations?.find((t: any) => t.languages_code === langCode) ||
|
||||||
|
item.translations?.[0] ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
sku: item.sku,
|
||||||
|
title: translation.name || '',
|
||||||
|
description: translation.description || '',
|
||||||
|
content: translation.content || '',
|
||||||
|
technicalData: {
|
||||||
|
technicalItems: translation.technical_items || [],
|
||||||
|
voltageTables: translation.voltage_tables || [],
|
||||||
|
},
|
||||||
|
locale: locale,
|
||||||
|
// Use proxy URL for assets to avoid CORS and handle internal/external issues
|
||||||
|
data_sheet_url: item.data_sheet ? `${proxyPath}/assets/${item.data_sheet}` : null,
|
||||||
|
categories: (item.categories_link || [])
|
||||||
|
.map((c: any) => c.categories_id?.translations?.[0]?.name)
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProducts(locale: string = 'de') {
|
||||||
|
await ensureAuthenticated();
|
||||||
|
try {
|
||||||
|
const items = await client.request(
|
||||||
|
readItems('products', {
|
||||||
|
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return items.map((item) => mapDirectusProduct(item, locale));
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
|
||||||
|
}
|
||||||
|
console.error('Error fetching products:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductBySlug(slug: string, locale: string = 'de') {
|
||||||
|
await ensureAuthenticated();
|
||||||
|
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||||
|
try {
|
||||||
|
const items = await client.request(
|
||||||
|
readItems('products', {
|
||||||
|
filter: {
|
||||||
|
translations: {
|
||||||
|
slug: { _eq: slug },
|
||||||
|
languages_code: { _eq: langCode },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||||
|
limit: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
return mapDirectusProduct(items[0], locale);
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(error, {
|
||||||
|
part: 'directus_get_product_by_slug',
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error(`Error fetching product ${slug}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkHealth() {
|
||||||
|
try {
|
||||||
|
// 1. Connectivity & Auth Check
|
||||||
|
try {
|
||||||
|
await ensureAuthenticated();
|
||||||
|
await client.request(readCollections());
|
||||||
|
} catch (e: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||||
|
}
|
||||||
|
console.error('Directus authentication failed during health check:', e);
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: shouldShowDevErrors
|
||||||
|
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
|
||||||
|
: 'CMS is currently unavailable due to an internal authentication error.',
|
||||||
|
code: 'AUTH_FAILED',
|
||||||
|
details: shouldShowDevErrors ? e.message : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Schema check (does the contact_submissions table exist?)
|
||||||
|
try {
|
||||||
|
await client.request(readItems('contact_submissions', { limit: 1 }));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
e.message?.includes('does not exist') ||
|
||||||
|
e.code === 'INVALID_PAYLOAD' ||
|
||||||
|
e.status === 404
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: shouldShowDevErrors
|
||||||
|
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
|
||||||
|
: 'Required data structures are currently unavailable.',
|
||||||
|
code: 'SCHEMA_MISSING',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: shouldShowDevErrors
|
||||||
|
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
|
||||||
|
: 'The data schema is currently misconfigured.',
|
||||||
|
code: 'SCHEMA_ERROR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok', message: 'Directus is reachable and responding.' };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
|
||||||
|
}
|
||||||
|
console.error('Directus health check failed with unexpected error:', error);
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: formatError(error),
|
||||||
|
code: error.code || 'UNKNOWN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default client;
|
||||||
70
lib/env.test.ts
Normal file
70
lib/env.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { envSchema } from './env';
|
||||||
|
|
||||||
|
describe('envSchema', () => {
|
||||||
|
it('should allow missing MAIL_HOST in development', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000',
|
||||||
|
TARGET: 'development',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require MAIL_HOST in production', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||||
|
TARGET: 'production',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
'MAIL_HOST is required in non-development environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require MAIL_HOST in testing', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://testing.example.com',
|
||||||
|
TARGET: 'testing',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
'MAIL_HOST is required in non-development environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require MAIL_HOST in staging', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://staging.example.com',
|
||||||
|
TARGET: 'staging',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
'MAIL_HOST is required in non-development environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass if MAIL_HOST is provided in production', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||||
|
TARGET: 'production',
|
||||||
|
MAIL_HOST: 'smtp.example.com',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip MAIL_HOST requirement if SKIP_RUNTIME_ENV_VALIDATION is true', () => {
|
||||||
|
process.env.SKIP_RUNTIME_ENV_VALIDATION = 'true';
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||||
|
TARGET: 'production',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
delete process.env.SKIP_RUNTIME_ENV_VALIDATION;
|
||||||
|
});
|
||||||
|
});
|
||||||
120
lib/env.ts
120
lib/env.ts
@@ -8,31 +8,83 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
|||||||
/**
|
/**
|
||||||
* Environment variable schema.
|
* Environment variable schema.
|
||||||
*/
|
*/
|
||||||
export const envSchema = z.object({
|
export const envSchema = z
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
.object({
|
||||||
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
||||||
// Analytics
|
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(preprocessEmptyString, z.string().url().default('https://analytics.infra.mintel.me/script.js')),
|
// Analytics
|
||||||
|
UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
// Error Tracking
|
UMAMI_API_ENDPOINT: z.preprocess(
|
||||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
preprocessEmptyString,
|
||||||
|
z.string().url().default('https://analytics.infra.mintel.me'),
|
||||||
// Logging
|
),
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
||||||
|
// Error Tracking
|
||||||
// Mail
|
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
// Logging
|
||||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
// Mail
|
||||||
MAIL_RECIPIENTS: z.preprocess(
|
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||||
z.array(z.string()).default([])
|
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
),
|
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
});
|
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
|
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||||
|
z.array(z.string()).default([]),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Directus
|
||||||
|
DIRECTUS_URL: z.preprocess(
|
||||||
|
preprocessEmptyString,
|
||||||
|
z.string().url().default('http://localhost:8055'),
|
||||||
|
),
|
||||||
|
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
|
|
||||||
|
// Deploy Target
|
||||||
|
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
|
// Gotify
|
||||||
|
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
|
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
// Gatekeeper
|
||||||
|
GATEKEEPER_URL: z.preprocess(
|
||||||
|
preprocessEmptyString,
|
||||||
|
z.string().url().default('http://gatekeeper:3000'),
|
||||||
|
),
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
|
||||||
|
(val) => val === 'true' || val === true,
|
||||||
|
z.boolean().default(false)
|
||||||
|
),
|
||||||
|
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
|
||||||
|
(val) => val === 'true' || val === true,
|
||||||
|
z.boolean().default(false)
|
||||||
|
),
|
||||||
|
INFRA_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
|
INFRA_DIRECTUS_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||||
|
const isDev = target === 'development' || !target;
|
||||||
|
const isBuildTimeValidation = process.env.SKIP_RUNTIME_ENV_VALIDATION === 'true';
|
||||||
|
const isServer = typeof window === 'undefined';
|
||||||
|
|
||||||
|
// Only enforce server-only variables when running on the server.
|
||||||
|
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
||||||
|
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'MAIL_HOST is required in non-development environments',
|
||||||
|
path: ['MAIL_HOST'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
@@ -44,8 +96,9 @@ export function getRawEnv() {
|
|||||||
return {
|
return {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
|
||||||
|
UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||||
MAIL_HOST: process.env.MAIL_HOST,
|
MAIL_HOST: process.env.MAIL_HOST,
|
||||||
@@ -54,5 +107,18 @@ export function getRawEnv() {
|
|||||||
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
||||||
MAIL_FROM: process.env.MAIL_FROM,
|
MAIL_FROM: process.env.MAIL_FROM,
|
||||||
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
||||||
|
DIRECTUS_URL: process.env.DIRECTUS_URL,
|
||||||
|
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
|
||||||
|
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
|
||||||
|
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
|
||||||
|
TARGET: process.env.TARGET,
|
||||||
|
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||||
|
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
||||||
|
GATEKEEPER_URL: process.env.GATEKEEPER_URL,
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED: process.env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
GATEKEEPER_BYPASS_ENABLED: process.env.GATEKEEPER_BYPASS_ENABLED,
|
||||||
|
INFRA_DIRECTUS_URL: process.env.INFRA_DIRECTUS_URL,
|
||||||
|
INFRA_DIRECTUS_TOKEN: process.env.INFRA_DIRECTUS_TOKEN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
59
lib/mail/mailer.test.ts
Normal file
59
lib/mail/mailer.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { sendEmail } from './mailer';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
// Mock getServerAppServices to avoid full app initialization
|
||||||
|
vi.mock('@/lib/services/create-services.server', () => ({
|
||||||
|
getServerAppServices: () => ({
|
||||||
|
logger: {
|
||||||
|
child: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
vi.mock('../config', () => ({
|
||||||
|
config: {
|
||||||
|
mail: {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
user: 'user',
|
||||||
|
pass: 'pass',
|
||||||
|
from: 'from@example.com',
|
||||||
|
recipients: ['to@example.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('mailer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendEmail', () => {
|
||||||
|
it('should throw error if MAIL_HOST is missing', async () => {
|
||||||
|
// Temporarily nullify host
|
||||||
|
const originalHost = config.mail.host;
|
||||||
|
(config.mail as any).host = '';
|
||||||
|
|
||||||
|
const result = await sendEmail({
|
||||||
|
subject: 'Test',
|
||||||
|
html: '<p>Test</p>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('MAIL_HOST is not configured');
|
||||||
|
|
||||||
|
// Restore host
|
||||||
|
(config.mail as any).host = originalHost;
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real environment, we'd mock nodemailer, but for now we focus on the validation logic
|
||||||
|
// we added. Full SMTP integration tests are usually out of scope for unit tests.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,33 +1,44 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from 'nodemailer';
|
||||||
import { render } from "@react-email/components";
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
import { ReactElement } from "react";
|
import { config } from '../config';
|
||||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
import { ReactElement } from 'react';
|
||||||
import { config } from "../config";
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
let transporterInstance: nodemailer.Transporter | null = null;
|
||||||
host: config.mail.host,
|
|
||||||
port: config.mail.port,
|
function getTransporter() {
|
||||||
secure: config.mail.port === 465,
|
if (transporterInstance) return transporterInstance;
|
||||||
auth: {
|
|
||||||
user: config.mail.user,
|
if (!config.mail.host) {
|
||||||
pass: config.mail.pass,
|
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
transporterInstance = nodemailer.createTransport({
|
||||||
|
host: config.mail.host,
|
||||||
|
port: config.mail.port,
|
||||||
|
secure: config.mail.port === 465,
|
||||||
|
auth: {
|
||||||
|
user: config.mail.user,
|
||||||
|
pass: config.mail.pass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return transporterInstance;
|
||||||
|
}
|
||||||
|
|
||||||
interface SendEmailOptions {
|
interface SendEmailOptions {
|
||||||
to?: string | string[];
|
to?: string | string[];
|
||||||
|
replyTo?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
template: ReactElement;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
|
||||||
const html = await render(template);
|
|
||||||
|
|
||||||
const recipients = to || config.mail.recipients;
|
const recipients = to || config.mail.recipients;
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: config.mail.from,
|
from: config.mail.from,
|
||||||
to: recipients,
|
to: recipients,
|
||||||
|
replyTo,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
};
|
};
|
||||||
@@ -35,11 +46,12 @@ export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
|||||||
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await getTransporter().sendMail(mailOptions);
|
||||||
logger.info("Email sent successfully", { messageId: info.messageId, subject, recipients });
|
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
|
||||||
return { success: true, messageId: info.messageId };
|
return { success: true, messageId: info.messageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error sending email", { error, subject, recipients });
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
return { success: false, error };
|
logger.error('Error sending email', { error: errorMsg, subject, recipients });
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
lib/mdx.ts
82
lib/mdx.ts
@@ -18,11 +18,61 @@ export interface ProductMdx {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProductMetadata(
|
||||||
|
slug: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<Partial<ProductMdx> | null> {
|
||||||
|
// Map translated slug to file slug
|
||||||
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||||
|
|
||||||
|
// Try exact slug first
|
||||||
|
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
// Try with -2 suffix (common in the dumped files)
|
||||||
|
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
// Fallback to English if locale is not 'en'
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||||
|
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
|
||||||
|
if (!fs.existsSync(enFilePath)) {
|
||||||
|
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(enFilePath)) {
|
||||||
|
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
return {
|
||||||
|
slug: fileSlug,
|
||||||
|
frontmatter: {
|
||||||
|
...data,
|
||||||
|
isFallback: true,
|
||||||
|
} as ProductFrontmatter & { isFallback?: boolean },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: fileSlug,
|
||||||
|
frontmatter: data as ProductFrontmatter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
||||||
// Map translated slug to file slug
|
// Map translated slug to file slug
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||||
|
|
||||||
// Try exact slug first
|
// Try exact slug first
|
||||||
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||||
|
|
||||||
@@ -41,7 +91,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
if (!fs.existsSync(enFilePath)) {
|
if (!fs.existsSync(enFilePath)) {
|
||||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(enFilePath)) {
|
if (fs.existsSync(enFilePath)) {
|
||||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
@@ -49,7 +99,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
slug: fileSlug,
|
slug: fileSlug,
|
||||||
frontmatter: {
|
frontmatter: {
|
||||||
...data,
|
...data,
|
||||||
isFallback: true
|
isFallback: true,
|
||||||
} as ProductFrontmatter & { isFallback?: boolean },
|
} as ProductFrontmatter & { isFallback?: boolean },
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
@@ -67,7 +117,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out products without images
|
// Filter out products without images
|
||||||
if (product && (!product.frontmatter.images || product.frontmatter.images.length === 0 || !product.frontmatter.images[0])) {
|
if (
|
||||||
|
product &&
|
||||||
|
(!product.frontmatter.images ||
|
||||||
|
product.frontmatter.images.length === 0 ||
|
||||||
|
!product.frontmatter.images[0])
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +132,9 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||||
if (!fs.existsSync(productsDir)) return [];
|
if (!fs.existsSync(productsDir)) return [];
|
||||||
|
|
||||||
const files = fs.readdirSync(productsDir);
|
const files = fs.readdirSync(productsDir);
|
||||||
return files.filter(file => file.endsWith('.mdx')).map(file => file.replace(/\.mdx$/, ''));
|
return files.filter((file) => file.endsWith('.mdx')).map((file) => file.replace(/\.mdx$/, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||||
@@ -91,6 +146,19 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
|||||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await Promise.all(allSlugs.map(slug => getProductBySlug(slug, locale)));
|
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale)));
|
||||||
return products.filter((p): p is ProductMdx => p !== null);
|
return products.filter((p): p is ProductMdx => p !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
|
||||||
|
const slugs = await getAllProductSlugs(locale);
|
||||||
|
let allSlugs = slugs;
|
||||||
|
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const enSlugs = await getAllProductSlugs('en');
|
||||||
|
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
|
||||||
|
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
|
||||||
|
}
|
||||||
|
|||||||
42
lib/og-helper.tsx
Normal file
42
lib/og-helper.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
|
||||||
|
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
||||||
|
*/
|
||||||
|
export async function getOgFonts() {
|
||||||
|
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.ttf');
|
||||||
|
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.ttf');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const boldFont = readFileSync(boldFontPath);
|
||||||
|
const regularFont = readFileSync(regularFontPath);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: boldFont,
|
||||||
|
weight: 700 as const,
|
||||||
|
style: 'normal' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: regularFont,
|
||||||
|
weight: 400 as const,
|
||||||
|
style: 'normal' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load OG fonts from filesystem, falling back to system fonts:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common configuration for OG images
|
||||||
|
*/
|
||||||
|
export const OG_IMAGE_SIZE = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
31
lib/pages.ts
31
lib/pages.ts
@@ -39,23 +39,42 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
|
|||||||
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
||||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||||
if (!fs.existsSync(pagesDir)) return [];
|
if (!fs.existsSync(pagesDir)) return [];
|
||||||
|
|
||||||
const files = fs.readdirSync(pagesDir);
|
const files = fs.readdirSync(pagesDir);
|
||||||
const pages = await Promise.all(
|
const pages = await Promise.all(
|
||||||
files
|
files
|
||||||
.filter(file => file.endsWith('.mdx'))
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
.map(file => {
|
.map((file) => {
|
||||||
const fileSlug = file.replace(/\.mdx$/, '');
|
const fileSlug = file.replace(/\.mdx$/, '');
|
||||||
const filePath = path.join(pagesDir, file);
|
const filePath = path.join(pagesDir, file);
|
||||||
const fileContent = { content: fs.readFileSync(filePath, 'utf8') };
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
const { data, content } = matter(fileContent.content);
|
const { data, content } = matter(fileContent);
|
||||||
return {
|
return {
|
||||||
slug: fileSlug,
|
slug: fileSlug,
|
||||||
frontmatter: data as PageFrontmatter,
|
frontmatter: data as PageFrontmatter,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return pages.filter((p): p is PageMdx => p !== null);
|
return pages.filter((p): p is PageMdx => p !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
|
||||||
|
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||||
|
if (!fs.existsSync(pagesDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(pagesDir);
|
||||||
|
return files
|
||||||
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
|
.map((file) => {
|
||||||
|
const fileSlug = file.replace(/\.mdx$/, '');
|
||||||
|
const filePath = path.join(pagesDir, file);
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
return {
|
||||||
|
slug: fileSlug,
|
||||||
|
frontmatter: data as PageFrontmatter,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -305,7 +305,6 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||||
product,
|
product,
|
||||||
locale,
|
locale,
|
||||||
logoUrl = '/media/logo.svg',
|
|
||||||
}) => {
|
}) => {
|
||||||
const labels = getLabels(locale);
|
const labels = getLabels(locale);
|
||||||
|
|
||||||
@@ -338,8 +337,12 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.productImageCol}>
|
<View style={styles.productImageCol}>
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
<Image
|
||||||
|
src={product.featuredImage}
|
||||||
|
style={styles.heroImage}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -370,7 +373,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
styles.specsTableRow,
|
styles.specsTableRow,
|
||||||
index === product.attributes.length - 1 &&
|
index === product.attributes.length - 1 &&
|
||||||
styles.specsTableRowLast,
|
styles.specsTableRowLast,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.specsTableLabelCell}>
|
<View style={styles.specsTableLabelCell}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { config } from './config';
|
||||||
|
|
||||||
export const SITE_URL = 'https://klz-cables.com';
|
export const SITE_URL = config.baseUrl || 'https://klz-cables.com';
|
||||||
export const LOGO_URL = `${SITE_URL}/logo.png`;
|
export const LOGO_URL = `${SITE_URL}/logo.png`;
|
||||||
|
|
||||||
export const getOrganizationSchema = () => ({
|
export const getOrganizationSchema = () => ({
|
||||||
@@ -8,16 +9,14 @@ export const getOrganizationSchema = () => ({
|
|||||||
name: 'KLZ Cables',
|
name: 'KLZ Cables',
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
logo: LOGO_URL,
|
logo: LOGO_URL,
|
||||||
sameAs: [
|
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||||
'https://www.linkedin.com/company/klz-cables',
|
|
||||||
],
|
|
||||||
contactPoint: {
|
contactPoint: {
|
||||||
'@type': 'ContactPoint' as const,
|
'@type': 'ContactPoint' as const,
|
||||||
telephone: '+49-881-92537298',
|
telephone: '+49-881-92537298',
|
||||||
contactType: 'customer service' as const,
|
contactType: 'customer service' as const,
|
||||||
email: 'info@klz-cables.com',
|
email: 'info@klz-cables.com',
|
||||||
availableLanguage: ['German', 'English']
|
availableLanguage: ['German', 'English'],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getBreadcrumbSchema = (items: { name: string; item: string }[]) => ({
|
export const getBreadcrumbSchema = (items: { name: string; item: string }[]) => ({
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
||||||
|
import { config } from '../../config';
|
||||||
/**
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
* Type definition for the Umami global object.
|
|
||||||
*
|
|
||||||
* This represents the `window.umami` object that the Umami script exposes.
|
|
||||||
* The `track` function can accept either an event name or a URL.
|
|
||||||
*/
|
|
||||||
type UmamiGlobal = {
|
|
||||||
track?: (eventOrUrl: string, props?: AnalyticsEventProperties) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for UmamiAnalyticsService.
|
* Configuration options for UmamiAnalyticsService.
|
||||||
@@ -20,133 +12,162 @@ export type UmamiAnalyticsServiceOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Umami Analytics Service Implementation.
|
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||||
*
|
*
|
||||||
* This service implements the AnalyticsService interface for Umami analytics.
|
* This version implements the Umami tracking protocol directly via fetch,
|
||||||
* It provides type-safe event tracking and pageview tracking.
|
* eliminating the need to load an external script.js file.
|
||||||
*
|
*
|
||||||
* @example
|
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||||
* ```typescript
|
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||||
* // Service creation (usually done by create-services.ts)
|
* On the server, it sends directly to the internal Umami API.
|
||||||
* const service = new UmamiAnalyticsService({ enabled: true });
|
|
||||||
*
|
|
||||||
* // Track events
|
|
||||||
* service.track('button_click', { button_id: 'cta' });
|
|
||||||
* service.trackPageview('/products/123');
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Using through the service layer (recommended)
|
|
||||||
* import { getAppServices } from '@/lib/services/create-services';
|
|
||||||
*
|
|
||||||
* const services = getAppServices();
|
|
||||||
* services.analytics.track('product_add_to_cart', {
|
|
||||||
* product_id: '123',
|
|
||||||
* price: 99.99,
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export class UmamiAnalyticsService implements AnalyticsService {
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
|
private websiteId?: string;
|
||||||
|
private endpoint: string;
|
||||||
|
private logger: LoggerService;
|
||||||
|
private serverContext?: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly options: UmamiAnalyticsServiceOptions,
|
||||||
|
logger: LoggerService,
|
||||||
|
) {
|
||||||
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
|
this.logger = logger.child({ component: 'analytics-umami' });
|
||||||
|
|
||||||
|
// On server, use the full internal URL; on client, use the proxied path
|
||||||
|
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
|
||||||
|
|
||||||
|
this.logger.debug('Umami service initialized', {
|
||||||
|
enabled: this.options.enabled,
|
||||||
|
websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)',
|
||||||
|
endpoint: this.endpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track a custom event with optional properties.
|
* Set the server-side context for the current request.
|
||||||
*
|
* This allows the service to use real request headers for tracking.
|
||||||
* This method checks if analytics are enabled and if we're in a browser environment
|
|
||||||
* before attempting to track the event.
|
|
||||||
*
|
|
||||||
* @param eventName - The name of the event to track
|
|
||||||
* @param props - Optional event properties
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* service.track('product_add_to_cart', {
|
|
||||||
* product_id: '123',
|
|
||||||
* product_name: 'Cable',
|
|
||||||
* price: 99.99,
|
|
||||||
* quantity: 1,
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
setServerContext(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
this.serverContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to send the payload to Umami API.
|
||||||
|
*/
|
||||||
|
private async sendPayload(type: 'event', data: Record<string, any>) {
|
||||||
if (!this.options.enabled) return;
|
if (!this.options.enabled) return;
|
||||||
|
|
||||||
// Server-side tracking via proxy
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
const { getServerAppServices } = require('../create-services.server');
|
|
||||||
const { config } = require('../../config');
|
|
||||||
const websiteId = config.analytics.umami.websiteId;
|
|
||||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
|
||||||
|
|
||||||
if (!websiteId) return;
|
|
||||||
|
|
||||||
const logger = getServerAppServices().logger.child({ component: 'analytics' });
|
// On the client, we don't need the websiteId (it's injected by the server-side proxy handler).
|
||||||
logger.info('Sending analytics event', { eventName, props });
|
// On the server, we need it because we're calling the Umami API directly.
|
||||||
|
const isClient = typeof window !== 'undefined';
|
||||||
|
|
||||||
fetch(`${umamiUrl}/api/send`, {
|
if (!isClient && !this.websiteId) {
|
||||||
method: 'POST',
|
this.logger.warn('Umami tracking called on server but no Website ID configured');
|
||||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
|
|
||||||
body: JSON.stringify({ type: 'event', payload: { website: websiteId, name: eventName, data: props } }),
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error('Failed to send analytics event', { eventName, props, error });
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
try {
|
||||||
umami?.track?.(eventName, props);
|
const payload = {
|
||||||
|
website: this.websiteId,
|
||||||
|
hostname: isClient ? window.location.hostname : 'server',
|
||||||
|
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
|
||||||
|
language: isClient ? navigator.language : this.serverContext?.language,
|
||||||
|
referrer: isClient ? document.referrer : this.serverContext?.referrer,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.trace('Sending analytics payload', { type, url: data.url });
|
||||||
|
|
||||||
|
// Add a timeout to prevent hanging requests
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set User-Agent
|
||||||
|
if (isClient) {
|
||||||
|
headers['User-Agent'] = navigator.userAgent;
|
||||||
|
} else if (this.serverContext?.userAgent) {
|
||||||
|
headers['User-Agent'] = this.serverContext.userAgent;
|
||||||
|
} else {
|
||||||
|
headers['User-Agent'] = 'KLZ-Server-Proxy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward client IP if available (Umami must be configured to trust this)
|
||||||
|
if (this.serverContext?.ip) {
|
||||||
|
headers['X-Forwarded-For'] = this.serverContext.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
keepalive: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger.warn('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if ((fetchError as Error).name === 'AbortError') {
|
||||||
|
this.logger.error('Umami request timed out');
|
||||||
|
} else {
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to send analytics', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a custom event.
|
||||||
|
*/
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||||
|
this.sendPayload('event', {
|
||||||
|
name: eventName,
|
||||||
|
data: props,
|
||||||
|
url:
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track a pageview.
|
* Track a pageview.
|
||||||
*
|
|
||||||
* This method checks if analytics are enabled and if we're in a browser environment
|
|
||||||
* before attempting to track the pageview.
|
|
||||||
*
|
|
||||||
* Umami treats `track(url)` as a pageview override, so we can use the same
|
|
||||||
* `track` function for both events and pageviews.
|
|
||||||
*
|
|
||||||
* @param url - The URL to track (defaults to current location)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Track current page
|
|
||||||
* service.trackPageview();
|
|
||||||
*
|
|
||||||
* // Track custom URL
|
|
||||||
* service.trackPageview('/products/123?category=cables');
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
trackPageview(url?: string) {
|
trackPageview(url?: string) {
|
||||||
if (!this.options.enabled) return;
|
this.sendPayload('event', {
|
||||||
|
url:
|
||||||
// Server-side tracking via proxy
|
url ||
|
||||||
if (typeof window === 'undefined') {
|
(typeof window !== 'undefined'
|
||||||
const { getServerAppServices } = require('../create-services.server');
|
? window.location.pathname + window.location.search
|
||||||
const { config } = require('../../config');
|
: undefined),
|
||||||
const websiteId = config.analytics.umami.websiteId;
|
});
|
||||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
|
||||||
|
|
||||||
if (!websiteId || !url) return;
|
|
||||||
|
|
||||||
const logger = getServerAppServices().logger.child({ component: 'analytics' });
|
|
||||||
logger.info('Sending analytics pageview', { url });
|
|
||||||
|
|
||||||
fetch(`${umamiUrl}/api/send`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
|
|
||||||
body: JSON.stringify({ type: 'event', payload: { website: websiteId, url } }),
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error('Failed to send analytics pageview', { url, error });
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
|
||||||
|
|
||||||
// Umami treats `track(url)` as a pageview override.
|
|
||||||
if (url) umami?.track?.(url);
|
|
||||||
else umami?.track?.(window.location.pathname + window.location.search);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service';
|
|||||||
import type { CacheService } from './cache/cache-service';
|
import type { CacheService } from './cache/cache-service';
|
||||||
import type { ErrorReportingService } from './errors/error-reporting-service';
|
import type { ErrorReportingService } from './errors/error-reporting-service';
|
||||||
import type { LoggerService } from './logging/logger-service';
|
import type { LoggerService } from './logging/logger-service';
|
||||||
|
import type { NotificationService } from './notifications/notification-service';
|
||||||
|
|
||||||
// Simple constructor-based DI container.
|
// Simple constructor-based DI container.
|
||||||
export class AppServices {
|
export class AppServices {
|
||||||
@@ -9,6 +10,7 @@ export class AppServices {
|
|||||||
public readonly analytics: AnalyticsService,
|
public readonly analytics: AnalyticsService,
|
||||||
public readonly errors: ErrorReportingService,
|
public readonly errors: ErrorReportingService,
|
||||||
public readonly cache: CacheService,
|
public readonly cache: CacheService,
|
||||||
public readonly logger: LoggerService
|
public readonly logger: LoggerService,
|
||||||
|
public readonly notifications: NotificationService,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
|||||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||||
|
import {
|
||||||
|
GotifyNotificationService,
|
||||||
|
NoopNotificationService,
|
||||||
|
} from './notifications/gotify-notification-service';
|
||||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||||
import { config, getMaskedConfig } from '../config';
|
import { config, getMaskedConfig } from '../config';
|
||||||
|
|
||||||
@@ -13,7 +17,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create logger first to log initialization
|
// Create logger first to log initialization
|
||||||
const logger = new PinoLoggerService('server');
|
const logger = new PinoLoggerService('server');
|
||||||
|
|
||||||
logger.info('Initializing server application services', {
|
logger.info('Initializing server application services', {
|
||||||
environment: getMaskedConfig(),
|
environment: getMaskedConfig(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -23,10 +27,11 @@ export function getServerAppServices(): AppServices {
|
|||||||
umamiEnabled: config.analytics.umami.enabled,
|
umamiEnabled: config.analytics.umami.enabled,
|
||||||
sentryEnabled: config.errors.glitchtip.enabled,
|
sentryEnabled: config.errors.glitchtip.enabled,
|
||||||
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||||
|
gotifyEnabled: config.notifications.gotify.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const analytics = config.analytics.umami.enabled
|
const analytics = config.analytics.umami.enabled
|
||||||
? new UmamiAnalyticsService({ enabled: true })
|
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||||
: new NoopAnalyticsService();
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
if (config.analytics.umami.enabled) {
|
if (config.analytics.umami.enabled) {
|
||||||
@@ -35,12 +40,28 @@ export function getServerAppServices(): AppServices {
|
|||||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notifications = config.notifications.gotify.enabled
|
||||||
|
? new GotifyNotificationService({
|
||||||
|
url: config.notifications.gotify.url!,
|
||||||
|
token: config.notifications.gotify.token!,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
: new NoopNotificationService();
|
||||||
|
|
||||||
|
if (config.notifications.gotify.enabled) {
|
||||||
|
logger.info('Gotify notification service initialized');
|
||||||
|
} else {
|
||||||
|
logger.info('Noop notification service initialized (notifications disabled)');
|
||||||
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true })
|
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
logger.info('GlitchTip error reporting service initialized');
|
logger.info('GlitchTip error reporting service initialized', {
|
||||||
|
dsnPresent: Boolean(config.errors.glitchtip.dsn),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||||
}
|
}
|
||||||
@@ -53,10 +74,9 @@ export function getServerAppServices(): AppServices {
|
|||||||
level: config.logging.level,
|
level: config.logging.level,
|
||||||
});
|
});
|
||||||
|
|
||||||
singleton = new AppServices(analytics, errors, cache, logger);
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
logger.info('All application services initialized successfully');
|
logger.info('All application services initialized successfully');
|
||||||
|
|
||||||
return singleton;
|
return singleton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { AppServices } from './app-services';
|
import { AppServices } from './app-services';
|
||||||
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
||||||
|
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
||||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||||
import { NoopLoggerService } from './logging/noop-logger-service';
|
import { NoopLoggerService } from './logging/noop-logger-service';
|
||||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||||
|
import { NoopNotificationService } from './notifications/gotify-notification-service';
|
||||||
import { config, getMaskedConfig } from '../config';
|
import { config, getMaskedConfig } from '../config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,9 +29,8 @@ let singleton: AppServices | undefined;
|
|||||||
* - Cache service (in-memory)
|
* - Cache service (in-memory)
|
||||||
*
|
*
|
||||||
* The services are configured based on environment variables:
|
* The services are configured based on environment variables:
|
||||||
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
|
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||||
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
|
||||||
*
|
*
|
||||||
* @returns {AppServices} The application services singleton
|
* @returns {AppServices} The application services singleton
|
||||||
*
|
*
|
||||||
@@ -71,9 +72,7 @@ export function getAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create logger first to log initialization
|
// Create logger first to log initialization
|
||||||
const logger =
|
const logger =
|
||||||
typeof window === 'undefined'
|
typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService();
|
||||||
? new PinoLoggerService('server')
|
|
||||||
: new NoopLoggerService();
|
|
||||||
|
|
||||||
// Log initialization
|
// Log initialization
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -101,12 +100,8 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create analytics service (Umami or no-op)
|
// Create analytics service (Umami or no-op)
|
||||||
// Use dynamic import to avoid importing server-only code in client components
|
|
||||||
const analytics = umamiEnabled
|
const analytics = umamiEnabled
|
||||||
? (() => {
|
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||||
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
|
|
||||||
return new UmamiAnalyticsService({ enabled: true });
|
|
||||||
})()
|
|
||||||
: new NoopAnalyticsService();
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
if (umamiEnabled) {
|
if (umamiEnabled) {
|
||||||
@@ -115,13 +110,19 @@ export function getAppServices(): AppServices {
|
|||||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create notification service
|
||||||
|
const notifications = new NoopNotificationService();
|
||||||
|
logger.info('Notification service initialized (noop)');
|
||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true })
|
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
logger.info(`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`);
|
logger.info(
|
||||||
|
`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||||
}
|
}
|
||||||
@@ -138,9 +139,9 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create and cache the singleton
|
// Create and cache the singleton
|
||||||
singleton = new AppServices(analytics, errors, cache, logger);
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
logger.info('All application services initialized successfully');
|
logger.info('All application services initialized successfully');
|
||||||
|
|
||||||
return singleton;
|
return singleton;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ export type ErrorReportingUser = {
|
|||||||
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
|
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
|
||||||
|
|
||||||
export interface ErrorReportingService {
|
export interface ErrorReportingService {
|
||||||
captureException(error: unknown, context?: Record<string, unknown>): string | undefined;
|
captureException(
|
||||||
captureMessage(message: string, level?: ErrorReportingLevel): string | undefined;
|
error: unknown,
|
||||||
|
context?: Record<string, unknown>,
|
||||||
|
): Promise<string | undefined> | string | undefined;
|
||||||
|
captureMessage(
|
||||||
|
message: string,
|
||||||
|
level?: ErrorReportingLevel,
|
||||||
|
): Promise<string | undefined> | string | undefined;
|
||||||
setUser(user: ErrorReportingUser | null): void;
|
setUser(user: ErrorReportingUser | null): void;
|
||||||
setTag(key: string, value: string): void;
|
setTag(key: string, value: string): void;
|
||||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
ErrorReportingService,
|
ErrorReportingService,
|
||||||
ErrorReportingUser,
|
ErrorReportingUser,
|
||||||
} from './error-reporting-service';
|
} from './error-reporting-service';
|
||||||
|
import type { NotificationService } from '../notifications/notification-service';
|
||||||
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
|
|
||||||
type SentryLike = typeof Sentry;
|
type SentryLike = typeof Sentry;
|
||||||
|
|
||||||
@@ -13,14 +15,36 @@ export type GlitchtipErrorReportingServiceOptions = {
|
|||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||||
|
private logger: LoggerService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||||
private readonly sentry: SentryLike = Sentry
|
logger: LoggerService,
|
||||||
) {}
|
private readonly notifications?: NotificationService,
|
||||||
|
private readonly sentry: SentryLike = Sentry,
|
||||||
|
) {
|
||||||
|
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
||||||
|
}
|
||||||
|
|
||||||
captureException(error: unknown, context?: Record<string, unknown>) {
|
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||||
if (!this.options.enabled) return undefined;
|
if (!this.options.enabled) return undefined;
|
||||||
return this.sentry.captureException(error, context as any) as any;
|
const result = this.sentry.captureException(error, context as any) as any;
|
||||||
|
|
||||||
|
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
||||||
|
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
|
||||||
|
// We'll treat all captureException calls as potentially critical or at least noteworthy
|
||||||
|
if (this.notifications) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
||||||
|
|
||||||
|
await this.notifications.notify({
|
||||||
|
title: '🔥 Critical Error Captured',
|
||||||
|
message: `Error: ${errorMessage}${contextStr}`,
|
||||||
|
priority: 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser } from './error-reporting-service';
|
import type {
|
||||||
|
ErrorReportingLevel,
|
||||||
|
ErrorReportingService,
|
||||||
|
ErrorReportingUser,
|
||||||
|
} from './error-reporting-service';
|
||||||
|
|
||||||
export class NoopErrorReportingService implements ErrorReportingService {
|
export class NoopErrorReportingService implements ErrorReportingService {
|
||||||
captureException(_error: unknown, _context?: Record<string, unknown>) {
|
async captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,19 @@ export class PinoLoggerService implements LoggerService {
|
|||||||
// In Next.js, especially in the Edge runtime or during instrumentation,
|
// In Next.js, especially in the Edge runtime or during instrumentation,
|
||||||
// pino transports (which use worker threads) can cause issues.
|
// pino transports (which use worker threads) can cause issues.
|
||||||
// We disable transport in production and during instrumentation.
|
// We disable transport in production and during instrumentation.
|
||||||
const useTransport = !config.isProduction && typeof window === 'undefined';
|
const useTransport = config.isDevelopment && typeof window === 'undefined';
|
||||||
|
|
||||||
this.logger = pino({
|
this.logger = pino({
|
||||||
name: name || 'app',
|
name: name || 'app',
|
||||||
level: config.logging.level,
|
level: config.logging.level,
|
||||||
transport:
|
transport: useTransport
|
||||||
useTransport
|
? {
|
||||||
? {
|
target: 'pino-pretty',
|
||||||
target: 'pino-pretty',
|
options: {
|
||||||
options: {
|
colorize: true,
|
||||||
colorize: true,
|
},
|
||||||
},
|
}
|
||||||
}
|
: undefined,
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/services/notifications/gotify-notification-service.ts
Normal file
49
lib/services/notifications/gotify-notification-service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NotificationOptions, NotificationService } from './notification-service';
|
||||||
|
|
||||||
|
export interface GotifyConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GotifyNotificationService implements NotificationService {
|
||||||
|
constructor(private config: GotifyConfig) {}
|
||||||
|
|
||||||
|
async notify(options: NotificationOptions): Promise<void> {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, message, priority = 4 } = options;
|
||||||
|
const url = new URL('message', this.config.url);
|
||||||
|
url.searchParams.set('token', this.config.token);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Gotify notification failed:', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gotify notification error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoopNotificationService implements NotificationService {
|
||||||
|
async notify(): Promise<void> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
9
lib/services/notifications/notification-service.ts
Normal file
9
lib/services/notifications/notification-service.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface NotificationOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationService {
|
||||||
|
notify(options: NotificationOptions): Promise<void>;
|
||||||
|
}
|
||||||
85
lib/strapi.ts
Normal file
85
lib/strapi.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
|
||||||
|
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
|
||||||
|
|
||||||
|
const strapi = axios.create({
|
||||||
|
baseURL: `${STRAPI_URL}/api`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${STRAPI_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface StrapiResponse<T> {
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
attributes: T;
|
||||||
|
}[];
|
||||||
|
meta: {
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
title: string;
|
||||||
|
sku: string;
|
||||||
|
description: string;
|
||||||
|
application: string;
|
||||||
|
content: string;
|
||||||
|
technicalData: any;
|
||||||
|
locale: string;
|
||||||
|
images?: {
|
||||||
|
data: {
|
||||||
|
attributes: {
|
||||||
|
url: string;
|
||||||
|
alternativeText: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProducts(locale: string = 'de') {
|
||||||
|
try {
|
||||||
|
const response = await strapi.get<StrapiResponse<Product>>('/products', {
|
||||||
|
params: {
|
||||||
|
locale,
|
||||||
|
populate: '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
...item.attributes,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching products from Strapi:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductBySku(sku: string, locale: string = 'de') {
|
||||||
|
try {
|
||||||
|
const response = await strapi.get<StrapiResponse<Product>>('/products', {
|
||||||
|
params: {
|
||||||
|
filters: { sku: { $eq: sku } },
|
||||||
|
locale,
|
||||||
|
populate: '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.data.data.length === 0) return null;
|
||||||
|
const item = response.data.data[0];
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
...item.attributes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching product ${sku} from Strapi:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default strapi;
|
||||||
19
lighthouserc.js
Normal file
19
lighthouserc.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
ci: {
|
||||||
|
collect: {
|
||||||
|
numberOfRuns: 1,
|
||||||
|
settings: {
|
||||||
|
preset: 'desktop',
|
||||||
|
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assert: {
|
||||||
|
assertions: {
|
||||||
|
'categories:performance': ['warn', { minScore: 0.9 }],
|
||||||
|
'categories:accessibility': ['warn', { minScore: 0.9 }],
|
||||||
|
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
||||||
|
'categories:seo': ['warn', { minScore: 0.9 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -191,7 +191,14 @@
|
|||||||
"emailPlaceholder": "ihre@email.de",
|
"emailPlaceholder": "ihre@email.de",
|
||||||
"message": "Nachricht",
|
"message": "Nachricht",
|
||||||
"messagePlaceholder": "Wie können wir Ihnen helfen?",
|
"messagePlaceholder": "Wie können wir Ihnen helfen?",
|
||||||
"submit": "Nachricht senden"
|
"submit": "Nachricht senden",
|
||||||
|
"submitting": "Wird gesendet...",
|
||||||
|
"successTitle": "Nachricht gesendet!",
|
||||||
|
"successDesc": "Vielen Dank für Ihre Nachricht. Wir werden uns so schnell wie möglich bei Ihnen melden.",
|
||||||
|
"sendAnother": "Weitere Nachricht senden",
|
||||||
|
"errorTitle": "Senden fehlgeschlagen!",
|
||||||
|
"error": "Etwas ist schief gelaufen. Bitte überprüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
|
||||||
|
"tryAgain": "Erneut versuchen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Products": {
|
"Products": {
|
||||||
@@ -386,4 +393,4 @@
|
|||||||
"cta": "Zurück zur Sicherheit"
|
"cta": "Zurück zur Sicherheit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,14 @@
|
|||||||
"emailPlaceholder": "your@email.com",
|
"emailPlaceholder": "your@email.com",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"messagePlaceholder": "How can we help you?",
|
"messagePlaceholder": "How can we help you?",
|
||||||
"submit": "Send Message"
|
"submit": "Send Message",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"successTitle": "Message Sent!",
|
||||||
|
"successDesc": "Thank you for your message. We will get back to you as soon as possible.",
|
||||||
|
"sendAnother": "Send another message",
|
||||||
|
"errorTitle": "Submission Failed!",
|
||||||
|
"error": "Something went wrong. Please check your input and try again.",
|
||||||
|
"tryAgain": "Try Again"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Products": {
|
"Products": {
|
||||||
@@ -386,4 +393,4 @@
|
|||||||
"cta": "Back to Safety"
|
"cta": "Back to Safety"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
// Create the internationalization middleware
|
|
||||||
const intlMiddleware = createMiddleware({
|
|
||||||
// A list of all locales that are supported
|
|
||||||
locales: ['en', 'de'],
|
|
||||||
|
|
||||||
// Used when no locale matches
|
|
||||||
defaultLocale: 'en'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main middleware that logs all requests
|
|
||||||
export default function middleware(request: NextRequest) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const { method, url, headers } = request;
|
|
||||||
const userAgent = headers.get('user-agent') || 'unknown';
|
|
||||||
const referer = headers.get('referer') || 'none';
|
|
||||||
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || 'unknown';
|
|
||||||
|
|
||||||
// Log incoming request
|
|
||||||
console.log(`Incoming request: method=${method} url=${url}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Apply internationalization middleware
|
|
||||||
const response = intlMiddleware(request);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Request failed: method=${method} url=${url}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
// Match only internationalized pathnames
|
|
||||||
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*']
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user