Compare commits
126 Commits
v1.0.0-rc.
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| fa6f27114b | |||
| a60e8af26b | |||
| c111efae1a | |||
| a12759d507 | |||
| eefabfa3ff | |||
| 86d28796a7 | |||
| bb9424d482 | |||
| b1515155b7 | |||
| 65d54ae789 | |||
| dc21d480ab | |||
| 51043da882 | |||
| 4a31cddf11 | |||
| 1b999510db | |||
| 0d852db651 | |||
| f3ff9cd364 | |||
| f15957847c | |||
| 55fc63fed5 | |||
| dac719efd2 | |||
| ec3f9d5c8e | |||
| 7ad5b5696d | |||
| 9bcf946752 | |||
| 1fefb794c1 | |||
| 1c1aebb804 | |||
| 30d8645f74 | |||
| 365cd50402 | |||
| a9f03b24c8 | |||
| 79a2a5121e | |||
| b2f26208ad | |||
| 6c739e2726 | |||
| 0ec830f5c6 | |||
| 713908ef95 | |||
| c3f41a24d5 | |||
| 013fbc5d66 | |||
| fd65b19f1d | |||
| 340c145863 | |||
| 2da182ec47 | |||
| 33a0877a6d | |||
| fdd1d5afb7 | |||
| bf996934af | |||
| 3e724f74fa | |||
| cfd5cbda55 | |||
| 0032da1562 | |||
| 7965e9c01a | |||
| f5df62c297 | |||
| 87ef5798d2 | |||
| 90e992636c | |||
| 44dbfdb3a8 | |||
| f60288a06c | |||
| 5906fc3375 | |||
| f36c6731e8 | |||
| 65ce8adc5d | |||
| 1d7c52fbca | |||
| 16f0e9b4e5 | |||
| 8dc41d52ed | |||
| 169b25ea12 | |||
| 205880b41a | |||
| 84555d11ed | |||
| 1dce82b74e | |||
| 3be4939ff5 | |||
| e054bb3490 | |||
| 75234095b7 | |||
| 4bdd4efdc3 | |||
| 47ca58a85a | |||
| d5d39a218a | |||
| ae7a45a911 | |||
| cb51c37207 | |||
| 8872d2424a | |||
| eb388610de | |||
| 6451a9e28e | |||
| 7ec826dae3 | |||
| 453a603392 | |||
| 5cfcc16dc2 | |||
| 5b43349205 | |||
| 96b296da12 | |||
| d5eb20a341 | |||
| 333111f03b | |||
| 698141f70b | |||
| e179e8162c | |||
| 259d712105 | |||
| 0178e828d6 | |||
| e3f7344daf | |||
| 21a7b0ade2 | |||
| d027fbeac2 | |||
| 8a751998eb | |||
| 48c3e1d013 | |||
| 3df4b44b8d | |||
| 07e0f237b9 | |||
| 57a3944301 | |||
| 5fe0a8d83e | |||
| 8062d33f35 | |||
| ebe67afd73 | |||
| b74f6b6f9e | |||
| 24eea9a2fe | |||
| c70288bba7 | |||
| d438dbdc9d | |||
| e0c4aaf298 | |||
| f44487eeac | |||
| a82b95a28f | |||
| ab688a3dab | |||
| a0ce37708e | |||
| 0379d1f05d | |||
| 50347d049d | |||
| 9678181927 | |||
| 3ffaafefe5 | |||
| e5bf8c861c | |||
| 651e14d665 | |||
| 580cd6789c | |||
| db4cf354ff | |||
| e8957e0672 | |||
| 7ef0bca9f6 | |||
| 198944649a | |||
| 6aa741ab0a | |||
| f69952a5da | |||
| 81af9bf3dd | |||
| f1b617e967 | |||
| d6be9beebf | |||
| 0a797260e3 | |||
| 2a4cc76292 | |||
| f87eb27f41 | |||
| acd86099e5 | |||
| 5ab9791c72 | |||
| 8152ccd5df | |||
| 8eeb571c2d | |||
| b1854d5255 | |||
| 7f4f970a38 | |||
| e5908c757c |
20
.env
20
.env
@@ -1,16 +1,10 @@
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 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"
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
@@ -26,6 +20,16 @@ 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
|
||||
|
||||
14
.env.example
14
.env.example
@@ -10,13 +10,18 @@
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
DIRECTUS_PORT=8055
|
||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||
TARGET=development
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Analytics (Umami)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
@@ -52,6 +57,11 @@ IMAGE_TAG=latest
|
||||
TRAEFIK_HOST=klz-cables.com
|
||||
ENV_FILE=.env
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
VARNISH_CACHE_SIZE=256M
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT NOTES
|
||||
# ============================================================================
|
||||
|
||||
@@ -12,8 +12,8 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=
|
||||
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Strapi
|
||||
STRAPI_DATABASE_NAME=strapi
|
||||
STRAPI_DATABASE_USERNAME=strapi
|
||||
STRAPI_DATABASE_PASSWORD=
|
||||
APP_KEYS=
|
||||
API_TOKEN_SALT=
|
||||
ADMIN_JWT_SECRET=
|
||||
TRANSFER_TOKEN_SALT=
|
||||
JWT_SECRET=
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
reference/
|
||||
public/
|
||||
dist/
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
name: CI - Lint, Typecheck & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -17,16 +14,23 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- 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: npm ci
|
||||
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
|
||||
- name: 🧪 QA Checks
|
||||
run: pnpm lint && pnpm typecheck && pnpm test
|
||||
|
||||
@@ -1,272 +1,228 @@
|
||||
name: Build & Deploy KLZ Cables
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_long_checks:
|
||||
skip_checks:
|
||||
description: 'Skip tests? (true/false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 1: Prepare & Determine Environment
|
||||
# JOB 1: Prepare Environment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
prepare:
|
||||
name: 🔍 Prepare Environment
|
||||
name: 🔍 Prepare
|
||||
runs-on: docker
|
||||
outputs:
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||
env_file: ${{ steps.determine.outputs.env_file }}
|
||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||
next_public_url: ${{ steps.determine.outputs.next_public_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:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 🔍 Environment & Version ermitteln
|
||||
- name: 🔍 Environment ermitteln
|
||||
id: determine
|
||||
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")
|
||||
REF="${{ github.ref_name }}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
DOMAIN="klz-cables.com"
|
||||
PRJ="klz"
|
||||
|
||||
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
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$TAG"
|
||||
IMAGE_TAG="$REF"
|
||||
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
|
||||
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||
else
|
||||
TARGET="skip"
|
||||
GOTIFY_TITLE="❓ Unbekannter Tag"
|
||||
GOTIFY_PRIORITY=3
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.staging"
|
||||
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||
fi
|
||||
else
|
||||
TARGET="skip"
|
||||
TARGET="branch"
|
||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||
ENV_FILE=".env.branch-${SLUG}"
|
||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||
fi
|
||||
|
||||
echo "target=$TARGET" >> $GITHUB_OUTPUT
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
|
||||
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
|
||||
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
|
||||
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
|
||||
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
||||
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
|
||||
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
|
||||
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
||||
# Standardize Traefik Rule
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
echo "image_tag=$IMAGE_TAG"
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$PRIMARY_HOST"
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "next_public_url=https://$PRIMARY_HOST"
|
||||
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: Quality Assurance (Lint & Test)
|
||||
# JOB 2: QA (Lint, Typecheck, Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 Quality Assurance
|
||||
name: 🧪 QA
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Restore npm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🧪 Run Checks in Parallel
|
||||
if: github.event.inputs.skip_long_checks != 'true'
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
npm run lint &
|
||||
LINT_PID=$!
|
||||
npm run typecheck &
|
||||
TYPE_PID=$!
|
||||
npm run 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
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push Docker Image
|
||||
# JOB 3: Build & Push
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: 🏗️ Build & Push
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||
name: 🏗️ Build
|
||||
needs: [prepare, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: 🏗️ Docker Image bauen & pushen
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
NEXT_PUBLIC_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) }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ 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) }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
run: |
|
||||
echo "🏗️ Building → $TARGET / $IMAGE_TAG"
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--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_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
||||
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
||||
-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 .
|
||||
|
||||
- name: 🏗️ Gatekeeper bauen & pushen
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables-gatekeeper:$IMAGE_TAG \
|
||||
--push ./gatekeeper
|
||||
|
||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy via SSH
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
needs: [prepare, build, qa]
|
||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
NEXT_PUBLIC_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) }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ 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: ${{ needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }}
|
||||
MAIL_HOST: ${{ secrets.MAIL_HOST }}
|
||||
MAIL_PORT: ${{ secrets.MAIL_PORT }}
|
||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
|
||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS }}
|
||||
DIRECTUS_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 }}
|
||||
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET }}
|
||||
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL }}
|
||||
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 }}
|
||||
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||
|
||||
# Secrets mapping (Directus)
|
||||
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
|
||||
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD) || secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
|
||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
|
||||
|
||||
# Secrets mapping (Mail)
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🚀 Deploy to ${{ env.TARGET }}
|
||||
- name: 📝 Generate Environment
|
||||
shell: bash
|
||||
env:
|
||||
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||
run: |
|
||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
||||
# Generate Environment File
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
cat > /tmp/klz-cables.env << EOF
|
||||
# Generated by CI - $TARGET - $(date -u)
|
||||
NODE_ENV=production
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
@@ -286,180 +242,66 @@ jobs:
|
||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||
AUTH_COOKIE_NAME=klz_gatekeeper_session
|
||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
ENV_FILE=$ENV_FILE
|
||||
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
||||
EOF
|
||||
|
||||
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
|
||||
# AUTH_MIDDLEWARE logic
|
||||
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> .env.deploy
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
||||
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 image: $IMAGE_TAG"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||
echo "→ Starting containers..."
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
docker system prune -f --filter "until=168h"
|
||||
echo "→ Waiting 15s for warmup..."
|
||||
sleep 15
|
||||
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
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
outputs:
|
||||
report_url: ${{ steps.save.outputs.report_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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"
|
||||
|
||||
# Multi-method Key Fetch
|
||||
SUCCESS=false
|
||||
echo "Fetching key $KEY_ID..."
|
||||
|
||||
# Method 1: gpg --recv-keys (standard)
|
||||
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
|
||||
gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --export > /etc/apt/keyrings/xtradeb.gpg
|
||||
SUCCESS=true && break
|
||||
fi
|
||||
done
|
||||
|
||||
# Method 2: Direct wget (fallback)
|
||||
if [ "$SUCCESS" = false ]; then
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg && SUCCESS=true
|
||||
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
|
||||
|
||||
# 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)
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
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: npm run pagespeed:test
|
||||
|
||||
- name: 💾 Save Report URL
|
||||
id: save
|
||||
if: always()
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
if [ -f pagespeed-report-url.txt ]; then
|
||||
URL=$(cat pagespeed-report-url.txt)
|
||||
echo "report_url=$URL" >> $GITHUB_OUTPUT
|
||||
echo "✅ Report URL found: $URL"
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Transfer and Restart
|
||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
scp -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
|
||||
# Apply Directus Schema Snapshot if available
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# JOB 5: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notifications
|
||||
needs: [prepare, qa, build, deploy, pagespeed]
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 📊 Deployment Summary
|
||||
- name: 🔔 Gotify
|
||||
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: |
|
||||
REPORT_MSG=""
|
||||
if [ -n "${{ needs.pagespeed.outputs.report_url }}" ]; then
|
||||
REPORT_MSG="\n\n⚡ **PageSpeed Report:**\n${{ needs.pagespeed.outputs.report_url }}"
|
||||
fi
|
||||
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
TITLE="klz-cables.com: $STATUS"
|
||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=${{ 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 }}${REPORT_MSG}" \
|
||||
-F "priority=4" || true
|
||||
|
||||
- name: 🔔 Gotify - Failure
|
||||
if: needs.deploy.result == 'failure' || needs.build.result == 'failure' || needs.qa.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
|
||||
-F "title=$TITLE" \
|
||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,4 +4,6 @@ node_modules
|
||||
|
||||
# Directus
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
!directus/extensions/
|
||||
!directus/schema/
|
||||
!directus/migrations/
|
||||
10
.lintstagedrc.cjs
Normal file
10
.lintstagedrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable no-undef */
|
||||
const path = require('path'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||
|
||||
module.exports = {
|
||||
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||
'*.{json,md,css,scss}': ['prettier --write'],
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`next lint --fix --file ${filenames
|
||||
.map((f) => path.relative(process.cwd(), f))
|
||||
.join(' --file ')}`;
|
||||
|
||||
module.exports = {
|
||||
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||
'*.{json,md,css,scss}': ['prettier --write'],
|
||||
};
|
||||
@@ -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.
|
||||
98
Dockerfile
98
Dockerfile
@@ -1,77 +1,55 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
# Clean the workspace in case the base image is dirty
|
||||
RUN rm -rf ./*
|
||||
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
|
||||
# Install dependencies with cache mount
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Build application
|
||||
RUN pnpm build
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
# These are baked into the client bundle during build
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG DIRECTUS_URL
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
|
||||
# Validate environment variables during build
|
||||
RUN npx tsx scripts/validate-env.ts
|
||||
|
||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Stage 2: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
# Copy standalone output and static files
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,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
|
||||
55
README.md
55
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
|
||||
````bash
|
||||
# Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
@@ -42,11 +44,12 @@ 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
|
||||
@@ -61,6 +64,7 @@ npm run cms:migrate
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SITE_URL=https://klz-cables.com
|
||||
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||
|
||||
# Umami
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# GlitchTip (Sentry compatible)
|
||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
@@ -81,6 +85,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
## 📊 Project Overview
|
||||
|
||||
### Migration Statistics
|
||||
|
||||
- **Content Exported**: 141 items
|
||||
- 18 pages (9 EN + 9 DE)
|
||||
- 59 posts (29 EN + 30 DE)
|
||||
@@ -91,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
- **Translation Pairs**: 16
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
- **Before**: Dynamic WordPress with database queries
|
||||
- **After**: Static HTML with CDN delivery
|
||||
- **Load Time**: <100ms (vs 500ms+)
|
||||
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: SCSS
|
||||
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
- **CAPTCHA**: Cloudflare Turnstile
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
@@ -163,6 +171,7 @@ scripts/
|
||||
## 🎯 Features
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||
- **Contact Forms**: Resend integration with validation
|
||||
- **GDPR Compliance**: Cookie consent banner
|
||||
@@ -175,12 +184,14 @@ scripts/
|
||||
- **Asset Management**: WordPress → local path mapping
|
||||
|
||||
### 🔄 In Progress
|
||||
|
||||
- Analytics integration (consent-based)
|
||||
- Turnstile CAPTCHA
|
||||
- Build testing
|
||||
- Deployment configuration
|
||||
|
||||
### 📝 Remaining
|
||||
|
||||
- Performance optimization
|
||||
- Final QA testing
|
||||
- Documentation updates
|
||||
@@ -188,6 +199,7 @@ scripts/
|
||||
## 📝 Content Management
|
||||
|
||||
### Data Export
|
||||
|
||||
```bash
|
||||
# Export from WordPress
|
||||
npm run data:export
|
||||
@@ -203,6 +215,7 @@ npm run data:improve-mapping
|
||||
```
|
||||
|
||||
### Adding New Content
|
||||
|
||||
1. Export new content from WordPress
|
||||
2. Process the data
|
||||
3. Rebuild the site
|
||||
@@ -210,17 +223,20 @@ npm run data:improve-mapping
|
||||
## 🎨 Design System
|
||||
|
||||
### Colors
|
||||
|
||||
- Primary: `#0066cc` (KLZ Blue)
|
||||
- Secondary: `#00a896` (Teal)
|
||||
- Text: `#1a1a1a`
|
||||
- Background: `#f8f9fa`
|
||||
|
||||
### Typography
|
||||
|
||||
- Font: Inter
|
||||
- Base: 16px
|
||||
- Scale: 1.25 (Major Third)
|
||||
|
||||
### Layout
|
||||
|
||||
- Max width: 1200px
|
||||
- Responsive grid
|
||||
- Mobile-first
|
||||
@@ -228,6 +244,7 @@ npm run data:improve-mapping
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Contact Form
|
||||
|
||||
```
|
||||
POST /api/contact
|
||||
{
|
||||
@@ -239,11 +256,13 @@ POST /api/contact
|
||||
```
|
||||
|
||||
### Sitemap
|
||||
|
||||
```
|
||||
GET /sitemap.xml
|
||||
```
|
||||
|
||||
### Robots
|
||||
|
||||
```
|
||||
GET /robots.txt
|
||||
```
|
||||
@@ -261,6 +280,7 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Branch Deployments**:
|
||||
|
||||
- `main` branch: Deploys to production using `.env.prod`
|
||||
- `staging` branch: Deploys to staging using `.env.staging`
|
||||
|
||||
@@ -268,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.
|
||||
|
||||
**Required Secrets** (configure in Gitea repository settings):
|
||||
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
||||
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||
|
||||
@@ -293,6 +314,7 @@ docker image prune -f
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-webhook.sh
|
||||
```
|
||||
@@ -304,11 +326,13 @@ Client → Traefik (TLS) → Next.js App
|
||||
```
|
||||
|
||||
**Domains**:
|
||||
|
||||
- `klz-cables.com` - Production
|
||||
- `www.klz-cables.com` - Production (www)
|
||||
- `staging.klz-cables.com` - Staging
|
||||
|
||||
**Services**:
|
||||
|
||||
- `app`: Next.js application (port 3000)
|
||||
- `traefik`: Reverse proxy (external)
|
||||
|
||||
@@ -317,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
## 📈 Performance
|
||||
|
||||
### Build Time
|
||||
|
||||
- **Target**: < 2 minutes
|
||||
- **Current**: ~1-2 minutes
|
||||
|
||||
### Page Load
|
||||
|
||||
- **Target**: < 100ms
|
||||
- **Current**: Static HTML from CDN
|
||||
|
||||
### Bundle Size
|
||||
|
||||
- **Target**: < 100KB gzipped
|
||||
- **Current**: Optimized with code splitting
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Never commit `.env` file
|
||||
- Rotate keys regularly
|
||||
- Use secrets in deployment platform
|
||||
|
||||
### Form Security
|
||||
|
||||
- Email validation
|
||||
- Rate limiting (recommended)
|
||||
- Turnstile CAPTCHA (pending)
|
||||
@@ -343,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
## 🎓 WordPress Specifics
|
||||
|
||||
### WPBakery Shortcodes Removed
|
||||
|
||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||
- `[nectar_*]` (Salient theme)
|
||||
- `[image_with_animation]`
|
||||
@@ -350,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
- `[divider]`
|
||||
|
||||
### HTML Sanitization
|
||||
|
||||
- Removes inline event handlers
|
||||
- Strips scripts
|
||||
- Normalizes classes
|
||||
- Preserves structure
|
||||
|
||||
### Asset Mapping
|
||||
|
||||
WordPress URLs → Local paths:
|
||||
|
||||
```
|
||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
```
|
||||
@@ -364,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
## 📚 Documentation
|
||||
|
||||
### Internal
|
||||
|
||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||
- `FINAL_SUMMARY.md` - Complete overview
|
||||
|
||||
### External
|
||||
|
||||
- [Next.js Docs](https://nextjs.org/docs)
|
||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||
- [Resend Docs](https://resend.com/docs)
|
||||
@@ -379,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
### Common Issues
|
||||
|
||||
**TypeScript Errors**
|
||||
|
||||
- The TypeScript errors shown in the editor are expected
|
||||
- They occur because modules reference each other
|
||||
- The build process resolves these correctly
|
||||
- Run `npm run build` to verify
|
||||
|
||||
**Build Failures**
|
||||
|
||||
- Check environment variables
|
||||
- Verify data files exist
|
||||
- Clear `.next` cache: `rm -rf .next`
|
||||
|
||||
**Missing Modules**
|
||||
|
||||
- Run `npm install --legacy-peer-deps`
|
||||
- Check `package.json` dependencies
|
||||
|
||||
@@ -404,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
✅ **i18n**: Multi-language support
|
||||
✅ **SEO**: Metadata and sitemaps
|
||||
✅ **Compatibility**: WPBakery content handled
|
||||
✅ **Media**: All images downloaded
|
||||
✅ **Media**: All images downloaded
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the documentation
|
||||
2. Review the troubleshooting section
|
||||
3. Check environment variables
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
@@ -9,10 +9,10 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
@@ -29,7 +29,8 @@ export async function generateStaticParams() {
|
||||
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);
|
||||
|
||||
if (!pageData) return {};
|
||||
@@ -59,7 +60,9 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
||||
};
|
||||
}
|
||||
|
||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
||||
export default async function StandardPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
const t = await getTranslations('StandardPage');
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { locale: string } }
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const locale = params.locale || 'en';
|
||||
const { locale } = await params;
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 });
|
||||
@@ -23,24 +23,29 @@ export async function GET(
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
if (categories.includes(slug)) {
|
||||
const categoryKey = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : slug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
||||
const categoryKey = slug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: slug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||
? t(`categories.${categoryKey}.description`)
|
||||
: '';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={categoryTitle}
|
||||
description={categoryDesc}
|
||||
label="Product Category"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,24 +56,21 @@ export async function GET(
|
||||
}
|
||||
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? (product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `${origin}${product.frontmatter.images[0]}`)
|
||||
: `${origin}${product.frontmatter.images[0]}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
@@ -12,17 +11,17 @@ import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale, slug },
|
||||
}: BlogPostProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
if (!post) return {};
|
||||
@@ -56,7 +55,9 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
||||
export default async function BlogPost({ params }: BlogPostProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
@@ -7,12 +8,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
return {
|
||||
title: t('title'),
|
||||
@@ -39,7 +41,8 @@ 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 posts = await getAllPosts(locale);
|
||||
|
||||
@@ -58,10 +61,12 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<img
|
||||
<Image
|
||||
src={featuredPost.frontmatter.featuredImage}
|
||||
alt={featuredPost.frontmatter.title}
|
||||
fill
|
||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient" />
|
||||
</>
|
||||
@@ -143,10 +148,12 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<img
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
|
||||
@@ -3,29 +3,20 @@ import JsonLd from '@/components/JsonLd';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Container, Heading, Section } from '@/components/ui';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: ContactPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
@@ -66,7 +57,8 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
const { locale } = params;
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
@@ -249,7 +241,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LeafletMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,15 @@ import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
@@ -31,27 +35,72 @@ export const viewport: Viewport = {
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: { locale },
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { locale: string };
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Providing all messages to the client
|
||||
// side is the easiest way to get started
|
||||
const messages = await getMessages();
|
||||
const { locale } = await params;
|
||||
|
||||
// Ensure locale is a valid string, fallback to 'en'
|
||||
const supportedLocales = ['en', 'de'];
|
||||
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||
|
||||
setRequestLocale(safeLocale);
|
||||
|
||||
let messages = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||
messages = {};
|
||||
}
|
||||
|
||||
// Track pageview on the server with high-fidelity header context
|
||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||
const serverServices = getServerAppServices();
|
||||
|
||||
// We wrap this in a try-catch to allow static rendering during build
|
||||
// headers() and cookies() force dynamic rendering in Next.js
|
||||
try {
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in serverServices.analytics) {
|
||||
(serverServices.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track initial server-side pageview
|
||||
serverServices.analytics.trackPageview();
|
||||
} catch {
|
||||
// Falls back to noop or client-side only during static generation
|
||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||
console.warn(
|
||||
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
{/* Sends pageviews for client-side navigations */}
|
||||
<AnalyticsProvider />
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
</Suspense>
|
||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,11 +11,13 @@ import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<JsonLd
|
||||
@@ -55,21 +57,22 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string };
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
// Use translations for meta where available (namespace: Index.meta)
|
||||
// Fallback to a sensible default if translation keys are missing.
|
||||
let t;
|
||||
try {
|
||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// If translations for Index.meta are not present, try generic Index namespace
|
||||
try {
|
||||
t = await getTranslations({ locale, namespace: 'Index' });
|
||||
} catch (e) {
|
||||
t = (key: string) => '';
|
||||
} catch {
|
||||
t = () => '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Script from 'next/script';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
@@ -11,7 +10,7 @@ import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
@@ -19,14 +18,14 @@ import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = params;
|
||||
const { locale, slug } = await params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
@@ -169,7 +168,8 @@ const components = {
|
||||
};
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = params;
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -10,14 +10,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: ProductsPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
@@ -47,13 +46,15 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Get translated category slugs
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
@@ -61,28 +62,28 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||
href: `/${params.locale}/products/${lowVoltageSlug}`,
|
||||
href: `/${locale}/products/${lowVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
desc: t('categories.mediumVoltage.description'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||
href: `/${params.locale}/products/${mediumVoltageSlug}`,
|
||||
href: `/${locale}/products/${mediumVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
desc: t('categories.highVoltage.description'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||
href: `/${params.locale}/products/${highVoltageSlug}`,
|
||||
href: `/${locale}/products/${highVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
desc: t('categories.solar.description'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${params.locale}/products/${solarSlug}`,
|
||||
href: `/${locale}/products/${solarSlug}`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -218,7 +219,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
href={`/${params.locale}/contact`}
|
||||
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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
|
||||
interface TeamPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
@@ -43,7 +44,9 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
export default async function TeamPage({ params }: TeamPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,72 +1,153 @@
|
||||
"use server";
|
||||
'use server';
|
||||
|
||||
import client, { ensureAuthenticated } from "@/lib/directus";
|
||||
import { createItem } from "@directus/sdk";
|
||||
import { sendEmail } from "@/lib/mail/mailer";
|
||||
import ContactEmail from "@/components/emails/ContactEmail";
|
||||
import React from "react";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||
import { createItem } from '@directus/sdk';
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||
const name = formData.get("name") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const message = formData.get("message") as string;
|
||||
const productName = formData.get("productName") as string | null;
|
||||
|
||||
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
const productName = formData.get('productName') as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
|
||||
return { success: false, error: "Missing required fields" };
|
||||
logger.warn('Missing required fields in contact form', {
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
});
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
// 1. Save to Directus
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
if (productName) {
|
||||
await client.request(createItem('product_requests', {
|
||||
product_name: productName,
|
||||
email,
|
||||
message
|
||||
}));
|
||||
await client.request(
|
||||
createItem('product_requests', {
|
||||
product_name: productName,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Product request stored in Directus');
|
||||
} else {
|
||||
await client.request(createItem('contact_submissions', {
|
||||
name,
|
||||
email,
|
||||
message
|
||||
}));
|
||||
await client.request(
|
||||
createItem('contact_submissions', {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Contact submission stored in Directus');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to store submission in Directus', { error });
|
||||
// We continue anyway to try sending the email, but maybe we should report this
|
||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||
}
|
||||
|
||||
// 2. Send Email
|
||||
logger.info('Sending contact form email', { email, productName });
|
||||
// 2. Send Emails
|
||||
logger.info('Sending branded emails', { email, productName });
|
||||
|
||||
const subject = productName
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: "New Contact Form Submission";
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
|
||||
const result = await sendEmail({
|
||||
subject,
|
||||
template: React.createElement(ContactEmail, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
subject,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
||||
} else {
|
||||
logger.error('Failed to send contact form email', { error: result.error });
|
||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
||||
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 };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handleFeedbackRequest(req as any, {
|
||||
url: config.infraCMS.url,
|
||||
token: config.infraCMS.token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handleFeedbackRequest(req as any, {
|
||||
url: config.infraCMS.url,
|
||||
token: config.infraCMS.token,
|
||||
});
|
||||
}
|
||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||
}
|
||||
69
app/errors/api/relay/route.ts
Normal file
69
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||
*
|
||||
* This Route Handler receives Sentry envelopes from the client,
|
||||
* injects the correct DSN if needed, and forwards them to the
|
||||
* internal GlitchTip/Sentry instance.
|
||||
*
|
||||
* This hides the real DSN from the client and bypasses ad-blockers
|
||||
* that target Sentry's default ingest endpoints.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||
|
||||
try {
|
||||
const envelope = await request.text();
|
||||
|
||||
// Sentry envelopes can contain multiple parts separated by newlines
|
||||
const lines = envelope.split('\n');
|
||||
if (lines.length < 1) {
|
||||
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||
}
|
||||
|
||||
JSON.parse(lines[0]);
|
||||
const realDsn = config.errors.glitchtip.dsn;
|
||||
|
||||
if (!realDsn) {
|
||||
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
const dsnUrl = new URL(realDsn);
|
||||
const projectId = dsnUrl.pathname.replace('/', '');
|
||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||
|
||||
logger.debug('Relaying Sentry envelope', {
|
||||
projectId,
|
||||
host: dsnUrl.host,
|
||||
});
|
||||
|
||||
const response = await fetch(relayUrl, {
|
||||
method: 'POST',
|
||||
body: envelope,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Sentry/GlitchTip API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to relay Sentry request', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
import { getAllProducts } from '@/lib/mdx';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getAllPages } from '@/lib/pages';
|
||||
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||
import { getAllPostsMetadata } from '@/lib/blog';
|
||||
import { getAllPagesMetadata } from '@/lib/pages';
|
||||
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
@@ -34,11 +36,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Products
|
||||
const products = await getAllProducts(locale);
|
||||
for (const product of products) {
|
||||
// We need to find the category for the product to build the URL
|
||||
// In this project, products are under /products/[category]/[slug]
|
||||
// The category is in product.frontmatter.categories
|
||||
const productsMetadata = await getAllProductsMetadata(locale);
|
||||
for (const product of productsMetadata) {
|
||||
if (!product.frontmatter || !product.slug) continue;
|
||||
|
||||
const category =
|
||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||
sitemapEntries.push({
|
||||
@@ -50,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Blog posts
|
||||
const posts = await getAllPosts(locale);
|
||||
for (const post of posts) {
|
||||
const postsMetadata = await getAllPostsMetadata(locale);
|
||||
for (const post of postsMetadata) {
|
||||
if (!post.frontmatter || !post.slug) continue;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.frontmatter.date),
|
||||
@@ -61,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Static pages
|
||||
const pages = await getAllPages(locale);
|
||||
for (const page of pages) {
|
||||
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||
for (const page of pagesMetadata) {
|
||||
if (!page.slug) continue;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||
lastModified: new Date(),
|
||||
|
||||
73
app/stats/api/send/route.ts
Normal file
73
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy for Umami Analytics.
|
||||
*
|
||||
* This Route Handler receives tracking events from the browser,
|
||||
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||
* internal Umami API endpoint.
|
||||
*
|
||||
* This ensures:
|
||||
* 1. The Website ID is NOT leaked to the client bundle.
|
||||
* 2. The Umami API endpoint is hidden behind our domain.
|
||||
* 3. We have full control over the tracking data.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
// Inject the secret websiteId from server config
|
||||
const websiteId = config.analytics.umami.websiteId;
|
||||
if (!websiteId) {
|
||||
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Prepare the enhanced payload with the secret ID
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: websiteId,
|
||||
};
|
||||
|
||||
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||
|
||||
// Log the event (internal only)
|
||||
logger.debug('Forwarding analytics event', {
|
||||
type,
|
||||
url: payload.url,
|
||||
website: websiteId.slice(0, 8) + '...',
|
||||
});
|
||||
|
||||
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Umami API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
85
build_output.txt
Normal file
85
build_output.txt
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
> klz-cables-nextjs@1.0.0 build /Users/marcmintel/Projects/klz-2026
|
||||
> next build
|
||||
|
||||
▲ Next.js 16.1.6 (Turbopack)
|
||||
- Environments: .env.production, .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 5.2s
|
||||
Running next.config.js provided runAfterProductionCompile ...
|
||||
✓ Completed runAfterProductionCompile in 329ms
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
Generating static pages using 15 workers (0/21) ...
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop notification service initialized (notifications disabled)"}
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
|
||||
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
|
||||
Generating static pages using 15 workers (5/21)
|
||||
Generating static pages using 15 workers (10/21)
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
|
||||
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Notification service initialized (noop)"}
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
|
||||
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
|
||||
Generating static pages using 15 workers (15/21)
|
||||
✓ Generating static pages using 15 workers (21/21) in 512.4ms
|
||||
Finalizing page optimization ...
|
||||
|
||||
Route (app)
|
||||
┌ ○ /_not-found
|
||||
├ ƒ /[locale]
|
||||
├ ƒ /[locale]/[slug]
|
||||
├ ƒ /[locale]/[slug]/opengraph-image
|
||||
├ ƒ /[locale]/api/og/product
|
||||
├ ƒ /[locale]/blog
|
||||
├ ƒ /[locale]/blog/[slug]
|
||||
├ ƒ /[locale]/blog/[slug]/opengraph-image
|
||||
├ ƒ /[locale]/blog/opengraph-image
|
||||
├ ƒ /[locale]/contact
|
||||
├ ƒ /[locale]/contact/opengraph-image
|
||||
├ ƒ /[locale]/opengraph-image
|
||||
├ ƒ /[locale]/products
|
||||
├ ƒ /[locale]/products/[...slug]
|
||||
├ ƒ /[locale]/products/opengraph-image
|
||||
├ ƒ /[locale]/team
|
||||
├ ƒ /[locale]/team/opengraph-image
|
||||
├ ƒ /api/feedback
|
||||
├ ƒ /api/health/cms
|
||||
├ ƒ /api/whoami
|
||||
├ ƒ /errors/api/relay
|
||||
├ ƒ /health
|
||||
├ ○ /manifest.webmanifest
|
||||
├ ○ /robots.txt
|
||||
├ ƒ /sitemap.xml
|
||||
└ ƒ /stats/api/send
|
||||
|
||||
|
||||
ƒ Proxy (Middleware)
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { config } from '../lib/config';
|
||||
|
||||
export default function CMSConnectivityNotice() {
|
||||
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -12,14 +13,12 @@ export default function CMSConnectivityNotice() {
|
||||
// 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 =
|
||||
window.location.hostname === 'localhost' || window.location.hostname.includes('127.0.0.1');
|
||||
const isStaging =
|
||||
window.location.hostname.includes('staging') ||
|
||||
window.location.hostname.includes('testing');
|
||||
const isLocal = config.isDevelopment;
|
||||
const isTesting = config.isTesting;
|
||||
|
||||
// Only proceed with check if it's developer context
|
||||
if (!isLocal && !isStaging && !isDebug) return;
|
||||
// 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');
|
||||
@@ -33,7 +32,7 @@ export default function CMSConnectivityNotice() {
|
||||
setStatus('ok');
|
||||
setIsVisible(false);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// If it's a connection error, only show if we are really debugging
|
||||
if (isDebug || isLocal) {
|
||||
setStatus('error');
|
||||
|
||||
@@ -17,10 +17,10 @@ export default function ContactForm() {
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
if (result?.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'general',
|
||||
email,
|
||||
@@ -41,7 +41,12 @@ export default function ContactForm() {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||
<svg className="w-10 h-10 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -49,7 +54,8 @@ export default function ContactForm() {
|
||||
{t('form.successTitle') || 'Message Sent!'}
|
||||
</Heading>
|
||||
<p className="text-text-secondary text-lg mb-8">
|
||||
{t('form.successDesc') || 'Thank you for your message. We will get back to you as soon as possible.'}
|
||||
{t('form.successDesc') ||
|
||||
'Thank you for your message. We will get back to you as soon as possible.'}
|
||||
</p>
|
||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||
{t('form.sendAnother') || 'Send another message'}
|
||||
@@ -62,7 +68,13 @@ export default function ContactForm() {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||
<svg className="w-10 h-10 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
className="w-10 h-10 text-destructive-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
@@ -74,7 +86,12 @@ export default function ContactForm() {
|
||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
||||
</p>
|
||||
<Button onClick={() => setStatus('idle')} variant="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'}
|
||||
</Button>
|
||||
</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">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
@@ -101,9 +118,9 @@ export default function ContactForm() {
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
@@ -114,32 +131,50 @@ export default function ContactForm() {
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('form.submitting') || 'Sending...'}
|
||||
</span>
|
||||
) : t('form.submit')}
|
||||
) : (
|
||||
t('form.submit')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface ContactMapProps {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||
}
|
||||
@@ -14,10 +14,10 @@ export default function Header() {
|
||||
const pathname = usePathname();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
|
||||
|
||||
// Check if homepage
|
||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||
|
||||
@@ -30,11 +30,6 @@ export default function Header() {
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
@@ -43,7 +38,7 @@ export default function Header() {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
|
||||
// Function to get path for a different locale
|
||||
const getPathForLocale = (newLocale: string) => {
|
||||
const segments = pathname.split('/');
|
||||
@@ -59,15 +54,15 @@ export default function Header() {
|
||||
];
|
||||
|
||||
const headerClass = cn(
|
||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu",
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
|
||||
{
|
||||
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen,
|
||||
}
|
||||
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const textColorClass = "text-white";
|
||||
const logoSrc = "/logo-white.svg";
|
||||
const textColorClass = 'text-white';
|
||||
const logoSrc = '/logo-white.svg';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,14 +70,14 @@ export default function Header() {
|
||||
className={headerClass}
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||
<motion.div
|
||||
className="flex-shrink-0 group touch-target"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||
>
|
||||
<Link href={`/${currentLocale}`}>
|
||||
<Image
|
||||
@@ -105,25 +100,20 @@ export default function Header() {
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.3
|
||||
}
|
||||
}
|
||||
delayChildren: 0.3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<motion.nav
|
||||
className="hidden lg:flex items-center space-x-10"
|
||||
variants={navVariants}
|
||||
>
|
||||
{menuItems.map((item, idx) => (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
variants={navLinkVariants}
|
||||
>
|
||||
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||
{menuItems.map((item, _idx) => (
|
||||
<motion.div key={item.href} variants={navLinkVariants}>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
"hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5"
|
||||
'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}
|
||||
@@ -134,7 +124,7 @@ export default function Header() {
|
||||
</motion.nav>
|
||||
|
||||
<motion.div
|
||||
className={cn("hidden lg:flex items-center space-x-8", textColorClass)}
|
||||
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||
variants={headerRightVariants}
|
||||
>
|
||||
<motion.div
|
||||
@@ -174,11 +164,11 @@ export default function Header() {
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }}
|
||||
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
@@ -193,11 +183,20 @@ export default function Header() {
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)}
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||
textColorClass,
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{ duration: 0.6, type: "spring", stiffness: 300, damping: 20, delay: 0.5 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
delay: 0.5,
|
||||
}}
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
<motion.svg
|
||||
@@ -236,21 +235,25 @@ export default function Header() {
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div className={cn(
|
||||
"fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col",
|
||||
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||
initial="closed"
|
||||
animate={isMobileMenuOpen ? "open" : "closed"}
|
||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||
variants={{
|
||||
open: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2
|
||||
}
|
||||
}
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item, idx) => (
|
||||
@@ -264,21 +267,22 @@ export default function Header() {
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
delay: idx * 0.08
|
||||
}
|
||||
}
|
||||
ease: 'easeOut',
|
||||
delay: idx * 0.08,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
|
||||
<motion.div
|
||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -322,11 +326,11 @@ export default function Header() {
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 20, delay: 1.2 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
@@ -338,23 +342,23 @@ export default function Header() {
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<motion.div
|
||||
className="p-12 flex justify-center opacity-20"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.5, delay: 1.4 }}
|
||||
>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, delay: 1.5 }}
|
||||
className="p-12 flex justify-center opacity-20"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.5, delay: 1.4 }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
<motion.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.header>
|
||||
</>
|
||||
@@ -367,9 +371,9 @@ const navVariants = {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.1
|
||||
}
|
||||
}
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const navLinkVariants = {
|
||||
@@ -380,9 +384,9 @@ const navLinkVariants = {
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut"
|
||||
}
|
||||
}
|
||||
ease: 'easeOut',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headerRightVariants = {
|
||||
@@ -390,6 +394,6 @@ const headerRightVariants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease: "easeOut" }
|
||||
}
|
||||
transition: { duration: 0.6, ease: 'easeOut' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -21,19 +21,22 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
const updateUrl = useCallback((index: number | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (index !== null) {
|
||||
params.set('photo', index.toString());
|
||||
} else {
|
||||
params.delete('photo');
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
const updateUrl = useCallback(
|
||||
(index: number | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (index !== null) {
|
||||
params.set('photo', index.toString());
|
||||
} else {
|
||||
params.delete('photo');
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
@@ -56,11 +59,16 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (photoParam !== null) {
|
||||
const index = parseInt(photoParam, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
setCurrentIndex(index);
|
||||
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}
|
||||
}, [searchParams, images.length]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
updateUrl(null);
|
||||
onClose();
|
||||
}, [updateUrl, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updateUrl(currentIndex);
|
||||
@@ -79,22 +87,17 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
// Lock scroll
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, prevImage, nextImage]);
|
||||
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
updateUrl(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{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>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
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"
|
||||
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
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
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"
|
||||
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.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
@@ -173,15 +180,15 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
||||
|
||||
|
||||
{/* Premium Reflection: Subtle gradient to give material feel */}
|
||||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
@@ -199,6 +206,6 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function OGImageTemplate({
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
@@ -182,4 +183,3 @@ export function OGImageTemplate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,14 +49,28 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-accent/20">
|
||||
<svg className="w-5 h-5 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||
{t('successTitle')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('successDesc', { productName })}
|
||||
</p>
|
||||
<button
|
||||
@@ -73,26 +87,36 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-destructive/20">
|
||||
<svg className="w-5 h-5 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-destructive-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive">{t('errorTitle') || 'Submission Failed'}</h3>
|
||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0">
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||
{t('errorTitle') || 'Submission Failed'}
|
||||
</h3>
|
||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
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'}
|
||||
</span>
|
||||
</button>
|
||||
{t('tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,22 +157,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-3 w-3 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="text-xs">{t('submitting')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs">{t('submit')}</span>
|
||||
<svg className="w-3 h-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-3 h-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
|
||||
@@ -3,25 +3,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
*
|
||||
* Automatically tracks pageviews on client-side route changes.
|
||||
* This component should be placed inside your layout to handle navigation events.
|
||||
* This component handles navigation events for the Umami analytics service.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In your layout.tsx
|
||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
||||
* <UmamiScript />
|
||||
* <Header />
|
||||
* <main>{children}</main>
|
||||
* <Footer />
|
||||
* <AnalyticsProvider />
|
||||
* </NextIntlClientProvider>
|
||||
* ```
|
||||
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||
* so it's no longer needed as a prop here.
|
||||
*/
|
||||
export default function AnalyticsProvider() {
|
||||
const pathname = usePathname();
|
||||
@@ -29,31 +19,17 @@ export default function AnalyticsProvider() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
|
||||
// Track pageview with the full URL
|
||||
// The service will relay this to our internal proxy which injects the Website ID
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Umami] Tracked pageview:', url);
|
||||
}
|
||||
|
||||
// Services like logger are already sub-initialized in getAppServices()
|
||||
// so we don't need to log here manually.
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
if (!websiteId) return null;
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="umami-analytics"
|
||||
src="/stats/script.js"
|
||||
data-website-id={websiteId}
|
||||
data-host-url="/stats"
|
||||
strategy="afterInteractive"
|
||||
data-domains="klz-cables.com"
|
||||
defer
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
@@ -19,19 +18,9 @@ export default function GallerySection() {
|
||||
'/uploads/2024/12/DSC07768-Large.webp',
|
||||
];
|
||||
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const photoParam = searchParams.get('photo');
|
||||
if (photoParam !== null) {
|
||||
const index = parseInt(photoParam, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
setLightboxIndex(index);
|
||||
setLightboxOpen(true);
|
||||
}
|
||||
}
|
||||
}, [searchParams, images.length]);
|
||||
const photoParam = searchParams.get('photo');
|
||||
const lightboxOpen = photoParam !== null;
|
||||
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||
|
||||
return (
|
||||
<Section className="bg-white text-white py-32">
|
||||
@@ -39,14 +28,16 @@ export default function GallerySection() {
|
||||
<Heading level={2} subtitle={t('subtitle')} align="center">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{images.map((src, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setLightboxIndex(idx);
|
||||
setLightboxOpen(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('photo', idx.toString());
|
||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||
// Since we're using derive-from-url, the component will re-render with the new value
|
||||
}}
|
||||
className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700 cursor-pointer"
|
||||
>
|
||||
@@ -68,7 +59,11 @@ export default function GallerySection() {
|
||||
isOpen={lightboxOpen}
|
||||
images={images}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
onClose={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('photo');
|
||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function Hero() {
|
||||
>
|
||||
<HeroIllustration />
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
@@ -22,8 +23,11 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
|
||||
{t('allArticles')}
|
||||
</Heading>
|
||||
<Link href={`/${locale}/blog`} className="group flex items-center text-primary font-bold text-base md:text-lg touch-target">
|
||||
{t('allArticles')}
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
|
||||
>
|
||||
{t('allArticles')}
|
||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -34,10 +38,12 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<img
|
||||
src={post.frontmatter.featuredImage}
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
@@ -53,7 +59,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||
@@ -61,8 +67,18 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||
{t('readMore')}
|
||||
<svg className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,43 @@ import Link from 'next/link';
|
||||
import { cn } from './utils';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'accent' | 'saturated' | 'outline' | 'ghost' | 'white';
|
||||
variant?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'saturated'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| 'white'
|
||||
| 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
href?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-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';
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
href,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
||||
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
||||
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||
outline: 'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||
outline:
|
||||
'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||
ghost: 'text-primary hover:shadow-lg',
|
||||
white: 'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
||||
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 = {
|
||||
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
|
||||
outline: 'bg-primary',
|
||||
ghost: 'bg-primary-light/10',
|
||||
white: 'bg-primary-light',
|
||||
destructive: 'bg-destructive/90',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className={cn(
|
||||
"relative z-10 flex items-center justify-center gap-2 transition-colors duration-500",
|
||||
variant === 'white' ? "group-hover/btn:text-primary-dark" : ""
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
|
||||
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out",
|
||||
overlayColors[variant]
|
||||
)} />
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
|
||||
overlayColors[variant],
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -48,18 +48,15 @@ Ein hochwertiges Netzanschlusskabel kombiniert all diese Eigenschaften und garan
|
||||
Ein Kabel allein reicht nicht – die richtige Installation entscheidet über seine Lebensdauer. Fehler in der Verlegung können dazu führen, dass selbst die besten Materialien frühzeitig versagen.
|
||||
### Warum die richtige Verlegeart entscheidend ist
|
||||
Die Art der Verlegung hat einen direkten Einfluss auf die Kabelbelastung:
|
||||
- <p><strong>Direkte Erdverlegung:<br />
|
||||
|
||||
– </strong>Hohe Wärmeableitung, da der Boden Wärme aufnimmt.<br />
|
||||
– Gefahr durch Erdbewegungen und Setzungen.
|
||||
- <p><strong>Kabelschutzrohre:<br />
|
||||
|
||||
</strong>– Schutz vor mechanischen Belastungen.<br />
|
||||
– Kann Wärmeabfuhr einschränken, wenn nicht ausreichend belüftet.
|
||||
- <p><strong>Freiluftverlegung:<br />
|
||||
|
||||
</strong>– Schnelle Wartung und Austauschmöglichkeit.<br />
|
||||
– Höhere Beanspruchung durch UV-Strahlung und Witterung.
|
||||
- **Direkte Erdverlegung:**
|
||||
– Hohe Wärmeableitung, da der Boden Wärme aufnimmt.
|
||||
– Gefahr durch Erdbewegungen und Setzungen.
|
||||
- **Kabelschutzrohre:**
|
||||
– Schutz vor mechanischen Belastungen.
|
||||
– Kann Wärmeabfuhr einschränken, wenn nicht ausreichend belüftet.
|
||||
- **Freiluftverlegung:**
|
||||
– Schnelle Wartung und Austauschmöglichkeit.
|
||||
– Höhere Beanspruchung durch UV-Strahlung und Witterung.
|
||||
### Thermische Belastung: Ein oft unterschätzter Faktor
|
||||
Die Betriebstemperatur beeinflusst maßgeblich die Lebensdauer eines Kabels. Jede** Temperaturerhöhung **um 10 °C** halbiert **die** Lebensdauer **des** Isolationsmaterials.**
|
||||
Daher müssen Kabel richtig dimensioniert werden, um eine Überhitzung zu vermeiden. Zusätzliche Maßnahmen wie Wärmeableitungsgräben oder spezielle Bettungsmaterialien können helfen, die Temperaturen im Betrieb zu kontrollieren.
|
||||
|
||||
@@ -10,16 +10,13 @@ What is particularly interesting is that **100 billion euros of this is specific
|
||||
While politicians are still debating the sense and nonsense of the use of the funds, one thing is certain for us as a cable supplier: nothing will work without cables. Neither in the expansion of wind farms, nor in the laying of power lines or the modernization of energy infrastructures. The demand for cable will therefore increase – considerably.
|
||||
### The billion-euro package and its distribution – who gets what?
|
||||
The distribution of the money is clearly defined and comprises three major areas:
|
||||
- <p>**500 billion euros total budget:**<br />
|
||||
|
||||
This sum will be made available over **twelve** years. An ambitious project that is being pursued with a lot of hope and just as much skepticism.
|
||||
- <p>**100 billion euros for the federal states:**<br />
|
||||
|
||||
This is intended to enable the federal states to push ahead with their own infrastructure projects. These include the expansion of electricity grids, the connection of new wind and solar parks and measures to increase grid stability.
|
||||
- <p>**100 billion euros for climate protection:**<br />
|
||||
|
||||
The green part of the package, which is clearly aimed at converting the economy to climate-friendly technologies. This means: more onshore wind turbines, more solar parks, more cables.<br />
|
||||
These funds will be made available via the existing **Climate and Transformation Fund (KTF)** and are intended to help reduce CO2 emissions while guaranteeing a stable energy supply.
|
||||
- **500 billion euros total budget:**
|
||||
This sum will be made available over **twelve** years. An ambitious project that is being pursued with a lot of hope and just as much skepticism.
|
||||
- **100 billion euros for the federal states:**
|
||||
This is intended to enable the federal states to push ahead with their own infrastructure projects. These include the expansion of electricity grids, the connection of new wind and solar parks and measures to increase grid stability.
|
||||
- **100 billion euros for climate protection:**
|
||||
The green part of the package, which is clearly aimed at converting the economy to climate-friendly technologies. This means: more onshore wind turbines, more solar parks, more cables.
|
||||
These funds will be made available via the existing **Climate and Transformation Fund (KTF)** and are intended to help reduce CO2 emissions while guaranteeing a stable energy supply.
|
||||
### Why cable suppliers should hit the ground running now
|
||||
There is a lot of talk about subsidies, funding and how to use it. But the real challenge remains: The necessary infrastructure must be created – and that only works with high-performance cables.
|
||||
The following trends are particularly relevant for us:
|
||||
@@ -34,15 +31,12 @@ This applies in particular to cable systems that are designed for high performan
|
||||
### KLZ’s role in this gigantic investment offensive
|
||||
With these billion-euro investments, the demand for underground cables, especially medium-voltage cables, will virtually explode. The question is not **whether** cables will be needed – but **when** and in **what** quantities. And that’s where we come in.
|
||||
<h4>Our strengths:</h4>
|
||||
- <p>**High-quality cables:**<br />
|
||||
|
||||
We only supply [high-quality cables](/power-cables/), such as the **NA2XS(F)2Y**, **NAYY** or even the **NAYY-J**. These are ideally suited for use in onshore wind farms, solar fields and transformer stations. They offer high reliability, resilience and durability.
|
||||
- <p>**Fast delivery thanks to logistical efficiency:**<br />
|
||||
|
||||
Thanks to our central logistics hub, we can deliver quickly and reliably – including to our customers in the Netherlands. This is a decisive advantage when projects have to be realized under time pressure.
|
||||
- <p>**Sustainability:**<br />
|
||||
|
||||
While the German government is pushing ahead with its climate targets, we are also doing our bit. We have long attached great importance to sustainable solutions that meet the requirements of the future.
|
||||
- **High-quality cables:**
|
||||
We only supply [high-quality cables](/power-cables/), such as the **NA2XS(F)2Y**, **NAYY** or even the **NAYY-J**. These are ideally suited for use in onshore wind farms, solar fields and transformer stations. They offer high reliability, resilience and durability.
|
||||
- **Fast delivery thanks to logistical efficiency:**
|
||||
Thanks to our central logistics hub, we can deliver quickly and reliably – including to our customers in the Netherlands. This is a decisive advantage when projects have to be realized under time pressure.
|
||||
- **Sustainability:**
|
||||
While the German government is pushing ahead with its climate targets, we are also doing our bit. We have long attached great importance to sustainable solutions that meet the requirements of the future.
|
||||
### Why the timing is ideal for grid expansion
|
||||
Of course, not everyone approves of this mega project. There are those who criticize the project as being too ambitious or poorly planned. But one thing is certain: the demand for modern infrastructure will increase, and it will increase dramatically.
|
||||
Instead of discussing whether it is the best solution, we are concentrating on **ensuring that the best cable technology is available when it is needed**. The energy transition will come – and we will make sure that it really works.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: Thanks – Deutsch
|
||||
excerpt: '[vc_column…'
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Thanks – Deutsch
|
||||
|
||||
<div class="flex-shrink-0 flex flex-col relative items-end">
|
||||
<div>
|
||||
<div class="pt-0">
|
||||
<div class="gizmo-bot-avatar flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<h2 class="relative p-1 rounded-sm flex items-center justify-center bg-token-main-surface-primary text-token-text-primary h-8 w-8">Vielen Dank!</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
|
||||
<div class="flex-col gap-1 md:gap-3">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="f524f802-9a51-4037-b74f-9dc5f97ba9ca" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen. Unser Team ist bereits startklar, um Ihnen weiterzuhelfen!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
JTNDJTIxLS0lMjBHb29nbGUlMjB0YWclMjAlMjhndGFnLmpzJTI5JTIwLS0lM0UlMjAlM0NzY3JpcHQlMjBhc3luYyUyMHNyYyUzRCUyMmh0dHBzJTNBJTJGJTJGd3d3Lmdvb2dsZXRhZ21hbmFnZXIuY29tJTJGZ3RhZyUyRmpzJTNGaWQlM0RBVy0xNzA5NTg5MjIzOCUyMiUzRSUzQyUyRnNjcmlwdCUzRSUyMCUzQ3NjcmlwdCUzRSUyMHdpbmRvdy5kYXRhTGF5ZXIlMjAlM0QlMjB3aW5kb3cuZGF0YUxheWVyJTIwJTdDJTdDJTIwJTVCJTVEJTNCJTIwZnVuY3Rpb24lMjBndGFnJTI4JTI5JTdCZGF0YUxheWVyLnB1c2glMjhhcmd1bWVudHMlMjklM0IlN0QlMjBndGFnJTI4JTI3anMlMjclMkMlMjBuZXclMjBEYXRlJTI4JTI5JTI5JTNCJTIwZ3RhZyUyOCUyN2NvbmZpZyUyNyUyQyUyMCUyN0FXLTE3MDk1ODkyMjM4JTI3JTI5JTNCJTIwJTNDJTJGc2NyaXB0JTNF
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
title: Contact – Deutsch
|
||||
excerpt: '[vc_column column_padding=”no-extra-padding”…'
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Contact – Deutsch
|
||||
|
||||
<h5>Wie können wir Ihnen helfen?</h5>
|
||||
<h2>Schwebt Ihnen bereits ein Projekt vor?</h2>
|
||||
|
||||
<p style="text-align: left;"><div class="frm_forms with_frm_style frm_style_klz" id="frm_form_2_container" data-token="1bab1f2ae16527f65d9f48545407888d">
|
||||
<form enctype="multipart/form-data" method="post" class="frm-show-form frm_pro_form " id="form_contact-deutsch" data-token="1bab1f2ae16527f65d9f48545407888d">
|
||||
<div class="frm_form_fields ">
|
||||
<fieldset>
|
||||
<legend class="frm_screen_reader">Contact Us - Deutsch</legend>
|
||||
|
||||
<div class="frm_fields_container">
|
||||
<input type="hidden" name="frm_action" value="create" />
|
||||
<input type="hidden" name="form_id" value="2" />
|
||||
<input type="hidden" name="frm_hide_fields_2" id="frm_hide_fields_2" value="" />
|
||||
<input type="hidden" name="form_key" value="contact-deutsch" />
|
||||
<input type="hidden" name="item_meta[0]" value="" />
|
||||
<input type="hidden" id="frm_submit_entry_2" name="frm_submit_entry_2" value="2aee9616df" /><input type="hidden" name="_wp_http_referer" value="/wp-json/wp/v2/pages?per_page=100&page=1&_embed=true" /><div id="frm_field_8_container" class="frm_form_field form-field frm_required_field frm_top_container frm_first frm_half">
|
||||
<label for="field_qh4icy2" id="field_qh4icy2_label" class="frm_primary_label">Name
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_qh4icy2" name="item_meta[8]" value="" data-reqmsg="Name cannot be blank." aria-required="true" data-invmsg="Name is invalid" aria-invalid="false" aria-describedby="frm_desc_field_qh4icy2" />
|
||||
<div class="frm_description" id="frm_desc_field_qh4icy2">First Name</div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_9_container" class="frm_form_field form-field frm_required_field frm_hidden_container frm_half">
|
||||
<label for="field_ocfup12" id="field_ocfup12_label" class="frm_primary_label">Nachname
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_ocfup12" name="item_meta[9]" value="" data-reqmsg="Nachname cannot be blank." aria-required="true" data-invmsg="Nachname is invalid" aria-invalid="false" aria-describedby="frm_desc_field_ocfup12" />
|
||||
<div class="frm_description" id="frm_desc_field_ocfup12">Last Name</div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_10_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_29yf4d2" id="field_29yf4d2_label" class="frm_primary_label">E-Mail
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="email" id="field_29yf4d2" name="item_meta[10]" value="" data-reqmsg="E-Mail cannot be blank." aria-required="true" data-invmsg="Please enter a valid email address" aria-invalid="false" />
|
||||
|
||||
</div>
|
||||
<div id="frm_field_11_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_e6lis62" id="field_e6lis62_label" class="frm_primary_label">Betreff
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_e6lis62" name="item_meta[11]" value="" data-reqmsg="Betreff cannot be blank." aria-required="true" data-invmsg="Betreff is invalid" aria-invalid="false" />
|
||||
|
||||
</div>
|
||||
<div id="frm_field_12_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_9jv0r12" id="field_9jv0r12_label" class="frm_primary_label">Nachricht
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<textarea name="item_meta[12]" id="field_9jv0r12" rows="5" data-reqmsg="Nachricht cannot be blank." aria-required="true" data-invmsg="Nachricht is invalid" aria-invalid="false" ></textarea>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_15_container" class="frm_form_field form-field frm_none_container">
|
||||
<label for="g-recaptcha-response" id="field_fvtwy_label" class="frm_primary_label">Captcha
|
||||
<span class="frm_required" aria-hidden="true"></span>
|
||||
</label>
|
||||
<div id="field_fvtwy" class="frm-g-recaptcha" data-sitekey="6LczZ7wqAAAAANwlgLaISgENVDZ1rTPe6LnTJgEk" data-size="invisible" data-theme="light"></div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_13_container" class="frm_form_field form-field ">
|
||||
<div class="frm_submit frm_flex">
|
||||
<button class="frm_button_submit frm_final_submit" type="submit" formnovalidate="formnovalidate">Senden</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="item_key" value="" />
|
||||
<div id="frm_field_27_container">
|
||||
<label for="field_eeiza" >
|
||||
If you are human, leave this field blank. </label>
|
||||
<input id="field_eeiza" type="text" class="frm_form_field form-field frm_verify" name="item_meta[27]" value="" />
|
||||
</div>
|
||||
<input name="frm_state" type="hidden" value="LzUMsLqCGT3RC/3NDZwBs8xiLSRtku+v3mS6FD5Px53CgMo7ngsrjnaIkQVSZFX3" /></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
KLZ Cables<br />
|
||||
Raiffeisenstraße 22<br />
|
||||
73630 Remshalden
|
||||
@@ -1,212 +0,0 @@
|
||||
---
|
||||
title: Home – Deutsch
|
||||
excerpt: >-
|
||||
[vc_row type=”full_width_background”
|
||||
full_screen_row_position=”middle”
|
||||
column_margin=”default” equal_height=”yes”
|
||||
content_placement=”bottom” column_direction=”default”
|
||||
column_direction_tablet=”default”
|
||||
column_direction_phone=”default” bg_color=”#d1d1ca”
|
||||
bg_image=”45569″ bg_position=”center bottom”
|
||||
background_image_loading=”lazy-load”
|
||||
bg_repeat=”no-repeat” video_bg=”use_video”
|
||||
video_mp4=”/uploads/2025/02/header.mp4″
|
||||
video_webm=”/uploads/2025/02/header.webm”
|
||||
background_video_loading=”lazy-load”
|
||||
scene_position=”center” top_padding=”15%”
|
||||
bottom_padding=”13%” text_color=”light”
|
||||
text_align=”left” row_border_radius=”none”
|
||||
row_border_radius_applies=”bg” overflow=”visible”
|
||||
enable_gradient=”true”
|
||||
color_overlay=”rgba(0,0,0,0.01)”
|
||||
color_overlay_2=”rgba(0,0,0,0.32)”…
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Home – Deutsch
|
||||
|
||||
<h1><strong>Wir tragen zum Ausbau der Energiekabelnetze für eine <em>grüne</em> Zukunft bei</strong></h1>
|
||||
|
||||
<h4>Niederspannung</h4>
|
||||
<p>Zuverlässige und sichere Stromversorgung für den Alltag.
|
||||
<h4>Mittelspannung</h4>
|
||||
<p>Das perfekte Gleichgewicht zwischen Kraft und Leistung für industrielle und städtische Netze.
|
||||
<h4>Hochspannung</h4>
|
||||
<p>Maximale Leistung über große Entfernungen – ohne Kompromisse.
|
||||
<h4>Solar Cables</h4>
|
||||
<p>Connecting the sun’s energy to your sustainable future.[fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6486″ image_url=”6521″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Zuverlässige und sichere Stromversorgung für den Alltag.” link_url=”/de/stromkabel/niederspannungskabel/”]
|
||||
<h3>Niederspannung</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6487″ image_url=”6517″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”” min_height=”500″ hover_content=”Das perfekte Gleichgewicht zwischen Kraft und Leistung für industrielle und städtische Netze.” link_url=”/de/stromkabel/mittelspannungskabel/”]
|
||||
<h3>Mittelspannung</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6485″ image_url=”6527″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Maximale Leistung über große Entfernungen – ohne Kompromisse.” link_url=”/de/stromkabel/hochspannungskabel/”]
|
||||
<h3>Hochspannung</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6484″ image_url=”6519″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Verbindet die Energie der Sonne mit einer nachhaltigen Zukunft.” link_url=”/de/solarkabel”]
|
||||
<h3>Solar</h3>
|
||||
[/fancy_box]
|
||||
<h3>Was wir machen</h3>
|
||||
Wir sorgen dafür, dass der Strom fließt – mit qualitätsgeprüften Kabeln. Von Niederspannung bis zur Hochspannung
|
||||
<h6>01</h6>
|
||||
|
||||
<h4>Belieferung von Energieversorgen, Wind- und Solarparks, Industrie und Handel</h4>
|
||||
Wir begleiten Ihre Projekte von 1 bis 220 kV, vom simplem <strong>NYY</strong> bis hin zum Hochspannungskabel mit Segmentleiter und Aluminium-Mantel, und der Schwerpunkt Mittelspannungskabel besonders hervorgehoben. Ob <strong>NA2XS(F)2Y</strong> in Standardausführung, oder mal bis zu 1200 mm2 Querschnitt, mit dickem Mantel oder in gewünschten Passlängen. Wir haben Partner mit ungeheurer Vielfalt.
|
||||
<h6>02</h6>
|
||||
|
||||
<h4>Lieferung von Kabeln, deren Qualität zertifiziert ist</h4>
|
||||
Kabel sind Produkte, die 100% funktionieren müssen. Jahrzehnte, oft 80 bis 100 Jahre. Unsere Kabel haben nicht nur die Approbation durch VDE. Die namhaftesten Energieversorger in Deutschland, den Niederlanden und in Österreich vertrauen uns und unseren Herstellern. Und oft liegen die Anforderungen noch über denen der schon strengen Vorschriften der VDE.
|
||||
<h6>03</h6>
|
||||
|
||||
<h4>Wir liefern pünktlich, denn wir kennen die Konsequenz für Sie</h4>
|
||||
Windpark Norddeutschland, Koordinaten XYZ, Anlieferung Mittwoch 14-16 Uhr, keine Ablademöglichkeit. Ja, das kennen wir. Wir organisieren die Logistik mit einem Backoffice-Team, was bis zu 20 Jahre Kabelerfahrung hat. Verzollung und ordentliche Papierabwicklung inklusive.
|
||||
<h6>04</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="06bd4556-b30a-464e-a28a-e8865e9dd302" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Das Kabel allein ist noch nicht die Lösung</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="6f143441-86ad-449a-a14e-67511b818d06" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Steiniger Boden? Besser vielleicht einen dickeren Außenmantel? Feuchter Boden? Darf es einen querwasserdichten Schutz noch zusätzlich zum längswasserdichten Band geben? Längere Einzellängen, aber nicht an die Limitierung des Verlegungskran gedacht? Oder oft unterschätzt? Was trägt denn der Boden im Lager. Ein Kupfer-Kabel wiegt gerne schon mal 10 Tonnen pro Kilometer. Wir denken für Sie mit und fragen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="1dd27af8-cd3b-409c-9f01-e578c14f4e43" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h3><strong>Jahrzehntelange Kabelkompetenz mit Tradition</strong></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Bei KLZ fließt Kabelgeschichte durch unsere Adern. Klaus begann seine Laufbahn bei der renommierten Felten & Guilleaume – in den Fußstapfen seiner Eltern, die ihr Leben derselben ikonischen Firma widmeten. Für Klaus ist das mehr als nur ein Beruf – es ist ein Erbe, das auf Handwerkskunst, Innovation und Stolz aufbaut.</p>
|
||||
<p>Wir ehren diese Geschichte mit originalen Illustrationen aus der Ära von Felten & Guilleaume, die einst als Postkarten verwendet wurden. Diese Bilder erinnern uns an die Generationen, die die Welt miteinander verbunden haben – eine Tradition, die wir mit Stolz fortführen.
|
||||
<h3>Warum Sie uns wählen sollten</h3>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="a3eafd75-c9c1-458b-ad89-f34df883f8e5" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Erfahrung verhindert zwar viele Fehler, aber wir lernen jeden Tag dazu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>01</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="c27756df-b62e-4794-b493-89d6b740edd6" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Fachkompetenz mit Tiefgang</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="b38876c8-b795-4cb4-be1e-2cd1ea9807b5" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Unser Team bringt jahrzehntelange Erfahrung mit – weit über die Gründung von KLZ im Jahr 2009 hinaus. Im Gesamtteam haben wir über 100 Jahre Kabelerfahrung, gesammelt in verschiedensten Werken, von Niederspannung, über Mittelspannung, bis zur Hochspannung. Wir wissen, wie Kabel riechen, was der Kollege an der Schirmmaschine zu verantworten hat, wie getestet wird. Wir kennen die wesentlichen Rohstoffhersteller, kennen die Risiken einer Fertigung, und können Werke vergleichen. Ob in alten oder neuen Gebäuden. Wer Jahrzehnte Audits und Präqualifikationen hinter sich hat, der weiß, wo er schauen muss. Und was die richtigen Fragen sind.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>02</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="ee8e3a2a-6dbb-4936-aa24-a2a489900578" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Maßgeschneiderte Lösungen für Ihr Projekt</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="e68fb13a-070e-458e-9a18-b0adb60ab50b" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Wenn es komplexer wird, binden wir unsere technischen Berater ein. Da braucht man Fachleute, die nicht gerade ihre Karriere gestartet haben. Da braucht es Leute die Normen lesen und verstehen, und manchmal mit begleitet haben. Die haben wir, und mit deren und unserer Erfahrung differenzieren wir uns vom einfachen Handel mit Kabeln</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>03</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="c714857c-00c3-44e4-98b9-e39eb7384fab" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Zuverlässigkeit, die Ihre Projekte auf Kurs hält</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="dca855b3-88c2-472f-92f9-1f9490411197" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Erreichbarkeit, schnell reagieren in einer schnelllebigen Welt. Sie haben noch Fragen nach 17 Uhr? Oder am Wochenende? Wir sind immer da. Und so haben wir unsere Partner entwickelt, damit wir als Team das realisieren, wofür Sie bezahlt haben. Und wenn mal doch was nicht gerade läuft, versteckt sich keiner.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>04</h6>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="1f8b2646-94f8-437d-b199-8b99c9f4b45d" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h4>Nachhaltigkeit ohne Kompromisse</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="50b94e59-2920-4a97-b8b4-daf5f83404bd" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Wir sind überzeugt davon, die Welt besser zu hinterlassen, als wir sie vorgefunden haben. Mit Initiativen wie unserem Trommelrückführungsservice und einem klaren Fokus auf Recycling sorgen wir dafür, dass jede Verbindung so umweltfreundlich wie möglich ist. Jeder unserer Partner hat entsprechende Zertifizierungen, die zunehmend von allen Kunden auch erwartet werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex flex-col relative items-end">
|
||||
<div class="pt-0">
|
||||
<div class="gizmo-shadow-stroke flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<div class="h-full w-full">
|
||||
<h3><strong>Das Team hinter KLZ</strong></h3>
|
||||
<p>Bei KLZ steckt die Energie nicht nur in den Kabeln, sondern vor allem im Team. Von erfahrenen Experten wie Michael und Klaus bis hin zu engagierten Planern, Logistikern und Kundenbetreuern – jeder spielt eine entscheidende Rolle. Gemeinsam verbinden wir jahrzehntelange Erfahrung mit innovativem Denken und dem klaren Ziel, zuverlässige Energielösungen zu liefern.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="0c8817e4-3d8c-41b1-9223-0468ae5ddd01" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2 style="text-align: center;">Vom einzelnen Draht zur grenzenlosen Energie – die <em>Zukunft</em> beginnt hier.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
title: Team – Deutsch
|
||||
excerpt: >-
|
||||
[vc_row type=”full_width_background”
|
||||
full_screen_row_position=”middle”
|
||||
column_margin=”default” column_direction=”default”
|
||||
column_direction_tablet=”default”
|
||||
column_direction_phone=”default” bg_color=”#ffffff”
|
||||
bg_image=”10440″ bg_position=”center center”
|
||||
background_image_loading=”default”
|
||||
bg_repeat=”no-repeat” scene_position=”center”
|
||||
top_padding=”14%” bottom_padding=”12%”
|
||||
text_color=”light” text_align=”left”
|
||||
row_border_radius=”none”
|
||||
row_border_radius_applies=”bg” overflow=”visible”
|
||||
enable_gradient=”true” color_overlay=”#0a0000″
|
||||
color_overlay_2=”rgba(10,10,10,0.5)”
|
||||
overlay_strength=”0.8″
|
||||
gradient_direction=”left_to_right”
|
||||
shape_divider_color=”#ffffff”
|
||||
shape_divider_position=”bottom”
|
||||
shape_divider_height=”350″
|
||||
bg_image_animation=”none”…
|
||||
featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
# Team – Deutsch
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="c62f4969-7567-4dbf-99d2-97426de29e09" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p><strong>Die Köpfe, die Energie zum Laufen bringen</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden @container/thread">
|
||||
<div class="h-full">
|
||||
<div class="react-scroll-to-bottom--css-jvmup-79elbk h-full">
|
||||
<div class="react-scroll-to-bottom--css-jvmup-1n7m0yu">
|
||||
<div class="flex flex-col text-sm md:pb-9">
|
||||
<article class="w-full scroll-mb-[var(--thread-trailing-height,150px)] text-token-text-primary focus-visible:outline-2 focus-visible:outline-offset-[-4px]" dir="auto" data-testid="conversation-turn-19" data-scroll-anchor="true">
|
||||
<div class="m-auto text-base py-[18px] px-3 md:px-4 w-full md:px-5 lg:px-4 xl:px-5">
|
||||
<div class="mx-auto flex flex-1 gap-4 text-base md:gap-5 lg:gap-6 md:max-w-3xl lg:max-w-[40rem] xl:max-w-[48rem]">
|
||||
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
|
||||
<div class="flex-col gap-1 md:gap-3">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="9b042263-4f19-47df-a312-d13f7eb5e2b1" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="bcfa4bbf-0457-47d6-8a1e-7ec5a650fc98" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>Wir verbinden Energie, Know-how und Innovation, um eine nachhaltigere Zukunft zu gestalten.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Michael Bodemer</h1>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="66eb3f45-dd35-419f-8c8e-be50fee94d71" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>Herausforderungen sind da, um gelöst zu werden – nicht, um über ihre Komplexität zu diskutieren.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="637cd8c0-70ac-4835-b453-5f50c9c188eb" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Michael Bodemer ist unser Mann, wenn es kompliziert wird – und das ist bei Kabelnetzen oft der Fall. Mit seinem scharfen Blick und einem Händchen für praktikable Lösungen ist er eine unserer zentralen Säulen. Michael denkt nicht nur an Details, er treibt Projekte voran – sei es in der Planung, im Kundengespräch oder bei der Auswahl der besten Kabel für jedes Vorhaben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/vcf/michael-bodemer" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 no-underline">
|
||||
vCard Michael Bodemer herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3>Verbindungen, die Geschichte schreiben</h3>
|
||||
<p>Bei KLZ vereinen wir Tradition und Innovation zu zuverlässigen Energielösungen. Unsere Wurzeln reichen tief in die Geschichte der Kabeltechnologie zurück – mit jeder Menge praktischer Erfahrung und einem Blick für zukunftsweisende Entwicklungen.</p>
|
||||
<p>In jedem Projekt steckt nicht nur technisches Know-how, sondern auch das Bewusstsein für das Handwerk, das die Welt seit Generationen verbindet. Historische Illustrationen aus den frühen Tagen der Energiebranche erinnern uns daran, wie weit wir gekommen sind – und dass echte Exzellenz immer mit Sorgfalt beginnt.
|
||||
<h1>Klaus Mintel</h1>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="d3bd1bc9-d279-4699-991f-cd5809bda6d7" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="58971071-dfeb-4164-b61b-b73c04879b2c" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="60674c1d-d9f3-43f5-baa6-5d0effc3ada4" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>Klaus ist der Fels in der Brandung – selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/vcf/klaus-mintel" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 no-underline">
|
||||
vCard Klaus Mintel herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>Unser Manifest</h2>
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
title: Contact – English
|
||||
excerpt: '[vc_column column_padding=”no-extra-padding”…'
|
||||
featuredImage: null
|
||||
locale: en
|
||||
---
|
||||
# Contact – English
|
||||
|
||||
<h5>How can we help you?</h5>
|
||||
<h2>Have a project in mind?</h2>
|
||||
|
||||
<p style="text-align: left;"><div class="frm_forms with_frm_style frm_style_klz" id="frm_form_1_container" data-token="1bab1f2ae16527f65d9f48545407888d" data-token="1bab1f2ae16527f65d9f48545407888d">
|
||||
<form enctype="multipart/form-data" method="post" class="frm-show-form frm_pro_form " id="form_contact-english" data-token="1bab1f2ae16527f65d9f48545407888d" data-token="1bab1f2ae16527f65d9f48545407888d">
|
||||
<div class="frm_form_fields ">
|
||||
<fieldset>
|
||||
<legend class="frm_screen_reader">Contact Us - English</legend>
|
||||
|
||||
<div class="frm_fields_container">
|
||||
<input type="hidden" name="frm_action" value="create" />
|
||||
<input type="hidden" name="form_id" value="1" />
|
||||
<input type="hidden" name="frm_hide_fields_1" id="frm_hide_fields_1" value="" />
|
||||
<input type="hidden" name="form_key" value="contact-english" />
|
||||
<input type="hidden" name="item_meta[0]" value="" />
|
||||
<input type="hidden" id="frm_submit_entry_1" name="frm_submit_entry_1" value="2aee9616df" /><input type="hidden" name="_wp_http_referer" value="/wp-json/wp/v2/pages?per_page=100&page=1&_embed=true" /><div id="frm_field_1_container" class="frm_form_field form-field frm_required_field frm_top_container frm_first frm_half">
|
||||
<label for="field_qh4icy" id="field_qh4icy_label" class="frm_primary_label">Name
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_qh4icy" name="item_meta[1]" value="" data-reqmsg="Name cannot be blank." aria-required="true" data-invmsg="Name is invalid" aria-invalid="false" aria-describedby="frm_desc_field_qh4icy" />
|
||||
<div class="frm_description" id="frm_desc_field_qh4icy">First Name</div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_2_container" class="frm_form_field form-field frm_required_field frm_hidden_container frm_half">
|
||||
<label for="field_ocfup1" id="field_ocfup1_label" class="frm_primary_label">Last
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_ocfup1" name="item_meta[2]" value="" data-reqmsg="Last cannot be blank." aria-required="true" data-invmsg="Last is invalid" aria-invalid="false" aria-describedby="frm_desc_field_ocfup1" />
|
||||
<div class="frm_description" id="frm_desc_field_ocfup1">Last Name</div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_3_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_29yf4d" id="field_29yf4d_label" class="frm_primary_label">Email
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="email" id="field_29yf4d" name="item_meta[3]" value="" data-reqmsg="Email cannot be blank." aria-required="true" data-invmsg="Please enter a valid email address" aria-invalid="false" />
|
||||
|
||||
</div>
|
||||
<div id="frm_field_4_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_e6lis6" id="field_e6lis6_label" class="frm_primary_label">Subject
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input type="text" id="field_e6lis6" name="item_meta[4]" value="" data-reqmsg="Subject cannot be blank." aria-required="true" data-invmsg="Subject is invalid" aria-invalid="false" />
|
||||
|
||||
</div>
|
||||
<div id="frm_field_5_container" class="frm_form_field form-field frm_required_field frm_top_container frm_full">
|
||||
<label for="field_9jv0r1" id="field_9jv0r1_label" class="frm_primary_label">Message
|
||||
<span class="frm_required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<textarea name="item_meta[5]" id="field_9jv0r1" rows="5" data-reqmsg="Message cannot be blank." aria-required="true" data-invmsg="Message is invalid" aria-invalid="false" ></textarea>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_14_container" class="frm_form_field form-field frm_none_container">
|
||||
<label for="g-recaptcha-response" id="field_cxwsw_label" class="frm_primary_label">Captcha
|
||||
<span class="frm_required" aria-hidden="true"></span>
|
||||
</label>
|
||||
<div id="field_cxwsw" class="frm-g-recaptcha" data-sitekey="6LczZ7wqAAAAANwlgLaISgENVDZ1rTPe6LnTJgEk" data-size="invisible" data-theme="light"></div>
|
||||
|
||||
</div>
|
||||
<div id="frm_field_6_container" class="frm_form_field form-field ">
|
||||
<div class="frm_submit frm_flex">
|
||||
<button class="frm_button_submit frm_final_submit" type="submit" formnovalidate="formnovalidate">Submit</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="item_key" value="" />
|
||||
<div id="frm_field_28_container">
|
||||
<label for="field_bx5bs" >
|
||||
If you are human, leave this field blank. </label>
|
||||
<input id="field_bx5bs" type="text" class="frm_form_field form-field frm_verify" name="item_meta[28]" value="" />
|
||||
</div>
|
||||
<input name="frm_state" type="hidden" value="LzUMsLqCGT3RC/3NDZwBsza9Nq38Ndzzi8fs2DDCQq3+8BPjktzvi4c9uX1qIOIg" /></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
KLZ Cables<br />
|
||||
Raiffeisenstraße 22<br />
|
||||
73630 Remshalden
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
title: Home – English
|
||||
excerpt: >-
|
||||
[vc_row type=”full_width_background”
|
||||
full_screen_row_position=”middle”
|
||||
column_margin=”default” equal_height=”yes”
|
||||
content_placement=”bottom” column_direction=”default”
|
||||
column_direction_tablet=”default”
|
||||
column_direction_phone=”default” bg_color=”#d1d1ca”
|
||||
bg_image=”45569″ bg_position=”center bottom”
|
||||
background_image_loading=”lazy-load”
|
||||
bg_repeat=”no-repeat” video_bg=”use_video”
|
||||
video_mp4=”/uploads/2025/02/header.mp4″
|
||||
video_webm=”/uploads/2025/02/header.webm”
|
||||
background_video_loading=”lazy-load”
|
||||
scene_position=”center” top_padding=”15%”
|
||||
bottom_padding=”13%” text_color=”light”
|
||||
text_align=”left” row_border_radius=”none”
|
||||
row_border_radius_applies=”bg” overflow=”visible”
|
||||
enable_gradient=”true”
|
||||
color_overlay=”rgba(0,0,0,0.01)”
|
||||
color_overlay_2=”rgba(0,0,0,0.32)”…
|
||||
featuredImage: null
|
||||
locale: en
|
||||
---
|
||||
# Home – English
|
||||
|
||||
<h1><strong>We are helping to expand the energy cable networks for a <em>green</em> future</strong></h1>
|
||||
|
||||
<h4>Low Voltage Cables</h4>
|
||||
<p><small>Powering everyday essentials with reliability and safety.</small>
|
||||
<h4>Medium Voltage Cables</h4>
|
||||
<p><small>The perfect balance between power and performance for industrial and urban grids.</small>
|
||||
<h4>High Voltage</h4>
|
||||
<p>Delivering maximum power over long distances—without compromise.
|
||||
<h4>Solar Cables</h4>
|
||||
<p>Connecting the sun’s energy to your sustainable future.[fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6486″ image_url=”6521″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Powering everyday essentials with reliability and safety.” link_url=”/power-cables/low-voltage-cables/”]
|
||||
<h3>Low Voltage Cables</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6487″ image_url=”6517″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”” min_height=”500″ hover_content=”The perfect balance between power and performance for industrial and urban grids.” link_url=”/power-cables/medium-voltage-cables/”]
|
||||
<h3>Medium Voltage Cables</h3>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6485″ image_url=”6527″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Delivering maximum power over long distances—without compromise.” link_url=”/power-cables/high-voltage-cables/”]
|
||||
<h3>High Voltage Cables</h3>
|
||||
<h5></h5>
|
||||
[/fancy_box][fancy_box box_style=”hover_desc” icon_family=”custom” custom_icon_image=”6484″ image_url=”6519″ hover_color=”accent-color” hover_desc_color_opacity=”default” hover_desc_hover_overlay_opacity=”default” icon_position=”bottom” box_alignment=”left” hover_desc_bg_animation=”long_zoom” border_radius=”default” image_loading=”lazy-load” color_scheme=”dark” secondary_content=”here’s some awesome text that would only be shown on hover” min_height=”500″ hover_content=”Connecting the sun’s energy to your sustainable future.” link_url=”/solar-cables”]
|
||||
<h3>Solar Cables</h3>
|
||||
[/fancy_box]
|
||||
<h3>What we do</h3>
|
||||
We ensure that the electricity flows – with quality-tested cables. From low voltage up to high voltage
|
||||
<h6>01</h6>
|
||||
|
||||
<h4>Supply to energy suppliers, wind and solar parks, industry and trade</h4>
|
||||
We support your projects from 1 to 220 kV, from simple NYY to high-voltage cables with segment conductors and aluminum sheaths, with a particular focus on medium-voltage cables. Whether NA2XS(F)2Y in standard design, or up to 1200 mm2 cross-section, with thick sheathing or in the desired lengths. We have partners with an enormous variety.
|
||||
<h6>02</h6>
|
||||
|
||||
<h4>Supply of cables whose quality is certified</h4>
|
||||
Cables are products that have to function 100%. For decades, often 80 to 100 years. Our cables are not only approved by VDE. The most well-known energy suppliers in Germany, the Netherlands and Austria trust us and our manufacturers. And often the requirements are even higher than those of the already strict VDE regulations.
|
||||
<h6>03</h6>
|
||||
|
||||
<h4>We deliver on time because we know the consequences for you</h4>
|
||||
Wind farm North Germany, coordinates XYZ, delivery Wednesday 2-4 p.m., no unloading option. Yes, we know that. We organize the logistics with a back office team that has up to 20 years of cable experience. Customs clearance and proper paperwork included.
|
||||
<h6>04</h6>
|
||||
|
||||
<h4>The cable alone is not the solution</h4>
|
||||
Stony ground? Perhaps a thicker outer sheath would be better? Damp ground? Can there be transverse watertight protection in addition to the longitudinal watertight tape? Longer individual lengths, but no thought given to the limitations of the laying crane? Or often underestimated? What can the floor in the warehouse support? A copper cable can easily weigh 10 tons per kilometer. We think for you and ask questions.
|
||||
<h3><strong>Decades of experience rooted in cable history</strong></h3>
|
||||
<p>At KLZ, cables run in our veins. Klaus began his journey at the renowned Felten & Guilleaume, following in the footsteps of his parents, who dedicated their lives to the same iconic company. For Klaus, this isn’t just work – it’s a legacy built on craftsmanship, innovation, and pride.</p>
|
||||
<p>We honor this history with original illustrations from Felten & Guilleaume’s era, once used as postcards. These images remind us of the generations who wired the world together – a tradition we proudly continue today.
|
||||
<h3>Why choose us</h3>
|
||||
Experience prevents many mistakes, but we learn something new every day
|
||||
<h6>01</h6>
|
||||
|
||||
<h4>Expertise with depth</h4>
|
||||
Our team has decades of experience – far beyond the founding of KLZ in 2009. The entire team has over 100 years of cable experience, gained in a wide variety of plants, from low voltage to medium voltage to high voltage. We know what cables smell like, what the colleague at the shielding machine is responsible for how testing is carried out. We know the main raw material manufacturers, know the risks of production, and can compare plants. Whether in old or new buildings. Anyone who has decades of audits and prequalification behind them knows where to look. And what are the right questions.
|
||||
<h6>02</h6>
|
||||
|
||||
<h4>Tailor-made solutions for your project</h4>
|
||||
When things get more complex, we involve our technical consultants. That’s where you need experts who haven’t just started their careers. You need people who read and understand standards and have sometimes been involved. We have them, and with their and our experience we differentiate ourselves from simple cable trading
|
||||
<h6>03</h6>
|
||||
|
||||
<h4>Reliability that keeps your projects on track</h4>
|
||||
Accessibility, quick response in a fast-moving world. Do you still have questions after 5 p.m.? Or at the weekend? We are always there. And that is how we have developed our partners so that as a team we can realize what you have paid for. And if something does not go well, no one hides.
|
||||
<h6>04</h6>
|
||||
|
||||
<h4>Sustainability without compromise</h4>
|
||||
We are convinced that we will leave the world better than we found it. With initiatives such as our drum return service and a clear focus on recycling, we ensure that every connection is as environmentally friendly as possible. Each of our partners has the appropriate certificates, which are increasingly expected by all customers.</p>
|
||||
<p>At KLZ we focus on precise, reliable and uncomplicated solutions for the energy of the future.
|
||||
<div class="flex-shrink-0 flex flex-col relative items-end">
|
||||
<div>
|
||||
<div class="pt-0">
|
||||
<div class="gizmo-shadow-stroke flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<div class="h-full w-full">
|
||||
<h3 class="gizmo-shadow-stroke overflow-hidden rounded-full"><strong>Meet the team behind KLZ</strong></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
|
||||
<div class="flex-col gap-1 md:gap-3">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="18b243fa-d554-47d5-a716-421a97340912" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<p>At KLZ, our team is the power behind the cables. From seasoned experts like Michael and Klaus to a dedicated group of planners, logistics specialists, and customer support professionals, every member plays a vital role. Together, we combine decades of experience, innovative thinking, and a shared commitment to delivering reliable energy solutions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="text-align: center;">From a single strand to infinite power – the <em>future</em> starts here.</h2>
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Team – English
|
||||
excerpt: >-
|
||||
[vc_row type=”full_width_background”
|
||||
full_screen_row_position=”middle”
|
||||
column_margin=”default” column_direction=”default”
|
||||
column_direction_tablet=”default”
|
||||
column_direction_phone=”default” bg_color=”#ffffff”
|
||||
bg_image=”10440″ bg_position=”center center”
|
||||
background_image_loading=”default”
|
||||
bg_repeat=”no-repeat” scene_position=”center”
|
||||
top_padding=”14%” bottom_padding=”12%”
|
||||
text_color=”light” text_align=”left”
|
||||
row_border_radius=”none”
|
||||
row_border_radius_applies=”bg” overflow=”visible”
|
||||
enable_gradient=”true” color_overlay=”#0a0000″
|
||||
color_overlay_2=”rgba(10,10,10,0.5)”
|
||||
overlay_strength=”0.8″
|
||||
gradient_direction=”left_to_right”
|
||||
shape_divider_color=”#ffffff”
|
||||
shape_divider_position=”bottom”
|
||||
shape_divider_height=”350″
|
||||
bg_image_animation=”none”…
|
||||
featuredImage: null
|
||||
locale: en
|
||||
---
|
||||
# Team – English
|
||||
|
||||
<h5>The bright sparks behind the power</h5>
|
||||
|
||||
<div class="flex-1 overflow-hidden @container/thread">
|
||||
<div class="h-full">
|
||||
<div class="react-scroll-to-bottom--css-jvmup-79elbk h-full">
|
||||
<div class="react-scroll-to-bottom--css-jvmup-1n7m0yu">
|
||||
<div class="flex flex-col text-sm md:pb-9">
|
||||
<article class="w-full scroll-mb-[var(--thread-trailing-height,150px)] text-token-text-primary focus-visible:outline-2 focus-visible:outline-offset-[-4px]" dir="auto" data-testid="conversation-turn-19" data-scroll-anchor="true">
|
||||
<div class="m-auto text-base py-[18px] px-3 md:px-4 w-full md:px-5 lg:px-4 xl:px-5">
|
||||
<div class="mx-auto flex flex-1 gap-4 text-base md:gap-5 lg:gap-6 md:max-w-3xl lg:max-w-[40rem] xl:max-w-[48rem]">
|
||||
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
|
||||
<div class="flex-col gap-1 md:gap-3">
|
||||
<div class="flex max-w-full flex-col flex-grow">
|
||||
<div class="min-h-8 text-message flex w-full flex-col items-end gap-2 whitespace-normal break-words text-start [.text-message+&]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="9b042263-4f19-47df-a312-d13f7eb5e2b1" data-message-model-slug="gpt-4o">
|
||||
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-[3px]">
|
||||
<div class="markdown prose w-full break-words dark:prose-invert dark">
|
||||
<h2>We connect energy, expertise, and innovation to power a greener future.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Michael Bodemer</h1>
|
||||
|
||||
<h2>Challenges exist to be solved, not to debate how complicated they are.</h2>
|
||||
Michael Bodemer is the go-to guy when things get complicated—and let’s face it, that’s often the case with cable networks. With sharp insight and a knack for practical solutions, Michael is one of our key players. He’s not just detail-oriented; he’s a driving force—whether it’s in planning, customer interactions, or securing the best cables for every project.
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/vcf/michael-bodemer" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 no-underline">
|
||||
Download vCard Michael Bodemer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3><strong>A Legacy of Excellence in Every Connection</strong></h3>
|
||||
<p>At KLZ, our expertise is built on generations of dedication to the energy industry. With decades of hands-on experience, we’ve grown alongside the evolution of cable technology, combining traditional craftsmanship with modern innovation. Each project we take on reflects a deep understanding of what it takes to create lasting, reliable energy solutions.</p>
|
||||
<p>Paired with historic illustrations from the industry’s early days, our story is a reminder of how far cables have come – and how much care has always gone into connecting the world.
|
||||
<h1>Klaus Mintel</h1>
|
||||
|
||||
<h2>Sometimes all it takes is a clear head and a good cable to make the world a little better.</h2>
|
||||
Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isn’t just a problem solver; he’s a strategic thinker who knows how to get to the point with a touch of humor.
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/vcf/klaus-mintel" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 no-underline">
|
||||
Download vCard Klaus Mintel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>Our manifesto</h2>
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: Thanks – English
|
||||
excerpt: '[vc_column…'
|
||||
featuredImage: null
|
||||
locale: en
|
||||
---
|
||||
# Thanks – English
|
||||
|
||||
<h2>Thank you very much!</h2>
|
||||
<p>We’ve received your message and will get back to you as soon as possible. Our team is already rolling up their sleeves to assist you!JTNDJTIxLS0lMjBHb29nbGUlMjB0YWclMjAlMjhndGFnLmpzJTI5JTIwLS0lM0UlMjAlM0NzY3JpcHQlMjBhc3luYyUyMHNyYyUzRCUyMmh0dHBzJTNBJTJGJTJGd3d3Lmdvb2dsZXRhZ21hbmFnZXIuY29tJTJGZ3RhZyUyRmpzJTNGaWQlM0RBVy0xNzA5NTg5MjIzOCUyMiUzRSUzQyUyRnNjcmlwdCUzRSUyMCUzQ3NjcmlwdCUzRSUyMHdpbmRvdy5kYXRhTGF5ZXIlMjAlM0QlMjB3aW5kb3cuZGF0YUxheWVyJTIwJTdDJTdDJTIwJTVCJTVEJTNCJTIwZnVuY3Rpb24lMjBndGFnJTI4JTI5JTdCZGF0YUxheWVyLnB1c2glMjhhcmd1bWVudHMlMjklM0IlN0QlMjBndGFnJTI4JTI3anMlMjclMkMlMjBuZXclMjBEYXRlJTI4JTI5JTI5JTNCJTIwZ3RhZyUyOCUyN2NvbmZpZyUyNyUyQyUyMCUyN0FXLTE3MDk1ODkyMjM4JTI3JTI5JTNCJTIwJTNDJTJGc2NyaXB0JTNF
|
||||
@@ -398,6 +398,24 @@ locale: de
|
||||
"55",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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
67
directus/schema/snapshot.yaml
Normal file
67
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
fields: []
|
||||
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: []
|
||||
@@ -1,27 +1,83 @@
|
||||
services:
|
||||
app:
|
||||
klz-app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npx next dev"
|
||||
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"
|
||||
- "traefik.http.routers.klz-app-local.rule=Host(`klz.localhost`)"
|
||||
- "traefik.http.routers.klz-app-local.entrypoints=web"
|
||||
- "traefik.http.routers.klz-app-local.service=klz-cables"
|
||||
# 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-directus-local.rule=Host(`cms.klz.localhost`)"
|
||||
- "traefik.http.routers.klz-directus-local.entrypoints=web"
|
||||
- "traefik.http.routers.klz-directus-local.service=klz-directus"
|
||||
- "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:
|
||||
- "8055:8055"
|
||||
- "${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,62 +1,63 @@
|
||||
services:
|
||||
app:
|
||||
klz-app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "traefik.http.routers.${PROJECT_NAME:-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.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(${TRAEFIK_HOST})"
|
||||
# HTTPS router (Standard)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||
|
||||
# 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=3000"
|
||||
- "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"
|
||||
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
# Middlewares
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
|
||||
|
||||
# Gatekeeper Router (to show the login page)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(${TRAEFIK_HOST}) && PathPrefix(`/gatekeeper`)"
|
||||
- "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"
|
||||
|
||||
# Middleware Definitions
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/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/klz-cables-gatekeeper:${IMAGE_TAG:-latest}
|
||||
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
PORT: 3000
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
directus:
|
||||
image: directus/directus:11
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
default:
|
||||
infra:
|
||||
aliases:
|
||||
- ${PROJECT_NAME:-klz-cables}-directus
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -75,23 +76,27 @@ services:
|
||||
# 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.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,${AUTH_MIDDLEWARE:-compress}"
|
||||
- "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:
|
||||
- infra
|
||||
- default
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -102,6 +107,8 @@ services:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||
infra:
|
||||
external: true
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
env_file:
|
||||
- .env
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
||||
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)"
|
||||
- "traefik.http.routers.klz-cables.entrypoints=websecure"
|
||||
- "traefik.http.routers.klz-cables.tls.certresolver=le"
|
||||
- "traefik.http.routers.klz-cables.tls=true"
|
||||
- "traefik.http.routers.klz-cables.service=klz-cables"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
# Middlewares
|
||||
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
|
||||
|
||||
cms:
|
||||
build:
|
||||
context: ./cms
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_CLIENT: postgres
|
||||
DATABASE_HOST: cms-db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_NAME: ${STRAPI_DATABASE_NAME:-strapi}
|
||||
DATABASE_USERNAME: ${STRAPI_DATABASE_USERNAME:-strapi}
|
||||
DATABASE_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
STRAPI_URL: ${STRAPI_URL:-https://cms.klz-cables.com}
|
||||
volumes:
|
||||
- ./cms/config:/opt/app/config
|
||||
- ./cms/src:/opt/app/src
|
||||
- ./cms/package.json:/opt/app/package.json
|
||||
- ./cms/package-lock.json:/opt/app/package-lock.json
|
||||
- ./cms/public/uploads:/opt/app/public/uploads
|
||||
- ./cms/dist:/opt/app/dist
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.klz-cms.rule=Host(`cms.klz-cables.com`) || Host(`cms-staging.klz-cables.com`)"
|
||||
- "traefik.http.routers.klz-cms.entrypoints=websecure"
|
||||
- "traefik.http.routers.klz-cms.tls.certresolver=le"
|
||||
- "traefik.http.routers.klz-cms.tls=true"
|
||||
- "traefik.http.services.klz-cms.loadbalancer.server.port=1337"
|
||||
|
||||
cms-db:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_DB: ${STRAPI_DATABASE_NAME:-strapi}
|
||||
POSTGRES_USER: ${STRAPI_DATABASE_USERNAME:-strapi}
|
||||
POSTGRES_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
|
||||
volumes:
|
||||
- cms-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
cms-db-data:
|
||||
@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
|
||||
|
||||
## 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:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `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 |
|
||||
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
|
||||
| Variable | Required | Description |
|
||||
| ---------------------- | -------- | ------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
||||
| `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
|
||||
| `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
|
||||
|
||||
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
||||
|
||||
@@ -58,38 +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:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
||||
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
||||
| `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 |
|
||||
| Variable | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------ |
|
||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
||||
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
||||
| `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
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and fill in your local configuration:
|
||||
|
||||
```bash
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -97,6 +99,7 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
@@ -112,8 +115,8 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
||||
# Build with build-time arguments
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
||||
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||
-t klz-cables:local .
|
||||
|
||||
# Run with runtime environment file
|
||||
@@ -138,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
|
||||
|
||||
**Build-Time Variables:**
|
||||
- `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
|
||||
- `UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||
- `UMAMI_API_ENDPOINT` - Umami API endpoint
|
||||
|
||||
**Runtime Variables:**
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
@@ -209,11 +212,12 @@ docker-compose logs -f app
|
||||
**Problem**: Build fails with "Environment validation failed"
|
||||
|
||||
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
||||
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||
-t klz-cables .
|
||||
```
|
||||
|
||||
@@ -222,6 +226,7 @@ docker build \
|
||||
**Problem**: Container starts but application crashes
|
||||
|
||||
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
||||
|
||||
```bash
|
||||
# On the server
|
||||
cat /home/deploy/sites/klz-cables.com/.env
|
||||
@@ -235,9 +240,11 @@ docker-compose logs app
|
||||
**Problem**: Features not working (email, analytics, etc.)
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Check that the secret is configured in Gitea
|
||||
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
||||
3. Redeploy to regenerate the `.env` file:
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "Trigger redeploy"
|
||||
git push origin main
|
||||
@@ -255,6 +262,7 @@ docker-compose logs app
|
||||
**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:
|
||||
|
||||
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
||||
2. Manually trigger a deployment by pushing to main
|
||||
3. If still missing, check server permissions and disk space
|
||||
@@ -264,6 +272,7 @@ docker-compose logs app
|
||||
**Problem**: Container can't connect to Traefik
|
||||
|
||||
**Solution**: Verify the `infra` network exists:
|
||||
|
||||
```bash
|
||||
docker network ls | grep 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)
|
||||
|
||||
❌ **Problems:**
|
||||
|
||||
- Environment variables passed individually via SSH (12+ vars)
|
||||
- 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
|
||||
- Difficult to maintain and error-prone
|
||||
|
||||
```yaml
|
||||
# Old deploy.yml - FRAGILE!
|
||||
ssh root@alpha.mintel.me \
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
```
|
||||
|
||||
### After (Clean & Robust)
|
||||
|
||||
✅ **Benefits:**
|
||||
|
||||
- Single `.env` file on server contains all runtime variables
|
||||
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
||||
- Clear separation: build-time vs runtime
|
||||
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
||||
### Step 1: Update Gitea Secrets
|
||||
|
||||
**Remove these secrets** (no longer needed in CI/CD):
|
||||
|
||||
- ❌ `MAIL_FROM`
|
||||
- ❌ `MAIL_HOST`
|
||||
- ❌ `MAIL_PASSWORD`
|
||||
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
||||
- ❌ `SENTRY_DSN` (from build args)
|
||||
|
||||
**Keep these secrets** (still needed for build):
|
||||
|
||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||
- ✅ `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
||||
- ✅ `NEXT_PUBLIC_UMAMI_SCRIPT_URL`
|
||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||
- ✅ `UMAMI_WEBSITE_ID`
|
||||
- ✅ `UMAMI_API_ENDPOINT`
|
||||
- ✅ `REGISTRY_USER`
|
||||
- ✅ `REGISTRY_PASS`
|
||||
- ✅ `ALPHA_SSH_KEY`
|
||||
@@ -81,8 +86,8 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=your-actual-id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking
|
||||
SENTRY_DSN=your-actual-dsn
|
||||
@@ -168,6 +173,7 @@ git push origin main
|
||||
```
|
||||
|
||||
The CI/CD workflow will:
|
||||
|
||||
1. Build with only `NEXT_PUBLIC_*` build args
|
||||
2. Push to registry
|
||||
3. SSH to server and run deploy.sh
|
||||
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||
| Aspect | Before | After |
|
||||
| ----------------- | ------------------------------- | ---------------------------- |
|
||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you need to rollback to the old system:
|
||||
|
||||
1. Revert the changes in git:
|
||||
|
||||
```bash
|
||||
git revert HEAD
|
||||
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?**
|
||||
|
||||
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
|
||||
nano /home/deploy/sites/klz-cables.com/.env
|
||||
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?**
|
||||
|
||||
A: Keep a secure backup outside the server:
|
||||
|
||||
```bash
|
||||
# Download from server
|
||||
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?**
|
||||
|
||||
A:
|
||||
A:
|
||||
|
||||
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
||||
2. Rotate all credentials in the file
|
||||
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
||||
@@ -267,6 +277,7 @@ If you encounter issues during migration:
|
||||
## Summary
|
||||
|
||||
The new system is:
|
||||
|
||||
- ✅ **Simpler**: One .env file instead of scattered variables
|
||||
- ✅ **Cleaner**: Clear separation of build vs runtime
|
||||
- ✅ **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)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
11
final_lint_output.txt
Normal file
11
final_lint_output.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
|
||||
> eslint .
|
||||
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
|
||||
3:17 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
||||
3:27 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 2 problems (0 errors, 2 warnings)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["node", "index.js"]
|
||||
@@ -1,60 +0,0 @@
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const GATEKEEPER_PASSWORD = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
const AUTH_COOKIE_NAME = 'klz_gatekeeper_session';
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ForwardAuth check endpoint
|
||||
app.get('/verify', (req, res) => {
|
||||
const session = req.cookies[AUTH_COOKIE_NAME];
|
||||
|
||||
if (session === GATEKEEPER_PASSWORD) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
// Traefik will use this to redirect if requested
|
||||
const originalUrl = req.headers['x-forwarded-uri'] || '/';
|
||||
const host = req.headers['x-forwarded-host'] || '';
|
||||
const proto = req.headers['x-forwarded-proto'] || 'https';
|
||||
|
||||
// Redirect to login
|
||||
res.redirect(`${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`);
|
||||
});
|
||||
|
||||
// Login page
|
||||
app.get('/gatekeeper/login', (req, res) => {
|
||||
res.render('login', {
|
||||
error: req.query.error ? 'Invalid password' : null,
|
||||
redirect: req.query.redirect || '/'
|
||||
});
|
||||
});
|
||||
|
||||
// Handle login
|
||||
app.post('/gatekeeper/login', (req, res) => {
|
||||
const { password, redirect } = req.body;
|
||||
|
||||
if (password === GATEKEEPER_PASSWORD) {
|
||||
res.cookie(AUTH_COOKIE_NAME, GATEKEEPER_PASSWORD, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
});
|
||||
return res.redirect(redirect || '/');
|
||||
}
|
||||
|
||||
res.redirect(`/gatekeeper/login?error=1&redirect=${encodeURIComponent(redirect || '/')}`);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Gatekeeper listening on port ${PORT}`);
|
||||
});
|
||||
922
gatekeeper/package-lock.json
generated
922
gatekeeper/package-lock.json
generated
@@ -1,922 +0,0 @@
|
||||
{
|
||||
"name": "klz-gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "klz-gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
},
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"filelist": "^1.0.4",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"jake": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "klz-gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple branded gatekeeper for Traefik ForwardAuth",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KLZ Cables | Access Control</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #001a4d;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
.accent-glow {
|
||||
box-shadow: 0 0 20px rgba(130, 237, 32, 0.4);
|
||||
}
|
||||
.scribble-animation {
|
||||
stroke-dasharray: 1000;
|
||||
stroke-dashoffset: 1000;
|
||||
animation: draw 2s ease-out forwards;
|
||||
}
|
||||
@keyframes draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center relative">
|
||||
<!-- Background Elements -->
|
||||
<div class="absolute inset-0 bg-grid pointer-events-none"></div>
|
||||
<div class="absolute top-0 right-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div class="absolute bottom-0 left-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
<div class="relative z-10 w-full max-w-md px-6">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-12">
|
||||
<svg class="h-16 w-auto" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="20" fill="#001a4d" />
|
||||
<path d="M30 30L70 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
|
||||
<path d="M70 30L30 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/5 backdrop-blur-xl border border-white/10 p-10 rounded-[40px] shadow-2xl">
|
||||
<h1 class="text-3xl font-black mb-2 tracking-tighter uppercase italic">
|
||||
KLZ <span class="text-[#82ed20]">Gatekeeper</span>
|
||||
</h1>
|
||||
<p class="text-white/60 text-sm mb-8">This environment is strictly protected.</p>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="bg-red-500/20 border border-red-500/50 text-red-200 p-4 rounded-2xl mb-6 text-sm flex items-center gap-3">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<%= error %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<form action="/gatekeeper/login" method="POST" class="space-y-6">
|
||||
<input type="hidden" name="redirect" value="<%= redirect %>">
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2 ml-4">Access Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autofocus
|
||||
class="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 focus:outline-none focus:border-[#82ed20]/50 transition-all text-lg tracking-widest text-center"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-[#82ed20] text-[#001a4d] font-black uppercase tracking-[0.2em] py-5 rounded-2xl hover:bg-[#82ed20]/90 transition-all accent-glow active:scale-[0.98]"
|
||||
>
|
||||
Enter Workspace →
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-[10px] font-bold text-white/20 uppercase tracking-[0.4em]">
|
||||
© 2026 KLZ Vertriebs GmbH
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +1,14 @@
|
||||
import {getRequestConfig} from 'next-intl/server';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
export default getRequestConfig(async ({requestLocale}) => {
|
||||
// This typically corresponds to the `[locale]` segment
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
|
||||
// Ensure that a valid locale is used
|
||||
if (!locale || !['en', 'de'].includes(locale)) {
|
||||
locale = 'en';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
},
|
||||
getMessageFallback({namespace, key, error}) {
|
||||
getMessageFallback({ namespace, key, error }) {
|
||||
const path = [namespace, key].filter((part) => part != null).join('.');
|
||||
if (error.code === 'MISSING_MESSAGE') {
|
||||
return path;
|
||||
}
|
||||
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[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
const posts = files
|
||||
.filter(file => file.endsWith('.mdx'))
|
||||
.map(file => {
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
@@ -55,14 +55,42 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
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 currentIndex = posts.findIndex(post => post.slug === slug);
|
||||
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return { prev: null, next: null };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Centralized configuration management for the application.
|
||||
* This file provides a type-safe way to access environment variables.
|
||||
*/
|
||||
import { envSchema, getRawEnv } from './env';
|
||||
import { getRawEnv } from './env';
|
||||
|
||||
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||
|
||||
@@ -11,33 +11,37 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||
* Throws if validation fails.
|
||||
*/
|
||||
function createConfig() {
|
||||
const env = envSchema.parse(getRawEnv());
|
||||
const env = getRawEnv();
|
||||
|
||||
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||
|
||||
return {
|
||||
env: env.NODE_ENV,
|
||||
isProduction: env.NODE_ENV === 'production',
|
||||
isDevelopment: env.NODE_ENV === 'development',
|
||||
isTest: env.NODE_ENV === 'test',
|
||||
target,
|
||||
isProduction: target === 'production' || !target,
|
||||
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,
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
// The proxied path used in the frontend
|
||||
proxyPath: '/stats/script.js',
|
||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
glitchtip: {
|
||||
// Use SENTRY_DSN for both server and client (proxied)
|
||||
dsn: env.SENTRY_DSN,
|
||||
// The proxied origin used in the frontend
|
||||
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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -65,6 +69,17 @@ function createConfig() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,15 +102,21 @@ export const config = {
|
||||
get env() {
|
||||
return getConfig().env;
|
||||
},
|
||||
get target() {
|
||||
return getConfig().target;
|
||||
},
|
||||
get isProduction() {
|
||||
return getConfig().isProduction;
|
||||
},
|
||||
get isStaging() {
|
||||
return getConfig().isStaging;
|
||||
},
|
||||
get isTesting() {
|
||||
return getConfig().isTesting;
|
||||
},
|
||||
get isDevelopment() {
|
||||
return getConfig().isDevelopment;
|
||||
},
|
||||
get isTest() {
|
||||
return getConfig().isTest;
|
||||
},
|
||||
get baseUrl() {
|
||||
return getConfig().baseUrl;
|
||||
},
|
||||
@@ -117,6 +138,18 @@ export const config = {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,7 +165,7 @@ export function getMaskedConfig() {
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: mask(c.analytics.umami.websiteId),
|
||||
scriptUrl: c.analytics.umami.scriptUrl,
|
||||
apiEndpoint: c.analytics.umami.apiEndpoint,
|
||||
enabled: c.analytics.umami.enabled,
|
||||
},
|
||||
},
|
||||
@@ -161,5 +194,12 @@ export function getMaskedConfig() {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
100
lib/directus.ts
100
lib/directus.ts
@@ -1,24 +1,47 @@
|
||||
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
|
||||
import { readItems, readCollections } from '@directus/sdk';
|
||||
import { createMintelDirectusClient, ensureDirectusAuthenticated } from '@mintel/next-utils';
|
||||
import { config } from './config';
|
||||
import { getServerAppServices } from './services/create-services.server';
|
||||
|
||||
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
|
||||
/**
|
||||
* Directus Schema Definitions
|
||||
*/
|
||||
export interface Schema {
|
||||
products: any[];
|
||||
categories: any[];
|
||||
contact_submissions: any[];
|
||||
product_requests: any[];
|
||||
translations: any[];
|
||||
categories_link: any[];
|
||||
}
|
||||
|
||||
// Use internal URL if on server to bypass Gatekeeper/Auth
|
||||
const effectiveUrl = typeof window === 'undefined' && internalUrl ? internalUrl : url;
|
||||
// Initialize client using Mintel standards (environment-aware)
|
||||
const client = createMintelDirectusClient<Schema>();
|
||||
|
||||
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.';
|
||||
}
|
||||
|
||||
export async function ensureAuthenticated() {
|
||||
if (token) {
|
||||
client.setToken(token);
|
||||
return;
|
||||
}
|
||||
if (adminEmail && password) {
|
||||
try {
|
||||
await client.login(adminEmail, password);
|
||||
} catch (e) {
|
||||
console.error('Failed to authenticate with Directus:', e);
|
||||
try {
|
||||
await ensureDirectusAuthenticated(client);
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||
}
|
||||
console.error(`Failed to authenticate with Directus:`, e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +66,8 @@ function mapDirectusProduct(item: any, locale: string): any {
|
||||
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,
|
||||
// Use standardized proxy path for assets to avoid CORS
|
||||
data_sheet_url: item.data_sheet ? `/api/directus/assets/${item.data_sheet}` : null,
|
||||
categories: (item.categories_link || [])
|
||||
.map((c: any) => c.categories_id?.translations?.[0]?.name)
|
||||
.filter(Boolean),
|
||||
@@ -61,6 +84,9 @@ export async function getProducts(locale: string = 'de') {
|
||||
);
|
||||
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 [];
|
||||
}
|
||||
@@ -86,6 +112,12 @@ export async function getProductBySlug(slug: string, locale: string = 'de') {
|
||||
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;
|
||||
}
|
||||
@@ -98,20 +130,29 @@ export async function checkHealth() {
|
||||
await ensureAuthenticated();
|
||||
await client.request(readCollections());
|
||||
} catch (e: any) {
|
||||
console.error('Directus authentication failed during health check:', e);
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||
}
|
||||
console.error('Directus authentication or collection-read failed during health check:', e);
|
||||
return {
|
||||
status: 'error',
|
||||
message:
|
||||
'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.',
|
||||
code: 'AUTH_FAILED',
|
||||
details: e.message,
|
||||
message: shouldShowDevErrors
|
||||
? `Directus Health Error: ${e.message || 'Unknown'}`
|
||||
: 'CMS is currently unavailable due to an internal authentication or connection error.',
|
||||
code: e.code || 'HEALTH_AUTH_FAILED',
|
||||
details: shouldShowDevErrors
|
||||
? { message: e.message, code: e.code, errors: e.errors }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Schema check (does the products table exist?)
|
||||
// 2. Schema check (does the contact_submissions table exist?)
|
||||
try {
|
||||
await client.request(readItems('products', { limit: 1 }));
|
||||
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' ||
|
||||
@@ -119,23 +160,30 @@ export async function checkHealth() {
|
||||
) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'The "products" collection is missing or inaccessible. Please sync your data.',
|
||||
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: `Schema error: ${e.message}`,
|
||||
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: error.message || 'An unexpected error occurred while connecting to the CMS.',
|
||||
message: formatError(error),
|
||||
code: error.code || 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
92
lib/env.ts
92
lib/env.ts
@@ -1,76 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Helper to treat empty strings as undefined.
|
||||
*/
|
||||
const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
||||
import { validateMintelEnv, mintelEnvSchema, withMintelRefinements } from '@mintel/next-utils';
|
||||
|
||||
/**
|
||||
* Environment variable schema.
|
||||
* Extends the default Mintel environment schema.
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
||||
const envExtension = {
|
||||
// Project specific overrides or additions
|
||||
AUTH_COOKIE_NAME: z.string().default('klz_gatekeeper_session'),
|
||||
|
||||
// Analytics
|
||||
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'),
|
||||
// Gatekeeper specifics not in base
|
||||
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
|
||||
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
|
||||
(val) => val === 'true' || val === true,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
|
||||
(val) => val === 'true' || val === true,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||
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()),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
INFRA_DIRECTUS_URL: z.string().url().optional(),
|
||||
INFRA_DIRECTUS_TOKEN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects all environment variables from the process.
|
||||
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
|
||||
* Full schema including Mintel base and refinements
|
||||
*/
|
||||
export const envSchema = withMintelRefinements(z.object(mintelEnvSchema).extend(envExtension));
|
||||
|
||||
/**
|
||||
* Validated environment object.
|
||||
*/
|
||||
export const env = validateMintelEnv(envExtension);
|
||||
|
||||
/**
|
||||
* For legacy compatibility with existing code.
|
||||
*/
|
||||
export function getRawEnv() {
|
||||
return {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_HOST: process.env.MAIL_HOST,
|
||||
MAIL_PORT: process.env.MAIL_PORT,
|
||||
MAIL_USERNAME: process.env.MAIL_USERNAME,
|
||||
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
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,
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
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,43 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { render } from "@react-email/components";
|
||||
import { ReactElement } from "react";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
import { config } from "../config";
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '../config';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
secure: config.mail.port === 465,
|
||||
auth: {
|
||||
user: config.mail.user,
|
||||
pass: config.mail.pass,
|
||||
},
|
||||
});
|
||||
let transporterInstance: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (transporterInstance) return transporterInstance;
|
||||
|
||||
if (!config.mail.host) {
|
||||
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 {
|
||||
to?: string | string[];
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
template: ReactElement;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
||||
const html = await render(template);
|
||||
|
||||
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
|
||||
const recipients = to || config.mail.recipients;
|
||||
|
||||
const mailOptions = {
|
||||
from: config.mail.from,
|
||||
to: recipients,
|
||||
replyTo,
|
||||
subject,
|
||||
html,
|
||||
};
|
||||
@@ -35,11 +45,12 @@ export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
||||
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
logger.info("Email sent successfully", { messageId: info.messageId, subject, recipients });
|
||||
const info = await getTransporter().sendMail(mailOptions);
|
||||
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
logger.error("Error sending email", { error, subject, recipients });
|
||||
return { success: false, error };
|
||||
const errorMsg = error instanceof Error ? error.message : String(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;
|
||||
}
|
||||
|
||||
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> {
|
||||
// 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`);
|
||||
|
||||
@@ -41,7 +91,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
if (!fs.existsSync(enFilePath)) {
|
||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
@@ -49,7 +99,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: true
|
||||
isFallback: true,
|
||||
} as ProductFrontmatter & { isFallback?: boolean },
|
||||
content,
|
||||
};
|
||||
@@ -67,7 +117,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -77,9 +132,9 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(productsDir)) return [];
|
||||
|
||||
|
||||
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[]> {
|
||||
@@ -91,6 +146,19 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
const pages = await Promise.all(
|
||||
files
|
||||
.filter(file => file.endsWith('.mdx'))
|
||||
.map(file => {
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = { content: fs.readFileSync(filePath, 'utf8') };
|
||||
const { data, content } = matter(fileContent.content);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
content,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config } from './config';
|
||||
|
||||
export const SITE_URL = config.baseUrl || 'https://klz-cables.com';
|
||||
export const SITE_URL = (config.baseUrl as string) || 'https://klz-cables.com';
|
||||
export const LOGO_URL = `${SITE_URL}/logo.png`;
|
||||
|
||||
export const getOrganizationSchema = () => ({
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-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;
|
||||
};
|
||||
import { config } from '../../config';
|
||||
import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* It provides type-safe event tracking and pageview tracking.
|
||||
* This version implements the Umami tracking protocol directly via fetch,
|
||||
* eliminating the need to load an external script.js file.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Service creation (usually done by create-services.ts)
|
||||
* 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,
|
||||
* });
|
||||
* ```
|
||||
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||
* On the server, it sends directly to the internal Umami API.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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,
|
||||
* });
|
||||
* ```
|
||||
* Set the server-side context for the current request.
|
||||
* This allows the service to use real request headers for tracking.
|
||||
*/
|
||||
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;
|
||||
|
||||
// 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' });
|
||||
logger.info('Sending analytics event', { eventName, props });
|
||||
// On the client, we don't need the websiteId (it's injected by the server-side proxy handler).
|
||||
// On the server, we need it because we're calling the Umami API directly.
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
fetch(`${umamiUrl}/api/send`, {
|
||||
method: 'POST',
|
||||
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 });
|
||||
});
|
||||
if (!isClient && !this.websiteId) {
|
||||
this.logger.warn('Umami tracking called on server but no Website ID configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
||||
umami?.track?.(eventName, props);
|
||||
try {
|
||||
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.
|
||||
*
|
||||
* 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) {
|
||||
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 || !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);
|
||||
this.sendPayload('event', {
|
||||
url:
|
||||
url ||
|
||||
(typeof window !== 'undefined'
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service';
|
||||
import type { CacheService } from './cache/cache-service';
|
||||
import type { ErrorReportingService } from './errors/error-reporting-service';
|
||||
import type { LoggerService } from './logging/logger-service';
|
||||
import type { NotificationService } from './notifications/notification-service';
|
||||
|
||||
// Simple constructor-based DI container.
|
||||
export class AppServices {
|
||||
@@ -9,6 +10,7 @@ export class AppServices {
|
||||
public readonly analytics: AnalyticsService,
|
||||
public readonly errors: ErrorReportingService,
|
||||
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 { GlitchtipErrorReportingService } from './errors/glitchtip-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 { config, getMaskedConfig } from '../config';
|
||||
|
||||
@@ -13,7 +17,7 @@ export function getServerAppServices(): AppServices {
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger = new PinoLoggerService('server');
|
||||
|
||||
|
||||
logger.info('Initializing server application services', {
|
||||
environment: getMaskedConfig(),
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -23,10 +27,11 @@ export function getServerAppServices(): AppServices {
|
||||
umamiEnabled: config.analytics.umami.enabled,
|
||||
sentryEnabled: config.errors.glitchtip.enabled,
|
||||
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||
gotifyEnabled: config.notifications.gotify.enabled,
|
||||
});
|
||||
|
||||
const analytics = config.analytics.umami.enabled
|
||||
? new UmamiAnalyticsService({ enabled: true })
|
||||
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (config.analytics.umami.enabled) {
|
||||
@@ -35,12 +40,28 @@ export function getServerAppServices(): AppServices {
|
||||
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
|
||||
? new GlitchtipErrorReportingService({ enabled: true })
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
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 {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
@@ -53,10 +74,9 @@ export function getServerAppServices(): AppServices {
|
||||
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');
|
||||
|
||||
|
||||
return singleton;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AppServices } from './app-services';
|
||||
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
||||
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||
import { NoopLoggerService } from './logging/noop-logger-service';
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { NoopNotificationService } from './notifications/gotify-notification-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
/**
|
||||
@@ -27,9 +29,8 @@ let singleton: AppServices | undefined;
|
||||
* - Cache service (in-memory)
|
||||
*
|
||||
* The services are configured based on environment variables:
|
||||
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
||||
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||
*
|
||||
* @returns {AppServices} The application services singleton
|
||||
*
|
||||
@@ -71,9 +72,7 @@ export function getAppServices(): AppServices {
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger =
|
||||
typeof window === 'undefined'
|
||||
? new PinoLoggerService('server')
|
||||
: new NoopLoggerService();
|
||||
typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService();
|
||||
|
||||
// Log initialization
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -101,12 +100,8 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// Create analytics service (Umami or no-op)
|
||||
// Use dynamic import to avoid importing server-only code in client components
|
||||
const analytics = umamiEnabled
|
||||
? (() => {
|
||||
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
|
||||
return new UmamiAnalyticsService({ enabled: true });
|
||||
})()
|
||||
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (umamiEnabled) {
|
||||
@@ -115,13 +110,19 @@ export function getAppServices(): AppServices {
|
||||
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)
|
||||
const errors = sentryEnabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true })
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
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 {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
@@ -138,9 +139,9 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// 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');
|
||||
|
||||
|
||||
return singleton;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,15 @@ export type ErrorReportingUser = {
|
||||
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
|
||||
|
||||
export interface ErrorReportingService {
|
||||
captureException(error: unknown, context?: Record<string, unknown>): string | undefined;
|
||||
captureMessage(message: string, level?: ErrorReportingLevel): string | undefined;
|
||||
captureException(
|
||||
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;
|
||||
setTag(key: string, value: string): void;
|
||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
ErrorReportingService,
|
||||
ErrorReportingUser,
|
||||
} from './error-reporting-service';
|
||||
import type { NotificationService } from '../notifications/notification-service';
|
||||
import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
type SentryLike = typeof Sentry;
|
||||
|
||||
@@ -13,14 +15,36 @@ export type GlitchtipErrorReportingServiceOptions = {
|
||||
|
||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
private logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
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;
|
||||
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') {
|
||||
|
||||
@@ -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 {
|
||||
captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||
async captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,20 +12,19 @@ export class PinoLoggerService implements LoggerService {
|
||||
// In Next.js, especially in the Edge runtime or during instrumentation,
|
||||
// pino transports (which use worker threads) can cause issues.
|
||||
// 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({
|
||||
name: name || 'app',
|
||||
level: config.logging.level,
|
||||
transport:
|
||||
useTransport
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
transport: useTransport
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
}
|
||||
: 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>;
|
||||
}
|
||||
@@ -1,22 +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 }],
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
target: 'temporary-public-storage',
|
||||
},
|
||||
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 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
132
lint_output.txt
Normal file
132
lint_output.txt
Normal file
@@ -0,0 +1,132 @@
|
||||
|
||||
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
|
||||
> eslint .
|
||||
|
||||
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/.lintstagedrc.cjs at line 2.
|
||||
(Use `node --trace-warnings ...` to show where the warning was created)
|
||||
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/commitlint.config.cjs at line 2.
|
||||
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/postcss.config.cjs at line 2.
|
||||
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/tailwind.config.cjs at line 2.
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/.lintstagedrc.cjs
|
||||
3:14 error A `require()` style import is forbidden @typescript-eslint/no-require-imports
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/app/[locale]/blog/[slug]/page.tsx
|
||||
2:8 warning 'Script' is defined but never used @typescript-eslint/no-unused-vars
|
||||
4:10 warning 'getBreadcrumbSchema' is defined but never used @typescript-eslint/no-unused-vars
|
||||
4:41 warning 'LOGO_URL' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/app/[locale]/blog/page.tsx
|
||||
63:15 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
148:25 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/app/[locale]/layout.tsx
|
||||
81:12 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/app/[locale]/page.tsx
|
||||
70:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
74:14 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
75:12 warning 'key' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/app/[locale]/products/[...slug]/page.tsx
|
||||
1:8 warning 'Script' is defined but never used @typescript-eslint/no-unused-vars
|
||||
3:10 warning 'getBreadcrumbSchema' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/app/errors/api/relay/route.ts
|
||||
28:11 warning 'header' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/CMSConnectivityNotice.tsx
|
||||
4:34 warning 'Database' is defined but never used @typescript-eslint/no-unused-vars
|
||||
8:10 warning 'status' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
35:16 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/Header.tsx
|
||||
36:7 warning Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/Header.tsx:36:7
|
||||
34 | useEffect(() => {
|
||||
35 | if (isMobileMenuOpen) {
|
||||
> 36 | setIsMobileMenuOpen(false);
|
||||
| ^^^^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
37 | }
|
||||
38 | }, [pathname, isMobileMenuOpen]);
|
||||
39 | react-hooks/set-state-in-effect
|
||||
116:37 warning 'idx' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx
|
||||
24:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:24:5
|
||||
22 |
|
||||
23 | useEffect(() => {
|
||||
> 24 | setMounted(true);
|
||||
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
25 | return () => setMounted(false);
|
||||
26 | }, []);
|
||||
27 | react-hooks/set-state-in-effect
|
||||
62:9 warning Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:62:9
|
||||
60 | const index = parseInt(photoParam, 10);
|
||||
61 | if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
> 62 | setCurrentIndex(index);
|
||||
| ^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
63 | }
|
||||
64 | }
|
||||
65 | }, [searchParams, images.length]); react-hooks/set-state-in-effect
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/OGImageTemplate.tsx
|
||||
49:11 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
|
||||
30:38 warning Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx:30:38
|
||||
28 | const index = parseInt(photoParam, 10);
|
||||
29 | if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
> 30 | if (lightboxIndex !== index) setLightboxIndex(index);
|
||||
| ^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
31 | if (!lightboxOpen) setLightboxOpen(true);
|
||||
32 | }
|
||||
33 | } react-hooks/set-state-in-effect
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/components/home/RecentPosts.tsx
|
||||
37:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/lib/config.ts
|
||||
5:10 warning 'env' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/lib/mail/mailer.ts
|
||||
4:10 warning 'ReactElement' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/middleware.ts
|
||||
2:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
33:12 warning 'publicHostname' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 27 problems (1 error, 26 warnings)
|
||||
|
||||
ELIFECYCLE Command failed with exit code 1.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user