Compare commits
74 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 |
13
.env
13
.env
@@ -2,15 +2,9 @@
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
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,11 +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
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
NEXT_PUBLIC_TARGET=development
|
||||
# TARGET is used server-side
|
||||
TARGET=development
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Analytics (Umami)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +12,7 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
@@ -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,141 +1,107 @@
|
||||
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 }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
|
||||
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: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
||||
- name: 🔍 Environment & 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
|
||||
|
||||
# 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=$TRAEFIK_HOST"
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "directus_url=$DIRECTUS_URL"
|
||||
echo "directus_host=$DIRECTUS_HOST"
|
||||
echo "project_name=$PROJECT_NAME"
|
||||
echo "is_prod=$IS_PROD"
|
||||
echo "gotify_title=$GOTIFY_TITLE"
|
||||
echo "gotify_priority=$GOTIFY_PRIORITY"
|
||||
echo "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"
|
||||
echo "commit_msg=$COMMIT_MSG"
|
||||
} >> "$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
|
||||
@@ -144,139 +110,119 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: 🧪 Run Checks in Parallel
|
||||
if: github.event.inputs.skip_long_checks != 'true'
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
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-app:
|
||||
name: 🏗️ Build App
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: [prepare, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- 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: 🏗️ App 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) }}
|
||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
run: |
|
||||
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 UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
|
||||
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
||||
--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 .
|
||||
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-app, qa]
|
||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||
needs: [prepare, build, qa]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
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) }}
|
||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (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 || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
||||
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
|
||||
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
|
||||
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
|
||||
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
|
||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
||||
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
|
||||
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
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
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🚀 Deploy to ${{ env.TARGET }}
|
||||
- name: 📝 Generate Environment
|
||||
shell: bash
|
||||
env:
|
||||
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||
run: |
|
||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
||||
|
||||
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
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
NEXT_PUBLIC_TARGET=$TARGET
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
# Generate Environment File
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
@@ -296,205 +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
|
||||
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
ENV_FILE=$ENV_FILE
|
||||
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
||||
EOF
|
||||
|
||||
# 1. Cleanup and Create Directories on server BEFORE SCP
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
||||
set -e
|
||||
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
||||
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads /home/deploy/sites/klz-cables.com/directus/extensions
|
||||
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
||||
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
||||
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
||||
fi
|
||||
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
|
||||
EOF
|
||||
# AUTH_MIDDLEWARE logic
|
||||
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> .env.deploy
|
||||
|
||||
# 2. Transfer files
|
||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
||||
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
||||
|
||||
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=24h"
|
||||
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
|
||||
|
||||
echo "→ Verifying Varnish Backend Health..."
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
|
||||
echo "❌ Fehler: Varnish Backend ist SICK!"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Varnish Backend ist Healthy."
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: PageSpeed Test
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
pagespeed:
|
||||
name: ⚡ PageSpeed
|
||||
needs: [prepare, deploy]
|
||||
if: |
|
||||
always() &&
|
||||
needs.prepare.outputs.target != 'skip' &&
|
||||
needs.deploy.result == 'success' &&
|
||||
github.event.inputs.skip_long_checks != 'true'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
# outputs:
|
||||
# report_url: ${{ steps.save.outputs.report_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- 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
|
||||
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Transfer and Restart
|
||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
scp -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
|
||||
# Apply Directus Schema Snapshot if available
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# JOB 5: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notifications
|
||||
needs: [prepare, qa, build-app, deploy, pagespeed]
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 📊 Deployment Summary
|
||||
run: |
|
||||
echo "┌──────────────────────────────┐"
|
||||
echo "│ Deployment Summary │"
|
||||
echo "├──────────────────────────────┤"
|
||||
echo "│ Status: ${{ needs.deploy.result }} │"
|
||||
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
|
||||
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
|
||||
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
|
||||
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
|
||||
echo "└──────────────────────────────┘"
|
||||
|
||||
- name: 🔔 Gotify - Success
|
||||
if: needs.deploy.result == 'success'
|
||||
- name: 🔔 Gotify
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
TITLE="klz-cables.com: $STATUS"
|
||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
||||
-F "priority=4" || true
|
||||
|
||||
- name: 🔔 Gotify - Failure
|
||||
if: |
|
||||
needs.prepare.result == 'failure' ||
|
||||
needs.qa.result == 'failure' ||
|
||||
needs.build-app.result == 'failure' ||
|
||||
needs.deploy.result == 'failure' ||
|
||||
needs.pagespeed.result == 'failure'
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
||||
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
|
||||
-F "priority=8" || true
|
||||
-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.
|
||||
91
Dockerfile
91
Dockerfile
@@ -1,82 +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 --legacy-peer-deps
|
||||
# Clean the workspace in case the base image is dirty
|
||||
RUN rm -rf ./*
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
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-time environment variables for Next.js
|
||||
# These are baked into the client bundle during build
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${UMAMI_SCRIPT_URL:-$NEXT_PUBLIC_UMAMI_SCRIPT_URL}}
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Validate environment variables during build
|
||||
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
|
||||
# Enable pnpm
|
||||
RUN corepack enable
|
||||
|
||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# 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 . .
|
||||
|
||||
# Build application
|
||||
RUN pnpm build
|
||||
|
||||
# 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
|
||||
@@ -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,12 +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),
|
||||
@@ -32,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 websiteId={config.analytics.umami.websiteId} />
|
||||
<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 (
|
||||
|
||||
@@ -10,6 +10,23 @@ import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||
|
||||
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
@@ -110,6 +127,11 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
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);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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,11 +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);
|
||||
|
||||
@@ -32,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');
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,19 +8,12 @@ import { getAppServices } from '@/lib/services/create-services';
|
||||
* 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.
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In your layout.tsx
|
||||
* const { websiteId } = config.analytics.umami;
|
||||
* <AnalyticsProvider websiteId={websiteId} />
|
||||
* ```
|
||||
* 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({ websiteId }: { websiteId?: string }) {
|
||||
export default function AnalyticsProvider() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -31,14 +24,12 @@ export default function AnalyticsProvider({ websiteId }: { websiteId?: string })
|
||||
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]);
|
||||
|
||||
if (!websiteId) return null;
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
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,36 +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"
|
||||
# Clear all production-related TLS/Middleware settings for the main routers
|
||||
- "traefik.http.routers.klz-cables.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables.rule=Host(`klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables.tls=false"
|
||||
- "traefik.http.routers.klz-cables.middlewares="
|
||||
# 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"
|
||||
|
||||
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-web.rule=Host(`klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-web.middlewares="
|
||||
# Web direct router
|
||||
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-local-web.tls=false"
|
||||
- "traefik.http.routers.klz-cables-local-web.middlewares="
|
||||
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
|
||||
|
||||
directus:
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.klz-cables-directus.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-directus.tls=false"
|
||||
- "traefik.http.routers.klz-cables-directus.middlewares="
|
||||
- "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"
|
||||
|
||||
@@ -4,56 +4,37 @@ services:
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
varnish:
|
||||
image: varnish:7
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
volumes:
|
||||
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||
tmpfs:
|
||||
- /var/lib/varnish:exec,mode=1777
|
||||
environment:
|
||||
VARNISH_SIZE: ${VARNISH_CACHE_SIZE:-256M}
|
||||
APP_VERSION: ${IMAGE_TAG:-latest}
|
||||
labels:
|
||||
- "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 (Protected)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST}`) && !PathPrefix(`/stats`, `/errors`)"
|
||||
# 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=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
|
||||
- "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=Host(`${TRAEFIK_HOST}`) && PathPrefix(`/stats`, `/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,compress"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=80"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
# Gatekeeper Router (to show the login page)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST}`)"
|
||||
- "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}-compress.compress=true"
|
||||
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
@@ -62,36 +43,21 @@ services:
|
||||
# Middleware Definitions
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
PORT: 3000
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
||||
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST}
|
||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
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:
|
||||
- default
|
||||
- infra
|
||||
default:
|
||||
infra:
|
||||
aliases:
|
||||
- ${PROJECT_NAME:-klz-cables}-directus
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -114,6 +80,8 @@ services:
|
||||
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}`)"
|
||||
|
||||
@@ -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:
|
||||
@@ -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,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;
|
||||
});
|
||||
|
||||
@@ -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,7 +11,7 @@ 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;
|
||||
|
||||
@@ -22,24 +22,26 @@ function createConfig() {
|
||||
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,
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
||||
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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,6 +69,10 @@ 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,
|
||||
@@ -135,6 +141,15 @@ export const config = {
|
||||
get notifications() {
|
||||
return getConfig().notifications;
|
||||
},
|
||||
get feedbackEnabled() {
|
||||
return getConfig().feedbackEnabled;
|
||||
},
|
||||
get infraCMS() {
|
||||
return getConfig().infraCMS;
|
||||
},
|
||||
get gatekeeperUrl() {
|
||||
return getConfig().gatekeeperUrl;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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
|
||||
// Use proxy path in browser to stay on the same origin
|
||||
const effectiveUrl =
|
||||
typeof window === 'undefined'
|
||||
? internalUrl || url
|
||||
: typeof window !== 'undefined'
|
||||
? `${window.location.origin}${proxyPath}`
|
||||
: proxyPath;
|
||||
|
||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
||||
// Initialize client using Mintel standards (environment-aware)
|
||||
const client = createMintelDirectusClient<Schema>();
|
||||
|
||||
/**
|
||||
* Helper to determine if we should show detailed errors
|
||||
@@ -31,19 +34,14 @@ function formatError(error: any) {
|
||||
}
|
||||
|
||||
export async function ensureAuthenticated() {
|
||||
if (token) {
|
||||
client.setToken(token);
|
||||
return;
|
||||
}
|
||||
if (adminEmail && password) {
|
||||
try {
|
||||
await client.login(adminEmail, password);
|
||||
} catch (e) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,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),
|
||||
@@ -135,14 +133,16 @@ export async function checkHealth() {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||
}
|
||||
console.error('Directus authentication failed during health check:', e);
|
||||
console.error('Directus authentication or collection-read failed during health check:', e);
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
|
||||
: 'CMS is currently unavailable due to an internal authentication error.',
|
||||
code: 'AUTH_FAILED',
|
||||
details: shouldShowDevErrors ? e.message : undefined,
|
||||
? `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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
126
lib/env.ts
126
lib/env.ts
@@ -1,108 +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()),
|
||||
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||
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()),
|
||||
UMAMI_API_ENDPOINT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default('https://analytics.infra.mintel.me'),
|
||||
),
|
||||
// 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()),
|
||||
|
||||
// Deploy Target
|
||||
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||
// Gotify
|
||||
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||
const isDev = target === 'development' || !target;
|
||||
const isBuildTimeValidation = process.env.SKIP_RUNTIME_ENV_VALIDATION === 'true';
|
||||
const isServer = typeof window === 'undefined';
|
||||
|
||||
// Only enforce server-only variables when running on the server.
|
||||
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
||||
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'MAIL_HOST is required in non-development environments',
|
||||
path: ['MAIL_HOST'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
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_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID:
|
||||
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID,
|
||||
UMAMI_API_ENDPOINT:
|
||||
process.env.UMAMI_API_ENDPOINT ||
|
||||
process.env.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,
|
||||
TARGET: process.env.TARGET,
|
||||
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '../config';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
let transporterInstance: nodemailer.Transporter | null = null;
|
||||
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
||||
import { config } from '../../config';
|
||||
import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
/**
|
||||
* Configuration options for UmamiAnalyticsService.
|
||||
@@ -18,56 +19,128 @@ export type UmamiAnalyticsServiceOptions = {
|
||||
*
|
||||
* 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 {
|
||||
private websiteId?: string;
|
||||
private endpoint: string;
|
||||
private logger: LoggerService;
|
||||
private serverContext?: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the server-side context for the current request.
|
||||
* This allows the service to use real request headers for tracking.
|
||||
*/
|
||||
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 || !this.websiteId) return;
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
// 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';
|
||||
|
||||
if (!isClient && !this.websiteId) {
|
||||
this.logger.warn('Umami tracking called on server but no Website ID configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
website: this.websiteId,
|
||||
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
|
||||
screen:
|
||||
typeof window !== 'undefined'
|
||||
? `${window.screen.width}x${window.screen.height}`
|
||||
: undefined,
|
||||
language: typeof window !== 'undefined' ? navigator.language : undefined,
|
||||
referrer: typeof window !== 'undefined' ? document.referrer : undefined,
|
||||
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,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent,
|
||||
},
|
||||
body: JSON.stringify({ type, payload }),
|
||||
// Use keepalive for page navigation events to ensure they complete
|
||||
keepalive: true,
|
||||
} as any);
|
||||
this.logger.trace('Sending analytics payload', { type, url: data.url });
|
||||
|
||||
if (!response.ok && process.env.NODE_ENV === 'development') {
|
||||
const errorText = await response.text();
|
||||
console.warn(`[Umami] API responded with ${response.status}: ${errorText}`);
|
||||
// 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) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('[Umami] Failed to send analytics:', error);
|
||||
}
|
||||
this.logger.error('Failed to send analytics', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getServerAppServices(): AppServices {
|
||||
});
|
||||
|
||||
const analytics = config.analytics.umami.enabled
|
||||
? new UmamiAnalyticsService({ enabled: true })
|
||||
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (config.analytics.umami.enabled) {
|
||||
@@ -55,7 +55,7 @@ export function getServerAppServices(): AppServices {
|
||||
}
|
||||
|
||||
const errors = config.errors.glitchtip.enabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -29,8 +30,7 @@ let singleton: AppServices | undefined;
|
||||
*
|
||||
* The services are configured based on environment variables:
|
||||
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
||||
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||
*
|
||||
* @returns {AppServices} The application services singleton
|
||||
*
|
||||
@@ -100,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) {
|
||||
@@ -114,9 +110,13 @@ 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) {
|
||||
@@ -139,7 +139,6 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// Create and cache the singleton
|
||||
const notifications = new NoopNotificationService();
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ErrorReportingUser,
|
||||
} from './error-reporting-service';
|
||||
import type { NotificationService } from '../notifications/notification-service';
|
||||
import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
type SentryLike = typeof Sentry;
|
||||
|
||||
@@ -14,11 +15,16 @@ 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,
|
||||
logger: LoggerService,
|
||||
private readonly notifications?: NotificationService,
|
||||
private readonly sentry: SentryLike = Sentry,
|
||||
) {}
|
||||
) {
|
||||
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
||||
}
|
||||
|
||||
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||
if (!this.options.enabled) return undefined;
|
||||
|
||||
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.
|
||||
65
lint_output_after_fixes.txt
Normal file
65
lint_output_after_fixes.txt
Normal file
@@ -0,0 +1,65 @@
|
||||
|
||||
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
|
||||
> eslint .
|
||||
|
||||
|
||||
/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
|
||||
|
||||
/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
|
||||
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/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 | }, []); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
27 | react-hooks/set-state-in-effect
|
||||
26:11 warning Unused eslint-disable directive (no problems were reported from '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]); // eslint-disable-line react-hooks/set-state-in-effect react-hooks/set-state-in-effect
|
||||
65:38 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/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
|
||||
|
||||
/Users/marcmintel/Projects/klz-2026/middleware.ts
|
||||
33:12 warning '_publicHostname' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 13 problems (0 errors, 13 warnings)
|
||||
0 errors and 2 warnings potentially fixable with the `--fix` option.
|
||||
|
||||
@@ -393,4 +393,4 @@
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,4 +393,4 @@
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Create the internationalization middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
@@ -30,11 +30,11 @@ export default function middleware(request: NextRequest) {
|
||||
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||
const hostHeader =
|
||||
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
||||
const [publicHostname] = hostHeader.split(':');
|
||||
hostHeader.split(':');
|
||||
|
||||
urlObj.protocol = proto;
|
||||
urlObj.hostname = publicHostname;
|
||||
urlObj.port = ''; // Explicitly clear internal port (3000)
|
||||
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
|
||||
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
|
||||
|
||||
effectiveRequest = new NextRequest(urlObj, {
|
||||
headers: request.headers,
|
||||
|
||||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts';
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import withMintelConfig from '@mintel/next-config';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
@@ -170,7 +170,7 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
source: '/posts/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project.html',
|
||||
destination: '/en/blog/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||
destination: '/de/blog/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
@@ -322,22 +322,9 @@ const nextConfig = {
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
async rewrites() {
|
||||
const umamiUrl = (process.env.UMAMI_API_ENDPOINT || process.env.UMAMI_SCRIPT_URL || process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me');
|
||||
const glitchtipUrl = process.env.SENTRY_DSN
|
||||
? new URL(process.env.SENTRY_DSN).origin
|
||||
: 'https://errors.infra.mintel.me';
|
||||
|
||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/stats/:path*',
|
||||
destination: `${umamiUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/errors/:path*',
|
||||
destination: `${glitchtipUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/cms/:path*',
|
||||
destination: `${directusUrl}/:path*`,
|
||||
@@ -348,18 +335,4 @@ const nextConfig = {
|
||||
|
||||
const nextIntlConfig = withNextIntl(nextConfig);
|
||||
|
||||
// GlitchTip is Sentry-compatible; we use the Sentry Next.js SDK.
|
||||
// Source map upload is optional; we keep this config minimal.
|
||||
export default withSentryConfig(
|
||||
nextIntlConfig,
|
||||
{
|
||||
silent: !process.env.CI,
|
||||
// Keep bundle size down; remove SDK debug logging.
|
||||
treeshake: { removeDebugLogging: true },
|
||||
},
|
||||
// Sentry Webpack plugin options (not needed unless you upload sourcemaps)
|
||||
{
|
||||
// no sourcemap upload by default
|
||||
authToken: undefined,
|
||||
}
|
||||
);
|
||||
export default withMintelConfig(nextIntlConfig);
|
||||
|
||||
22731
package-lock.json
generated
22731
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
75
package.json
75
package.json
@@ -1,58 +1,66 @@
|
||||
{
|
||||
"name": "klz-cables-nextjs",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^18.0.3",
|
||||
"@mintel/mail": "^1.2.3",
|
||||
"@react-email/components": "^1.0.6",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@mintel/mail": "^1.6.0",
|
||||
"@mintel/next-config": "^1.6.0",
|
||||
"@mintel/next-feedback": "^1.6.0",
|
||||
"@mintel/next-utils": "^1.7.15",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"@swc/helpers": "^0.5.18",
|
||||
"@types/cheerio": "^0.22.35",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"axios": "^1.13.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.27.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.3",
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^14.2.35",
|
||||
"next": "16.1.6",
|
||||
"next-i18next": "^15.4.3",
|
||||
"next-intl": "^4.6.1",
|
||||
"next-intl": "^4.8.2",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-email": "^5.2.5",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"sharp": "^0.34.5",
|
||||
"svg-to-pdfkit": "^0.1.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.0",
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/eslint-config": "^1.6.0",
|
||||
"@mintel/tsconfig": "^1.6.0",
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.35",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint": "^9.18.0",
|
||||
"happy-dom": "^20.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -63,14 +71,12 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"name": "klz-cables-nextjs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db",
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db gatekeeper",
|
||||
"dev:local": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
@@ -78,18 +84,29 @@
|
||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:bootstrap": "npm run cms:branding:local",
|
||||
"cms:bootstrap": "pnpm run cms:branding:local",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"cms:push:staging": "./scripts/sync-directus.sh push staging",
|
||||
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
|
||||
"cms:push:testing": "./scripts/sync-directus.sh push testing",
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:testing": "./scripts/cms-apply.sh testing",
|
||||
"cms:schema:apply:staging": "./scripts/cms-apply.sh staging",
|
||||
"cms:schema:apply:prod": "./scripts/cms-apply.sh production",
|
||||
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
|
||||
"cms:push:prod": "./scripts/sync-directus.sh push production",
|
||||
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
|
||||
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
|
||||
"cms:push:staging:DANGER": "./scripts/sync-directus.sh push staging",
|
||||
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
|
||||
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14048
pnpm-lock.yaml
generated
Normal file
14048
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
@@ -1,268 +0,0 @@
|
||||
# Migrating Analytics from Independent Analytics to Umami
|
||||
|
||||
This guide explains how to migrate your analytics data from the Independent Analytics WordPress plugin to Umami.
|
||||
|
||||
## What You Have
|
||||
|
||||
You have exported your analytics data from Independent Analytics:
|
||||
- **data/pages(1).csv** - Page-level analytics data with:
|
||||
- Title, Visitors, Views, View Duration, Bounce Rate, URL, Page Type
|
||||
- 220 pages with historical data
|
||||
|
||||
## What You Need
|
||||
|
||||
Before migrating, you need:
|
||||
1. **Umami instance** running (self-hosted or cloud)
|
||||
2. **Website ID** from Umami (create a new website in Umami dashboard)
|
||||
3. **Access credentials** for Umami (API key or database access)
|
||||
|
||||
## Migration Options
|
||||
|
||||
The migration script provides three output formats:
|
||||
|
||||
### Option 1: JSON Import (Recommended for API)
|
||||
```bash
|
||||
python3 scripts/migrate-analytics-to-umami.py \
|
||||
--input data/pages\(1\).csv \
|
||||
--output data/umami-import.json \
|
||||
--format json \
|
||||
--site-id YOUR_UMAMI_SITE_ID
|
||||
```
|
||||
|
||||
**Import via API:**
|
||||
```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 2: SQL Import (Direct Database)
|
||||
```bash
|
||||
python3 scripts/migrate-analytics-to-umami.py \
|
||||
--input data/pages\(1\).csv \
|
||||
--output data/umami-import.sql \
|
||||
--format sql \
|
||||
--site-id YOUR_UMAMI_SITE_ID
|
||||
```
|
||||
|
||||
**Import via PostgreSQL:**
|
||||
```bash
|
||||
psql -U umami -d umami -f data/umami-import.sql
|
||||
```
|
||||
|
||||
### Option 3: API Payload (Manual Import)
|
||||
```bash
|
||||
python3 scripts/migrate-analytics-to-umami.py \
|
||||
--input data/pages\(1\).csv \
|
||||
--output data/umami-import-api.json \
|
||||
--format api \
|
||||
--site-id YOUR_UMAMI_SITE_ID
|
||||
```
|
||||
|
||||
## Step-by-Step Migration Guide
|
||||
|
||||
### 1. Prepare Your Umami Instance
|
||||
|
||||
**If self-hosting:**
|
||||
```bash
|
||||
# Clone Umami
|
||||
git clone https://github.com/umami-software/umami.git
|
||||
cd umami
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials
|
||||
|
||||
# Run migrations
|
||||
npm run migrate
|
||||
|
||||
# Start the server
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
**If using Umami Cloud:**
|
||||
1. Sign up at https://umami.is
|
||||
2. Create a new website
|
||||
3. Get your Website ID from the dashboard
|
||||
|
||||
### 2. Run the Migration Script
|
||||
|
||||
Choose one of the migration options above based on your needs.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Make the script executable
|
||||
chmod +x scripts/migrate-analytics-to-umami.py
|
||||
|
||||
# Run the migration
|
||||
python3 scripts/migrate-analytics-to-umami.py \
|
||||
--input data/pages\(1\).csv \
|
||||
--output data/umami-import.json \
|
||||
--format json \
|
||||
--site-id klz-cables
|
||||
```
|
||||
|
||||
### 3. Import the Data
|
||||
|
||||
#### Option A: Using Umami API (Recommended)
|
||||
|
||||
1. **Get your API key:**
|
||||
- Go to Umami dashboard → Settings → API Keys
|
||||
- Create a new API key
|
||||
|
||||
2. **Import the data:**
|
||||
```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: Direct Database Import
|
||||
|
||||
1. **Connect to your Umami database:**
|
||||
```bash
|
||||
psql -U umami -d umami
|
||||
```
|
||||
|
||||
2. **Import the SQL file:**
|
||||
```bash
|
||||
psql -U umami -d umami -f data/umami-import.sql
|
||||
```
|
||||
|
||||
3. **Verify the import:**
|
||||
```sql
|
||||
SELECT COUNT(*) FROM website_event WHERE website_id = 'klz-cables';
|
||||
```
|
||||
|
||||
### 4. Verify the Migration
|
||||
|
||||
1. **Check Umami dashboard:**
|
||||
- Log into Umami
|
||||
- Select your website
|
||||
- View the analytics dashboard
|
||||
|
||||
2. **Verify data:**
|
||||
- Check page views count
|
||||
- Verify top pages
|
||||
- Check visitor counts
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Data Limitations
|
||||
|
||||
The CSV export from Independent Analytics 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 script creates **simulated historical data**:
|
||||
- Each page view becomes a separate event
|
||||
- Timestamps are set to current time (for historical data, you'd need to adjust)
|
||||
- Duration is preserved from the average view duration
|
||||
- No session tracking (each view is independent)
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Start fresh with Umami:**
|
||||
- Let Umami collect new data going forward
|
||||
- Use the migrated data for historical reference only
|
||||
|
||||
2. **Keep the original CSV:**
|
||||
- Store `data/pages(1).csv` as a backup
|
||||
- You can re-import if needed
|
||||
|
||||
3. **Update your website:**
|
||||
- Replace Independent Analytics tracking code with Umami tracking code
|
||||
- Test that Umami is collecting new data
|
||||
|
||||
4. **Monitor for a few days:**
|
||||
- Verify Umami is collecting data correctly
|
||||
- Compare with any remaining Independent Analytics data
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "ModuleNotFoundError: No module named 'csv'"
|
||||
|
||||
**Solution:** Ensure Python 3 is installed:
|
||||
```bash
|
||||
python3 --version
|
||||
# Should be 3.7 or higher
|
||||
```
|
||||
|
||||
### Issue: "Permission denied" when running script
|
||||
|
||||
**Solution:** Make the script executable:
|
||||
```bash
|
||||
chmod +x scripts/migrate-analytics-to-umami.py
|
||||
```
|
||||
|
||||
### Issue: API import fails
|
||||
|
||||
**Solution:** Check:
|
||||
1. API key is correct and has import permissions
|
||||
2. Website ID exists in Umami
|
||||
3. Umami instance is accessible
|
||||
4. JSON format is valid
|
||||
|
||||
### Issue: SQL import fails
|
||||
|
||||
**Solution:** Check:
|
||||
1. Database credentials in `.env`
|
||||
2. Database is running
|
||||
3. Tables exist (run `npm run migrate` first)
|
||||
4. Permissions to insert into `website_event` table
|
||||
|
||||
## Additional Data Migration
|
||||
|
||||
If you have other CSV exports from Independent Analytics (referrers, devices, locations), you can:
|
||||
|
||||
1. **Export additional data** from Independent Analytics:
|
||||
- Referrers
|
||||
- Devices (browsers, OS)
|
||||
- Geographic data
|
||||
- Custom events
|
||||
|
||||
2. **Create custom migration scripts** for each data type
|
||||
|
||||
3. **Contact Umami support** for bulk import assistance
|
||||
|
||||
## Support
|
||||
|
||||
- **Umami Documentation:** https://umami.is/docs
|
||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
||||
- **Independent Analytics:** https://independentanalytics.com/
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Completed:**
|
||||
- Created migration script (`scripts/migrate-analytics-to-umami.py`)
|
||||
- Generated JSON import file (`data/umami-import.json`)
|
||||
- Generated SQL import file (`data/umami-import.sql`)
|
||||
- Created documentation (`scripts/README-migration.md`)
|
||||
|
||||
📊 **Data Migrated:**
|
||||
- 7,634 simulated page view events
|
||||
- 220 unique pages
|
||||
- Historical view counts and durations
|
||||
|
||||
🎯 **Next Steps:**
|
||||
1. Choose your import method (API or SQL)
|
||||
2. Run the migration script
|
||||
3. Import data into Umami
|
||||
4. Verify the migration
|
||||
5. Update your website to use Umami tracking
|
||||
@@ -1,19 +0,0 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import { readCollections, deleteCollection } from '@directus/sdk';
|
||||
|
||||
async function cleanup() {
|
||||
await ensureAuthenticated();
|
||||
const collections = await (client as any).request(readCollections());
|
||||
for (const c of collections) {
|
||||
if (!c.collection.startsWith('directus_')) {
|
||||
console.log(`Deleting ${c.collection}...`);
|
||||
try {
|
||||
await (client as any).request(deleteCollection(c.collection));
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete ${c.collection}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup().catch(console.error);
|
||||
54
scripts/cms-apply.sh
Executable file
54
scripts/cms-apply.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
ENV=$1
|
||||
REMOTE_HOST="root@alpha.mintel.me"
|
||||
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
|
||||
if [ -z "$ENV" ]; then
|
||||
echo "Usage: ./scripts/cms-apply.sh [local|testing|staging|production]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
|
||||
|
||||
case $ENV in
|
||||
local)
|
||||
CONTAINER=$(docker compose ps -q directus)
|
||||
if [ -z "$CONTAINER" ]; then
|
||||
echo "❌ Local directus container not found."
|
||||
exit 1
|
||||
fi
|
||||
echo "🚀 Applying schema locally..."
|
||||
docker exec "$CONTAINER" npx directus schema apply /directus/schema/snapshot.yaml --yes
|
||||
;;
|
||||
testing|staging|production)
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-prod" ;;
|
||||
esac
|
||||
|
||||
echo "📤 Uploading snapshot to $ENV..."
|
||||
scp ./directus/schema/snapshot.yaml "$REMOTE_HOST:$REMOTE_DIR/directus/schema/snapshot.yaml"
|
||||
|
||||
echo "🔍 Detecting remote container..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||
echo "❌ Remote container for $ENV not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 Applying schema to $ENV..."
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply /directus/schema/snapshot.yaml --yes"
|
||||
|
||||
echo "🔄 Restarting Directus to clear cache..."
|
||||
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid environment."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "✨ Schema apply complete!"
|
||||
15
scripts/cms-snapshot.sh
Executable file
15
scripts/cms-snapshot.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Detect local container
|
||||
LOCAL_CONTAINER=$(docker compose ps -q directus)
|
||||
|
||||
if [ -z "$LOCAL_CONTAINER" ]; then
|
||||
echo "❌ Local directus container not found. Is it running?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📸 Creating schema snapshot..."
|
||||
# Note: we save it to the mounted volume path inside the container
|
||||
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot /directus/schema/snapshot.yaml
|
||||
|
||||
echo "✅ Snapshot saved to ./directus/schema/snapshot.yaml"
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Manual Translation Mapping Generator
|
||||
* Creates translationKey mappings for posts that couldn't be auto-detected
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: { rendered: string };
|
||||
date: string;
|
||||
lang: string;
|
||||
pll_translation_id?: number;
|
||||
pll_master_post_id?: number;
|
||||
}
|
||||
|
||||
interface TranslationMapping {
|
||||
posts: Record<string, string[]>; // translationKey -> [en_id, de_id]
|
||||
products: Record<string, string[]>;
|
||||
pages: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface RawData {
|
||||
posts: {
|
||||
en: Post[];
|
||||
de: Post[];
|
||||
};
|
||||
products: {
|
||||
en: any[];
|
||||
de: any[];
|
||||
};
|
||||
pages: {
|
||||
en: any[];
|
||||
de: any[];
|
||||
};
|
||||
}
|
||||
|
||||
// Simple text similarity function
|
||||
function calculateSimilarity(text1: string, text2: string): number {
|
||||
const normalize = (str: string) =>
|
||||
str.toLowerCase()
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const s1 = normalize(text1);
|
||||
const s2 = normalize(text2);
|
||||
|
||||
if (s1 === s2) return 1.0;
|
||||
|
||||
// Simple overlap calculation
|
||||
const words1 = s1.split(' ');
|
||||
const words2 = s2.split(' ');
|
||||
const intersection = words1.filter(w => words2.includes(w));
|
||||
const union = new Set([...words1, ...words2]);
|
||||
|
||||
return intersection.length / union.size;
|
||||
}
|
||||
|
||||
// Generate translation key from title
|
||||
function generateKeyFromTitle(title: string): string {
|
||||
return title.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findPostTranslations(
|
||||
postsEn: Post[],
|
||||
postsDe: Post[]
|
||||
): TranslationMapping['posts'] {
|
||||
const mapping: TranslationMapping['posts'] = {};
|
||||
|
||||
// First pass: try to match by Polylang metadata
|
||||
const deById = new Map(postsDe.map(p => [p.id, p]));
|
||||
const deByTranslationId = new Map(postsDe.map(p => [p.pll_translation_id, p]));
|
||||
|
||||
for (const enPost of postsEn) {
|
||||
// Try by pll_translation_id
|
||||
if (enPost.pll_translation_id && deByTranslationId.has(enPost.pll_translation_id)) {
|
||||
const dePost = deByTranslationId.get(enPost.pll_translation_id)!;
|
||||
const key = `post-${enPost.pll_translation_id}`;
|
||||
mapping[key] = [enPost.id, dePost.id];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try by pll_master_post_id
|
||||
if (enPost.pll_master_post_id && deById.has(enPost.pll_master_post_id)) {
|
||||
const dePost = deById.get(enPost.pll_master_post_id)!;
|
||||
const key = `post-${enPost.pll_master_post_id}`;
|
||||
mapping[key] = [enPost.id, dePost.id];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: content-based matching for remaining unmatched posts
|
||||
const matchedEnIds = new Set(
|
||||
Object.values(mapping).flat()
|
||||
);
|
||||
|
||||
const unmatchedEn = postsEn.filter(p => !matchedEnIds.includes(p.id));
|
||||
const unmatchedDe = postsDe.filter(p => !matchedEnIds.includes(p.id));
|
||||
|
||||
for (const enPost of unmatchedEn) {
|
||||
let bestMatch: { post: Post; score: number } | null = null;
|
||||
|
||||
for (const dePost of unmatchedDe) {
|
||||
const titleScore = calculateSimilarity(enPost.title.rendered, dePost.title.rendered);
|
||||
const slugScore = calculateSimilarity(enPost.slug, dePost.slug);
|
||||
const dateScore = enPost.date === dePost.date ? 1.0 : 0.0;
|
||||
|
||||
// Weighted average
|
||||
const score = (titleScore * 0.6) + (slugScore * 0.3) + (dateScore * 0.1);
|
||||
|
||||
if (score > 0.7 && (!bestMatch || score > bestMatch.score)) {
|
||||
bestMatch = { post: dePost, score };
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
const key = generateKeyFromTitle(enPost.title.rendered);
|
||||
mapping[key] = [enPost.id, bestMatch.post.id];
|
||||
unmatchedDe.splice(unmatchedDe.indexOf(bestMatch.post), 1);
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function findProductTranslations(
|
||||
productsEn: any[],
|
||||
productsDe: any[]
|
||||
): TranslationMapping['products'] {
|
||||
const mapping: TranslationMapping['products'] = {};
|
||||
|
||||
// Use SKU as primary key if available
|
||||
const deBySku = new Map(productsDe.map(p => [p.sku, p]));
|
||||
|
||||
for (const enProduct of productsEn) {
|
||||
if (enProduct.sku && deBySku.has(enProduct.sku)) {
|
||||
const key = `product-${enProduct.sku}`;
|
||||
mapping[key] = [enProduct.id, deBySku.get(enProduct.sku)!.id];
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function findPageTranslations(
|
||||
pagesEn: any[],
|
||||
pagesDe: any[]
|
||||
): TranslationMapping['pages'] {
|
||||
const mapping: TranslationMapping['pages'] = {};
|
||||
|
||||
// Pages should have better Polylang metadata
|
||||
const deById = new Map(pagesDe.map(p => [p.id, p]));
|
||||
const deByTranslationId = new Map(pagesDe.map(p => [p.pll_translation_id, p]));
|
||||
|
||||
for (const enPage of pagesEn) {
|
||||
if (enPage.pll_translation_id && deByTranslationId.has(enPage.pll_translation_id)) {
|
||||
const dePage = deByTranslationId.get(enPage.pll_translation_id)!;
|
||||
const key = `page-${enPage.pll_translation_id}`;
|
||||
mapping[key] = [enPage.id, dePage.id];
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('🔍 Creating manual translation mapping...\n');
|
||||
|
||||
// Read raw data
|
||||
const rawData: RawData = {
|
||||
posts: {
|
||||
en: JSON.parse(readFileSync('data/raw/posts.en.json', 'utf8')),
|
||||
de: JSON.parse(readFileSync('data/raw/posts.de.json', 'utf8'))
|
||||
},
|
||||
products: {
|
||||
en: JSON.parse(readFileSync('data/raw/products.en.json', 'utf8')),
|
||||
de: JSON.parse(readFileSync('data/raw/products.de.json', 'utf8'))
|
||||
},
|
||||
pages: {
|
||||
en: JSON.parse(readFileSync('data/raw/pages.en.json', 'utf8')),
|
||||
de: JSON.parse(readFileSync('data/raw/pages.de.json', 'utf8'))
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📊 Raw data loaded:');
|
||||
console.log(` - Posts: ${rawData.posts.en.length} EN, ${rawData.posts.de.length} DE`);
|
||||
console.log(` - Products: ${rawData.products.en.length} EN, ${rawData.products.de.length} DE`);
|
||||
console.log(` - Pages: ${rawData.pages.en.length} EN, ${rawData.pages.de.length} DE`);
|
||||
console.log('');
|
||||
|
||||
// Generate mappings
|
||||
const mapping: TranslationMapping = {
|
||||
posts: findPostTranslations(rawData.posts.en, rawData.posts.de),
|
||||
products: findProductTranslations(rawData.products.en, rawData.products.de),
|
||||
pages: findPageTranslations(rawData.pages.en, rawData.pages.de)
|
||||
};
|
||||
|
||||
// Save mapping
|
||||
const outputPath = 'data/manual-translation-mapping.json';
|
||||
writeFileSync(outputPath, JSON.stringify(mapping, null, 2));
|
||||
|
||||
console.log('✅ Manual translation mapping created:\n');
|
||||
console.log(`Posts: ${Object.keys(mapping.posts).length} pairs`);
|
||||
console.log(`Products: ${Object.keys(mapping.products).length} pairs`);
|
||||
console.log(`Pages: ${Object.keys(mapping.pages).length} pairs`);
|
||||
console.log(`\nSaved to: ${outputPath}`);
|
||||
|
||||
// Show some examples
|
||||
if (Object.keys(mapping.posts).length > 0) {
|
||||
console.log('\n📝 Post mapping examples:');
|
||||
Object.entries(mapping.posts).slice(0, 3).forEach(([key, ids]) => {
|
||||
const enPost = rawData.posts.en.find(p => p.id === ids[0]);
|
||||
const dePost = rawData.posts.de.find(p => p.id === ids[1]);
|
||||
console.log(` ${key}:`);
|
||||
console.log(` EN: [${ids[0]}] ${enPost?.title.rendered}`);
|
||||
console.log(` DE: [${ids[1]}] ${dePost?.title.rendered}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Deploy analytics data to your Umami instance on alpha.mintel.me
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration - Umami is on infra.mintel.me
|
||||
SERVER="root@infra.mintel.me"
|
||||
REMOTE_PATH="/home/deploy/sites/klz-cables.com"
|
||||
WEBSITE_ID="59a7db94-0100-4c7e-98ef-99f45b17f9c3"
|
||||
|
||||
# Umami API endpoint (assuming it's running on the same server)
|
||||
UMAMI_API="http://localhost:3000/api/import"
|
||||
|
||||
echo "🚀 Deploying analytics data to your Umami instance..."
|
||||
echo "Server: $SERVER"
|
||||
echo "Remote path: $REMOTE_PATH"
|
||||
echo "Website ID: $WEBSITE_ID"
|
||||
echo "Umami API: $UMAMI_API"
|
||||
echo ""
|
||||
|
||||
# Check if files exist
|
||||
if [ ! -f "data/umami-import.json" ]; then
|
||||
echo "❌ Error: data/umami-import.json not found"
|
||||
echo "Please run the migration script first:"
|
||||
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.json --format json --site-id $WEBSITE_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test SSH connection
|
||||
echo "🔍 Testing SSH connection to $SERVER..."
|
||||
if ! ssh -o ConnectTimeout=5 "$SERVER" "echo 'SSH connection successful'"; then
|
||||
echo "❌ Error: Cannot connect to $SERVER"
|
||||
echo "Please check your SSH key and connection"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ SSH connection successful"
|
||||
echo ""
|
||||
|
||||
# Create directory and copy files to server
|
||||
echo "📁 Creating remote directory..."
|
||||
ssh "$SERVER" "mkdir -p $REMOTE_PATH/data"
|
||||
echo "✅ Remote directory created"
|
||||
|
||||
echo "📤 Copying analytics files to server..."
|
||||
scp data/umami-import.json "$SERVER:$REMOTE_PATH/data/"
|
||||
scp data/umami-import.sql "$SERVER:$REMOTE_PATH/data/"
|
||||
echo "✅ Files copied successfully"
|
||||
echo ""
|
||||
|
||||
# Detect Umami container
|
||||
echo "🔍 Detecting Umami container..."
|
||||
UMAMI_CONTAINER=$(ssh "$SERVER" "docker ps -q --filter 'name=umami'")
|
||||
if [ -z "$UMAMI_CONTAINER" ]; then
|
||||
echo "❌ Error: Could not detect Umami container"
|
||||
echo "Make sure Umami is running on $SERVER"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Umami container detected: $UMAMI_CONTAINER"
|
||||
echo ""
|
||||
|
||||
# Import data via database (most reliable method)
|
||||
echo "📥 Importing data via database..."
|
||||
ssh "$SERVER" "
|
||||
echo 'Importing data into Umami database...'
|
||||
docker exec -i core-postgres-1 psql -U infra -d umami < $REMOTE_PATH/data/umami-import.sql
|
||||
echo '✅ Database import completed'
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "✅ Migration Complete!"
|
||||
echo ""
|
||||
echo "Your analytics data has been imported into Umami."
|
||||
echo "Website ID: $WEBSITE_ID"
|
||||
echo ""
|
||||
echo "Verify in Umami dashboard: https://analytics.infra.mintel.me"
|
||||
echo "You should see 7,634 historical page view events."
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Deploy analytics data to Umami server
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
SERVER="root@alpha.mintel.me"
|
||||
REMOTE_PATH="/home/deploy/sites/klz-cables.com"
|
||||
WEBSITE_ID="59a7db94-0100-4c7e-98ef-99f45b17f9c3"
|
||||
|
||||
echo "🚀 Deploying analytics data to Umami server..."
|
||||
echo "Server: $SERVER"
|
||||
echo "Remote path: $REMOTE_PATH"
|
||||
echo "Website ID: $WEBSITE_ID"
|
||||
echo ""
|
||||
|
||||
# Check if files exist
|
||||
if [ ! -f "data/umami-import.json" ]; then
|
||||
echo "❌ Error: data/umami-import.json not found"
|
||||
echo "Please run the migration script first:"
|
||||
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.json --format json --site-id $WEBSITE_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "data/umami-import.sql" ]; then
|
||||
echo "❌ Error: data/umami-import.sql not found"
|
||||
echo "Please run the migration script first:"
|
||||
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.sql --format sql --site-id $WEBSITE_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if SSH connection works
|
||||
echo "🔍 Testing SSH connection..."
|
||||
if ! ssh -o ConnectTimeout=5 "$SERVER" "echo 'SSH connection successful'"; then
|
||||
echo "❌ Error: Cannot connect to $SERVER"
|
||||
echo "Please check your SSH key and connection"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ SSH connection successful"
|
||||
echo ""
|
||||
|
||||
# Create remote directory if it doesn't exist
|
||||
echo "📁 Creating remote directory..."
|
||||
ssh "$SERVER" "mkdir -p $REMOTE_PATH/data"
|
||||
echo "✅ Remote directory created"
|
||||
echo ""
|
||||
|
||||
# Copy files to server
|
||||
echo "📤 Copying files to server..."
|
||||
scp data/umami-import.json "$SERVER:$REMOTE_PATH/data/"
|
||||
scp data/umami-import.sql "$SERVER:$REMOTE_PATH/data/"
|
||||
echo "✅ Files copied successfully"
|
||||
echo ""
|
||||
|
||||
# Option 1: Import via API (if Umami API is accessible)
|
||||
echo "📋 Import Options:"
|
||||
echo ""
|
||||
echo "Option 1: Import via API (Recommended)"
|
||||
echo "--------------------------------------"
|
||||
echo "1. SSH into your server:"
|
||||
echo " ssh $SERVER"
|
||||
echo ""
|
||||
echo "2. Navigate to the directory:"
|
||||
echo " cd $REMOTE_PATH"
|
||||
echo ""
|
||||
echo "3. Get your Umami API key:"
|
||||
echo " - Log into Umami dashboard"
|
||||
echo " - Go to Settings → API Keys"
|
||||
echo " - Create a new API key"
|
||||
echo ""
|
||||
echo "4. Import the data:"
|
||||
echo " curl -X POST \\"
|
||||
echo " -H \"Content-Type: application/json\" \\"
|
||||
echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\"
|
||||
echo " -d @data/umami-import.json \\"
|
||||
echo " http://localhost:3000/api/import"
|
||||
echo ""
|
||||
echo " Or if Umami is on a different port/domain:"
|
||||
echo " curl -X POST \\"
|
||||
echo " -H \"Content-Type: application/json\" \\"
|
||||
echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\"
|
||||
echo " -d @data/umami-import.json \\"
|
||||
echo " https://your-umami-domain.com/api/import"
|
||||
echo ""
|
||||
|
||||
# Option 2: Import via Database
|
||||
echo "Option 2: Import via Database"
|
||||
echo "------------------------------"
|
||||
echo "1. SSH into your server:"
|
||||
echo " ssh $SERVER"
|
||||
echo ""
|
||||
echo "2. Navigate to the directory:"
|
||||
echo " cd $REMOTE_PATH"
|
||||
echo ""
|
||||
echo "3. Import the SQL file:"
|
||||
echo " psql -U umami -d umami -f data/umami-import.sql"
|
||||
echo ""
|
||||
echo " If you need to specify host/port:"
|
||||
echo " PGPASSWORD=your_password psql -h localhost -U umami -d umami -f data/umami-import.sql"
|
||||
echo ""
|
||||
|
||||
# Option 3: Manual import via Umami dashboard
|
||||
echo "Option 3: Manual Import via Umami Dashboard"
|
||||
echo "--------------------------------------------"
|
||||
echo "1. Log into Umami dashboard"
|
||||
echo "2. Go to Settings → Import"
|
||||
echo "3. Upload data/umami-import.json"
|
||||
echo "4. Select your website (ID: $WEBSITE_ID)"
|
||||
echo "5. Click Import"
|
||||
echo ""
|
||||
|
||||
echo "📊 File Information:"
|
||||
echo "-------------------"
|
||||
echo "JSON file: $(ls -lh data/umami-import.json | awk '{print $5}')"
|
||||
echo "SQL file: $(ls -lh data/umami-import.sql | awk '{print $5}')"
|
||||
echo ""
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Choose one of the import methods above"
|
||||
echo "2. Import the data into Umami"
|
||||
echo "3. Verify the data in Umami dashboard"
|
||||
echo "4. Update your website to use Umami tracking code"
|
||||
echo ""
|
||||
echo "For detailed instructions, see: scripts/README-migration.md"
|
||||
@@ -1,32 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const blogDir = path.join(process.cwd(), 'data', 'blog', 'en');
|
||||
const outputDir = path.join(process.cwd(), 'reference', 'klz-cables-clone', 'posts');
|
||||
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(blogDir);
|
||||
|
||||
files.forEach(file => {
|
||||
if (!file.endsWith('.mdx')) return;
|
||||
|
||||
const slug = file.replace('.mdx', '');
|
||||
const url = `https://klz-cables.com/${slug}/`;
|
||||
const outputPath = path.join(outputDir, `${slug}.html`);
|
||||
|
||||
if (fs.existsSync(outputPath)) {
|
||||
console.log(`Skipping ${slug}, already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Fetching ${slug}...`);
|
||||
try {
|
||||
execSync(`curl -L -s "${url}" -o "${outputPath}"`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch ${slug}: ${e.message}`);
|
||||
}
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cheerio = require('cheerio');
|
||||
|
||||
const API_URL = 'https://klz-cables.com/wp-json/wp/v2/posts?per_page=100&_embed';
|
||||
|
||||
async function fetchPosts() {
|
||||
console.log('Fetching posts...');
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts: ${response.statusText}`);
|
||||
}
|
||||
const posts = await response.json();
|
||||
console.log(`Fetched ${posts.length} posts.`);
|
||||
return posts;
|
||||
}
|
||||
|
||||
function cleanContent(content) {
|
||||
let cleaned = content;
|
||||
|
||||
// Decode HTML entities first to make regex easier
|
||||
cleaned = cleaned.replace(/”/g, '"').replace(/“/g, '"').replace(/’/g, "'").replace(/&/g, '&').replace(/″/g, '"');
|
||||
|
||||
// Remove vc_row and vc_column wrappers
|
||||
cleaned = cleaned.replace(/\[\/?vc_row.*?\]/g, '');
|
||||
cleaned = cleaned.replace(/\[\/?vc_column.*?\]/g, '');
|
||||
|
||||
// Remove vc_column_text wrapper but keep content
|
||||
cleaned = cleaned.replace(/\[vc_column_text.*?\]/g, '');
|
||||
cleaned = cleaned.replace(/\[\/vc_column_text\]/g, '');
|
||||
|
||||
// Convert split_line_heading to h2
|
||||
cleaned = cleaned.replace(/\[split_line_heading[^\]]*text_content="([^"]+)"[^\]]*\](?:\[\/split_line_heading\])?/g, '<h2>$1</h2>');
|
||||
|
||||
// Remove other shortcodes
|
||||
cleaned = cleaned.replace(/\[image_with_animation.*?\]/g, '');
|
||||
cleaned = cleaned.replace(/\[divider.*?\]/g, '');
|
||||
cleaned = cleaned.replace(/\[nectar_global_section.*?\]/g, '');
|
||||
|
||||
// Use Cheerio for HTML manipulation
|
||||
const $ = cheerio.load(cleaned, { xmlMode: false, decodeEntities: false });
|
||||
|
||||
// Convert VisualLinkPreview
|
||||
$('.vlp-link-container').each((i, el) => {
|
||||
const $el = $(el);
|
||||
const url = $el.find('a.vlp-link').attr('href');
|
||||
const title = $el.find('.vlp-link-title').text().trim() || $el.find('a.vlp-link').attr('title');
|
||||
const image = $el.find('.vlp-link-image img').attr('src');
|
||||
const summary = $el.find('.vlp-link-summary').text().trim();
|
||||
|
||||
if (url && title) {
|
||||
// We use a placeholder to avoid Cheerio messing up the React component syntax
|
||||
const component = `__VISUAL_LINK_PREVIEW_START__ url="${url}" title="${title}" image="${image || ''}" summary="${summary || ''}" __VISUAL_LINK_PREVIEW_END__`;
|
||||
$el.replaceWith(component);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove data attributes
|
||||
$('*').each((i, el) => {
|
||||
const attribs = el.attribs;
|
||||
for (const name in attribs) {
|
||||
if (name.startsWith('data-')) {
|
||||
$(el).removeAttr(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Unwrap divs (remove div tags but keep content)
|
||||
$('div').each((i, el) => {
|
||||
$(el).replaceWith($(el).html());
|
||||
});
|
||||
|
||||
// Remove empty paragraphs
|
||||
$('p').each((i, el) => {
|
||||
if ($(el).text().trim() === '' && $(el).children().length === 0) {
|
||||
$(el).remove();
|
||||
}
|
||||
});
|
||||
|
||||
let output = $('body').html() || '';
|
||||
|
||||
// Restore VisualLinkPreview
|
||||
output = output.replace(/__VISUAL_LINK_PREVIEW_START__/g, '<VisualLinkPreview').replace(/__VISUAL_LINK_PREVIEW_END__/g, '/>');
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
function generateMdx(post) {
|
||||
const title = post.title.rendered.replace(/”/g, '"').replace(/“/g, '"').replace(/’/g, "'").replace(/&/g, '&');
|
||||
const date = post.date;
|
||||
const slug = post.slug;
|
||||
const lang = post.lang || 'en'; // Default to en if not specified
|
||||
|
||||
let featuredImage = '';
|
||||
if (post._embedded && post._embedded['wp:featuredmedia'] && post._embedded['wp:featuredmedia'][0]) {
|
||||
featuredImage = post._embedded['wp:featuredmedia'][0].source_url;
|
||||
}
|
||||
|
||||
const content = cleanContent(post.content.rendered);
|
||||
|
||||
return `---
|
||||
title: "${title}"
|
||||
date: '${date}'
|
||||
featuredImage: ${featuredImage}
|
||||
locale: ${lang}
|
||||
---
|
||||
|
||||
${content}
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const posts = await fetchPosts();
|
||||
|
||||
for (const post of posts) {
|
||||
const lang = post.lang || 'en';
|
||||
const slug = post.slug;
|
||||
const mdxContent = generateMdx(post);
|
||||
|
||||
const dir = path.join('data/blog', lang);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(dir, `${slug}.mdx`);
|
||||
fs.writeFileSync(filePath, mdxContent);
|
||||
console.log(`Saved ${filePath}`);
|
||||
}
|
||||
console.log('Done.');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,87 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const blogDir = path.join(process.cwd(), 'data', 'blog');
|
||||
|
||||
function fixFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
if (lines[0].trim() !== '---') {
|
||||
return; // Not a frontmatter file or already fixed/different format
|
||||
}
|
||||
|
||||
let newLines = [];
|
||||
let inFrontmatter = false;
|
||||
let frontmatterLines = [];
|
||||
let contentLines = [];
|
||||
|
||||
// Separate frontmatter and content
|
||||
if (lines[0].trim() === '---') {
|
||||
inFrontmatter = true;
|
||||
let i = 1;
|
||||
// Skip empty line after first ---
|
||||
if (lines[1].trim() === '') {
|
||||
i = 2;
|
||||
}
|
||||
|
||||
for (; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') {
|
||||
inFrontmatter = false;
|
||||
contentLines = lines.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
frontmatterLines.push(lines[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Process frontmatter lines to fix multiline strings
|
||||
let fixedFrontmatter = [];
|
||||
for (let i = 0; i < frontmatterLines.length; i++) {
|
||||
let line = frontmatterLines[i];
|
||||
|
||||
// Check for multiline indicator >-
|
||||
if (line.includes('>-')) {
|
||||
const [key, ...rest] = line.split(':');
|
||||
if (rest.join(':').trim() === '>-') {
|
||||
// It's a multiline start
|
||||
let value = '';
|
||||
let j = i + 1;
|
||||
while (j < frontmatterLines.length) {
|
||||
const nextLine = frontmatterLines[j];
|
||||
// If next line is a new key (contains : and doesn't start with space), stop
|
||||
if (nextLine.includes(':') && !nextLine.startsWith(' ')) {
|
||||
break;
|
||||
}
|
||||
value += (value ? ' ' : '') + nextLine.trim();
|
||||
j++;
|
||||
}
|
||||
fixedFrontmatter.push(`${key}: '${value.replace(/'/g, "''")}'`);
|
||||
i = j - 1; // Skip processed lines
|
||||
} else {
|
||||
fixedFrontmatter.push(line);
|
||||
}
|
||||
} else {
|
||||
fixedFrontmatter.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const newContent = `---\n${fixedFrontmatter.join('\n')}\n---\n${contentLines.join('\n')}`;
|
||||
fs.writeFileSync(filePath, newContent);
|
||||
console.log(`Fixed ${filePath}`);
|
||||
}
|
||||
|
||||
function processDir(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
processDir(filePath);
|
||||
} else if (file.endsWith('.mdx')) {
|
||||
fixFile(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processDir(blogDir);
|
||||
@@ -1,99 +0,0 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import {
|
||||
createCollection,
|
||||
createField,
|
||||
createItem,
|
||||
readCollections,
|
||||
deleteCollection
|
||||
} from '@directus/sdk';
|
||||
|
||||
async function fixSchema() {
|
||||
console.log('🚑 EXTERNAL RESCUE: Fixing Schema & Data...');
|
||||
await ensureAuthenticated();
|
||||
|
||||
// 1. Reset Products Collection to be 100% Standard
|
||||
console.log('🗑️ Clearing broken collections...');
|
||||
try { await client.request(deleteCollection('products')); } catch (e) { }
|
||||
try { await client.request(deleteCollection('products_translations')); } catch (e) { }
|
||||
|
||||
// 2. Create Products (Simple, Standard ID)
|
||||
console.log('🏗️ Rebuilding Products Schema...');
|
||||
await client.request(createCollection({
|
||||
collection: 'products',
|
||||
schema: {}, // Let Directus decide defaults
|
||||
meta: {
|
||||
display_template: '{{sku}}',
|
||||
archive_field: 'status',
|
||||
archive_value: 'archived',
|
||||
unarchive_value: 'published'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
field: 'id',
|
||||
type: 'integer',
|
||||
schema: { is_primary_key: true, has_auto_increment: true },
|
||||
meta: { hidden: true }
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
type: 'string',
|
||||
schema: { default_value: 'published' },
|
||||
meta: { width: 'full', options: { choices: [{ text: 'Published', value: 'published' }] } }
|
||||
},
|
||||
{
|
||||
field: 'sku',
|
||||
type: 'string',
|
||||
meta: { interface: 'input', width: 'half' }
|
||||
}
|
||||
]
|
||||
} as any));
|
||||
|
||||
// 3. Create Translation Relation Safely
|
||||
console.log('🌍 Rebuilding Translations...');
|
||||
await client.request(createCollection({
|
||||
collection: 'products_translations',
|
||||
schema: {},
|
||||
fields: [
|
||||
{
|
||||
field: 'id',
|
||||
type: 'integer',
|
||||
schema: { is_primary_key: true, has_auto_increment: true },
|
||||
meta: { hidden: true }
|
||||
},
|
||||
{ field: 'products_id', type: 'integer' },
|
||||
{ field: 'languages_code', type: 'string' },
|
||||
{ field: 'name', type: 'string', meta: { interface: 'input', width: 'full' } },
|
||||
{ field: 'description', type: 'text', meta: { interface: 'input-multiline' } },
|
||||
{ field: 'technical_items', type: 'json', meta: { interface: 'input-code-json' } }
|
||||
]
|
||||
} as any));
|
||||
|
||||
// 4. Manually Insert ONE Product to Verify
|
||||
console.log('📦 Injecting Test Product...');
|
||||
try {
|
||||
// We do this in two steps to be absolutely sure permissions don't block us
|
||||
// Step A: Create User-Facing Product
|
||||
const product = await client.request(createItem('products', {
|
||||
sku: 'H1Z2Z2-K-TEST',
|
||||
status: 'published'
|
||||
}));
|
||||
|
||||
// Step B: Add Translation
|
||||
await client.request(createItem('products_translations', {
|
||||
products_id: product.id,
|
||||
languages_code: 'de-DE',
|
||||
name: 'H1Z2Z2-K Test Cable',
|
||||
description: 'This is a verified imported product.',
|
||||
technical_items: [{ label: 'Test', value: '100%' }]
|
||||
}));
|
||||
|
||||
console.log(`✅ SUCCESS! Product Created with ID: ${product.id}`);
|
||||
console.log(`verify at: ${process.env.DIRECTUS_URL}/admin/content/products/${product.id}`);
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('❌ Failed to create product:', e);
|
||||
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
fixSchema().catch(console.error);
|
||||
@@ -1,305 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate Independent Analytics data to Umami format
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
import uuid
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
|
||||
def parse_view_duration(duration_str):
|
||||
"""Convert view duration from 'X:XX' format to seconds"""
|
||||
if not duration_str or duration_str == '-':
|
||||
return 0
|
||||
|
||||
parts = duration_str.split(':')
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]) * 60 + int(parts[1])
|
||||
elif len(parts) == 3:
|
||||
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
||||
return 0
|
||||
|
||||
def convert_to_umami_format(csv_file, output_file, site_id="your-site-id"):
|
||||
"""
|
||||
Convert Independent Analytics CSV to Umami import format
|
||||
|
||||
Umami expects data in this format for API import:
|
||||
{
|
||||
"website_id": "uuid",
|
||||
"hostname": "example.com",
|
||||
"path": "/path",
|
||||
"referrer": "",
|
||||
"event_name": null,
|
||||
"pageview": true,
|
||||
"session": true,
|
||||
"duration": 0,
|
||||
"created_at": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
"""
|
||||
|
||||
umami_records = []
|
||||
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
|
||||
for row in reader:
|
||||
# Skip 404 pages and empty entries
|
||||
if row.get('Page Type') == '404' or not row.get('URL'):
|
||||
continue
|
||||
|
||||
# Extract data
|
||||
title = row.get('Title', '')
|
||||
url = row.get('URL', '/')
|
||||
visitors = int(row.get('Visitors', 0))
|
||||
views = int(row.get('Views', 0))
|
||||
view_duration = parse_view_duration(row.get('View Duration', '0:00'))
|
||||
bounce_rate = float(row.get('Bounce Rate', '0').strip('%')) if row.get('Bounce Rate') else 0
|
||||
|
||||
# Calculate total session duration (views * average duration)
|
||||
total_duration = views * view_duration
|
||||
|
||||
# Create multiple records for each view to simulate historical data
|
||||
# This is a simplified approach - in reality, you'd want more granular data
|
||||
for i in range(min(views, 100)): # Limit to 100 records per page to avoid huge files
|
||||
umami_record = {
|
||||
"website_id": site_id,
|
||||
"hostname": "your-domain.com", # Update this
|
||||
"path": url,
|
||||
"referrer": "",
|
||||
"event_name": None,
|
||||
"pageview": True,
|
||||
"session": True,
|
||||
"duration": view_duration,
|
||||
"created_at": datetime.now().isoformat() + "Z"
|
||||
}
|
||||
umami_records.append(umami_record)
|
||||
|
||||
# Write to JSON file
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(umami_records, f, indent=2)
|
||||
|
||||
print(f"✅ Converted {len(umami_records)} records to Umami format")
|
||||
print(f"📁 Output saved to: {output_file}")
|
||||
return umami_records
|
||||
|
||||
def generate_sql_import(csv_file, output_file, site_id="your-site-id"):
|
||||
"""
|
||||
Generate SQL statements for direct database import into Umami.
|
||||
Optimized to match target metrics:
|
||||
- Visitors: ~7,639
|
||||
- Views: ~20,718
|
||||
- Sessions: ~9,216
|
||||
- Avg Duration: ~3:41
|
||||
- Bounce Rate: ~61%
|
||||
"""
|
||||
|
||||
sql_statements = []
|
||||
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = [r for r in reader if r.get('Page Type') != '404' and r.get('URL')]
|
||||
|
||||
# Target totals
|
||||
TARGET_VISITORS = 7639
|
||||
TARGET_VIEWS = 20718
|
||||
TARGET_SESSIONS = 9216
|
||||
TARGET_AVG_DURATION = 221 # 3:41 in seconds
|
||||
TARGET_BOUNCE_RATE = 0.61
|
||||
|
||||
# Umami "Visitors" = count(distinct session_id)
|
||||
# Umami "Visits" = count(distinct visit_id)
|
||||
# Umami "Views" = count(*) where event_type = 1
|
||||
|
||||
# To get 7639 Visitors and 9216 Sessions, we need 7639 unique session_ids.
|
||||
# Wait, if Visitors < Sessions, it usually means some visitors had multiple sessions.
|
||||
# But in Umami DB, session_id IS the visitor.
|
||||
# If we want 7639 Visitors, we MUST have exactly 7639 unique session_ids.
|
||||
# If we want 9216 Sessions, we need to understand what Umami calls a "Session" in the UI.
|
||||
# In Umami v2, "Sessions" in the UI often refers to unique visit_id.
|
||||
# Let's aim for:
|
||||
# 7639 unique session_id (Visitors)
|
||||
# 9216 unique visit_id (Sessions/Visits)
|
||||
# 20718 total events (Views)
|
||||
|
||||
session_ids = [str(uuid.uuid4()) for _ in range(TARGET_VISITORS)]
|
||||
|
||||
# Distribute sessions over 30 days
|
||||
# We'll create 9216 "visits" distributed among 7639 "sessions"
|
||||
visits = []
|
||||
for i in range(TARGET_SESSIONS):
|
||||
visit_id = str(uuid.uuid4())
|
||||
sess_id = session_ids[i % len(session_ids)]
|
||||
|
||||
# Distribute over 30 days
|
||||
# Last 7 days target: ~218 visitors, ~249 sessions
|
||||
# 249/9216 = ~2.7% of data in last 7 days.
|
||||
# Let's use a weighted distribution to match the "Last 7 days" feedback.
|
||||
if random.random() < 0.027: # ~2.7% chance for last 7 days
|
||||
days_ago = random.randint(0, 6)
|
||||
else:
|
||||
days_ago = random.randint(7, 30)
|
||||
|
||||
hour = random.randint(0, 23)
|
||||
minute = random.randint(0, 59)
|
||||
start_time = (datetime.now() - timedelta(days=days_ago, hours=hour, minutes=minute))
|
||||
|
||||
visits.append({'sess_id': sess_id, 'visit_id': visit_id, 'time': start_time, 'views': 0})
|
||||
|
||||
# Create the unique sessions in DB
|
||||
for sess_id in session_ids:
|
||||
# Find the earliest visit for this session to use as session created_at
|
||||
sess_time = min([v['time'] for v in visits if v['sess_id'] == sess_id])
|
||||
sql_sess = f"""
|
||||
INSERT INTO session (session_id, website_id, browser, os, device, screen, language, country, created_at)
|
||||
VALUES ('{sess_id}', '{site_id}', 'Chrome', 'Windows', 'desktop', '1920x1080', 'en', 'DE', '{sess_time.strftime('%Y-%m-%d %H:%M:%S')}')
|
||||
ON CONFLICT (session_id) DO NOTHING;
|
||||
"""
|
||||
sql_statements.append(sql_sess.strip())
|
||||
|
||||
# Distribute 20718 views among 9216 visits
|
||||
views_remaining = TARGET_VIEWS - TARGET_SESSIONS
|
||||
|
||||
# Every visit gets at least 1 view
|
||||
url_pool = []
|
||||
for row in rows:
|
||||
weight = int(row['Views'])
|
||||
url_pool.extend([{'url': row['URL'], 'title': row['Title'].replace("'", "''")}] * weight)
|
||||
random.shuffle(url_pool)
|
||||
url_idx = 0
|
||||
|
||||
for v in visits:
|
||||
url_data = url_pool[url_idx % len(url_pool)]
|
||||
url_idx += 1
|
||||
|
||||
event_id = str(uuid.uuid4())
|
||||
sql_ev = f"""
|
||||
INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
|
||||
VALUES ('{event_id}', '{site_id}', '{v['sess_id']}', '{v['time'].strftime('%Y-%m-%d %H:%M:%S')}', '{url_data['url']}', '', '', '', '', '{url_data['title']}', 1, NULL, '{v['visit_id']}', 'klz-cables.com');
|
||||
"""
|
||||
sql_statements.append(sql_ev.strip())
|
||||
v['views'] += 1
|
||||
|
||||
# Add remaining views to visits
|
||||
# To match bounce rate, we only add views to (1 - bounce_rate) of visits
|
||||
num_non_bounces = int(TARGET_SESSIONS * (1 - TARGET_BOUNCE_RATE))
|
||||
non_bounce_visits = random.sample(visits, num_non_bounces)
|
||||
|
||||
for _ in range(views_remaining):
|
||||
v = random.choice(non_bounce_visits)
|
||||
url_data = url_pool[url_idx % len(url_pool)]
|
||||
url_idx += 1
|
||||
|
||||
v['views'] += 1
|
||||
# Add duration
|
||||
view_time = v['time'] + timedelta(seconds=random.randint(30, 300))
|
||||
|
||||
event_id = str(uuid.uuid4())
|
||||
sql_ev = f"""
|
||||
INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
|
||||
VALUES ('{event_id}', '{site_id}', '{v['sess_id']}', '{view_time.strftime('%Y-%m-%d %H:%M:%S')}', '{url_data['url']}', '', '', '', '', '{url_data['title']}', 1, NULL, '{v['visit_id']}', 'klz-cables.com');
|
||||
"""
|
||||
sql_statements.append(sql_ev.strip())
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write("\n".join(sql_statements))
|
||||
|
||||
print(f"✅ Generated {len(sql_statements)} SQL statements")
|
||||
print(f"📁 Output saved to: {output_file}")
|
||||
return sql_statements
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write("\n".join(sql_statements))
|
||||
|
||||
print(f"✅ Generated {len(sql_statements)} SQL statements")
|
||||
print(f"📁 Output saved to: {output_file}")
|
||||
return sql_statements
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write("\n".join(sql_statements))
|
||||
|
||||
print(f"✅ Generated {len(sql_statements)} SQL statements")
|
||||
print(f"📁 Output saved to: {output_file}")
|
||||
return sql_statements
|
||||
|
||||
def generate_api_payload(csv_file, output_file, site_id="your-site-id"):
|
||||
"""
|
||||
Generate payload for Umami API import
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"website_id": site_id,
|
||||
"events": []
|
||||
}
|
||||
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
|
||||
for row in reader:
|
||||
if row.get('Page Type') == '404' or not row.get('URL'):
|
||||
continue
|
||||
|
||||
url = row.get('URL', '/')
|
||||
views = int(row.get('Views', 0))
|
||||
view_duration = parse_view_duration(row.get('View Duration', '0:00'))
|
||||
|
||||
# Add pageview events
|
||||
for i in range(min(views, 20)): # Limit for API payload size
|
||||
payload["events"].append({
|
||||
"type": "pageview",
|
||||
"url": url,
|
||||
"referrer": "",
|
||||
"duration": view_duration,
|
||||
"timestamp": datetime.now().isoformat() + "Z"
|
||||
})
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
print(f"✅ Generated API payload with {len(payload['events'])} events")
|
||||
print(f"📁 Output saved to: {output_file}")
|
||||
return payload
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Migrate Independent Analytics to Umami')
|
||||
parser.add_argument('--input', '-i', required=True, help='Input CSV file from Independent Analytics')
|
||||
parser.add_argument('--output', '-o', required=True, help='Output file path')
|
||||
parser.add_argument('--format', '-f', choices=['json', 'sql', 'api'], default='json',
|
||||
help='Output format: json (for API), sql (for DB), api (for API payload)')
|
||||
parser.add_argument('--site-id', '-s', default='your-site-id', help='Umami website ID')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"🔄 Converting {args.input} to Umami format...")
|
||||
print(f"Format: {args.format}")
|
||||
print(f"Site ID: {args.site_id}")
|
||||
print()
|
||||
|
||||
try:
|
||||
if args.format == 'json':
|
||||
convert_to_umami_format(args.input, args.output, args.site_id)
|
||||
elif args.format == 'sql':
|
||||
generate_sql_import(args.input, args.output, args.site_id)
|
||||
elif args.format == 'api':
|
||||
generate_api_payload(args.input, args.output, args.site_id)
|
||||
|
||||
print("\n✅ Migration completed successfully!")
|
||||
print("\nNext steps:")
|
||||
if args.format == 'json':
|
||||
print("1. Use the JSON file with Umami's import API")
|
||||
elif args.format == 'sql':
|
||||
print("1. Import the SQL file into Umami's database")
|
||||
print("2. Run: psql -U umami -d umami -f output.sql")
|
||||
elif args.format == 'api':
|
||||
print("1. POST the JSON payload to Umami's API endpoint")
|
||||
print("2. Example: curl -X POST -H 'Content-Type: application/json' -d @output.json https://your-umami-instance.com/api/import")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,87 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const WP_URL = 'https://klz-cables.com';
|
||||
|
||||
async function fetchAllPosts() {
|
||||
let page = 1;
|
||||
let allPosts: any[] = [];
|
||||
|
||||
while (true) {
|
||||
console.log(`Fetching posts page ${page}...`);
|
||||
try {
|
||||
const response = await axios.get(`${WP_URL}/wp-json/wp/v2/posts`, {
|
||||
params: {
|
||||
per_page: 100,
|
||||
page: page,
|
||||
_embed: true
|
||||
}
|
||||
});
|
||||
|
||||
const posts = response.data;
|
||||
if (posts.length === 0) break;
|
||||
|
||||
allPosts = allPosts.concat(posts);
|
||||
page++;
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 400) {
|
||||
// End of pagination
|
||||
break;
|
||||
}
|
||||
console.error('Error fetching posts:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allPosts;
|
||||
}
|
||||
|
||||
function generateMdxContent(post: any, locale: 'en' | 'de') {
|
||||
const frontmatter = {
|
||||
title: post.title.rendered,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt.rendered.replace(/<[^>]*>/g, '').trim(),
|
||||
featuredImage: post._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
|
||||
locale: locale
|
||||
};
|
||||
|
||||
return `---
|
||||
${JSON.stringify(frontmatter, null, 2)}
|
||||
---
|
||||
|
||||
# ${post.title.rendered}
|
||||
|
||||
${post.content.rendered}
|
||||
`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const posts = await fetchAllPosts();
|
||||
console.log(`Fetched ${posts.length} posts.`);
|
||||
|
||||
for (const post of posts) {
|
||||
// Determine locale.
|
||||
// If using Polylang, we might check categories or tags, or a specific field if exposed.
|
||||
// Or we can check the link structure if it contains /de/ or /en/ (though API link might be different)
|
||||
// Let's try to guess from the link or content language detection if needed.
|
||||
// For now, let's assume we can filter by category or just save all and manually sort if needed.
|
||||
// Actually, Polylang usually exposes 'lang' in the API if configured, or we might need to fetch by lang.
|
||||
|
||||
// Simple heuristic: check if link contains '/de/'
|
||||
const locale = post.link.includes('/de/') ? 'de' : 'en';
|
||||
|
||||
const mdx = generateMdxContent(post, locale);
|
||||
|
||||
const outDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${post.slug}.mdx`;
|
||||
fs.writeFileSync(path.join(outDir, filename), mdx);
|
||||
console.log(`Saved ${filename} (${locale})`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -1,175 +0,0 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import {
|
||||
createCollection,
|
||||
createField,
|
||||
createRelation,
|
||||
uploadFiles,
|
||||
createItem,
|
||||
updateSettings,
|
||||
readFolders,
|
||||
createFolder
|
||||
} from '@directus/sdk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function run() {
|
||||
console.log('🚀 CLEAN SLATE MIGRATION 🚀');
|
||||
await ensureAuthenticated();
|
||||
|
||||
// 1. Folders
|
||||
console.log('📂 Creating Folders...');
|
||||
const folders: any = {};
|
||||
const folderNames = ['Products', 'Blog', 'Pages', 'Technical'];
|
||||
for (const name of folderNames) {
|
||||
try {
|
||||
const res = await client.request(createFolder({ name }));
|
||||
folders[name] = res.id;
|
||||
} catch (e) {
|
||||
const existing = await client.request(readFolders({ filter: { name: { _eq: name } } }));
|
||||
folders[name] = existing[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Assets
|
||||
const assetMap: Record<string, string> = {};
|
||||
const uploadDir = async (dir: string, folderId: string) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
await uploadDir(fullPath, folderId);
|
||||
} else {
|
||||
const relPath = '/' + path.relative(path.join(process.cwd(), 'public'), fullPath).split(path.sep).join('/');
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('folder', folderId);
|
||||
form.append('file', new Blob([fs.readFileSync(fullPath)]), file.name);
|
||||
const res = await client.request(uploadFiles(form));
|
||||
assetMap[relPath] = res.id;
|
||||
console.log(`✅ Asset: ${relPath}`);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
};
|
||||
await uploadDir(path.join(process.cwd(), 'public/uploads'), folders.Products);
|
||||
|
||||
// 3. Collections (Minimalist)
|
||||
const collections = [
|
||||
'categories', 'products', 'posts', 'pages', 'globals',
|
||||
'categories_translations', 'products_translations', 'posts_translations', 'pages_translations', 'globals_translations',
|
||||
'categories_link'
|
||||
];
|
||||
|
||||
console.log('🏗️ Creating Collections...');
|
||||
for (const name of collections) {
|
||||
try {
|
||||
const isSingleton = name === 'globals';
|
||||
await client.request(createCollection({
|
||||
collection: name,
|
||||
schema: {},
|
||||
meta: { singleton: isSingleton }
|
||||
} as any));
|
||||
|
||||
// Add ID field
|
||||
await client.request(createField(name, {
|
||||
field: 'id',
|
||||
type: 'integer',
|
||||
meta: { hidden: true },
|
||||
schema: { is_primary_key: true, has_auto_increment: name !== 'globals' }
|
||||
}));
|
||||
console.log(`✅ Collection: ${name}`);
|
||||
} catch (e: any) {
|
||||
console.log(`ℹ️ Collection ${name} exists or error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fields & Relations
|
||||
console.log('🔧 Configuring Schema...');
|
||||
const safeAdd = async (col: string, f: any) => { try { await client.request(createField(col, f)); } catch (e) { } };
|
||||
|
||||
// Products
|
||||
await safeAdd('products', { field: 'sku', type: 'string' });
|
||||
await safeAdd('products', { field: 'image', type: 'uuid', meta: { interface: 'file' } });
|
||||
|
||||
// Translations Generic
|
||||
for (const col of ['categories', 'products', 'posts', 'pages', 'globals']) {
|
||||
const transTable = `${col}_translations`;
|
||||
await safeAdd(transTable, { field: `${col}_id`, type: 'integer' });
|
||||
await safeAdd(transTable, { field: 'languages_code', type: 'string' });
|
||||
|
||||
// Link to Parent
|
||||
try {
|
||||
await client.request(createRelation({
|
||||
collection: transTable,
|
||||
field: `${col}_id`,
|
||||
related_collection: col,
|
||||
meta: { one_field: 'translations' }
|
||||
}));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// Specific Fields
|
||||
await safeAdd('products_translations', { field: 'name', type: 'string' });
|
||||
await safeAdd('products_translations', { field: 'slug', type: 'string' });
|
||||
await safeAdd('products_translations', { field: 'description', type: 'text' });
|
||||
await safeAdd('products_translations', { field: 'content', type: 'text', meta: { interface: 'input-rich-text-html' } });
|
||||
await safeAdd('products_translations', { field: 'technical_items', type: 'json' });
|
||||
await safeAdd('products_translations', { field: 'voltage_tables', type: 'json' });
|
||||
|
||||
await safeAdd('categories_translations', { field: 'name', type: 'string' });
|
||||
await safeAdd('posts_translations', { field: 'title', type: 'string' });
|
||||
await safeAdd('posts_translations', { field: 'slug', type: 'string' });
|
||||
await safeAdd('posts_translations', { field: 'content', type: 'text' });
|
||||
|
||||
await safeAdd('globals', { field: 'company_name', type: 'string' });
|
||||
await safeAdd('globals_translations', { field: 'tagline', type: 'string' });
|
||||
|
||||
// M2M Link
|
||||
await safeAdd('categories_link', { field: 'products_id', type: 'integer' });
|
||||
await safeAdd('categories_link', { field: 'categories_id', type: 'integer' });
|
||||
try {
|
||||
await client.request(createRelation({ collection: 'categories_link', field: 'products_id', related_collection: 'products', meta: { one_field: 'categories_link' } }));
|
||||
await client.request(createRelation({ collection: 'categories_link', field: 'categories_id', related_collection: 'categories' }));
|
||||
} catch (e) { }
|
||||
|
||||
// 5. Data Import
|
||||
console.log('📥 Importing Data...');
|
||||
const deDir = path.join(process.cwd(), 'data/products/de');
|
||||
const files = fs.readdirSync(deDir).filter(f => f.endsWith('.mdx'));
|
||||
|
||||
for (const file of files) {
|
||||
const doc = matter(fs.readFileSync(path.join(deDir, file), 'utf8'));
|
||||
const enPath = path.join(process.cwd(), `data/products/en/${file}`);
|
||||
const enDoc = fs.existsSync(enPath) ? matter(fs.readFileSync(enPath, 'utf8')) : doc;
|
||||
|
||||
const clean = (c: string) => c.replace(/<ProductTabs.*?>|<\/ProductTabs>|<ProductTechnicalData.*?\/>/gs, '').trim();
|
||||
const extract = (c: string) => {
|
||||
const m = c.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
|
||||
try { return m ? JSON.parse(m[1]) : {}; } catch (e) { return {}; }
|
||||
};
|
||||
|
||||
try {
|
||||
await client.request(createItem('products', {
|
||||
sku: doc.data.sku,
|
||||
image: assetMap[doc.data.images?.[0]] || null,
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', name: doc.data.title, slug: file.replace('.mdx', ''), description: doc.data.description, content: clean(doc.content), technical_items: extract(doc.content).technicalItems, voltage_tables: extract(doc.content).voltageTables },
|
||||
{ languages_code: 'en-US', name: enDoc.data.title, slug: file.replace('.mdx', ''), description: enDoc.data.description, content: clean(enDoc.content), technical_items: extract(enDoc.content).technicalItems, voltage_tables: extract(enDoc.content).voltageTables }
|
||||
]
|
||||
}));
|
||||
console.log(`✅ Product: ${doc.data.sku}`);
|
||||
} catch (e: any) {
|
||||
console.error(`❌ Product ${file}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✨ DONE!');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -1,78 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const WP_URL = 'https://klz-cables.com';
|
||||
|
||||
async function fetchAllPages() {
|
||||
let page = 1;
|
||||
let allPages: any[] = [];
|
||||
|
||||
while (true) {
|
||||
console.log(`Fetching pages page ${page}...`);
|
||||
try {
|
||||
const response = await axios.get(`${WP_URL}/wp-json/wp/v2/pages`, {
|
||||
params: {
|
||||
per_page: 100,
|
||||
page: page,
|
||||
_embed: true
|
||||
}
|
||||
});
|
||||
|
||||
const pages = response.data;
|
||||
if (pages.length === 0) break;
|
||||
|
||||
allPages = allPages.concat(pages);
|
||||
page++;
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 400) {
|
||||
break;
|
||||
}
|
||||
console.error('Error fetching pages:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allPages;
|
||||
}
|
||||
|
||||
function generateMdxContent(page: any, locale: 'en' | 'de') {
|
||||
const frontmatter = {
|
||||
title: page.title.rendered,
|
||||
excerpt: page.excerpt.rendered.replace(/<[^>]*>/g, '').trim(),
|
||||
featuredImage: page._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
|
||||
locale: locale
|
||||
};
|
||||
|
||||
return `---
|
||||
${JSON.stringify(frontmatter, null, 2)}
|
||||
---
|
||||
|
||||
# ${page.title.rendered}
|
||||
|
||||
${page.content.rendered}
|
||||
`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const pages = await fetchAllPages();
|
||||
console.log(`Fetched ${pages.length} pages.`);
|
||||
|
||||
for (const page of pages) {
|
||||
// Determine locale.
|
||||
const locale = page.link.includes('/de/') ? 'de' : 'en';
|
||||
|
||||
const mdx = generateMdxContent(page, locale);
|
||||
|
||||
const outDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${page.slug}.mdx`;
|
||||
fs.writeFileSync(path.join(outDir, filename), mdx);
|
||||
console.log(`Saved ${filename} (${locale})`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -1,143 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { buildDatasheetModel } from './pdf/model/build-datasheet-model';
|
||||
import type { ProductData } from './pdf/model/types';
|
||||
|
||||
const WC_URL = process.env.WOOCOMMERCE_URL;
|
||||
const WC_KEY = process.env.WOOCOMMERCE_CONSUMER_KEY;
|
||||
const WC_SECRET = process.env.WOOCOMMERCE_CONSUMER_SECRET;
|
||||
|
||||
if (!WC_URL || !WC_KEY || !WC_SECRET) {
|
||||
console.error('Missing WooCommerce credentials in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function fetchAllProducts() {
|
||||
let page = 1;
|
||||
let allProducts: any[] = [];
|
||||
|
||||
while (true) {
|
||||
console.log(`Fetching page ${page}...`);
|
||||
try {
|
||||
const response = await axios.get(`${WC_URL}/wp-json/wc/v3/products`, {
|
||||
params: {
|
||||
consumer_key: WC_KEY,
|
||||
consumer_secret: WC_SECRET,
|
||||
per_page: 100,
|
||||
page: page
|
||||
}
|
||||
});
|
||||
|
||||
const products = response.data;
|
||||
if (products.length === 0) break;
|
||||
|
||||
allProducts = allProducts.concat(products);
|
||||
page++;
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allProducts;
|
||||
}
|
||||
|
||||
function mapWcProductToProductData(wcProduct: any, locale: 'en' | 'de'): ProductData {
|
||||
// This mapping needs to be adjusted based on actual WC response structure
|
||||
// and how translations are handled (e.g. if they are separate products or same product with different fields)
|
||||
|
||||
// Assuming standard WC response
|
||||
return {
|
||||
id: wcProduct.id,
|
||||
name: wcProduct.name,
|
||||
shortDescriptionHtml: wcProduct.short_description,
|
||||
descriptionHtml: wcProduct.description,
|
||||
images: wcProduct.images.map((img: any) => img.src),
|
||||
featuredImage: wcProduct.images[0]?.src || null,
|
||||
sku: wcProduct.sku,
|
||||
slug: wcProduct.slug,
|
||||
categories: wcProduct.categories.map((cat: any) => ({ name: cat.name })),
|
||||
attributes: wcProduct.attributes.map((attr: any) => ({
|
||||
name: attr.name,
|
||||
options: attr.options
|
||||
})),
|
||||
locale: locale // This might need to be derived
|
||||
};
|
||||
}
|
||||
|
||||
function generateMdxContent(product: ProductData, technicalData: any, locale: 'en' | 'de') {
|
||||
const frontmatter = {
|
||||
title: product.name,
|
||||
sku: product.sku,
|
||||
description: product.shortDescriptionHtml.replace(/<[^>]*>/g, ''), // Simple strip tags
|
||||
categories: product.categories.map(c => c.name),
|
||||
images: product.images,
|
||||
locale: locale
|
||||
};
|
||||
|
||||
const technicalDataJson = JSON.stringify(technicalData, null, 2);
|
||||
|
||||
return `---
|
||||
${JSON.stringify(frontmatter, null, 2)}
|
||||
---
|
||||
|
||||
# ${product.name}
|
||||
|
||||
${product.descriptionHtml}
|
||||
|
||||
## Technical Data
|
||||
|
||||
<ProductTechnicalData data={${technicalDataJson}} />
|
||||
`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const products = await fetchAllProducts();
|
||||
console.log(`Fetched ${products.length} products.`);
|
||||
|
||||
for (const product of products) {
|
||||
// Determine locale. WC might return 'lang' property if using plugins like Polylang
|
||||
// Or we might have to infer it.
|
||||
// For now, let's assume we can detect it or default to 'en'.
|
||||
// If the site uses Polylang, usually there is a 'lang' field.
|
||||
|
||||
const locale = product.lang || 'en'; // Default to en if not found
|
||||
|
||||
// We need to handle both en and de.
|
||||
// If the API returns mixed, we process them.
|
||||
// If the API only returns default lang, we might need to fetch translations specifically.
|
||||
|
||||
// Let's try to generate for the detected locale.
|
||||
|
||||
const productData = mapWcProductToProductData(product, locale as 'en' | 'de');
|
||||
|
||||
// Build datasheet model to get technical data
|
||||
// We need to try both locales if we are not sure, or just the one we have.
|
||||
// But buildDatasheetModel takes a locale.
|
||||
|
||||
const model = buildDatasheetModel({ product: productData, locale: locale as 'en' | 'de' });
|
||||
|
||||
if (model.voltageTables.length > 0 || model.technicalItems.length > 0) {
|
||||
console.log(`Generated technical data for ${product.name} (${locale})`);
|
||||
} else {
|
||||
console.warn(`No technical data found for ${product.name} (${locale})`);
|
||||
}
|
||||
|
||||
const mdx = generateMdxContent(productData, {
|
||||
technicalItems: model.technicalItems,
|
||||
voltageTables: model.voltageTables
|
||||
}, locale as 'en' | 'de');
|
||||
|
||||
const outDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${product.slug}.mdx`;
|
||||
fs.writeFileSync(path.join(outDir, filename), mdx);
|
||||
console.log(`Saved ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import axios from 'axios';
|
||||
|
||||
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
|
||||
const STRAPI_TOKEN = process.env.STRAPI_ADMIN_TOKEN; // You'll need to generate this
|
||||
|
||||
async function migrateProducts() {
|
||||
const productsDir = path.join(process.cwd(), 'data/products');
|
||||
const locales = ['de', 'en'];
|
||||
|
||||
for (const locale of locales) {
|
||||
const localeDir = path.join(productsDir, locale);
|
||||
if (!fs.existsSync(localeDir)) continue;
|
||||
|
||||
const files = fs.readdirSync(localeDir).filter(f => f.endsWith('.mdx'));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(localeDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
console.log(`Migrating ${data.title} (${locale})...`);
|
||||
|
||||
try {
|
||||
// 1. Check if product exists (by SKU)
|
||||
const existing = await axios.get(`${STRAPI_URL}/api/products?filters[sku][$eq]=${data.sku}&locale=${locale}`, {
|
||||
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
|
||||
});
|
||||
|
||||
const productData = {
|
||||
title: data.title,
|
||||
sku: data.sku,
|
||||
description: data.description,
|
||||
application: data.application,
|
||||
content: content,
|
||||
technicalData: data.technicalData || {}, // This might need adjustment based on how it's stored in MDX
|
||||
locale: locale,
|
||||
};
|
||||
|
||||
if (existing.data.data.length > 0) {
|
||||
// Update
|
||||
const id = existing.data.data[0].id;
|
||||
await axios.put(`${STRAPI_URL}/api/products/${id}`, { data: productData }, {
|
||||
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
|
||||
});
|
||||
console.log(`Updated ${data.title}`);
|
||||
} else {
|
||||
// Create
|
||||
await axios.post(`${STRAPI_URL}/api/products`, { data: productData }, {
|
||||
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
|
||||
});
|
||||
console.log(`Created ${data.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error migrating ${data.title}:`, error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This script requires a running Strapi instance and an admin token.
|
||||
// migrateProducts();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user