Compare commits
146 Commits
v2.1.0-rc.
...
feature/ai
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc52da677 | |||
| 7f1aeaee7e | |||
| 590c542b73 | |||
| ab477e8d8e | |||
| d83ab182db | |||
| 159ee66f55 | |||
| 45c385d62e | |||
| 8e99c9d121 | |||
| 3acf0c3740 | |||
| 4dcdb717f0 | |||
| 81ce3a4588 | |||
| 8b2e82888c | |||
| da637d4a74 | |||
| 1640b57c87 | |||
| 57d624839d | |||
| e3393d73c3 | |||
| 94e15656a4 | |||
| d41ec9b66b | |||
| 68380a3af9 | |||
| 3496720e22 | |||
| 8162a8b1cb | |||
| 6729d53c78 | |||
| 884adeabaf | |||
| a66674fcdf | |||
| ee7a40d39b | |||
| 79bbf852a1 | |||
| 8adc3c5b51 | |||
| d89b7930c2 | |||
| 6cea2f0c45 | |||
| 9be7cb7ac9 | |||
| e7116f75fc | |||
| dacb100218 | |||
| 1862b54540 | |||
| df2514e461 | |||
| 1434569dd8 | |||
| 7bab6a55db | |||
| 5797261d1a | |||
| 88dfeba502 | |||
| ce8829ece5 | |||
| 6f393fbc59 | |||
| b1363d9d52 | |||
| 0bd55c1dee | |||
| bb7c50c780 | |||
| 3c89c50a88 | |||
| 118cecf423 | |||
| 4ca0b53bd9 | |||
| c8286f4f67 | |||
| 73c8b4dcc2 | |||
| 7ca1b9c143 | |||
| 3570e766f6 | |||
| ec0abffc55 | |||
| 6a748a3ac8 | |||
| d69e0eebe6 | |||
| 1577bfd2ec | |||
| 6440d893f0 | |||
| d8e3c7d9a3 | |||
| aa14f39dba | |||
| 1cfc0523f3 | |||
| 3ff20fd2c9 | |||
| 549ee34490 | |||
| 8a8e30400c | |||
| 4faed38f47 | |||
| 1e0886144f | |||
| c933d9b886 | |||
| 5c56d8babf | |||
| c4c6fb3b07 | |||
| ff685b9933 | |||
| 980258af5c | |||
| 57b6963efe | |||
| 1a136540d0 | |||
| 92bc88dfbd | |||
| fb3ec6e10a | |||
| acf642d7e6 | |||
| d5da2a91c8 | |||
| ebe664f984 | |||
| 9c7324ee92 | |||
| 0c8d9ea669 | |||
| 1bb0efc85b | |||
| 4adf547265 | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 | |||
| a2cf9791ae | |||
| aa4e3aab4f | |||
| f1a28b9db2 | |||
| 7fb1945ce5 | |||
| ce719a1d70 | |||
| bd2f92125b | |||
| ec013a32a2 | |||
| eebe7972e0 | |||
| a9c7fa7c5e | |||
| 85e7ff71d5 | |||
| 2acb0c1608 | |||
| 082733c4f4 | |||
| af67ae7994 | |||
| 1fd247e358 | |||
| 44401cf546 | |||
| 7f106b1fa7 | |||
| 08425a3a42 | |||
| 62f1e9a89c | |||
| a5718c5013 | |||
| 82bb7240d5 | |||
| 9e7f6ec76f | |||
| b3057d8be0 | |||
| 3b45a967f7 | |||
| cadb104917 | |||
| 0be885428d | |||
| 009f12a3bf | |||
| 8e2a06d6f2 | |||
| 4f2bf3fa51 | |||
| 064ebf45e3 | |||
| e6dfeaffef | |||
| 7cdfe5d7f8 | |||
| 83f4b8eea8 | |||
| 97e76c7cac | |||
| 6caa850045 | |||
| 04ce0ecedd | |||
| 083859d52d | |||
| a13074902b | |||
| 4280f11772 | |||
| 3049c1b6e7 | |||
| 647f9a5f19 | |||
| a2872be02e | |||
| 9c3c7bd34b | |||
| 45602db7ff | |||
| 89405e6e18 | |||
| 57d54231eb | |||
| 5c4225d0a9 | |||
| e1101f2e60 | |||
| 0be6076512 | |||
| 62400943c2 | |||
| 4c60029e21 | |||
| b3c5b911d9 | |||
| 89f00c79a1 | |||
| 98ac3dbd10 | |||
| 0db4c819ff | |||
| 08a3b0be7b | |||
| a953820241 | |||
| fa02ac597f | |||
| 40e26117bd | |||
| 925765233e | |||
| 20fd889751 | |||
| 0487bd8ebe | |||
| 87b2624ab3 | |||
| 7cad437eb4 |
27
.env
27
.env
@@ -1,27 +0,0 @@
|
|||||||
# Application
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
|
||||||
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
|
||||||
LOG_LEVEL=info
|
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
|
||||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
|
||||||
|
|
||||||
# SMTP Configuration
|
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
|
||||||
MAIL_PORT=587
|
|
||||||
MAIL_USERNAME=postmaster@mg.mintel.me
|
|
||||||
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
|
||||||
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
|
||||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Payload Infrastructure (Dockerized)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
|
||||||
# by docker-compose.yml using these base DB credentials, so you don't need to
|
|
||||||
# manually write the connection strings here.
|
|
||||||
PAYLOAD_DB_NAME=payload
|
|
||||||
PAYLOAD_DB_USER=payload
|
|
||||||
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
|
||||||
@@ -48,6 +48,12 @@ GATEKEEPER_PASSWORD=klz2026
|
|||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# AI Agent (Payload CMS Agent via OpenRouter)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Required for the Payload CMS AI Chat Agent
|
||||||
|
MISTRAL_API_KEY=
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Payload Infrastructure (Dockerized)
|
# Payload Infrastructure (Dockerized)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
name: CI - Lint, Typecheck & Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-pipeline
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality-assurance:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: 🔐 Configure Private Registry
|
|
||||||
run: |
|
|
||||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
|
||||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
|
||||||
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
|
||||||
|
|
||||||
- name: 🧪 QA Checks
|
|
||||||
env:
|
|
||||||
TURBO_TELEMETRY_DISABLED: "1"
|
|
||||||
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
|
|
||||||
|
|
||||||
- name: 🏗️ Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: ♿ Accessibility Check
|
|
||||||
run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000"
|
|
||||||
|
|
||||||
- name: ♿ WCAG Sitemap Audit
|
|
||||||
run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000"
|
|
||||||
# monitor trigger
|
|
||||||
@@ -14,8 +14,8 @@ on:
|
|||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: 'true'
|
||||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
COREPACK_NPM_REGISTRY: 'https://registry.npmmirror.com'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-pipeline
|
group: deploy-pipeline
|
||||||
@@ -29,14 +29,16 @@ jobs:
|
|||||||
name: 🔍 Prepare
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
target: ${{ steps.determine.outputs.target }}
|
target: ${{ steps.determine.outputs.target }}
|
||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
env_file: ${{ steps.determine.outputs.env_file }}
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
slug: ${{ steps.determine.outputs.slug }}
|
||||||
|
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -83,24 +85,27 @@ jobs:
|
|||||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.branch-${SLUG}"
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Standardize Traefik Rule
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
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?"":" || ")}')
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}')
|
||||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
else
|
else
|
||||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
|
||||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "target=$TARGET"
|
echo "target=$TARGET"
|
||||||
echo "image_tag=$IMAGE_TAG"
|
echo "image_tag=$IMAGE_TAG"
|
||||||
echo "env_file=$ENV_FILE"
|
echo "env_file=$ENV_FILE"
|
||||||
echo "traefik_host=$PRIMARY_HOST"
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
echo "traefik_rule=$TRAEFIK_RULE"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "gatekeeper_host=$GATEKEEPER_HOST"
|
||||||
echo "next_public_url=https://$PRIMARY_HOST"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
echo "project_name=klz-cablescom"
|
echo "project_name=klz-cablescom"
|
||||||
@@ -110,6 +115,7 @@ jobs:
|
|||||||
echo "project_name=$PRJ-$TARGET"
|
echo "project_name=$PRJ-$TARGET"
|
||||||
fi
|
fi
|
||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
echo "slug=$SLUG"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
@@ -153,6 +159,8 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -169,19 +177,24 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
- name: 🔒 Security Audit
|
- name: 🔒 Security Audit
|
||||||
run: pnpm audit --audit-level high
|
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||||
|
|
||||||
|
- name: 🧹 Clean Workspace
|
||||||
|
run: rm -rf .next .turbo || true
|
||||||
|
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
if: github.event.inputs.skip_checks != 'true'
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
env:
|
env:
|
||||||
TURBO_TELEMETRY_DISABLED: "1"
|
TURBO_TELEMETRY_DISABLED: '1'
|
||||||
run: npx turbo run lint check:spell typecheck test --cache-dir=".turbo"
|
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push
|
# JOB 3: Build & Push
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -198,23 +211,39 @@ jobs:
|
|||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
- name: 🏗️ Build and Push
|
- name: 🏗️ Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
platforms: linux/arm64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||||
secrets: |
|
secrets: |
|
||||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
- name: 🔄 Build and Push Migrator
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
platforms: linux/amd64
|
||||||
|
target: migrator
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
tags: registry.infra.mintel.me/mintel/klz-2026:migrate-${{ needs.prepare.outputs.image_tag }}
|
||||||
|
secrets: |
|
||||||
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy
|
# JOB 4: Deploy
|
||||||
@@ -226,43 +255,54 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||||
|
SLUG: ${{ needs.prepare.outputs.slug }}
|
||||||
|
|
||||||
# Secrets mapping (Payload CMS)
|
# Secrets mapping (Payload CMS)
|
||||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
||||||
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
||||||
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
|
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
|
||||||
|
|
||||||
# Secrets mapping (Mail)
|
# Secrets mapping (Mail)
|
||||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
|
||||||
# Gatekeeper
|
# Gatekeeper
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
|
# Search & AI
|
||||||
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
||||||
|
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
|
||||||
|
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
||||||
|
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
|
||||||
|
# Container Registry (standalone)
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: 📝 Generate Environment
|
- name: 📝 Generate Environment
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
# Middleware Selection Logic
|
# Middleware Selection Logic
|
||||||
# Regular app routes get auth on non-production
|
# Regular app routes get auth on non-production
|
||||||
@@ -270,7 +310,7 @@ jobs:
|
|||||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||||
|
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
AUTH_MIDDLEWARE="$STD_MW"
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
COMPOSE_PROFILES=""
|
COMPOSE_PROFILES=""
|
||||||
@@ -282,7 +322,7 @@ jobs:
|
|||||||
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
# Gatekeeper Origin
|
# Gatekeeper Origin
|
||||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "# Generated by CI - $TARGET"
|
echo "# Generated by CI - $TARGET"
|
||||||
@@ -313,11 +353,18 @@ jobs:
|
|||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "# Search & AI"
|
||||||
|
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
||||||
|
echo "QDRANT_URL=$QDRANT_URL"
|
||||||
|
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
||||||
|
echo "REDIS_URL=$REDIS_URL"
|
||||||
|
echo ""
|
||||||
echo "TARGET=$TARGET"
|
echo "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
|
||||||
echo "TRAEFIK_ENTRYPOINT=websecure"
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
echo "TRAEFIK_TLS=true"
|
echo "TRAEFIK_TLS=true"
|
||||||
echo "TRAEFIK_CERT_RESOLVER=le"
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
@@ -331,16 +378,45 @@ jobs:
|
|||||||
cat .env.deploy
|
cat .env.deploy
|
||||||
echo "----------------------------"
|
echo "----------------------------"
|
||||||
|
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
id: auth
|
||||||
|
run: |
|
||||||
|
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||||
|
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||||
|
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||||
|
|
||||||
|
VALID_TOKEN=""
|
||||||
|
VALID_USER=""
|
||||||
|
for T in $TOKENS; do
|
||||||
|
if [ -n "$T" ]; then
|
||||||
|
for U in $USERS; do
|
||||||
|
if [ -n "$U" ]; then
|
||||||
|
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||||
|
VALID_TOKEN="$T"
|
||||||
|
VALID_USER="$U"
|
||||||
|
break 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
|
||||||
|
echo "token=$VALID_TOKEN" >> $GITHUB_OUTPUT
|
||||||
|
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: 🚀 SSH Deploy
|
- name: 🚀 SSH Deploy
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Determine deployment paths
|
||||||
|
echo "Preparing deployment for $TARGET..."
|
||||||
|
|
||||||
# Transfer and Restart
|
# Transfer and Restart
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
@@ -349,17 +425,16 @@ jobs:
|
|||||||
elif [[ "$TARGET" == "staging" ]]; then
|
elif [[ "$TARGET" == "staging" ]]; then
|
||||||
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
||||||
else
|
else
|
||||||
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
|
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
|
||||||
fi
|
fi
|
||||||
|
# Transfer files
|
||||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||||
|
|
||||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
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 docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
# Execute remote commands — alpha is pre-logged into registry.infra.mintel.me
|
||||||
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 pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans"
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
|
||||||
|
|
||||||
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
||||||
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
||||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||||
@@ -372,31 +447,32 @@ jobs:
|
|||||||
echo " Attempt $i/15..."
|
echo " Attempt $i/15..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "🔧 Sanitizing payload_migrations table (if exists)..."
|
echo "🔧 Sanitizing payload_migrations table (if exists)..."
|
||||||
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
|
||||||
DO \\\$\\\$ BEGIN
|
# Run Payload migrations via a temporary container before restarting the app.
|
||||||
DELETE FROM payload_migrations WHERE batch = -1;
|
# This ensures fresh branch deployments (empty DBs) get their schema on first deploy.
|
||||||
INSERT INTO payload_migrations (name, batch)
|
echo "🔄 Running Payload migrations..."
|
||||||
SELECT name, batch FROM (VALUES
|
MIGRATOR_IMAGE="registry.infra.mintel.me/mintel/klz-2026:migrate-$IMAGE_TAG"
|
||||||
('20260223_195005_products_collection', 1),
|
|
||||||
('20260223_195151_remove_sku_unique', 2),
|
ssh root@alpha.mintel.me "
|
||||||
('20260225_003500_add_pages_collection', 3)
|
echo '${{ steps.auth.outputs.token }}' | docker login registry.infra.mintel.me -u '${{ steps.auth.outputs.user }}' --password-stdin 2>/dev/null || true
|
||||||
) AS v(name, batch)
|
docker pull $MIGRATOR_IMAGE
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
docker run --rm \
|
||||||
EXCEPTION WHEN undefined_table THEN
|
--network ${PROJECT_NAME}_default \
|
||||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
--env-file $SITE_DIR/$ENV_FILE \
|
||||||
END \\\$\\\$;
|
$MIGRATOR_IMAGE \
|
||||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
&& echo '✅ Migrations complete.' \
|
||||||
|
|| echo '⚠️ Migrations failed or already up-to-date — continuing.'
|
||||||
|
"
|
||||||
|
|
||||||
# Restart app to pick up clean migration state
|
# Restart app to pick up clean migration state
|
||||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
APP_CONTAINER="${PROJECT_NAME}-klz-app-1"
|
||||||
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
@@ -426,12 +502,28 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
id: deps
|
id: deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
- name: 🔍 Install Chromium (for Asset Scan)
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Cache APT Packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||||
|
|
||||||
|
- name: 💾 Cache Chromium
|
||||||
|
id: cache-chromium
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /usr/bin/chromium
|
||||||
|
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
apt-get update
|
apt-get update
|
||||||
@@ -453,139 +545,54 @@ jobs:
|
|||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
|
- name: 🏥 CMS Deep Health Check
|
||||||
|
env:
|
||||||
|
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: |
|
||||||
|
echo "Waiting 10s for app to fully start..."
|
||||||
|
sleep 10
|
||||||
|
echo "Checking basic health..."
|
||||||
|
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||||
|
echo "✅ Basic health OK"
|
||||||
|
echo "Checking CMS DB connectivity..."
|
||||||
|
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
|
||||||
|
echo "❌ CMS health check failed!"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
echo ""
|
||||||
|
echo "This usually means Payload CMS migrations failed or DB tables are missing."
|
||||||
|
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✅ CMS health: $RESPONSE"
|
||||||
- name: 🚀 OG Image Check
|
- name: 🚀 OG Image Check
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
run: pnpm run check:og
|
run: pnpm run check:og
|
||||||
- name: 🌐 Full Sitemap HTTP Validation
|
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
with:
|
||||||
|
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
run: pnpm run check:http
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
- name: 🌐 Locale & Language Switcher Validation
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm run check:locale
|
|
||||||
|
|
||||||
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
- name: 📝 E2E Form Submission Test
|
||||||
- name: 🌐 HTML DOM Validation
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:html
|
|
||||||
- name: 🔒 Security Headers Scan
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:security
|
|
||||||
- name: 🔗 Lychee Deep Link Crawl
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:links
|
|
||||||
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
CHROME_PATH: /usr/bin/chromium
|
run: pnpm run check:forms
|
||||||
run: pnpm check:assets
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# JOB 6: Performance & Accessibility (Lighthouse + WCAG)
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
performance:
|
|
||||||
name: ⚡ Performance & Accessibility
|
|
||||||
needs: [prepare, post_deploy_checks]
|
|
||||||
continue-on-error: true
|
|
||||||
if: needs.post_deploy_checks.result == 'success' && needs.prepare.outputs.target != 'branch'
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: 🔐 Registry Auth
|
|
||||||
run: |
|
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
|
||||||
run: |
|
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y gnupg wget ca-certificates
|
|
||||||
|
|
||||||
# Detect OS
|
|
||||||
OS_ID=$(. /etc/os-release && echo $ID)
|
|
||||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
|
||||||
|
|
||||||
if [ "$OS_ID" = "debian" ]; then
|
|
||||||
echo "🎯 Debian detected - installing native chromium"
|
|
||||||
apt-get install -y chromium
|
|
||||||
else
|
|
||||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
|
||||||
mkdir -p /etc/apt/keyrings
|
|
||||||
KEY_ID="82BB6851C64F6880"
|
|
||||||
|
|
||||||
# Fetch PPA key
|
|
||||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
|
||||||
|
|
||||||
# Add PPA repository
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
|
|
||||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
|
||||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
|
||||||
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --allow-downgrades chromium
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Standardize binary paths
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
|
||||||
- name: ⚡ Lighthouse CI
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
run: pnpm run pagespeed:test
|
|
||||||
- name: ♿ WCAG Audit
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
run: pnpm run check:wcag
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 7: Notifications
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy, post_deploy_checks, performance]
|
needs: [prepare, deploy, post_deploy_checks]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -596,7 +603,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
DEPLOY="${{ needs.deploy.result }}"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
PERF="${{ needs.performance.result }}"
|
PERF="${{ needs.post_deploy_checks.result }}"
|
||||||
TARGET="${{ needs.prepare.outputs.target }}"
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
@@ -624,10 +631,8 @@ jobs:
|
|||||||
STATUS_LINE="All checks passed"
|
STATUS_LINE="All checks passed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
|
||||||
MESSAGE="$STATUS_LINE
|
MESSAGE="$STATUS_LINE | Deploy: $DEPLOY | Smoke: $SMOKE | $URL"
|
||||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
|
||||||
$URL"
|
|
||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
|
|||||||
233
.gitea/workflows/qa.yml
Normal file
233
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
name: Nightly QA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_URL: 'https://testing.klz-cables.com'
|
||||||
|
PROJECT_NAME: 'klz-2026'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 1. Static Checks (HTML, Assets, HTTP)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
static:
|
||||||
|
name: 🔍 Static Analysis
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🌐 HTML Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:html
|
||||||
|
- name: 🖼️ Broken Assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
ASSET_CHECK_LIMIT: 10
|
||||||
|
run: pnpm run check:assets
|
||||||
|
- name: 🔒 HTTP Headers
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:http
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 2. Accessibility (WCAG)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
a11y:
|
||||||
|
name: ♿ Accessibility
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: ♿ WCAG Scan
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 3. Performance (Lighthouse)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: 🎭 Lighthouse
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🎭 Desktop
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||||
|
- name: 📱 Mobile
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 4. Link Check & Dependency Audit
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
links:
|
||||||
|
name: 🔗 Links & Deps
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Depcheck
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||||
|
- name: 🔗 Lychee Link Check
|
||||||
|
uses: lycheeverse/lychee-action@v2
|
||||||
|
with:
|
||||||
|
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||||
|
fail: true
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 5. Notification
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
notify:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [static, a11y, lighthouse, links]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
STATIC="${{ needs.static.result }}"
|
||||||
|
A11Y="${{ needs.a11y.result }}"
|
||||||
|
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||||
|
LINKS="${{ needs.links.result }}"
|
||||||
|
|
||||||
|
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS="Failed"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS="Passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||||
|
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||||
|
${{ env.TARGET_URL }}"
|
||||||
|
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=$TITLE" \
|
||||||
|
-F "message=$MESSAGE" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,3 +28,9 @@ html-errors*.json
|
|||||||
reference/
|
reference/
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Payload CMS auto-generated
|
||||||
|
# Knowledge base source files
|
||||||
|
kabelhandbuch.txt
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
"valid-id": "off",
|
"valid-id": "off",
|
||||||
"element-required-attributes": "off",
|
"element-required-attributes": "off",
|
||||||
"attribute-empty-style": "off",
|
"attribute-empty-style": "off",
|
||||||
"element-permitted-content": "off"
|
"element-permitted-content": "off",
|
||||||
|
"element-required-content": "off",
|
||||||
|
"element-permitted-parent": "off",
|
||||||
|
"no-implicit-close": "off",
|
||||||
|
"close-order": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.npmrc
3
.npmrc
@@ -1,2 +1 @@
|
|||||||
@mintel:registry=https://npm.infra.mintel.me/
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
|
||||||
|
|||||||
35
Dockerfile
35
Dockerfile
@@ -1,18 +1,16 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
|
||||||
ARG UMAMI_WEBSITE_ID
|
ARG UMAMI_WEBSITE_ID
|
||||||
ARG UMAMI_API_ENDPOINT
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
# Environment variables for Next.js build
|
# Environment variables for Next.js build
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
|
||||||
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
@@ -20,14 +18,16 @@ ENV CI=true
|
|||||||
|
|
||||||
# Copy lockfile and manifest for dependency installation caching
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
COPY patches* ./patches/
|
||||||
|
|
||||||
# Configure private registry and install dependencies
|
# Configure private registry and install dependencies
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
pnpm install --frozen-lockfile && \
|
pnpm store prune && \
|
||||||
|
pnpm install --no-frozen-lockfile && \
|
||||||
rm .npmrc
|
rm .npmrc
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
@@ -38,22 +38,27 @@ FROM base AS development
|
|||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
CMD ["pnpm", "dev:local"]
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
|
# Stage: Migrator
|
||||||
|
FROM base AS migrator
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
CMD ["pnpm", "cms:migrate"]
|
||||||
|
|
||||||
# Build application
|
# Build application
|
||||||
# Stage 3: Builder (Production)
|
# Stage 3: Builder (Production)
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Limit memory to 2GB to prevent ResourceExhausted on 4GB runner
|
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
|
||||||
|
ENV RAYON_NUM_THREADS=3
|
||||||
|
ENV UV_THREADPOOL_SIZE=3
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 3: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
|
||||||
USER root
|
|
||||||
RUN chown -R nextjs:nodejs /app
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install essential build tools if needed (e.g., for node-gyp)
|
# Install essential build tools if needed (e.g., for node-gyp)
|
||||||
RUN apk add --no-cache libc6-compat python3 make g++
|
RUN apk add --no-cache libc6-compat python3 make g++ curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -462,3 +462,4 @@ Proprietary - KLZ Cables
|
|||||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0.0
|
||||||
**Last Updated**: December 27, 2025
|
**Last Updated**: December 27, 2025
|
||||||
|
Trigger rebuilding for x86 architecture.
|
||||||
|
|||||||
@@ -1,57 +1,84 @@
|
|||||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||||
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon'
|
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
|
||||||
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo'
|
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
|
||||||
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
|
||||||
|
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc,
|
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
|
||||||
"/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
|
||||||
}
|
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
|
||||||
|
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||||
|
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||||
|
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
|
||||||
|
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
|
||||||
|
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
|
||||||
|
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
|
||||||
|
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||||
|
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||||
|
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||||
|
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'/src/payload/components/Icon#default': default_9ed509b5e5f7d08a16335393f27586cc,
|
||||||
|
'/src/payload/components/Logo#default': default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
||||||
|
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
|
||||||
|
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
|
||||||
|
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -21,15 +21,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
|
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
||||||
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
||||||
|
|
||||||
|
// Determine correct localized slug based on current locale
|
||||||
|
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${deSlug}`,
|
de: `${SITE_URL}/de/${deSlug}`,
|
||||||
en: `${SITE_URL}/en/${enSlug}`,
|
en: `${SITE_URL}/en/${enSlug}`,
|
||||||
@@ -39,7 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
url: `${SITE_URL}/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -59,6 +62,13 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect if accessed via a different locale's slug
|
||||||
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
|
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||||
|
if (correctSlug && correctSlug !== slug) {
|
||||||
|
redirect(`/${locale}/${correctSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
||||||
if (pageData.frontmatter.layout === 'fullBleed') {
|
if (pageData.frontmatter.layout === 'fullBleed') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
|
|||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(url: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
return `data:${contentType};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch OG image:', url, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Image({
|
export default async function Image({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -32,12 +46,19 @@ export default async function Image({
|
|||||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
|
||||||
|
// fetching remote URLs directly inside ImageResponse correctly in various environments.
|
||||||
|
let base64Image: string | undefined = undefined;
|
||||||
|
if (featuredImage) {
|
||||||
|
base64Image = await fetchImageAsBase64(featuredImage);
|
||||||
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={post.frontmatter.title}
|
title={post.frontmatter.title}
|
||||||
description={post.frontmatter.excerpt}
|
description={post.frontmatter.excerpt}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
image={featuredImage}
|
image={base64Image || featuredImage}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
import {
|
||||||
|
getPostBySlug,
|
||||||
|
getAdjacentPosts,
|
||||||
|
getReadingTime,
|
||||||
|
extractLexicalHeadings,
|
||||||
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import PostNavigation from '@/components/blog/PostNavigation';
|
import PostNavigation from '@/components/blog/PostNavigation';
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
import PowerCTA from '@/components/blog/PowerCTA';
|
||||||
|
import TableOfContents from '@/components/blog/TableOfContents';
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
@@ -32,7 +38,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
@@ -40,7 +46,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -54,12 +60,23 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user accessed this post using a slug from a different locale
|
||||||
|
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
|
||||||
|
if (post.slug && post.slug !== slug) {
|
||||||
|
redirect(`/${locale}/blog/${post.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
// Extract headings for TOC
|
||||||
|
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||||
|
|
||||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
const rawTextContent = JSON.stringify(post.content);
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
@@ -81,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
|
quality={100}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
style={{
|
style={{
|
||||||
@@ -88,7 +106,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
@@ -105,8 +123,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -142,8 +160,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -224,10 +242,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
{/* Right Column: Sticky Sidebar - TOC */}
|
||||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 lg:sticky lg:top-32">
|
||||||
{/* Future Payload Table of Contents Implementation */}
|
<TableOfContents headings={headings} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 bg-neutral-dark/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -84,12 +84,9 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
{featuredPost &&
|
{featuredPost &&
|
||||||
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
featuredPost.frontmatter.public === false) && (
|
featuredPost.frontmatter.public === false) && (
|
||||||
<Badge
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
variant="neutral"
|
|
||||||
className="border border-white/30 bg-transparent text-white/80 shadow-none"
|
|
||||||
>
|
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
@@ -156,66 +153,76 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Grid for remaining posts */}
|
{/* Grid for remaining posts */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
|
<div className="grid grid-cols-1 gap-12">
|
||||||
{remainingPosts.map((post, idx) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 50}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block focus:outline-none"
|
||||||
|
>
|
||||||
<Card
|
<Card
|
||||||
tag="article"
|
tag="article"
|
||||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]"
|
||||||
>
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
style={{
|
style={{
|
||||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
}}
|
}}
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
sizes="(max-width: 768px) 100vw, 100vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<Badge
|
<Badge variant="accent" className="shadow-md">
|
||||||
variant="accent"
|
|
||||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
|
||||||
>
|
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||||
<div className="flex items-center gap-3 text-[10px] md:text-sm font-bold text-primary/70 mb-2 md:mb-4 tracking-widest uppercase">
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
<span>
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</span>
|
</time>
|
||||||
{(new Date(post.frontmatter.date) > new Date() ||
|
|
||||||
post.frontmatter.public === false) && (
|
|
||||||
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">
|
|
||||||
Draft
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
|
|
||||||
|
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-3 md:line-clamp-4 mb-4 md:mb-8 leading-relaxed">
|
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt && (
|
||||||
</p>
|
<p className="text-white/90 text-sm md:text-lg line-clamp-3 mb-6 max-w-4xl drop-shadow-sm leading-relaxed">
|
||||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
{post.frontmatter.excerpt}
|
||||||
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
|
||||||
|
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
className="w-5 h-5 transition-transform group-hover:translate-x-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SITE_URL } from '@/lib/schema';
|
|||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import ContactMap from '@/components/ContactMap';
|
import ContactMap from '@/components/ContactMap';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -59,6 +60,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
|
|
||||||
|
// Get translated slug to redirect if user used incorrect static slug
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const headersList = await headers();
|
||||||
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
|
const currentSlug = urlPath.split('/').pop();
|
||||||
|
|
||||||
|
if (currentSlug) {
|
||||||
|
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
|
||||||
|
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
|
||||||
|
const { redirect } = await import('next/navigation');
|
||||||
|
redirect(`/${locale}/${contactSlugDe}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -189,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
{t('info.email')}
|
{t('info.email')}
|
||||||
</h4>
|
</h4>
|
||||||
<a
|
<ObfuscatedEmail
|
||||||
href="mailto:info@klz-cables.com"
|
email="info@klz-cables.com"
|
||||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||||
>
|
/>
|
||||||
info@klz-cables.com
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</address>
|
</address>
|
||||||
|
|||||||
@@ -1,66 +1,137 @@
|
|||||||
'use client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Container, Button, Heading } from '@/components/ui';
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { useEffect } from 'react';
|
import { getPayload } from 'payload';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import configPromise from '@payload-config';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { headers } from 'next/headers';
|
||||||
|
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default async function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = await getTranslations('Error.notFound');
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Try to determine the requested path
|
||||||
const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown';
|
const headersList = await headers();
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
type: '404_not_found',
|
|
||||||
path: errorUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Explicitly send the 404 to Sentry so we have visibility into broken links
|
let suggestedUrl = null;
|
||||||
import('@sentry/nextjs').then((Sentry) => {
|
let suggestedLang = null;
|
||||||
Sentry.withScope((scope) => {
|
|
||||||
scope.setTag('status_code', '404');
|
// If we have a path, try to see if the last segment (slug) exists in ANY locale
|
||||||
scope.setTag('path', errorUrl);
|
if (urlPath) {
|
||||||
Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning');
|
const slug = urlPath.split('/').filter(Boolean).pop();
|
||||||
});
|
if (slug) {
|
||||||
});
|
try {
|
||||||
}, [trackEvent]);
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Check posts
|
||||||
|
const postRes = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check products
|
||||||
|
const productRes =
|
||||||
|
postRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
// Check pages
|
||||||
|
const pageRes =
|
||||||
|
postRes.docs.length === 0 && productRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
const anyDoc = postRes.docs[0] || productRes.docs[0] || pageRes.docs[0];
|
||||||
|
|
||||||
|
if (anyDoc) {
|
||||||
|
// If the doc exists, we can figure out its native locale or
|
||||||
|
// offer the alternative locale (if we are in 'de', offer 'en')
|
||||||
|
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
|
||||||
|
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
|
||||||
|
|
||||||
|
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
|
||||||
|
|
||||||
|
// Reconstruct the URL for the alternative locale
|
||||||
|
const pathParts = urlPath.split('/').filter(Boolean);
|
||||||
|
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
|
||||||
|
pathParts[0] = alternativeLocale;
|
||||||
|
} else {
|
||||||
|
pathParts.unshift(alternativeLocale);
|
||||||
|
}
|
||||||
|
suggestedUrl = '/' + pathParts.join('/');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore Payload errors in 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<>
|
||||||
{/* Industrial Background Element */}
|
<ClientNotFoundTracker path={urlPath} />
|
||||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
<span className="text-[20rem] font-bold select-none">404</span>
|
{/* Industrial Background Element */}
|
||||||
</div>
|
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||||
|
<span className="text-[20rem] font-bold select-none">404</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-8">
|
<div className="relative mb-8">
|
||||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||||
404
|
404
|
||||||
|
</Heading>
|
||||||
|
<Scribble
|
||||||
|
variant="circle"
|
||||||
|
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||||
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Scribble
|
|
||||||
variant="circle"
|
|
||||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('title')}
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
{suggestedUrl && (
|
||||||
|
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
|
||||||
|
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-primary font-bold mb-2 text-lg">
|
||||||
|
Did you mean to visit the {suggestedLang} version?
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-sm mb-4">
|
||||||
|
This page exists, but in another language.
|
||||||
|
</p>
|
||||||
|
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
|
||||||
|
Go to {suggestedLang} Version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="/contact" variant="outline" size="lg">
|
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||||
Contact Support
|
Contact Support
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative Industrial Line */}
|
{/* Decorative Industrial Line */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||||
</Container>
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
import PayloadRichText from '@/components/PayloadRichText';
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
@@ -75,11 +75,13 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
|
const currentLocalePath = await getLocalizedPath(locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
@@ -90,7 +92,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -114,7 +116,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
'high-voltage-cables',
|
'high-voltage-cables',
|
||||||
'solar-cables',
|
'solar-cables',
|
||||||
];
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const translatedSlugsForLocale = await Promise.all(
|
||||||
|
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the requested slugs don't exactly match the translated slugs for the current locale
|
||||||
|
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
|
||||||
|
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
|
||||||
|
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSlug = fileSlugs[fileSlugs.length - 1];
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
@@ -125,11 +139,26 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
? t(`categories.${categoryKey}.title`)
|
? t(`categories.${categoryKey}.title`)
|
||||||
: fileSlug;
|
: fileSlug;
|
||||||
|
|
||||||
const filteredProducts = allProducts.filter((p) =>
|
const filteredProducts = allProducts.filter((p) => {
|
||||||
p.frontmatter.categories.some(
|
const firstCat = p.frontmatter.categories[0] || '';
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
),
|
let pFileSlug = 'low-voltage-cables';
|
||||||
);
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
pFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
pFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
pFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
return pFileSlug === fileSlug;
|
||||||
|
});
|
||||||
|
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
@@ -293,6 +322,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||||
|
|
||||||
const descriptionContent = {
|
const descriptionContent = {
|
||||||
root: {
|
root: {
|
||||||
...product.content.root,
|
...product.content.root,
|
||||||
@@ -324,29 +355,31 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
sku={product.frontmatter.sku}
|
sku={product.frontmatter.sku}
|
||||||
/>
|
/>
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}`}
|
href={`/${locale}/${productsSlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||||
>
|
>
|
||||||
{categoryTitle}
|
{categoryTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
@@ -357,7 +390,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{t('englishVersion')}
|
{t('englishVersion')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -368,10 +401,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Heading level={1} className="text-white mb-8 uppercase">
|
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||||
{product.frontmatter.title}
|
{product.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,11 +418,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative -mt-32 mb-32 animate-slide-up"
|
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{ animationDelay: '200ms' }}
|
||||||
>
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
@@ -424,10 +457,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||||
{/* Description Area Next to Sidebar */}
|
{/* Description Area Next to Sidebar */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||||
{descriptionChildren.length > 0 ? (
|
{descriptionChildren.length > 0 ? (
|
||||||
<PayloadRichText data={descriptionContent} />
|
<PayloadRichText data={descriptionContent} />
|
||||||
) : product.frontmatter.description ? (
|
) : product.frontmatter.description ? (
|
||||||
@@ -435,6 +468,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{product.application?.root?.children?.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<PayloadRichText data={product.application} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,7 +482,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-width Technical Data Below */}
|
{/* Full-width Technical Data Below */}
|
||||||
<div className="mt-16 pt-16 border-t-0">
|
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||||
<PayloadRichText data={technicalContent} />
|
<PayloadRichText data={technicalContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -501,7 +540,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -106,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="circle"
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||||
@@ -223,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
|
|||||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
{t('michael.role')}
|
{t('michael.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||||
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
{t('michael.quote')}
|
{t('michael.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('michael.name')}
|
alt={t('michael.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('klaus.name')}
|
alt={t('klaus.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
{t('klaus.role')}
|
{t('klaus.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
{t('klaus.quote')}
|
{t('klaus.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: 'New Contact Form Submission';
|
: 'New Contact Form Submission';
|
||||||
const confirmationSubject = 'Thank you for your inquiry';
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
const isTestSubmission = email === 'testing@mintel.me';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2a. Send notification to Mintel/Client
|
// 2a. Send notification to Mintel/Client
|
||||||
@@ -84,26 +85,30 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationResult = await sendEmail({
|
if (!isTestSubmission) {
|
||||||
replyTo: email,
|
const notificationResult = await sendEmail({
|
||||||
subject: notificationSubject,
|
replyTo: email,
|
||||||
html: notificationHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notificationResult.success) {
|
|
||||||
logger.info('Notification email sent successfully', {
|
|
||||||
messageId: notificationResult.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Notification email FAILED', {
|
|
||||||
error: notificationResult.error,
|
|
||||||
subject: notificationSubject,
|
subject: notificationSubject,
|
||||||
email,
|
html: notificationHtml,
|
||||||
});
|
});
|
||||||
services.errors.captureException(
|
|
||||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
if (notificationResult.success) {
|
||||||
{ action: 'sendContactFormAction_notification', email },
|
logger.info('Notification email sent successfully', {
|
||||||
);
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Notification email FAILED', {
|
||||||
|
error: notificationResult.error,
|
||||||
|
subject: notificationSubject,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_notification', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping notification email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
@@ -115,26 +120,30 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmationResult = await sendEmail({
|
if (!isTestSubmission) {
|
||||||
to: email,
|
const confirmationResult = await sendEmail({
|
||||||
subject: confirmationSubject,
|
|
||||||
html: confirmationHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmationResult.success) {
|
|
||||||
logger.info('Confirmation email sent successfully', {
|
|
||||||
messageId: confirmationResult.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Confirmation email FAILED', {
|
|
||||||
error: confirmationResult.error,
|
|
||||||
subject: confirmationSubject,
|
|
||||||
to: email,
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
});
|
});
|
||||||
services.errors.captureException(
|
|
||||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
if (confirmationResult.success) {
|
||||||
{ action: 'sendContactFormAction_confirmation', email },
|
logger.info('Confirmation email sent successfully', {
|
||||||
);
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Confirmation email FAILED', {
|
||||||
|
error: confirmationResult.error,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
to: email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_confirmation', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping confirmation email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
|
|||||||
223
app/api/ai-search/route.ts
Normal file
223
app/api/ai-search/route.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
||||||
|
import { searchProducts } from '../../../src/lib/qdrant';
|
||||||
|
import redis from '../../../src/lib/redis';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60; // Max allowed duration (Vercel)
|
||||||
|
|
||||||
|
// Config and constants
|
||||||
|
const RATE_LIMIT_POINTS = 20; // 20 requests per minute
|
||||||
|
const RATE_LIMIT_DURATION = 60; // 1 minute window
|
||||||
|
const DAILY_BUDGET_LIMIT = 200; // max 200 requests per IP per day
|
||||||
|
const DAILY_BUDGET_DURATION = 60 * 60 * 24; // 24h
|
||||||
|
const MAX_CONVERSATION_MESSAGES = 20; // max messages in context
|
||||||
|
const MAX_RESPONSE_TOKENS = 300; // cap AI response length — keeps it chat-like
|
||||||
|
|
||||||
|
// Removed requestSchema as it's replaced by direct parsing
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Changed req type to NextRequest
|
||||||
|
try {
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { messages, honeypot } = body;
|
||||||
|
|
||||||
|
// Get client IP for rate limiting
|
||||||
|
const forwarded = req.headers.get('x-forwarded-for');
|
||||||
|
const clientIp = forwarded?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown';
|
||||||
|
|
||||||
|
// 1. Basic Validation
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestMessage = messages[messages.length - 1].content;
|
||||||
|
const isBot = honeypot && honeypot.length > 0;
|
||||||
|
|
||||||
|
// Check if the input itself is obviously spam/too long
|
||||||
|
if (latestMessage.length > 500) {
|
||||||
|
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Honeypot check
|
||||||
|
if (isBot) {
|
||||||
|
console.warn('Honeypot triggered in AI search');
|
||||||
|
// Tarpit the bot
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
return NextResponse.json({
|
||||||
|
answerText: 'Vielen Dank für Ihre Anfrage.',
|
||||||
|
products: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rate Limiting via Redis (IP-based)
|
||||||
|
try {
|
||||||
|
// Per-minute burst limit
|
||||||
|
const minuteKey = `ai_rate:${clientIp}:min`;
|
||||||
|
const minuteCount = await redis.incr(minuteKey);
|
||||||
|
if (minuteCount === 1) await redis.expire(minuteKey, RATE_LIMIT_DURATION);
|
||||||
|
|
||||||
|
if (minuteCount > RATE_LIMIT_POINTS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Zu viele Anfragen. Bitte warte einen Moment.' },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily budget limit
|
||||||
|
const dayKey = `ai_rate:${clientIp}:day`;
|
||||||
|
const dayCount = await redis.incr(dayKey);
|
||||||
|
if (dayCount === 1) await redis.expire(dayKey, DAILY_BUDGET_DURATION);
|
||||||
|
|
||||||
|
if (dayCount > DAILY_BUDGET_LIMIT) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Tägliches Limit erreicht. Bitte versuche es morgen erneut.' },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (redisError) {
|
||||||
|
console.error('Redis Rate Limiting Error:', redisError);
|
||||||
|
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
||||||
|
// Fail open if Redis is down
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cap conversation length to limit token usage
|
||||||
|
const cappedMessages = messages.slice(-MAX_CONVERSATION_MESSAGES);
|
||||||
|
|
||||||
|
// 4. Fetch Context from Qdrant based on the latest message
|
||||||
|
let contextStr = '';
|
||||||
|
let foundProducts: any[] = [];
|
||||||
|
|
||||||
|
// Team context — hardcoded from translation data (no Payload collection for team)
|
||||||
|
const teamContextStr = `
|
||||||
|
Das ECHTE KLZ Team:
|
||||||
|
- Michael Bodemer (Geschäftsführer) — Der Macher, packt an wenn es kompliziert wird, kennt Kabelnetze in- und auswendig
|
||||||
|
- Klaus Mintel (Geschäftsführer) — Der Fels in der Brandung, jahrzehntelange Erfahrung, stabiles Netzwerk`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResults = await searchProducts(latestMessage, 5);
|
||||||
|
|
||||||
|
if (searchResults && searchResults.length > 0) {
|
||||||
|
const productDescriptions = searchResults
|
||||||
|
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
||||||
|
.map((p: any) => p.payload?.content)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const knowledgeDescriptions = searchResults
|
||||||
|
.filter((p) => p.payload?.type === 'knowledge')
|
||||||
|
.map((p: any) => p.payload?.content)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
|
||||||
|
|
||||||
|
foundProducts = searchResults
|
||||||
|
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
||||||
|
.map((p: any) => ({
|
||||||
|
id: p.id as string,
|
||||||
|
title: p.payload?.data?.title as string,
|
||||||
|
sku: p.payload?.data?.sku as string,
|
||||||
|
slug: p.payload?.data?.slug as string,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (searchError) {
|
||||||
|
console.error('Qdrant Search Error:', searchError);
|
||||||
|
Sentry.captureException(searchError, { tags: { context: 'ai-search-qdrant' } });
|
||||||
|
// We can still proceed without context if Qdrant fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
||||||
|
const systemPrompt = `Du bist "Ohm" — der digitale KI-Berater von KLZ Cables. Dein Name ist eine Anspielung auf die Einheit des elektrischen Widerstands.
|
||||||
|
|
||||||
|
STIL & PERSÖNLICHKEIT:
|
||||||
|
- Antworte KURZ, KNAPP und PROFESSIONELL (maximal 2-3 Sätze).
|
||||||
|
- Schreibe wie in einem lockeren, aber kompetenten B2B-Chat (Du-Form ist okay, aber fachlich top).
|
||||||
|
- Kein Markdown, nur Fließtext.
|
||||||
|
- NIEMALS Platzhalter wie [Ihr Name], [Name], [Firma] verwenden.
|
||||||
|
|
||||||
|
DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
|
||||||
|
- Wenn der Kunde ein Projekt nennt (z.B. "Windpark 30kV"), dann lies im KONTEXT nach, welche Kabel passen, und EMPFIEHL SIE DIREKT! (z.B. "Für 30kV Windparks nehmen wir meistens NA2XS(F)2Y.").
|
||||||
|
- Stelle NIEMALS mehr als EINE Rückfrage pro Nachricht.
|
||||||
|
- FRAGE NICHT nach abstrakten Dingen wie "Welchen Kabeltyp brauchst du?" -> DAS IST DEIN JOB, IHM DAS ZU SAGEN!
|
||||||
|
- FRAGE NICHT nach Längen oder genauen Trassen, es sei denn, der Kunde hat schon ganz klar gesagt, was er kaufen will.
|
||||||
|
- Biete aktiv Hilfe an: "Ich kann dir die passenden Querschnitte raussuchen, wenn du willst."
|
||||||
|
|
||||||
|
VORGEHEN:
|
||||||
|
1. Prüfe den KONTEXT auf passende Kabel für das Kundenprojekt.
|
||||||
|
2. Nenne direkt 1-2 passende Produktserien aus dem Kontext, die für diesen Fall Sinn machen.
|
||||||
|
3. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?).
|
||||||
|
4. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
|
||||||
|
|
||||||
|
GRENZEN:
|
||||||
|
- PRIVAT-ANFRAGEN: B2B only. Private Hausinstallationen lehnen wir freundlich ab.
|
||||||
|
- Keine Preise oder genauen Lieferzeiten versprechen. Immer auf die menschlichen Kollegen verweisen für finale Angebote.
|
||||||
|
|
||||||
|
KONTEXT KABEL & TEAM:
|
||||||
|
${contextStr || 'Kein Katalogkontext verfügbar.'}
|
||||||
|
${teamContextStr}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const mistralKey = process.env.MISTRAL_API_KEY;
|
||||||
|
if (!mistralKey) {
|
||||||
|
throw new Error('MISTRAL_API_KEY is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSGVO: Mistral AI API direkt (EU/Frankreich) statt OpenRouter (US)
|
||||||
|
const fetchRes = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${mistralKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'ministral-8b-latest',
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: MAX_RESPONSE_TOKENS,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...cappedMessages.map((m: any) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fetchRes.ok) {
|
||||||
|
const errBody = await fetchRes.text();
|
||||||
|
console.error('Mistral API Error:', errBody);
|
||||||
|
Sentry.captureException(new Error(`Mistral ${fetchRes.status}: ${errBody}`), {
|
||||||
|
tags: { context: 'ai-search-mistral' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return user-friendly error based on status
|
||||||
|
const userMsg =
|
||||||
|
fetchRes.status === 429
|
||||||
|
? 'Der KI-Service ist gerade überlastet. Bitte versuche es in ein paar Sekunden erneut.'
|
||||||
|
: fetchRes.status >= 500
|
||||||
|
? 'Der KI-Service ist vorübergehend nicht erreichbar. Bitte versuche es gleich nochmal.'
|
||||||
|
: 'Es gab ein Problem mit der KI-Anfrage. Bitte versuche es erneut.';
|
||||||
|
return NextResponse.json({ error: userMsg }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchRes.json();
|
||||||
|
const text = data.choices[0].message.content;
|
||||||
|
|
||||||
|
// Return the AI's answer along with any found products
|
||||||
|
return NextResponse.json({
|
||||||
|
answerText: text,
|
||||||
|
products: foundProducts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI Search API Error:', error);
|
||||||
|
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ein interner Fehler ist aufgetreten. Bitte versuche es erneut.' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,41 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep CMS Health Check
|
||||||
|
* Validates that Payload CMS can actually query the database.
|
||||||
|
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||||
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
|
const checks: Record<string, string> = {};
|
||||||
// Further DB health checks can be implemented via Payload Local API later.
|
|
||||||
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
checks.init = 'ok';
|
||||||
|
|
||||||
|
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||||
|
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
||||||
|
for (const collection of collections) {
|
||||||
|
try {
|
||||||
|
await payload.find({ collection, limit: 1, locale: 'en' });
|
||||||
|
checks[collection] = 'ok';
|
||||||
|
} catch (e: any) {
|
||||||
|
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: e.message?.substring(0, 200), checks },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
165
app/api/sync-qdrant/route.ts
Normal file
165
app/api/sync-qdrant/route.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '../../../payload.config';
|
||||||
|
import { upsertProductVector } from '../../../src/lib/qdrant';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal endpoint called by the warmup script on every dev boot.
|
||||||
|
* Syncs posts, pages, and products from Payload CMS into Qdrant.
|
||||||
|
* NOT for form entries, media, or users.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const results = { products: 0, posts: 0, pages: 0, errors: [] as string[] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// ── Products ──
|
||||||
|
const { docs: products } = await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
limit: 1000,
|
||||||
|
depth: 0,
|
||||||
|
where: { _status: { equals: 'published' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
try {
|
||||||
|
const contentText = `${product.title} - SKU: ${product.sku}\n${product.description || ''}`;
|
||||||
|
await upsertProductVector(String(product.id), contentText, {
|
||||||
|
type: 'product',
|
||||||
|
data: {
|
||||||
|
title: product.title,
|
||||||
|
sku: product.sku,
|
||||||
|
slug: product.slug,
|
||||||
|
description: product.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.products++;
|
||||||
|
} catch (e: any) {
|
||||||
|
results.errors.push(`product:${product.sku}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posts ──
|
||||||
|
const { docs: posts } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
limit: 1000,
|
||||||
|
depth: 0,
|
||||||
|
where: { _status: { equals: 'published' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
try {
|
||||||
|
const contentText = [
|
||||||
|
`Blog-Artikel: ${post.title}`,
|
||||||
|
post.excerpt ? `Zusammenfassung: ${post.excerpt}` : '',
|
||||||
|
post.category ? `Kategorie: ${post.category}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
await upsertProductVector(`post_${post.id}`, contentText, {
|
||||||
|
type: 'knowledge',
|
||||||
|
content: contentText,
|
||||||
|
data: {
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.posts++;
|
||||||
|
} catch (e: any) {
|
||||||
|
results.errors.push(`post:${post.slug}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages ──
|
||||||
|
const { docs: pages } = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
limit: 1000,
|
||||||
|
depth: 0,
|
||||||
|
where: { _status: { equals: 'published' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
try {
|
||||||
|
const contentText = [
|
||||||
|
`Seite: ${page.title}`,
|
||||||
|
page.excerpt ? `Beschreibung: ${page.excerpt}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
await upsertProductVector(`page_${page.id}`, contentText, {
|
||||||
|
type: 'knowledge',
|
||||||
|
content: contentText,
|
||||||
|
data: {
|
||||||
|
title: page.title,
|
||||||
|
slug: page.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.pages++;
|
||||||
|
} catch (e: any) {
|
||||||
|
results.errors.push(`page:${page.slug}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kabelhandbuch (Static Text) ──
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
|
||||||
|
const txtPath = path.join(process.cwd(), 'kabelhandbuch.txt');
|
||||||
|
let manualChunks = 0;
|
||||||
|
|
||||||
|
if (fs.existsSync(txtPath)) {
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(txtPath, 'utf8');
|
||||||
|
const chunks = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((c: string) => c.trim())
|
||||||
|
.filter((c: string) => c.length > 50);
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunkText = chunks[i];
|
||||||
|
const syntheticId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await upsertProductVector(syntheticId, chunkText, {
|
||||||
|
type: 'knowledge',
|
||||||
|
content: chunkText,
|
||||||
|
data: {
|
||||||
|
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
||||||
|
source: 'Kabelhandbuch KLZ.pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
manualChunks++;
|
||||||
|
}
|
||||||
|
console.log(`[Qdrant Sync] ✅ ${manualChunks} Kabelhandbuch-Chunks synced`);
|
||||||
|
} catch (e: any) {
|
||||||
|
results.errors.push(`kabelhandbuch: ${e.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[Qdrant Sync] ⚠️ skipped Kabelhandbuch: ${txtPath} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced, ${manualChunks} manual chunks synced`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
synced: {
|
||||||
|
products: results.products,
|
||||||
|
posts: results.posts,
|
||||||
|
pages: results.pages,
|
||||||
|
},
|
||||||
|
errors: results.errors.length > 0 ? results.errors : undefined,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Qdrant Sync] ❌ Fatal error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,9 +58,24 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
for (const product of productsMetadata) {
|
for (const product of productsMetadata) {
|
||||||
if (!product.frontmatter || !product.slug) continue;
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
|
|
||||||
const category =
|
const firstCat = product.frontmatter.categories[0] || '';
|
||||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
let categoryFileSlug = 'low-voltage-cables';
|
||||||
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
categoryFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
|
||||||
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ export default function CMSConnectivityNotice() {
|
|||||||
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||||
const isLocal = config.isDevelopment;
|
const isLocal = config.isDevelopment;
|
||||||
const isTesting = config.isTesting;
|
const isTesting = config.isTesting;
|
||||||
|
const target = process.env.NEXT_PUBLIC_TARGET || '';
|
||||||
|
const isBranch = target === 'branch';
|
||||||
|
|
||||||
// Only proceed with check if it's developer context (Local or Testing)
|
// Only proceed with check if it's developer context (Local, Testing, or Branch preview)
|
||||||
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||||
if (!isLocal && !isTesting && !isDebug) return;
|
if (!isLocal && !isTesting && !isBranch && !isDebug) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/health/cms');
|
const response = await fetch('/api/health/cms');
|
||||||
@@ -58,8 +60,8 @@ export default function CMSConnectivityNotice() {
|
|||||||
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
||||||
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
||||||
{errorMsg === 'relation "products" does not exist'
|
{errorMsg === 'relation "products" does not exist'
|
||||||
? 'The database schema is missing. Please sync your local data to this environment.'
|
? 'The database schema is missing. Please run migrations for this environment.'
|
||||||
: errorMsg || 'The application cannot connect to the Directus CMS.'}
|
: 'A content service is unavailable. Check the deployment logs for details.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
@@ -15,14 +16,14 @@ export default function Footer() {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<h2 className="sr-only">Footer Navigation</h2>
|
<h2 className="sr-only">Footer Navigation</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-block group"
|
className="inline-block group"
|
||||||
@@ -67,9 +68,9 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links Columns */}
|
{/* Legal Column */}
|
||||||
<div className="lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('legal')}
|
{t('legal')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -121,8 +122,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Company Column */}
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('company')}
|
{t('company')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -189,9 +191,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('recentPosts')}
|
{t('recentPosts')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
@@ -242,7 +244,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
|
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link
|
<Link
|
||||||
@@ -275,6 +277,48 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Brand & Quality Sub-Footer */}
|
||||||
|
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://mintel.me"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
target: 'mintel_agency',
|
||||||
|
location: 'sub_footer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
Website entwickelt von Marc Mintel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
|
||||||
|
<div className="flex items-center gap-1.5" title="SSL Secured">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
<span>SSL Secured</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5" title="Green Hosting">
|
||||||
|
<Leaf className="w-3.5 h-3.5" />
|
||||||
|
<span>Green Hosting</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
|
||||||
|
<Lock className="w-3.5 h-3.5" />
|
||||||
|
<span>DSGVO Compliant</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5" title="WCAG">
|
||||||
|
<Accessibility className="w-3.5 h-3.5" />
|
||||||
|
<span>WCAG</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
<span>PageSpeed 90+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { AISearchResults } from './search/AISearchResults';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
@@ -16,6 +18,7 @@ export default function Header() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
@@ -141,7 +144,8 @@ export default function Header() {
|
|||||||
{
|
{
|
||||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||||
|
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,9 +156,7 @@ export default function Header() {
|
|||||||
<>
|
<>
|
||||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<div
|
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||||
className="flex-shrink-0 group touch-target fill-mode-both"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}`}
|
href={`/${currentLocale}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -274,6 +276,19 @@ export default function Header() {
|
|||||||
<div
|
<div
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSearchOpen(true)}
|
||||||
|
className="hover:text-accent transition-colors p-2"
|
||||||
|
aria-label="Search"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5 md:w-6 md:h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
|
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
@@ -336,115 +351,140 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
isMobileMenuOpen
|
isMobileMenuOpen
|
||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0'
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
)}
|
)}
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('menu')}
|
aria-label={t('menu')}
|
||||||
ref={mobileMenuRef}
|
ref={mobileMenuRef}
|
||||||
inert={isMobileMenuOpen ? undefined : true}
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
>
|
>
|
||||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
{/* Close Button inside overlay */}
|
||||||
{menuItems.map((item, idx) => (
|
<div className="flex justify-end p-6 pt-8">
|
||||||
<div
|
<button
|
||||||
key={item.href}
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||||
className={cn(
|
aria-label={t('toggleMenu')}
|
||||||
'transition-all duration-500 transform',
|
onClick={() => {
|
||||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
setIsMobileMenuOpen(false);
|
||||||
)}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
type: 'mobile_menu',
|
||||||
>
|
action: 'close',
|
||||||
<Link
|
});
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
}}
|
||||||
aria-current={
|
>
|
||||||
(
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
item.href === '/'
|
<path
|
||||||
? pathname === `/${currentLocale}` || pathname === '/'
|
strokeLinecap="round"
|
||||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
strokeLinejoin="round"
|
||||||
)
|
strokeWidth={2}
|
||||||
? 'page'
|
d="M6 18L18 6M6 6l12 12"
|
||||||
: undefined
|
/>
|
||||||
}
|
</svg>
|
||||||
onClick={() => {
|
</button>
|
||||||
setIsMobileMenuOpen(false);
|
</div>
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
label: item.label,
|
{menuItems.map((item, idx) => (
|
||||||
href: item.href,
|
|
||||||
location: 'mobile_menu',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
|
||||||
(item.href === '/'
|
|
||||||
? pathname === `/${currentLocale}` || pathname === '/'
|
|
||||||
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
key={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
'transition-all duration-500 transform',
|
||||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
)}
|
)}
|
||||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
<Link
|
||||||
<div>
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
<Link
|
aria-current={
|
||||||
href={getPathForLocale('en')}
|
(
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
item.href === '/'
|
||||||
>
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
EN
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||||
</Link>
|
)
|
||||||
</div>
|
? 'page'
|
||||||
<div className="w-px h-6 bg-white/30" />
|
: undefined
|
||||||
<div>
|
}
|
||||||
<Link
|
onClick={() => {
|
||||||
href={getPathForLocale('de')}
|
setIsMobileMenuOpen(false);
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
>
|
label: item.label,
|
||||||
DE
|
href: item.href,
|
||||||
</Link>
|
location: 'mobile_menu',
|
||||||
</div>
|
});
|
||||||
</div>
|
}}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||||
|
(item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="w-full max-w-xs">
|
<div
|
||||||
<Button
|
className={cn(
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||||
variant="accent"
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
size="lg"
|
)}
|
||||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||||
>
|
>
|
||||||
{t('contact')}
|
EN
|
||||||
</Button>
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-white/30" />
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
<div className="w-full max-w-xs">
|
||||||
<div
|
<Button
|
||||||
className={cn(
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
'p-12 flex justify-center transition-all duration-700',
|
variant="accent"
|
||||||
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
size="lg"
|
||||||
)}
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
>
|
||||||
>
|
{t('contact')}
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
{/* Bottom Branding */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-12 flex justify-center transition-all duration-700',
|
||||||
|
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
||||||
|
)}
|
||||||
|
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
components/ObfuscatedEmail.tsx
Normal file
38
components/ObfuscatedEmail.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedEmailProps {
|
||||||
|
email: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect email addresses from simple spambots.
|
||||||
|
* It uses client-side mounting to render the actual email address,
|
||||||
|
* making it harder for static crawlers to harvest.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once mounted on the client, render the real mailto link
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className={className}>
|
||||||
|
{children || email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/ObfuscatedPhone.tsx
Normal file
41
components/ObfuscatedPhone.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedPhoneProps {
|
||||||
|
phone: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect phone numbers from simple spambots.
|
||||||
|
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format phone number for tel: link (remove spaces, etc.)
|
||||||
|
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
// e.g. +49 881 925 [at] 37298
|
||||||
|
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || obscured}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={telLink} className={className}>
|
||||||
|
{children || phone}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
||||||
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Suspense } from 'react';
|
import { Suspense, Fragment } from 'react';
|
||||||
|
|
||||||
// Import all custom React components that were previously mapped via Markdown
|
// Import all custom React components that were previously mapped via Markdown
|
||||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||||
@@ -24,6 +24,8 @@ import Reveal from '@/components/Reveal';
|
|||||||
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
|
||||||
|
|
||||||
import HomeHero from '@/components/home/Hero';
|
import HomeHero from '@/components/home/Hero';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import ProductCategories from '@/components/home/ProductCategories';
|
||||||
@@ -36,96 +38,246 @@ import GallerySection from '@/components/home/GallerySection';
|
|||||||
import VideoSection from '@/components/home/VideoSection';
|
import VideoSection from '@/components/home/VideoSection';
|
||||||
import CTA from '@/components/home/CTA';
|
import CTA from '@/components/home/CTA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a text string on \n and intersperses <br /> elements.
|
||||||
|
* This is needed because Lexical stores newlines as literal \n characters inside
|
||||||
|
* text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
|
||||||
|
*/
|
||||||
|
function textWithLineBreaks(text: string, key: string) {
|
||||||
|
const parts = text.split('\n');
|
||||||
|
if (parts.length === 1) return text;
|
||||||
|
return parts.map((part, i) => (
|
||||||
|
<Fragment key={`${key}-${i}`}>
|
||||||
|
{part}
|
||||||
|
{i < parts.length - 1 && <br />}
|
||||||
|
</Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const jsxConverters: JSXConverters = {
|
const jsxConverters: JSXConverters = {
|
||||||
...defaultJSXConverters,
|
...defaultJSXConverters,
|
||||||
// Let the default converters handle text nodes to preserve valid formatting
|
// Handle Lexical linebreak nodes (explicit shift+enter)
|
||||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
linebreak: () => <br />,
|
||||||
|
// Custom text converter: preserve \n inside text nodes as <br /> and obfuscate emails
|
||||||
text: ({ node }: any) => {
|
text: ({ node }: any) => {
|
||||||
const text = node.text;
|
let content: React.ReactNode = node.text || '';
|
||||||
// Handle markdown-style lists embedded in text nodes from Markdown migration
|
// Split newlines first
|
||||||
if (text && text.includes('\n- ')) {
|
if (typeof content === 'string' && content.includes('\n')) {
|
||||||
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
|
||||||
// If first part doesn't start with "- ", it's a prefix paragraph
|
|
||||||
const startsWithDash = text.trimStart().startsWith('- ');
|
|
||||||
const prefix = startsWithDash ? null : parts.shift();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{prefix && (
|
|
||||||
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
|
||||||
{!prefix.includes('<') ? prefix : undefined}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
|
||||||
{parts.map((item: string, i: number) => {
|
|
||||||
const cleanItem = item.trim();
|
|
||||||
if (cleanItem.includes('<')) {
|
|
||||||
return <li key={i} dangerouslySetInnerHTML={{ __html: cleanItem }} />;
|
|
||||||
}
|
|
||||||
return <li key={i}>{cleanItem}</li>;
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
// Obfuscate emails in text content
|
||||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
if (typeof content === 'string' && content.includes('@')) {
|
||||||
}
|
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
|
||||||
|
const parts = content.split(emailRegex);
|
||||||
// Handle markdown-style links [text](url) from Markdown migration
|
content = parts.map((part, i) => {
|
||||||
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
|
if (part.match(emailRegex)) {
|
||||||
const parts: React.ReactNode[] = [];
|
return <ObfuscatedEmail key={`e-${i}`} email={part} />;
|
||||||
const remaining = text;
|
|
||||||
let key = 0;
|
|
||||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
||||||
let match;
|
|
||||||
let lastIndex = 0;
|
|
||||||
while ((match = linkRegex.exec(remaining)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
|
|
||||||
}
|
}
|
||||||
parts.push(
|
return part;
|
||||||
<a
|
});
|
||||||
key={key++}
|
|
||||||
href={match[2]}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
|
||||||
>
|
|
||||||
{match[1]}
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
if (lastIndex < remaining.length) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
|
||||||
}
|
|
||||||
return <>{parts}</>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
|
||||||
if (text && text.includes('\n')) {
|
if (typeof content === 'string' && content.match(/\+\d+/)) {
|
||||||
const lines = text.split('\n');
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
|
const parts = content.split(phoneRegex);
|
||||||
|
content = parts.map((part, i) => {
|
||||||
|
if (part.match(phoneRegex)) {
|
||||||
|
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array content (from previous mappings)
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
content = content.map((item, idx) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
// Re-apply phone regex to strings in array
|
||||||
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
|
if (item.match(phoneRegex)) {
|
||||||
|
const parts = item.split(phoneRegex);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (part.match(phoneRegex)) {
|
||||||
|
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Lexical formatting flags
|
||||||
|
if (node.format) {
|
||||||
|
if (node.format & 1) content = <strong>{content}</strong>;
|
||||||
|
if (node.format & 2) content = <em>{content}</em>;
|
||||||
|
if (node.format & 8) content = <u>{content}</u>;
|
||||||
|
if (node.format & 4) content = <s>{content}</s>;
|
||||||
|
if (node.format & 16)
|
||||||
|
content = (
|
||||||
|
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
|
||||||
|
{content}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
if (node.format & 32) content = <sub>{content}</sub>;
|
||||||
|
if (node.format & 64) content = <sup>{content}</sup>;
|
||||||
|
}
|
||||||
|
return <>{content}</>;
|
||||||
|
},
|
||||||
|
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||||
|
paragraph: ({ node, nodesToJSX }: any) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 leading-relaxed text-text-secondary">
|
||||||
|
{nodesToJSX({ nodes: node.children })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
||||||
|
heading: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
const tag = node?.tag;
|
||||||
|
|
||||||
|
// Extract text to generate an ID for the TOC
|
||||||
|
// Lexical children might contain various nodes; we need a plain text representation
|
||||||
|
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||||
|
const id = textContent
|
||||||
|
? textContent
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[*_`]/g, '')
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (tag === 'h1')
|
||||||
return (
|
return (
|
||||||
<>
|
<h2
|
||||||
{lines.map((line: string, i: number) => (
|
id={id}
|
||||||
<span key={i}>
|
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||||
{line}
|
>
|
||||||
{i < lines.length - 1 && <br />}
|
{children}
|
||||||
</span>
|
</h2>
|
||||||
))}
|
);
|
||||||
</>
|
if (tag === 'h2')
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
id={id}
|
||||||
|
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
if (tag === 'h3')
|
||||||
|
return (
|
||||||
|
<h4
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
if (tag === 'h4')
|
||||||
|
return (
|
||||||
|
<h5
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
|
if (tag === 'h5')
|
||||||
|
return (
|
||||||
|
<h6
|
||||||
|
id={id}
|
||||||
|
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
list: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
if (node?.listType === 'number') {
|
||||||
|
return (
|
||||||
|
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node?.listType === 'check') {
|
||||||
|
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
listitem: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
if (node?.checked != null) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={node.checked}
|
||||||
|
readOnly
|
||||||
|
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||||
|
},
|
||||||
|
quote: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
return (
|
||||||
|
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
link: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
// Handling Payload CMS link nodes
|
||||||
|
const href = node?.fields?.url || node?.url || '#';
|
||||||
|
const newTab = node?.fields?.newTab || node?.newTab;
|
||||||
|
|
||||||
|
if (href.startsWith('mailto:')) {
|
||||||
|
const email = href.replace('mailto:', '');
|
||||||
|
return (
|
||||||
|
<ObfuscatedEmail
|
||||||
|
email={email}
|
||||||
|
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.format === 1) return <strong>{text}</strong>;
|
return (
|
||||||
if (node.format === 2) return <em>{text}</em>;
|
<a
|
||||||
return <span>{text}</span>;
|
href={href}
|
||||||
|
target={newTab ? '_blank' : undefined}
|
||||||
|
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||||
|
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc)
|
|
||||||
h1: ({ children }: any) => <h2 className="text-3xl md:text-4xl font-bold my-6">{children}</h2>,
|
|
||||||
h2: ({ children }: any) => <h3 className="text-2xl md:text-3xl font-bold my-5">{children}</h3>,
|
|
||||||
h3: ({ children }: any) => <h4 className="text-xl md:text-2xl font-bold my-4">{children}</h4>,
|
|
||||||
|
|
||||||
blocks: {
|
blocks: {
|
||||||
// ... preserved existing blocks ...
|
// ... preserved existing blocks ...
|
||||||
@@ -170,10 +322,10 @@ const jsxConverters: JSXConverters = {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
technicalGrid: ({ node }: any) => (
|
technicalGrid: ({ node }: any) => (
|
||||||
<TechnicalGrid title={node.fields.title} items={node.fields.items} />
|
<TechnicalGrid title={node?.fields?.title} items={node?.fields?.items} />
|
||||||
),
|
),
|
||||||
'block-technicalGrid': ({ node }: any) => {
|
'block-technicalGrid': ({ node }: any) => {
|
||||||
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
|
if (!node?.fields) return null;
|
||||||
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
||||||
},
|
},
|
||||||
highlightBox: ({ node }: any) => (
|
highlightBox: ({ node }: any) => (
|
||||||
@@ -246,20 +398,23 @@ const jsxConverters: JSXConverters = {
|
|||||||
{node.fields.title}
|
{node.fields.title}
|
||||||
</SplitHeading>
|
</SplitHeading>
|
||||||
),
|
),
|
||||||
productTabs: ({ node }: any) => (
|
productTabs: ({ node }: any) => {
|
||||||
<ProductTabs
|
if (!node?.fields) return null;
|
||||||
technicalData={
|
return (
|
||||||
<ProductTechnicalData
|
<ProductTabs
|
||||||
data={{
|
technicalData={
|
||||||
technicalItems: node.fields.technicalItems,
|
<ProductTechnicalData
|
||||||
voltageTables: node.fields.voltageTables,
|
data={{
|
||||||
}}
|
technicalItems: node.fields.technicalItems,
|
||||||
/>
|
voltageTables: node.fields.voltageTables,
|
||||||
}
|
}}
|
||||||
>
|
/>
|
||||||
<></>
|
}
|
||||||
</ProductTabs>
|
>
|
||||||
),
|
<></>
|
||||||
|
</ProductTabs>
|
||||||
|
);
|
||||||
|
},
|
||||||
'block-productTabs': ({ node }: any) => (
|
'block-productTabs': ({ node }: any) => (
|
||||||
<ProductTabs
|
<ProductTabs
|
||||||
technicalData={
|
technicalData={
|
||||||
@@ -1009,6 +1164,10 @@ export default function PayloadRichText({
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.root?.children?.length > 0) {
|
||||||
|
console.log('[PayloadRichText DEBUG] received children', data.root.children.length);
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicConverters: JSXConverters = {
|
const dynamicConverters: JSXConverters = {
|
||||||
...jsxConverters,
|
...jsxConverters,
|
||||||
blocks: {
|
blocks: {
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-8 md:space-y-16">
|
||||||
{technicalItems.length > 0 && (
|
{technicalItems.length > 0 && (
|
||||||
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
General Data
|
General Data
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex flex-col group">
|
<div key={idx} className="flex flex-col group">
|
||||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||||
@@ -72,7 +72,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
@@ -83,7 +83,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{table.metaItems.length > 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||||
{table.metaItems.map((item, mIdx) => (
|
{table.metaItems.map((item, mIdx) => (
|
||||||
<div key={mIdx}>
|
<div key={mIdx}>
|
||||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
@@ -98,9 +98,11 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{/* Scroll hint gradient on right edge for mobile */}
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||||
<div
|
<div
|
||||||
id={`voltage-table-${idx}`}
|
id={`voltage-table-${idx}`}
|
||||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -41,10 +41,8 @@ export default async function RelatedProducts({
|
|||||||
];
|
];
|
||||||
const catFileSlug =
|
const catFileSlug =
|
||||||
categorySlugs.find((slug) => {
|
categorySlugs.find((slug) => {
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const title = t(`categories.${key}.title`);
|
|
||||||
return product.frontmatter.categories.some(
|
return product.frontmatter.categories.some(
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
|
||||||
);
|
);
|
||||||
}) || 'low-voltage-cables';
|
}) || 'low-voltage-cables';
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
|||||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function AnalyticsShell() {
|
export default function AnalyticsShell() {
|
||||||
const [shouldLoad, setShouldLoad] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
@@ -34,6 +37,7 @@ export default function AnalyticsShell() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DynamicAnalyticsProvider />
|
<DynamicAnalyticsProvider />
|
||||||
<DynamicScrollDepthTracker />
|
<DynamicScrollDepthTracker />
|
||||||
|
<DynamicWebVitalsTracker />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
export default function ClientNotFoundTracker({ path }: { path: string }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
import('@sentry/nextjs').then((Sentry) => {
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag('status_code', '404');
|
||||||
|
scope.setTag('path', path);
|
||||||
|
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [trackEvent, path]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
54
components/analytics/WebVitalsTracker.tsx
Normal file
54
components/analytics/WebVitalsTracker.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReportWebVitals } from 'next/web-vitals';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVitalsTracker component.
|
||||||
|
*
|
||||||
|
* Captures Next.js Web Vitals and reports them to Umami as custom events.
|
||||||
|
* This provides "meaningful" page speed tracking by measuring real user
|
||||||
|
* experiences (LCP, CLS, INP, etc.).
|
||||||
|
*/
|
||||||
|
export default function WebVitalsTracker() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useReportWebVitals((metric) => {
|
||||||
|
const { name, value, id, label } = metric;
|
||||||
|
|
||||||
|
// Determine rating (simplified version of web-vitals standards)
|
||||||
|
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
|
||||||
|
|
||||||
|
if (name === 'LCP') {
|
||||||
|
if (value > 4000) rating = 'poor';
|
||||||
|
else if (value > 2500) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'CLS') {
|
||||||
|
if (value > 0.25) rating = 'poor';
|
||||||
|
else if (value > 0.1) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FID') {
|
||||||
|
if (value > 300) rating = 'poor';
|
||||||
|
else if (value > 100) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FCP') {
|
||||||
|
if (value > 3000) rating = 'poor';
|
||||||
|
else if (value > 1800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'TTFB') {
|
||||||
|
if (value > 1500) rating = 'poor';
|
||||||
|
else if (value > 800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'INP') {
|
||||||
|
if (value > 500) rating = 'poor';
|
||||||
|
else if (value > 200) rating = 'needs-improvement';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Umami
|
||||||
|
trackEvent('web-vital', {
|
||||||
|
metric: name,
|
||||||
|
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
|
||||||
|
rating,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||||
|
|||||||
@@ -6,98 +6,216 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { AISearchResults } from '../search/AISearchResults';
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero({ data }: { data?: any }) {
|
export default function Hero({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
||||||
|
'Projekt beschreiben oder Kabel suchen...',
|
||||||
|
);
|
||||||
|
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const HERO_PLACEHOLDERS = [
|
||||||
|
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
|
||||||
|
'Wie schwer ist NAYY 4x150?',
|
||||||
|
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
|
||||||
|
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
|
||||||
|
'Mittelspannungskabel für Windkraftanlage', // Windpark
|
||||||
|
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
|
||||||
|
];
|
||||||
|
|
||||||
|
// Typing animation for the hero search placeholder
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery) {
|
||||||
|
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let textIdx = 0;
|
||||||
|
let charIdx = 0;
|
||||||
|
let deleting = false;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const fullText = HERO_PLACEHOLDERS[textIdx];
|
||||||
|
|
||||||
|
if (deleting) {
|
||||||
|
charIdx--;
|
||||||
|
setHeroPlaceholder(fullText.substring(0, charIdx));
|
||||||
|
} else {
|
||||||
|
charIdx++;
|
||||||
|
setHeroPlaceholder(fullText.substring(0, charIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let delay = deleting ? 30 : 70;
|
||||||
|
|
||||||
|
if (!deleting && charIdx === fullText.length) {
|
||||||
|
delay = 2500;
|
||||||
|
deleting = true;
|
||||||
|
} else if (deleting && charIdx === 0) {
|
||||||
|
deleting = false;
|
||||||
|
textIdx = (textIdx + 1) % HERO_PLACEHOLDERS.length;
|
||||||
|
delay = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
typingRef.current = setTimeout(tick, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
typingRef.current = setTimeout(tick, 1500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typingRef.current) clearTimeout(typingRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<>
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<div className="max-w-5xl mx-auto md:mx-0">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<div>
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
<Heading
|
|
||||||
level={1}
|
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
|
||||||
>
|
|
||||||
{data?.title ? (
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
|
|
||||||
) : (
|
|
||||||
t.rich('title', {
|
|
||||||
green: (chunks) => (
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
|
|
||||||
<div
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '500ms' }}
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Heading>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
|
||||||
{data?.subtitle || t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
|
||||||
<div>
|
<div>
|
||||||
|
<Heading
|
||||||
|
level={1}
|
||||||
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||||
|
>
|
||||||
|
{data?.title ? (
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: data.title
|
||||||
|
.replace(
|
||||||
|
/<green>/g,
|
||||||
|
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/<\/green>/g,
|
||||||
|
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
t.rich('title', {
|
||||||
|
green: (chunks) => (
|
||||||
|
<span className="relative inline-block">
|
||||||
|
<span className="relative z-10 text-accent italic inline-block">
|
||||||
|
{chunks}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||||
|
style={{ animationDelay: '500ms' }}
|
||||||
|
>
|
||||||
|
<Scribble variant="circle" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
|
{data?.subtitle || t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
|
||||||
|
>
|
||||||
|
<div className="absolute left-1 w-20 h-20 flex items-center justify-center z-10 overflow-visible">
|
||||||
|
<AIOrb isThinking={false} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={heroPlaceholder}
|
||||||
|
className="flex-1 bg-transparent border-none text-white pl-20 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
href="/contact"
|
type="submit"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: data?.ctaLabel || t('cta'),
|
|
||||||
location: 'home_hero_primary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{data?.ctaLabel || t('cta')}
|
Fragen
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
|
||||||
variant="white"
|
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
|
||||||
location: 'home_hero_secondary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data?.secondaryCtaLabel || t('exploreProducts')}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.ctaLabel || t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data?.ctaLabel || t('cta')}
|
||||||
|
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data?.secondaryCtaLabel || t('exploreProducts')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Container>
|
||||||
</Container>
|
|
||||||
|
|
||||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '2000ms' }}
|
|
||||||
>
|
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
|
||||||
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Section>
|
<div
|
||||||
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
||||||
|
style={{ animationDelay: '2000ms' }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
|
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
<AISearchResults
|
||||||
|
isOpen={isSearchOpen}
|
||||||
|
onClose={() => setIsSearchOpen(false)}
|
||||||
|
initialQuery={searchQuery}
|
||||||
|
triggerSearch={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
interface RecentPostsProps {
|
interface RecentPostsProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -13,7 +13,7 @@ interface RecentPostsProps {
|
|||||||
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const recentPosts = posts.slice(0, 3);
|
const recentPosts = posts.slice(0, 4);
|
||||||
|
|
||||||
if (recentPosts.length === 0) return null;
|
if (recentPosts.length === 0) return null;
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
const subtitle = data?.subtitle || t('latestNews');
|
const subtitle = data?.subtitle || t('latestNews');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral py-16 md:py-24">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
|
||||||
<Container>
|
<Container className="py-12 md:py-16">
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
|
||||||
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -35,74 +35,76 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
|
||||||
{recentPosts.map((post, idx) => (
|
|
||||||
<li key={`${post.slug}-${idx}`}>
|
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
|
||||||
<Card
|
|
||||||
tag="article"
|
|
||||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
|
||||||
>
|
|
||||||
{post.frontmatter.featuredImage && (
|
|
||||||
<div className="relative h-64 overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
|
||||||
alt={post.frontmatter.title}
|
|
||||||
fill
|
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
||||||
style={{
|
|
||||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
|
||||||
}}
|
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
{post.frontmatter.category && (
|
|
||||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
|
||||||
{post.frontmatter.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
|
||||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
|
||||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
|
||||||
<time dateTime={post.frontmatter.date}>
|
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
|
||||||
{post.frontmatter.title}
|
|
||||||
</h3>
|
|
||||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
|
||||||
{t('readMore')}
|
|
||||||
<svg
|
|
||||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
|
||||||
|
{recentPosts.map((post, idx) => (
|
||||||
|
<li key={`${post.slug}-${idx}`} className="block">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
|
||||||
|
>
|
||||||
|
{post.frontmatter.featuredImage && (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
|
{post.frontmatter.category && (
|
||||||
|
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
|
||||||
|
{post.frontmatter.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<time
|
||||||
|
dateTime={post.frontmatter.date}
|
||||||
|
suppressHydrationWarning
|
||||||
|
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
|
locale?.length === 2 ? locale : 'de',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
|
||||||
|
{post.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
|
{t('readMore')}{' '}
|
||||||
|
<span className="ml-2 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection({ data }: { data?: any }) {
|
export default function VideoSection({ data }: { data?: any }) {
|
||||||
@@ -41,18 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
|
|||||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: data.title
|
||||||
|
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||||
|
.replace(/<\/future>/g, '</span>'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
future: (chunks) => (
|
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||||
<span className="relative inline-block mx-2">
|
|
||||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="underline"
|
|
||||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
371
components/search/AIOrb.tsx
Normal file
371
components/search/AIOrb.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface AIOrbProps {
|
||||||
|
isThinking: boolean;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple noise function for organic movement
|
||||||
|
function noise(x: number, y: number, t: number): number {
|
||||||
|
return (
|
||||||
|
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
|
||||||
|
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
|
||||||
|
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Particle ───────────────────────────────────────────────────
|
||||||
|
interface Particle {
|
||||||
|
// Sphere position (target shape)
|
||||||
|
theta: number;
|
||||||
|
phi: number;
|
||||||
|
// Current position
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
// Velocity
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
vz: number;
|
||||||
|
// Properties
|
||||||
|
size: number;
|
||||||
|
baseSize: number;
|
||||||
|
hue: number; // 0=blue, 1=green
|
||||||
|
brightness: number;
|
||||||
|
phase: number;
|
||||||
|
orbitSpeed: number;
|
||||||
|
noiseScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticles(count: number): Particle[] {
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Fibonacci sphere distribution for even spacing
|
||||||
|
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
const y = 1 - (i / (count - 1)) * 2;
|
||||||
|
const radiusAtY = Math.sqrt(1 - y * y);
|
||||||
|
const theta = golden * i;
|
||||||
|
const phi = Math.acos(y);
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
theta,
|
||||||
|
phi,
|
||||||
|
x: Math.cos(theta) * radiusAtY,
|
||||||
|
y,
|
||||||
|
z: Math.sin(theta) * radiusAtY,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
vz: 0,
|
||||||
|
size: 0.4 + Math.random() * 0.8,
|
||||||
|
baseSize: 0.4 + Math.random() * 0.8,
|
||||||
|
hue: Math.random() > 0.45 ? 0 : 1,
|
||||||
|
brightness: 0.5 + Math.random() * 0.5,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
|
||||||
|
noiseScale: 0.5 + Math.random() * 1.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return particles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animRef = useRef<number>(0);
|
||||||
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
|
|
||||||
|
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
|
||||||
|
const state = useRef({
|
||||||
|
pulse: 0,
|
||||||
|
hover: 0,
|
||||||
|
error: 0,
|
||||||
|
mouseX: 0.5,
|
||||||
|
mouseY: 0.5,
|
||||||
|
rotY: 0,
|
||||||
|
rotX: 0,
|
||||||
|
breathe: 0,
|
||||||
|
scatter: 0,
|
||||||
|
shake: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
const r = wrapRef.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
mouse.current.x = (e.clientX - r.left) / r.width;
|
||||||
|
mouse.current.y = (e.clientY - r.top) / r.height;
|
||||||
|
}, []);
|
||||||
|
const onEnter = useCallback(() => {
|
||||||
|
mouse.current.hover = true;
|
||||||
|
}, []);
|
||||||
|
const onLeave = useCallback(() => {
|
||||||
|
mouse.current.hover = false;
|
||||||
|
mouse.current.x = 0.5;
|
||||||
|
mouse.current.y = 0.5;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const draw = useCallback(
|
||||||
|
function drawStep() {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const w = rect.width * dpr;
|
||||||
|
const h = rect.height * dpr;
|
||||||
|
if (canvas.width !== w || canvas.height !== h) {
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const minDim = Math.min(w, h);
|
||||||
|
// Reduced further to give maximum breathing room for glow + movement
|
||||||
|
const sphereR = minDim * 0.16;
|
||||||
|
const time = performance.now() / 1000;
|
||||||
|
const s = state.current;
|
||||||
|
const m = mouse.current;
|
||||||
|
|
||||||
|
// ── Interpolate state ──
|
||||||
|
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
|
||||||
|
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
|
||||||
|
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
|
||||||
|
s.mouseX = lerp(s.mouseX, m.x, 0.12);
|
||||||
|
s.mouseY = lerp(s.mouseY, m.y, 0.12);
|
||||||
|
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
|
||||||
|
s.shake += 0.15 * s.error;
|
||||||
|
|
||||||
|
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
|
||||||
|
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
|
||||||
|
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
|
||||||
|
const mouseRotX = (s.mouseY - 0.5) * 0.8;
|
||||||
|
|
||||||
|
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
|
||||||
|
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
|
||||||
|
|
||||||
|
// ── Clear ──
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// ── Subtle core glow ──
|
||||||
|
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
|
||||||
|
const glowCX = cx + shakeX;
|
||||||
|
const glowCY = cy;
|
||||||
|
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
|
||||||
|
const glowR = Math.min(
|
||||||
|
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
|
||||||
|
minDim * 0.48,
|
||||||
|
);
|
||||||
|
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
|
||||||
|
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
|
||||||
|
// Glow color: blue normally, red on error
|
||||||
|
const glowR1 = Math.round(lerp(20, 255, s.error));
|
||||||
|
const glowG1 = Math.round(lerp(60, 40, s.error));
|
||||||
|
const glowB1 = Math.round(lerp(255, 40, s.error));
|
||||||
|
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
|
||||||
|
glow.addColorStop(
|
||||||
|
0.25,
|
||||||
|
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
|
||||||
|
);
|
||||||
|
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
|
||||||
|
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// ── Create particles if empty ──
|
||||||
|
if (particlesRef.current.length === 0) {
|
||||||
|
particlesRef.current = createParticles(350);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update & draw particles ──
|
||||||
|
const cosRY = Math.cos(s.rotY + mouseRotY);
|
||||||
|
const sinRY = Math.sin(s.rotY + mouseRotY);
|
||||||
|
const cosRX = Math.cos(mouseRotX);
|
||||||
|
const sinRX = Math.sin(mouseRotX);
|
||||||
|
|
||||||
|
// Sort by z for correct layering
|
||||||
|
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
|
||||||
|
const projected: ParticleWithScreen[] = [];
|
||||||
|
|
||||||
|
for (const p of particlesRef.current) {
|
||||||
|
// Target position: sphere surface + noise displacement
|
||||||
|
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
|
||||||
|
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
|
||||||
|
|
||||||
|
// Orbit: rotate theta — always moving, faster idle
|
||||||
|
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
|
||||||
|
|
||||||
|
// Sphere coordinates to cartesian
|
||||||
|
const sinPhi = Math.sin(p.phi);
|
||||||
|
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
|
||||||
|
// Excitement from hover + pulse + error
|
||||||
|
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
|
||||||
|
const tgtY = Math.cos(p.phi) * displacement;
|
||||||
|
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
|
||||||
|
|
||||||
|
// Scatter on hover: push particles outward
|
||||||
|
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
|
||||||
|
|
||||||
|
// Spring physics toward target
|
||||||
|
const tx = tgtX * scatterMul;
|
||||||
|
const ty = tgtY * scatterMul;
|
||||||
|
const tz = tgtZ * scatterMul;
|
||||||
|
|
||||||
|
p.vx += (tx - p.x) * 0.08;
|
||||||
|
p.vy += (ty - p.y) * 0.08;
|
||||||
|
p.vz += (tz - p.z) * 0.08;
|
||||||
|
p.vx *= 0.88;
|
||||||
|
p.vy *= 0.88;
|
||||||
|
p.vz *= 0.88;
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.z += p.vz;
|
||||||
|
|
||||||
|
// 3D rotation (Y then X)
|
||||||
|
const rx = p.x * cosRY - p.z * sinRY;
|
||||||
|
const rz = p.x * sinRY + p.z * cosRY;
|
||||||
|
const ry = p.y * cosRX - rz * sinRX;
|
||||||
|
const finalZ = p.y * sinRX + rz * cosRX;
|
||||||
|
|
||||||
|
// Project to screen
|
||||||
|
const perspective = 3;
|
||||||
|
const scale = perspective / (perspective + finalZ);
|
||||||
|
const sx = cx + rx * sphereR * scale;
|
||||||
|
const sy = cy + ry * sphereR * scale;
|
||||||
|
|
||||||
|
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort back-to-front
|
||||||
|
projected.sort((a, b) => a.sz - b.sz);
|
||||||
|
|
||||||
|
for (const { p, sx, sy, sz, depth } of projected) {
|
||||||
|
// Depth-based alpha and size
|
||||||
|
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
|
||||||
|
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
|
||||||
|
|
||||||
|
const alpha =
|
||||||
|
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
|
||||||
|
|
||||||
|
const drawSize =
|
||||||
|
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
|
||||||
|
|
||||||
|
// Color — shift to red on error
|
||||||
|
let r: number, g: number, b: number;
|
||||||
|
if (s.error > 0.1) {
|
||||||
|
// Error: red family
|
||||||
|
if (p.hue === 0) {
|
||||||
|
r = Math.round(lerp(40 + sz * 30, 255, s.error));
|
||||||
|
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
|
||||||
|
b = Math.round(lerp(255, 40, s.error));
|
||||||
|
} else {
|
||||||
|
r = Math.round(lerp(100 + sz * 30, 230, s.error));
|
||||||
|
g = Math.round(lerp(220 + sz * 17, 60, s.error));
|
||||||
|
b = Math.round(lerp(20, 20, s.error));
|
||||||
|
}
|
||||||
|
} else if (p.hue === 0) {
|
||||||
|
r = 60 + Math.round(sz * 40);
|
||||||
|
g = 100 + Math.round(sz * 50);
|
||||||
|
b = 255;
|
||||||
|
} else {
|
||||||
|
r = 120 + Math.round(sz * 30);
|
||||||
|
g = 237 + Math.round(sz * 10);
|
||||||
|
b = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking: shift toward brighter, more saturated
|
||||||
|
if (s.pulse > 0.1) {
|
||||||
|
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
|
||||||
|
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
|
||||||
|
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Micro glow — always visible, stronger on front
|
||||||
|
if (depthAlpha > 0.25) {
|
||||||
|
const gSize = drawSize * lerp(4, 7, s.hover);
|
||||||
|
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
|
||||||
|
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
|
||||||
|
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||||||
|
ctx.fillStyle = pg;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core dot — bright
|
||||||
|
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading rings (thinking) ──
|
||||||
|
if (s.pulse > 0.02) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
|
||||||
|
// Spinning arc
|
||||||
|
const spinAngle = time * 2;
|
||||||
|
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
|
||||||
|
ctx.rotate(spinAngle);
|
||||||
|
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
|
||||||
|
ctx.lineWidth = 1.2 * dpr;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Counter-spinning arc
|
||||||
|
ctx.rotate(-spinAngle * 2);
|
||||||
|
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
|
||||||
|
ctx.lineWidth = 0.8 * dpr;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Expanding pulse
|
||||||
|
const pulsePhase = (time * 0.8) % 1;
|
||||||
|
const pulseR = sphereR * (1 + pulsePhase * 1.5);
|
||||||
|
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
|
||||||
|
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
|
||||||
|
ctx.lineWidth = 1 * dpr;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
animRef.current = requestAnimationFrame(drawStep);
|
||||||
|
},
|
||||||
|
[isThinking, hasError],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
animRef.current = requestAnimationFrame(draw);
|
||||||
|
return () => cancelAnimationFrame(animRef.current);
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
className="w-full h-full relative overflow-visible"
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerEnter={onEnter}
|
||||||
|
onPointerLeave={onLeave}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
646
components/search/AISearchResults.tsx
Normal file
646
components/search/AISearchResults.tsx
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
|
import { ArrowUp, X, Sparkles, ChevronRight, RotateCcw, Copy, Check } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
||||||
|
|
||||||
|
const LOADING_TEXTS = [
|
||||||
|
'Durchsuche das Kabelhandbuch... 📖',
|
||||||
|
'Frage den Senior-Ingenieur... 👴🔧',
|
||||||
|
'Frage ChatGPTs Cousin 2. Grades... 🤖',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ProductMatch {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
sku: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
products?: ProductMatch[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
interface ComponentProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialQuery?: string;
|
||||||
|
triggerSearch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AISearchResults({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialQuery = '',
|
||||||
|
triggerSearch = false,
|
||||||
|
}: ComponentProps) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [honeypot, setHoneypot] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
const [copiedAll, setCopiedAll] = useState(false);
|
||||||
|
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const hasTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
// Dedicated focus effect — polls until the input actually has focus
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
let attempts = 0;
|
||||||
|
const focusTimer = setInterval(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (el && document.activeElement !== el) {
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
if (attempts >= 15 || document.activeElement === el) {
|
||||||
|
clearInterval(focusTimer);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(focusTimer);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Trigger initial search only once
|
||||||
|
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
|
||||||
|
hasTriggeredRef.current = true;
|
||||||
|
handleSearch(initialQuery);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
setQuery('');
|
||||||
|
setMessages([]);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
hasTriggeredRef.current = false;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen, triggerSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
// Global ESC handler
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleEsc = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const isInputFocused = activeElement === inputRef.current;
|
||||||
|
|
||||||
|
if (query.trim()) {
|
||||||
|
// If there's text, clear it but keep chat open
|
||||||
|
setQuery('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
} else if (!isInputFocused) {
|
||||||
|
// If no text and input is not focused, focus it
|
||||||
|
inputRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
// If no text and input IS focused, close the chat
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEsc);
|
||||||
|
return () => document.removeEventListener('keydown', handleEsc);
|
||||||
|
}, [isOpen, onClose, query]);
|
||||||
|
|
||||||
|
const handleSearch = async (searchQuery: string = query) => {
|
||||||
|
if (!searchQuery.trim() || isLoading) return;
|
||||||
|
|
||||||
|
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
|
||||||
|
const newMessagesContext = [...messages, newUserMessage];
|
||||||
|
|
||||||
|
setMessages(newMessagesContext);
|
||||||
|
setQuery(''); // Always clear input after send
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Give the user message animation 400ms to arrive before showing "thinking"
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
// Start rotating loading texts
|
||||||
|
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
|
||||||
|
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||||
|
loadingIntervalRef.current = setInterval(() => {
|
||||||
|
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
|
||||||
|
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||||
|
}, 2500);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
||||||
|
type: 'ai_chat',
|
||||||
|
query: searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 60000);
|
||||||
|
|
||||||
|
const res = await fetch('/api/ai-search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: newMessagesContext,
|
||||||
|
_honeypot: honeypot,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok || !data) {
|
||||||
|
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.answerText,
|
||||||
|
products: data.products,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
const msg =
|
||||||
|
err.name === 'AbortError'
|
||||||
|
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
||||||
|
: err.message || 'Ein Fehler ist aufgetreten.';
|
||||||
|
|
||||||
|
// Show error as a system message in the chat instead of a separate error banner
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: `⚠️ ${msg}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
location: 'ai_chat',
|
||||||
|
message: err.message,
|
||||||
|
query: searchQuery,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (loadingIntervalRef.current) {
|
||||||
|
clearInterval(loadingIntervalRef.current);
|
||||||
|
loadingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
// Always re-focus the input
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' && !query) {
|
||||||
|
// Find the last user message and put it into the input
|
||||||
|
const lastUserNav = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (lastUserNav) {
|
||||||
|
e.preventDefault();
|
||||||
|
setQuery(lastUserNav.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (content: string, index?: number) => {
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
if (index !== undefined) {
|
||||||
|
setCopiedIndex(index);
|
||||||
|
setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
|
} else {
|
||||||
|
setCopiedAll(true);
|
||||||
|
setTimeout(() => setCopiedAll(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyChat = () => {
|
||||||
|
const fullChat = messages
|
||||||
|
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
|
||||||
|
.join('\n\n');
|
||||||
|
handleCopy(fullChat);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-start justify-center pt-6 md:pt-12 px-4"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ animation: 'chatBackdropIn 0.4s ease-out forwards' }}
|
||||||
|
>
|
||||||
|
{/* Animated backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-[#000a18]/90 backdrop-blur-2xl"
|
||||||
|
style={{ animation: 'chatFadeIn 0.3s ease-out' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="relative w-full max-w-3xl flex flex-col"
|
||||||
|
style={{
|
||||||
|
height: 'min(90vh, 900px)',
|
||||||
|
animation: 'chatSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Glassmorphism container ── */}
|
||||||
|
<div className="flex flex-col h-full rounded-3xl overflow-hidden border border-white/[0.08] bg-gradient-to-b from-white/[0.06] to-white/[0.02] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.6)]">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 overflow-hidden rounded-full">
|
||||||
|
<AIOrb isThinking={isLoading} hasError={!!error} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
||||||
|
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
|
||||||
|
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopyChat}
|
||||||
|
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
|
||||||
|
title="gesamten Chat kopieren"
|
||||||
|
>
|
||||||
|
{copiedAll ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-accent" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/30 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-xl p-2 cursor-pointer"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Chat Area ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-6 space-y-5 scroll-smooth chat-scrollbar">
|
||||||
|
{/* Empty state */}
|
||||||
|
{messages.length === 0 && !isLoading && !error && (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center h-full text-center space-y-5"
|
||||||
|
style={{ animation: 'chatFadeIn 0.6s ease-out 0.3s both' }}
|
||||||
|
>
|
||||||
|
<div className="w-24 h-24 mb-2">
|
||||||
|
<AIOrb isThinking={false} hasError={false} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xl md:text-2xl font-bold text-white/80">
|
||||||
|
Wie kann ich helfen?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/30 mt-2 max-w-md">
|
||||||
|
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
|
||||||
|
Anforderungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Quick prompts */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||||
|
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
|
||||||
|
(prompt) => (
|
||||||
|
<button
|
||||||
|
key={prompt}
|
||||||
|
onClick={() => handleSearch(prompt)}
|
||||||
|
className="text-xs text-white/40 hover:text-white/80 border border-white/10 hover:border-white/20 hover:bg-white/5 rounded-full px-4 py-2 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{messages.map((msg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
style={{
|
||||||
|
animation: `chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${index * 0.05}s both`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative group max-w-[85%] rounded-2xl px-5 py-4 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-accent text-primary font-semibold rounded-br-lg'
|
||||||
|
: 'bg-white/[0.05] border border-white/[0.06] text-white/90 rounded-bl-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Copy Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(msg.content, index)}
|
||||||
|
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg cursor-pointer ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
|
||||||
|
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Nachricht kopieren"
|
||||||
|
>
|
||||||
|
{copiedIndex === index ? (
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles className="w-3 h-3 text-accent/60" />
|
||||||
|
<span className="text-[10px] font-bold tracking-widest uppercase text-accent/50">
|
||||||
|
Ohm
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`text-sm md:text-[15px] leading-relaxed ${
|
||||||
|
msg.role === 'assistant'
|
||||||
|
? 'prose prose-invert prose-sm prose-p:leading-relaxed prose-a:text-accent prose-strong:text-accent/90 prose-ul:list-disc prose-ol:list-decimal'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
{!msg.products?.length && (
|
||||||
|
<p
|
||||||
|
className={`text-[9px] mt-2 font-medium tracking-wide ${msg.role === 'user' ? 'text-primary/40' : 'text-white/20'}`}
|
||||||
|
>
|
||||||
|
{new Date(msg.timestamp).toLocaleTimeString('de', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product cards */}
|
||||||
|
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
|
||||||
|
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
|
||||||
|
Empfohlene Produkte
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{msg.products.map((product, idx) => (
|
||||||
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={`/produkte/${product.slug}`}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
target: product.slug,
|
||||||
|
location: 'ai_chat',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="group flex items-center justify-between bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] hover:border-accent/30 rounded-xl px-4 py-3 transition-all duration-300"
|
||||||
|
style={{ animation: `chatFadeIn 0.3s ease-out ${idx * 0.1}s both` }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[9px] font-bold text-white/25 tracking-wider">
|
||||||
|
{product.sku}
|
||||||
|
</p>
|
||||||
|
<h5 className="text-xs font-bold text-white/70 group-hover:text-accent truncate transition-colors">
|
||||||
|
{product.title}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-white/20 group-hover:text-accent shrink-0 ml-3 group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
className="flex justify-start"
|
||||||
|
style={{ animation: 'chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl rounded-bl-lg px-5 py-4">
|
||||||
|
<div className="w-10 h-10 shrink-0">
|
||||||
|
<AIOrb isThinking={true} hasError={false} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-sm text-white/50 font-medium"
|
||||||
|
style={{ animation: 'chatTextSwap 0.4s ease-out' }}
|
||||||
|
key={loadingText}
|
||||||
|
>
|
||||||
|
{loadingText}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1 mt-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-1.5 h-1.5 rounded-full bg-accent/40"
|
||||||
|
style={{
|
||||||
|
animation: 'chatDotBounce 1.2s ease-in-out infinite',
|
||||||
|
animationDelay: `${i * 0.15}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex justify-start" style={{ animation: 'chatShake 0.5s ease-out' }}>
|
||||||
|
<div className="flex items-center gap-4 bg-red-500/[0.06] border border-red-500/20 rounded-2xl rounded-bl-lg px-5 py-4">
|
||||||
|
<div className="w-10 h-10 shrink-0">
|
||||||
|
<AIOrb isThinking={false} hasError={true} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
|
||||||
|
<p className="text-xs text-red-300/60 mt-1">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Nochmal versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Input Area ── */}
|
||||||
|
<div className="px-5 pb-5 pt-3 border-t border-white/[0.04]">
|
||||||
|
<div
|
||||||
|
className={`relative flex items-center rounded-2xl transition-all duration-300 ${
|
||||||
|
query.trim()
|
||||||
|
? 'bg-white/[0.08] border border-accent/30 shadow-[0_0_20px_-4px_rgba(130,237,32,0.1)]'
|
||||||
|
: 'bg-white/[0.04] border border-white/[0.06]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Nachricht eingeben..."
|
||||||
|
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
|
||||||
|
disabled={isLoading}
|
||||||
|
tabIndex={1}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="hidden"
|
||||||
|
value={honeypot}
|
||||||
|
onChange={(e) => setHoneypot(e.target.value)}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch()}
|
||||||
|
disabled={!query.trim() || isLoading}
|
||||||
|
className={`mr-2 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 cursor-pointer ${
|
||||||
|
query.trim()
|
||||||
|
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
|
||||||
|
: 'bg-white/5 text-white/20'
|
||||||
|
}`}
|
||||||
|
aria-label="Nachricht senden"
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3 mt-2.5">
|
||||||
|
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||||
|
Enter zum Senden · Esc zum Schließen
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
|
||||||
|
🛡️ DSGVO-konform · EU-Server
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Keyframe animations ── */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes chatBackdropIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes chatFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes chatSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(40px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes chatMessageIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes chatDotBounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes chatTextSwap {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes chatShake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
15% { transform: translateX(-6px); }
|
||||||
|
30% { transform: translateX(5px); }
|
||||||
|
45% { transform: translateX(-4px); }
|
||||||
|
60% { transform: translateX(3px); }
|
||||||
|
75% { transform: translateX(-1px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
.chat-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.chat-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.chat-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.chat-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
.chat-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ci": {
|
"ci": {
|
||||||
"collect": {
|
"collect": {
|
||||||
"numberOfRuns": 3,
|
"numberOfRuns": 1,
|
||||||
"settings": {
|
"settings": {
|
||||||
"preset": "desktop",
|
"preset": "desktop",
|
||||||
"onlyCategories": [
|
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||||
"performance",
|
|
||||||
"accessibility",
|
|
||||||
"best-practices",
|
|
||||||
"seo"
|
|
||||||
],
|
|
||||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18,7 +13,7 @@
|
|||||||
"categories:performance": [
|
"categories:performance": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"minScore": 0.9
|
"minScore": 0.7
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories:accessibility": [
|
"categories:accessibility": [
|
||||||
@@ -54,4 +49,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- infra
|
- infra
|
||||||
labels:
|
labels:
|
||||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||||
- "caddy.reverse_proxy=host.docker.internal:3100"
|
- "caddy.reverse_proxy=http://klz-app:3000"
|
||||||
|
|
||||||
# Full Docker dev (use with `pnpm run dev:docker`)
|
# Full Docker dev (use with `pnpm run dev:docker`)
|
||||||
klz-app:
|
klz-app:
|
||||||
@@ -26,13 +26,20 @@ services:
|
|||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
# Force Garbage Collection before Docker kills the container (OOM)
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=6144"
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
UV_THREADPOOL_SIZE: "1"
|
||||||
UV_THREADPOOL_SIZE: "4"
|
RAYON_NUM_THREADS: "1"
|
||||||
|
NEXT_PRIVATE_WORKER_THREADS: "false"
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
CI: "true"
|
CI: "true"
|
||||||
|
QDRANT_URL: "http://klz-qdrant:6333"
|
||||||
|
REDIS_URL: "redis://klz-redis:6379"
|
||||||
|
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- klz_node_modules:/app/node_modules
|
- klz_node_modules:/app/node_modules
|
||||||
@@ -42,19 +49,34 @@ services:
|
|||||||
- /app/.git
|
- /app/.git
|
||||||
- /app/reference
|
- /app/reference
|
||||||
- /app/data
|
- /app/data
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '4'
|
|
||||||
memory: 8G
|
memory: 8G
|
||||||
command: >
|
command: >
|
||||||
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
|
sh -c "pnpm install --no-frozen-lockfile &&
|
||||||
|
while true; do
|
||||||
|
(
|
||||||
|
echo '[warmup] Waiting for Next.js to be reachable...'
|
||||||
|
until curl -sf http://localhost:3000 > /dev/null; do sleep 2; done
|
||||||
|
echo '[warmup] Server is up! Pre-compiling routes...'
|
||||||
|
curl -sf http://localhost:3000/de > /dev/null 2>&1 && echo '[warmup] /de ready'
|
||||||
|
curl -sf http://localhost:3000/api/health/cms > /dev/null 2>&1 && echo '[warmup] /api/health/cms ready'
|
||||||
|
curl -sf -X POST -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"warmup\"}]}' http://localhost:3000/api/ai-search > /dev/null 2>&1 && echo '[warmup] /api/ai-search ready'
|
||||||
|
echo '[warmup] Syncing CMS data to Qdrant...'
|
||||||
|
SYNC_RESULT=$(curl -sf http://localhost:3000/api/sync-qdrant 2>&1)
|
||||||
|
echo \"[warmup] Qdrant sync: $SYNC_RESULT\"
|
||||||
|
echo '[warmup] All routes pre-compiled + Qdrant synced ✓'
|
||||||
|
) &
|
||||||
|
pnpm next dev --webpack --hostname 0.0.0.0;
|
||||||
|
echo '[klz-app] next dev exited, restarting in 2s...';
|
||||||
|
sleep 2;
|
||||||
|
done"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
|
||||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
|
||||||
|
|
||||||
klz-db:
|
klz-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
@@ -75,6 +97,24 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "54322:5432"
|
- "54322:5432"
|
||||||
|
|
||||||
|
klz-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
ports:
|
||||||
|
- "16379:6379"
|
||||||
|
|
||||||
|
klz-qdrant:
|
||||||
|
image: qdrant/qdrant:v1.13.2
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- klz_qdrant_data:/qdrant/storage
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
ports:
|
||||||
|
- "16333:6333"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -84,6 +124,8 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
klz_db_data:
|
klz_db_data:
|
||||||
external: false
|
external: false
|
||||||
|
klz_qdrant_data:
|
||||||
|
external: false
|
||||||
klz_node_modules:
|
klz_node_modules:
|
||||||
klz_next_cache:
|
klz_next_cache:
|
||||||
klz_turbo_cache:
|
klz_turbo_cache:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-payload}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
||||||
volumes:
|
volumes:
|
||||||
- klz_media_data:/app/public/media
|
- klz_media_data:/app/public/media
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||||
|
|
||||||
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(health|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -46,9 +46,21 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
|
||||||
|
# Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.regex=^https?://[^/]+/([a-z]{2}/)?login(.*)"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.replacement=https://${TRAEFIK_HOST:-klz-cables.com}/gatekeeper/login$${2}"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.permanent=false"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls=${TRAEFIK_TLS:-false}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.middlewares=${PROJECT_NAME:-klz}-loginredirect"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.service=${PROJECT_NAME:-klz}-app-svc"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.priority=2002"
|
||||||
|
|
||||||
klz-gatekeeper:
|
klz-gatekeeper:
|
||||||
profiles: [ "gatekeeper" ]
|
profiles: [ "gatekeeper" ]
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
@@ -66,8 +78,8 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Public Router (Login/Auth UI)
|
# Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(login|gatekeeper)(/.*)?`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathPrefix(`/gatekeeper`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -82,12 +94,31 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_DB: ${PAYLOAD_DB_NAME:-payload}
|
||||||
POSTGRES_USER: ${PAYLOAD_DB_USER:-payload}
|
POSTGRES_USER: ${PAYLOAD_DB_USER:-payload}
|
||||||
POSTGRES_PASSWORD: ${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
POSTGRES_PASSWORD: ${PAYLOAD_DB_PASSWORD:-payload}
|
||||||
volumes:
|
volumes:
|
||||||
- klz_db_data:/var/lib/postgresql/data
|
- klz_db_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
|
klz-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
klz-qdrant:
|
||||||
|
image: qdrant/qdrant:v1.13.2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6333:6333"
|
||||||
|
environment:
|
||||||
|
QDRANT__SERVICE__HTTP_PORT: 6333
|
||||||
|
QDRANT__SERVICE__GRPC_PORT: 6334
|
||||||
|
volumes:
|
||||||
|
- klz_qdrant_data:/qdrant/storage
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -99,3 +130,5 @@ volumes:
|
|||||||
external: false
|
external: false
|
||||||
klz_media_data:
|
klz_media_data:
|
||||||
external: false
|
external: false
|
||||||
|
klz_qdrant_data:
|
||||||
|
external: false
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export default [
|
|||||||
"tests/**",
|
"tests/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"reference/**",
|
"reference/**",
|
||||||
"data/**"
|
"data/**",
|
||||||
|
"remotion/**",
|
||||||
|
"components/record-mode/**"
|
||||||
],
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// from being included in the initial JS bundle.
|
// from being included in the initial JS bundle.
|
||||||
export {};
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
84
lib/blog.ts
84
lib/blog.ts
@@ -59,18 +59,50 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
// First try: Find in the requested locale
|
||||||
const { docs } = await payload.find({
|
let { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
slug: { equals: slug },
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
draft: isDev,
|
draft: config.showDrafts,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback: If not found, try searching across all locales.
|
||||||
|
// This happens when a user uses the static language switcher
|
||||||
|
// e.g. switching from /en/blog/en-slug to /de/blog/en-slug.
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
const { docs: crossLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossLocaleDocs && crossLocaleDocs.length > 0) {
|
||||||
|
// Fetch the found document again, but strictly in the requested locale
|
||||||
|
// so we get the correctly translated fields (like the localized slug)
|
||||||
|
const { docs: correctLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
id: { equals: crossLocaleDocs[0].id },
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
docs = correctLocaleDocs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!docs || docs.length === 0) return null;
|
if (!docs || docs.length === 0) return null;
|
||||||
|
|
||||||
const doc = docs[0];
|
const doc = docs[0];
|
||||||
@@ -84,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -107,15 +139,14 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
sort: '-date',
|
sort: '-date',
|
||||||
draft: isDev,
|
draft: config.showDrafts,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -255,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; level:
|
|||||||
return { id, text: cleanText, level };
|
return { id, text: cleanText, level };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractLexicalHeadings(
|
||||||
|
node: any,
|
||||||
|
headings: { id: string; text: string; level: number }[] = [],
|
||||||
|
): { id: string; text: string; level: number }[] {
|
||||||
|
if (!node) return headings;
|
||||||
|
|
||||||
|
if (node.type === 'heading' && node.tag) {
|
||||||
|
const level = parseInt(node.tag.replace('h', ''));
|
||||||
|
const text = getTextContentFromLexical(node);
|
||||||
|
if (text) {
|
||||||
|
headings.push({
|
||||||
|
id: generateHeadingId(text),
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
node.children.forEach((child: any) => extractLexicalHeadings(child, headings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContentFromLexical(node: any): string {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.text || '';
|
||||||
|
}
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
return node.children.map(getTextContentFromLexical).join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function createConfig() {
|
|||||||
isStaging: target === 'staging',
|
isStaging: target === 'staging',
|
||||||
isTesting: target === 'testing',
|
isTesting: target === 'testing',
|
||||||
isDevelopment: target === 'development',
|
isDevelopment: target === 'development',
|
||||||
|
showDrafts: target === 'development' || target === 'testing' || target === 'staging',
|
||||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||||
|
|
||||||
@@ -116,6 +117,9 @@ export const config = {
|
|||||||
get isDevelopment() {
|
get isDevelopment() {
|
||||||
return getConfig().isDevelopment;
|
return getConfig().isDevelopment;
|
||||||
},
|
},
|
||||||
|
get showDrafts() {
|
||||||
|
return getConfig().showDrafts;
|
||||||
|
},
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return getConfig().baseUrl;
|
return getConfig().baseUrl;
|
||||||
},
|
},
|
||||||
|
|||||||
76
lib/pages.ts
76
lib/pages.ts
@@ -1,5 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from 'payload';
|
||||||
import configPromise from '@payload-config';
|
import configPromise from '@payload-config';
|
||||||
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export interface PageFrontmatter {
|
export interface PageFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,19 +46,81 @@ function mapDoc(doc: any): PageData {
|
|||||||
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const result = await payload.find({
|
// Try finding exact match first
|
||||||
collection: 'pages' as any,
|
let result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
and: [
|
||||||
|
{ slug: { equals: fileSlug } },
|
||||||
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
|
depth: 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const docs = result.docs as any[];
|
// Fallback: search ALL locales
|
||||||
if (!docs || docs.length === 0) return null;
|
if (result.docs.length === 0) {
|
||||||
return mapDoc(docs[0]);
|
const crossResult = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ slug: { equals: fileSlug } },
|
||||||
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossResult.docs.length > 0) {
|
||||||
|
// Fetch missing exact match by internal id
|
||||||
|
result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
id: { equals: crossResult.docs[0].id },
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.docs.length > 0) {
|
||||||
|
const doc = result.docs[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: doc.slug,
|
||||||
|
frontmatter: {
|
||||||
|
title: doc.title,
|
||||||
|
excerpt: doc.excerpt || '',
|
||||||
|
featuredImage:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||||
|
: null,
|
||||||
|
focalX:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.focalX
|
||||||
|
: 50,
|
||||||
|
focalY:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.focalY
|
||||||
|
: 50,
|
||||||
|
layout:
|
||||||
|
doc.layout === 'fullBleed' || doc.layout === 'default'
|
||||||
|
? doc.layout
|
||||||
|
: ('default' as const),
|
||||||
|
},
|
||||||
|
content: doc.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from 'payload';
|
||||||
import configPromise from '@payload-config';
|
import configPromise from '@payload-config';
|
||||||
import { mapSlugToFileSlug } from './slugs';
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export interface ProductFrontmatter {
|
export interface ProductFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,6 +18,7 @@ export interface ProductData {
|
|||||||
slug: string;
|
slug: string;
|
||||||
frontmatter: ProductFrontmatter;
|
frontmatter: ProductFrontmatter;
|
||||||
content: any; // Lexical AST from Payload
|
content: any; // Lexical AST from Payload
|
||||||
|
application?: any; // Lexical AST for Application field
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductMetadata(
|
export async function getProductMetadata(
|
||||||
@@ -26,13 +28,12 @@ export async function getProductMetadata(
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ slug: { equals: fileSlug } },
|
{ slug: { equals: fileSlug } },
|
||||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
@@ -70,13 +71,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ slug: { equals: fileSlug } },
|
{ slug: { equals: fileSlug } },
|
||||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
@@ -114,6 +114,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: doc.content,
|
content: doc.content,
|
||||||
|
application: doc.application,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +128,10 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
pagination: false,
|
pagination: false,
|
||||||
@@ -157,11 +157,10 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
images: true,
|
images: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -198,6 +197,7 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: null,
|
content: null,
|
||||||
|
application: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,15 @@ export function getServerAppServices(): AppServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 1.0, // Server-side we usually want higher visibility
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ export function getAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 0.1, // Default to 10% sampling
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { LoggerService } from '../logging/logger-service';
|
|||||||
|
|
||||||
export type GlitchtipErrorReportingServiceOptions = {
|
export type GlitchtipErrorReportingServiceOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
dsn?: string;
|
||||||
|
tracesSampleRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
@@ -46,12 +48,12 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
if (!this.sentryPromise) {
|
if (!this.sentryPromise) {
|
||||||
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
||||||
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
||||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
|
if (typeof window !== 'undefined' && this.options.enabled) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://public@errors.infra.mintel.me/1',
|
dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1',
|
||||||
tunnel: '/errors/api/relay',
|
tunnel: '/errors/api/relay',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ export default async function middleware(request: NextRequest) {
|
|||||||
if (internalHosts.includes(urlObj.hostname)) {
|
if (internalHosts.includes(urlObj.hostname)) {
|
||||||
const proto = headers.get('x-forwarded-proto') || 'https';
|
const proto = headers.get('x-forwarded-proto') || 'https';
|
||||||
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||||
const hostHeader =
|
const fallbackHost = process.env.NEXT_PUBLIC_BASE_URL
|
||||||
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
? new URL(process.env.NEXT_PUBLIC_BASE_URL).host
|
||||||
|
: 'klz-cables.com';
|
||||||
|
const hostHeader = headers.get('x-forwarded-host') || headers.get('host') || fallbackHost;
|
||||||
|
|
||||||
urlObj.protocol = proto;
|
urlObj.protocol = proto;
|
||||||
|
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -7,12 +7,26 @@ import { withPayload } from '@payloadcms/next/withPayload';
|
|||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
transpilePackages: ['react-image-crop', '@react-three/fiber'],
|
||||||
onDemandEntries: {
|
onDemandEntries: {
|
||||||
// Make sure entries are not disposed too quickly
|
// Keep compiled pages/routes in memory for 5 minutes (reduced from 25m to prevent OOM)
|
||||||
maxInactiveAge: 60 * 1000,
|
maxInactiveAge: 5 * 60 * 1000,
|
||||||
|
// Keep up to 2 pages in the dev buffer (reduced from 10 to prevent OOM)
|
||||||
|
pagesBufferLength: 2,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
optimizePackageImports: [
|
||||||
|
'lucide-react',
|
||||||
|
'framer-motion',
|
||||||
|
'@/components/ui',
|
||||||
|
'@sentry/nextjs',
|
||||||
|
'@payloadcms/richtext-lexical',
|
||||||
|
'react-hook-form',
|
||||||
|
'zod',
|
||||||
|
'date-fns',
|
||||||
|
],
|
||||||
|
workerThreads: false,
|
||||||
|
memoryBasedWorkersCount: true,
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
@@ -22,6 +36,21 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isProd ? { output: 'standalone' } : {}),
|
...(isProd ? { output: 'standalone' } : {}),
|
||||||
|
// Prevent webpack from restarting when .env files are touched via Docker volume mount
|
||||||
|
webpack: (config, { dev }) => {
|
||||||
|
if (dev) {
|
||||||
|
config.watchOptions = {
|
||||||
|
...config.watchOptions,
|
||||||
|
ignored: /node_modules|\.env/,
|
||||||
|
// Reduce poll frequency to lower CPU churn from VirtioFS
|
||||||
|
poll: 1000,
|
||||||
|
aggregateTimeout: 300,
|
||||||
|
};
|
||||||
|
// Reduce source map quality in dev for faster rebuilds
|
||||||
|
config.devtool = 'eval';
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
||||||
@@ -44,7 +73,7 @@ const nextConfig = {
|
|||||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||||
font-src 'self' https://fonts.gstatic.com;
|
font-src 'self' https://fonts.gstatic.com;
|
||||||
img-src 'self' data: blob: ${extraImgDomains};
|
img-src 'self' data: blob: ${extraImgDomains};
|
||||||
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
|
connect-src 'self' ${umamiDomain} ${glitchtipDomain} https://raw.githack.com https://raw.githubusercontent.com;
|
||||||
frame-src 'self';
|
frame-src 'self';
|
||||||
object-src 'none';
|
object-src 'none';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
@@ -390,7 +419,9 @@ const nextConfig = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
|
qualities: [25, 50, 75, 100],
|
||||||
formats: ['image/webp'],
|
formats: ['image/webp'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
@@ -400,6 +431,14 @@ const nextConfig = {
|
|||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.klz-cables.com',
|
hostname: '*.klz-cables.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'klz-cables.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '*.klz-cables.com',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
hostname: 'klz.localhost',
|
hostname: 'klz.localhost',
|
||||||
@@ -425,6 +464,31 @@ const nextConfig = {
|
|||||||
source: '/de/kontakt',
|
source: '/de/kontakt',
|
||||||
destination: '/de/contact',
|
destination: '/de/contact',
|
||||||
},
|
},
|
||||||
|
// Safety rewrites for English locale using German slugs (legacy or content errors)
|
||||||
|
{
|
||||||
|
source: '/en/produkte',
|
||||||
|
destination: '/en/products',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/produkte/:path*',
|
||||||
|
destination: '/en/products/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/kontakt',
|
||||||
|
destination: '/en/contact',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/impressum',
|
||||||
|
destination: '/en/legal-notice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/datenschutz',
|
||||||
|
destination: '/en/privacy-policy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/agbs',
|
||||||
|
destination: '/en/terms',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
afterFiles: [],
|
afterFiles: [],
|
||||||
fallback: [],
|
fallback: [],
|
||||||
|
|||||||
37
package.json
37
package.json
@@ -4,19 +4,26 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/mail": "1.8.3",
|
"@ai-sdk/google": "^3.0.31",
|
||||||
"@mintel/next-config": "1.8.3",
|
"@ai-sdk/openai": "^3.0.36",
|
||||||
"@mintel/next-feedback": "1.8.10",
|
"@mintel/mail": "^1.9.0",
|
||||||
"@mintel/next-utils": "^1.7.15",
|
"@mintel/next-config": "^1.9.0",
|
||||||
|
"@mintel/next-feedback": "^1.9.0",
|
||||||
|
"@mintel/next-utils": "^1.9.0",
|
||||||
|
"@mintel/payload-ai": "^1.9.15",
|
||||||
"@payloadcms/db-postgres": "^3.77.0",
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||||
"@payloadcms/ui": "^3.77.0",
|
"@payloadcms/ui": "^3.77.0",
|
||||||
|
"@qdrant/js-client-rest": "^1.17.0",
|
||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "^1.0.7",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@sentry/nextjs": "^10.39.0",
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"@types/recharts": "^2.0.1",
|
"@types/recharts": "^2.0.1",
|
||||||
|
"ai": "^6.0.101",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
@@ -24,6 +31,7 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
"import-in-the-middle": "^1.11.0",
|
"import-in-the-middle": "^1.11.0",
|
||||||
|
"ioredis": "^5.9.3",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -38,13 +46,17 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"require-in-the-middle": "^8.0.1",
|
"require-in-the-middle": "^8.0.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svg-to-pdfkit": "^0.1.8",
|
"svg-to-pdfkit": "^0.1.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"three": "^0.183.1",
|
||||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
@@ -53,8 +65,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@cspell/dict-de-de": "^4.1.2",
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "1.8.3",
|
"@mintel/eslint-config": "^1.9.0",
|
||||||
"@mintel/tsconfig": "1.8.3",
|
"@mintel/tsconfig": "^1.9.0",
|
||||||
"@next/bundle-analyzer": "^16.1.6",
|
"@next/bundle-analyzer": "^16.1.6",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -65,6 +77,7 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
@@ -79,6 +92,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pa11y-ci": "^4.0.1",
|
"pa11y-ci": "^4.0.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.37.3",
|
"puppeteer": "^24.37.3",
|
||||||
@@ -91,8 +105,8 @@
|
|||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans'",
|
"dev": "bash -c '[ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=klz-cables.com/TRAEFIK_HOST=klz.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db klz-proxy klz-qdrant klz-redis --remove-orphans'",
|
||||||
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
|
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy klz-qdrant klz-redis && POSTGRES_URI=\"\" NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
|
||||||
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
|
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -110,6 +124,8 @@
|
|||||||
"check:security": "tsx ./scripts/check-security.ts",
|
"check:security": "tsx ./scripts/check-security.ts",
|
||||||
"check:links": "bash ./scripts/check-links.sh",
|
"check:links": "bash ./scripts/check-links.sh",
|
||||||
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
||||||
|
"check:forms": "tsx ./scripts/check-forms.ts",
|
||||||
|
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||||
"cms:migrate": "payload migrate",
|
"cms:migrate": "payload migrate",
|
||||||
@@ -136,7 +152,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.0.2",
|
"version": "2.2.12",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
@@ -148,6 +164,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"minimatch": ">=10.2.2"
|
"minimatch": ">=10.2.2"
|
||||||
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@mintel/payload-ai@1.9.15": "patches/@mintel__payload-ai@1.9.15.patch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
131
patches/@mintel__payload-ai@1.9.15.patch
Normal file
131
patches/@mintel__payload-ai@1.9.15.patch
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
diff --git a/dist/components/ChatWindow/index.js b/dist/components/ChatWindow/index.js
|
||||||
|
index 90c65bae4abb78beec98d8308e808e8ba341dcc2..f675dbc69ff82b64438288f53599c93a56391b64 100644
|
||||||
|
--- a/dist/components/ChatWindow/index.js
|
||||||
|
+++ b/dist/components/ChatWindow/index.js
|
||||||
|
@@ -2,7 +2,6 @@
|
||||||
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useChat } from '@ai-sdk/react';
|
||||||
|
-import './ChatWindow.scss';
|
||||||
|
export const ChatWindowProvider = ({ children }) => {
|
||||||
|
return (_jsxs(_Fragment, { children: [children, _jsx(ChatWindow, {})] }));
|
||||||
|
};
|
||||||
|
@@ -14,47 +13,63 @@ const ChatWindow = () => {
|
||||||
|
initialMessages: []
|
||||||
|
});
|
||||||
|
// Basic implementation to toggle chat window and submit messages
|
||||||
|
- return (_jsxs("div", { className: "payload-mcp-chat-container", children: [_jsx("button", { className: "payload-mcp-chat-toggle", onClick: () => setIsOpen(!isOpen), style: {
|
||||||
|
- position: 'fixed',
|
||||||
|
- bottom: '20px',
|
||||||
|
- right: '20px',
|
||||||
|
- zIndex: 9999,
|
||||||
|
- padding: '12px 24px',
|
||||||
|
- backgroundColor: '#000',
|
||||||
|
- color: '#fff',
|
||||||
|
- borderRadius: '8px',
|
||||||
|
- border: 'none',
|
||||||
|
- cursor: 'pointer',
|
||||||
|
- fontWeight: 'bold'
|
||||||
|
- }, children: isOpen ? 'Close AI Chat' : 'Ask AI' }), isOpen && (_jsxs("div", { className: "payload-mcp-chat-window", style: {
|
||||||
|
- position: 'fixed',
|
||||||
|
- bottom: '80px',
|
||||||
|
- right: '20px',
|
||||||
|
- width: '400px',
|
||||||
|
- height: '600px',
|
||||||
|
- backgroundColor: '#fff',
|
||||||
|
- border: '1px solid #eaeaea',
|
||||||
|
- borderRadius: '12px',
|
||||||
|
- zIndex: 9999,
|
||||||
|
- display: 'flex',
|
||||||
|
- flexDirection: 'column',
|
||||||
|
- boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
|
||||||
|
- }, children: [_jsx("div", { className: "chat-header", style: { padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }, children: _jsx("h3", { style: { margin: 0, fontSize: '16px' }, children: "Payload MCP Chat" }) }), _jsx("div", { className: "chat-messages", style: { flex: 1, padding: '16px', overflowY: 'auto' }, children: messages.map((m) => (_jsx("div", { style: {
|
||||||
|
- marginBottom: '12px',
|
||||||
|
- textAlign: m.role === 'user' ? 'right' : 'left'
|
||||||
|
- }, children: _jsxs("div", { style: {
|
||||||
|
- display: 'inline-block',
|
||||||
|
- padding: '8px 12px',
|
||||||
|
- borderRadius: '8px',
|
||||||
|
- backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
|
||||||
|
- color: m.role === 'user' ? '#fff' : '#000',
|
||||||
|
- maxWidth: '80%'
|
||||||
|
- }, children: [m.role === 'user' ? 'G: ' : 'AI: ', m.content] }) }, m.id))) }), _jsx("form", { onSubmit: handleSubmit, style: { padding: '16px', borderTop: '1px solid #eaeaea' }, children: _jsx("input", { value: input, placeholder: "Ask me anything or use /commands...", onChange: handleInputChange, style: {
|
||||||
|
- width: '100%',
|
||||||
|
- padding: '12px',
|
||||||
|
- borderRadius: '8px',
|
||||||
|
- border: '1px solid #eaeaea',
|
||||||
|
- boxSizing: 'border-box'
|
||||||
|
- } }) })] }))] }));
|
||||||
|
+ return (_jsxs("div", {
|
||||||
|
+ className: "payload-mcp-chat-container", children: [_jsx("button", {
|
||||||
|
+ className: "payload-mcp-chat-toggle", onClick: () => setIsOpen(!isOpen), style: {
|
||||||
|
+ position: 'fixed',
|
||||||
|
+ bottom: '20px',
|
||||||
|
+ right: '20px',
|
||||||
|
+ zIndex: 9999,
|
||||||
|
+ padding: '12px 24px',
|
||||||
|
+ backgroundColor: '#000',
|
||||||
|
+ color: '#fff',
|
||||||
|
+ borderRadius: '8px',
|
||||||
|
+ border: 'none',
|
||||||
|
+ cursor: 'pointer',
|
||||||
|
+ fontWeight: 'bold'
|
||||||
|
+ }, children: isOpen ? 'Close AI Chat' : 'Ask AI'
|
||||||
|
+ }), isOpen && (_jsxs("div", {
|
||||||
|
+ className: "payload-mcp-chat-window", style: {
|
||||||
|
+ position: 'fixed',
|
||||||
|
+ bottom: '80px',
|
||||||
|
+ right: '20px',
|
||||||
|
+ width: '400px',
|
||||||
|
+ height: '600px',
|
||||||
|
+ backgroundColor: '#fff',
|
||||||
|
+ border: '1px solid #eaeaea',
|
||||||
|
+ borderRadius: '12px',
|
||||||
|
+ zIndex: 9999,
|
||||||
|
+ display: 'flex',
|
||||||
|
+ flexDirection: 'column',
|
||||||
|
+ boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
|
||||||
|
+ }, children: [_jsx("div", { className: "chat-header", style: { padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }, children: _jsx("h3", { style: { margin: 0, fontSize: '16px' }, children: "Payload MCP Chat" }) }), _jsx("div", {
|
||||||
|
+ className: "chat-messages", style: { flex: 1, padding: '16px', overflowY: 'auto' }, children: messages.map((m) => (_jsx("div", {
|
||||||
|
+ style: {
|
||||||
|
+ marginBottom: '12px',
|
||||||
|
+ textAlign: m.role === 'user' ? 'right' : 'left'
|
||||||
|
+ }, children: _jsxs("div", {
|
||||||
|
+ style: {
|
||||||
|
+ display: 'inline-block',
|
||||||
|
+ padding: '8px 12px',
|
||||||
|
+ borderRadius: '8px',
|
||||||
|
+ backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
|
||||||
|
+ color: m.role === 'user' ? '#fff' : '#000',
|
||||||
|
+ maxWidth: '80%'
|
||||||
|
+ }, children: [m.role === 'user' ? 'G: ' : 'AI: ', m.content]
|
||||||
|
+ })
|
||||||
|
+ }, m.id)))
|
||||||
|
+ }), _jsx("form", {
|
||||||
|
+ onSubmit: handleSubmit, style: { padding: '16px', borderTop: '1px solid #eaeaea' }, children: _jsx("input", {
|
||||||
|
+ value: input, placeholder: "Ask me anything or use /commands...", onChange: handleInputChange, style: {
|
||||||
|
+ width: '100%',
|
||||||
|
+ padding: '12px',
|
||||||
|
+ borderRadius: '8px',
|
||||||
|
+ border: '1px solid #eaeaea',
|
||||||
|
+ boxSizing: 'border-box'
|
||||||
|
+ }
|
||||||
|
+ })
|
||||||
|
+ })]
|
||||||
|
+ }))]
|
||||||
|
+ }));
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
|
\ No newline at end of file
|
||||||
|
diff --git a/src/components/ChatWindow/index.tsx b/src/components/ChatWindow/index.tsx
|
||||||
|
index 9081ae77d4eae53ce660e285c1a6babde99ceaab..f262f1dd0fd1199734024cc27905d956e31900a2 100644
|
||||||
|
--- a/src/components/ChatWindow/index.tsx
|
||||||
|
+++ b/src/components/ChatWindow/index.tsx
|
||||||
|
@@ -2,7 +2,6 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useChat } from '@ai-sdk/react'
|
||||||
|
-import './ChatWindow.scss'
|
||||||
|
|
||||||
|
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
@@ -23,6 +23,13 @@ import { Products } from './src/payload/collections/Products';
|
|||||||
import { Pages } from './src/payload/collections/Pages';
|
import { Pages } from './src/payload/collections/Pages';
|
||||||
import { seedDatabase } from './src/payload/seed';
|
import { seedDatabase } from './src/payload/seed';
|
||||||
|
|
||||||
|
const isMigrate = process.argv.includes('migrate');
|
||||||
|
let chatPlugin: any = null;
|
||||||
|
if (!isMigrate) {
|
||||||
|
const mod = await import('@mintel/payload-ai');
|
||||||
|
chatPlugin = mod.payloadChatPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
@@ -45,9 +52,7 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
titleSuffix: ' – KLZ Cables',
|
titleSuffix: ' – KLZ Cables',
|
||||||
icons: [
|
icons: [{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' }],
|
||||||
{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localization: {
|
localization: {
|
||||||
@@ -80,18 +85,34 @@ export default buildConfig({
|
|||||||
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || '120in09oenaoinsd9iaidon'}@127.0.0.1:54322/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || '120in09oenaoinsd9iaidon'}@127.0.0.1:54322/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
email: nodemailerAdapter({
|
email: process.env.MAIL_HOST
|
||||||
defaultFromAddress: process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
? nodemailerAdapter({
|
||||||
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
defaultFromAddress:
|
||||||
transportOptions: {
|
process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
||||||
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
||||||
port: Number(process.env.MAIL_PORT) || 587,
|
transportOptions: {
|
||||||
auth: {
|
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
||||||
user: process.env.MAIL_USERNAME,
|
port: Number(process.env.MAIL_PORT) || 587,
|
||||||
pass: process.env.MAIL_PASSWORD,
|
...(process.env.MAIL_USERNAME
|
||||||
},
|
? {
|
||||||
},
|
auth: {
|
||||||
}),
|
user: process.env.MAIL_USERNAME,
|
||||||
|
pass: process.env.MAIL_PASSWORD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
sharp,
|
sharp,
|
||||||
plugins: [],
|
plugins: [
|
||||||
|
...(chatPlugin
|
||||||
|
? [
|
||||||
|
chatPlugin({
|
||||||
|
enabled: true,
|
||||||
|
mcpServers: [{ name: 'klz-qdrant-mcp' }],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
2210
pnpm-lock.yaml
generated
2210
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
119
scripts/check-apis.ts
Normal file
119
scripts/check-apis.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import dns from 'dns';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import url from 'url';
|
||||||
|
|
||||||
|
const resolve4 = promisify(dns.resolve4);
|
||||||
|
|
||||||
|
// This script verifies that external logging and analytics APIs are reachable
|
||||||
|
// from the deployment environment (which could be behind corporate firewalls or VPNs).
|
||||||
|
|
||||||
|
const umamiEndpoint = process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me';
|
||||||
|
const sentryDsn = process.env.SENTRY_DSN || '';
|
||||||
|
|
||||||
|
async function checkUmami() {
|
||||||
|
console.log(`\n🔍 Checking Umami Analytics API Availability...`);
|
||||||
|
console.log(` Endpoint: ${umamiEndpoint}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Umami usually exposes a /api/heartbeat or /api/health if we know the route.
|
||||||
|
// Trying root or /api/auth/verify (which will give 401 but proves routing works).
|
||||||
|
// A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout.
|
||||||
|
const response = await axios.get(`${umamiEndpoint.replace(/\/$/, '')}/api/health`, {
|
||||||
|
timeout: 5000,
|
||||||
|
validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx
|
||||||
|
});
|
||||||
|
|
||||||
|
// As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw new Error(`Umami API responded with server error HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Umami Analytics is reachable (HTTP ${response.status})`);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
// If /api/health fails completely, maybe try a DNS check as a fallback
|
||||||
|
try {
|
||||||
|
console.warn(` ⚠️ HTTP check failed, falling back to DNS resolution...`);
|
||||||
|
const umamiHost = new url.URL(umamiEndpoint).hostname;
|
||||||
|
await resolve4(umamiHost);
|
||||||
|
console.log(` ✅ Umami Analytics DNS resolved successfully (${umamiHost})`);
|
||||||
|
return true;
|
||||||
|
} catch (dnsErr: any) {
|
||||||
|
console.error(
|
||||||
|
` ❌ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSentry() {
|
||||||
|
console.log(`\n🔍 Checking Glitchtip/Sentry Error Tracking Availability...`);
|
||||||
|
|
||||||
|
if (!sentryDsn) {
|
||||||
|
console.log(` ℹ️ No SENTRY_DSN provided in environment. Skipping.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedDsn = new url.URL(sentryDsn);
|
||||||
|
const host = parsedDsn.hostname;
|
||||||
|
console.log(` Host: ${host}`);
|
||||||
|
|
||||||
|
// We do a DNS lookup to ensure the runner can actually resolve the tracking server
|
||||||
|
const addresses = await resolve4(host);
|
||||||
|
|
||||||
|
if (addresses && addresses.length > 0) {
|
||||||
|
console.log(` ✅ Glitchtip/Sentry domain resolved: ${addresses[0]}`);
|
||||||
|
|
||||||
|
// Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root)
|
||||||
|
try {
|
||||||
|
const proto = parsedDsn.protocol || 'https:';
|
||||||
|
await axios.get(`${proto}//${host}/api/0/`, {
|
||||||
|
timeout: 5000,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
console.log(` ✅ Glitchtip/Sentry API root responds to HTTP.`);
|
||||||
|
} catch (ignore) {
|
||||||
|
console.log(
|
||||||
|
` ⚠️ Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error('No IP addresses found for DSN host');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
` ❌ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🚀 Starting External API Connectivity Smoke Test...');
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
const umamiOk = await checkUmami();
|
||||||
|
if (!umamiOk) hasErrors = true;
|
||||||
|
|
||||||
|
const sentryOk = await checkSentry();
|
||||||
|
if (!sentryOk) hasErrors = true;
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error(
|
||||||
|
`\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`,
|
||||||
|
);
|
||||||
|
console.error(` This might mean the deployment environment lacks outbound internet access, `);
|
||||||
|
console.error(` DNS is misconfigured, or the upstream services are down.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 SUCCESS: All required external APIs are reachable!`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -2,11 +2,16 @@ import puppeteer, { HTTPResponse } from 'puppeteer';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl =
|
||||||
|
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
'http://localhost:3000';
|
||||||
|
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
||||||
|
console.log(`📊 Limit: ${limit} pages\n`);
|
||||||
|
|
||||||
// 1. Fetch Sitemap to discover all routes
|
// 1. Fetch Sitemap to discover all routes
|
||||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
@@ -31,6 +36,17 @@ async function main() {
|
|||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
console.log(`✅ Found ${urls.length} target URLs.`);
|
console.log(`✅ Found ${urls.length} target URLs.`);
|
||||||
|
|
||||||
|
if (urls.length > limit) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||||
|
);
|
||||||
|
// Simplify selection: home pages + a slice of the rest
|
||||||
|
const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
|
||||||
|
const homeDE = urls.filter((u) => u.endsWith('/de'));
|
||||||
|
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
|
||||||
|
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
253
scripts/check-forms.ts
Normal file
253
scripts/check-forms.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import puppeteer, { HTTPResponse } from 'puppeteer';
|
||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
|
||||||
|
|
||||||
|
// 1. Fetch Sitemap to discover the contact page and a product page
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
|
let urls: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
const response = await axios.get(sitemapUrl, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
|
urls = $('url loc')
|
||||||
|
.map((i, el) => $(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Normalize to target URL instance
|
||||||
|
const urlPattern = /https?:\/\/[^\/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith('http'))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||||
|
.sort();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactUrl = urls.find((u) => u.includes('/de/kontakt'));
|
||||||
|
// Ensure we select an actual product page (depth >= 7: http://host/de/produkte/category/product)
|
||||||
|
const productUrl = urls.find(
|
||||||
|
(u) =>
|
||||||
|
u.includes('/de/produkte/') && new URL(u).pathname.split('/').filter(Boolean).length >= 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contactUrl) {
|
||||||
|
console.error(`❌ Could not find contact page in sitemap. Ensure /de/kontakt exists.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productUrl) {
|
||||||
|
console.error(
|
||||||
|
`❌ Could not find a product page in sitemap. Form testing requires at least one product page.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Discovered Contact Page: ${contactUrl}`);
|
||||||
|
console.log(`✅ Discovered Product Page: ${productUrl}`);
|
||||||
|
|
||||||
|
// 2. Launch Headless Browser
|
||||||
|
console.log(`\n🕷️ Launching Puppeteer Headless Engine...`);
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
page.on('console', (msg) => console.log('💻 BROWSER CONSOLE:', msg.text()));
|
||||||
|
page.on('pageerror', (error) => console.error('💻 BROWSER ERROR:', error.message));
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
console.error('💻 BROWSER REQUEST FAILED:', request.url(), request.failure()?.errorText);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Authenticate through Gatekeeper login form
|
||||||
|
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||||
|
try {
|
||||||
|
// Navigate to a protected page so Gatekeeper redirects us to the login screen
|
||||||
|
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||||
|
|
||||||
|
// Check if we landed on the Gatekeeper login page
|
||||||
|
const isGatekeeperPage = await page.$('input[name="password"]');
|
||||||
|
if (isGatekeeperPage) {
|
||||||
|
await page.type('input[name="password"]', gatekeeperPassword);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 30000 }),
|
||||||
|
page.click('button[type="submit"]'),
|
||||||
|
]);
|
||||||
|
console.log(`✅ Gatekeeper authentication successful!`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Gatekeeper authentication failed: ${err.message}`);
|
||||||
|
await browser.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// 4. Test Contact Form
|
||||||
|
try {
|
||||||
|
console.log(`\n🧪 Testing Contact Form on: ${contactUrl}`);
|
||||||
|
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||||
|
|
||||||
|
// Ensure React has hydrated completely
|
||||||
|
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
|
||||||
|
|
||||||
|
// Ensure form is visible and interactive
|
||||||
|
try {
|
||||||
|
// Find the form input by name
|
||||||
|
await page.waitForSelector('input[name="name"]', { visible: true, timeout: 15000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to find Contact Form input. Page Title:', await page.title());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait specifically for hydration logic to initialize the onSubmit handler
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
|
await page.type('input[name="name"]', 'Automated E2E Test');
|
||||||
|
await page.type('input[name="email"]', 'testing@mintel.me');
|
||||||
|
await page.type(
|
||||||
|
'textarea[name="message"]',
|
||||||
|
'This is an automated test verifying the contact form submission.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Give state a moment to settle
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
|
||||||
|
|
||||||
|
console.log(` Submitting Contact Form...`);
|
||||||
|
|
||||||
|
// Explicitly click submit and wait for navigation/state-change
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
|
||||||
|
page.click('button[type="submit"]'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
|
||||||
|
console.log(` Alert text: ${alertText}`);
|
||||||
|
|
||||||
|
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
|
||||||
|
throw new Error(`Form submitted but showed error: ${alertText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Contact Form Test Failed: ${err.message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Test Product Quote Form
|
||||||
|
try {
|
||||||
|
console.log(`\n🧪 Testing Product Quote Form on: ${productUrl}`);
|
||||||
|
await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||||
|
|
||||||
|
// Ensure React has hydrated completely
|
||||||
|
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
|
||||||
|
|
||||||
|
// The product form uses dynamic IDs, so we select by input type in the specific form context
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('form input[type="email"]', { visible: true, timeout: 15000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to find Product Quote Form input. Page Title:', await page.title());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait specifically for hydration logic to initialize the onSubmit handler
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
|
||||||
|
|
||||||
|
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
|
||||||
|
await page.type('form input[type="email"]', 'testing@mintel.me');
|
||||||
|
await page.type(
|
||||||
|
'form textarea',
|
||||||
|
'Automated request for product quote via E2E testing framework.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Give state a moment to settle
|
||||||
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
|
||||||
|
|
||||||
|
console.log(` Submitting Product Quote Form...`);
|
||||||
|
|
||||||
|
// Submit and wait for success state
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
|
||||||
|
page.click('form button[type="submit"]'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
|
||||||
|
console.log(` Alert text: ${alertText}`);
|
||||||
|
|
||||||
|
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
|
||||||
|
throw new Error(`Form submitted but showed error: ${alertText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Cleanup: Delete test submissions from Payload CMS
|
||||||
|
console.log(`\n🧹 Starting cleanup of test submissions...`);
|
||||||
|
try {
|
||||||
|
const apiUrl = `${targetUrl.replace(/\/$/, '')}/api/form-submissions`;
|
||||||
|
const searchUrl = `${apiUrl}?where[email][equals]=testing@mintel.me`;
|
||||||
|
|
||||||
|
// Fetch test submissions
|
||||||
|
const searchResponse = await axios.get(searchUrl, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const testSubmissions = searchResponse.data.docs || [];
|
||||||
|
console.log(` Found ${testSubmissions.length} test submissions to clean up.`);
|
||||||
|
|
||||||
|
for (const doc of testSubmissions) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${apiUrl}/${doc.id}`, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
});
|
||||||
|
console.log(` ✅ Deleted submission: ${doc.id}`);
|
||||||
|
} catch (delErr: any) {
|
||||||
|
// Log but don't fail, 403s on Directus / Payload APIs for guest Gatekeeper sessions are normal
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ Cleanup attempt on ${doc.id} returned an error, typically due to API Auth separation: ${delErr.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ Cleanup fetch failed with 403 Forbidden. This is expected if the runner lacks admin API credentials. Test submissions remain in the database.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(` ❌ Cleanup fetch failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
// Don't mark the whole test as failed just because cleanup failed
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// 6. Evaluation
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`\n🎉 SUCCESS: All form submissions arrived and handled correctly!`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -50,7 +50,12 @@ async function main() {
|
|||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400,
|
||||||
});
|
});
|
||||||
const filename = `page-${i}.html`;
|
|
||||||
|
// Generate a safe filename that retains URL information
|
||||||
|
const urlStr = new URL(u);
|
||||||
|
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
const filename = `${safePath || 'index'}.html`;
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outputDir, filename), res.data);
|
fs.writeFileSync(path.join(outputDir, filename), res.data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
|
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
|
||||||
|
|||||||
@@ -53,12 +53,17 @@ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
|||||||
REMOTE_DB_USER=""
|
REMOTE_DB_USER=""
|
||||||
REMOTE_DB_NAME=""
|
REMOTE_DB_NAME=""
|
||||||
|
|
||||||
# Migration names to insert after restore (keeps Payload from prompting)
|
# Auto-detect migrations from src/migrations/*.ts (no manual maintenance needed)
|
||||||
MIGRATIONS=(
|
MIGRATIONS=()
|
||||||
"20260223_195005_products_collection:1"
|
BATCH=1
|
||||||
"20260223_195151_remove_sku_unique:2"
|
for migration_file in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
||||||
"20260225_003500_add_pages_collection:3"
|
name=$(basename "$migration_file" .ts)
|
||||||
)
|
MIGRATIONS+=("$name:$BATCH")
|
||||||
|
((BATCH++))
|
||||||
|
done
|
||||||
|
if [ ${#MIGRATIONS[@]} -eq 0 ]; then
|
||||||
|
echo "⚠️ No migration files found in src/migrations/"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Resolve target environment ─────────────────────────────────────────────
|
# ── Resolve target environment ─────────────────────────────────────────────
|
||||||
resolve_target() {
|
resolve_target() {
|
||||||
@@ -158,6 +163,29 @@ backup_remote_db() {
|
|||||||
REMOTE_BACKUP_FILE="$file"
|
REMOTE_BACKUP_FILE="$file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Pre-flight: Verify remote containers exist ─────────────────────────────
|
||||||
|
check_remote_containers() {
|
||||||
|
echo "🔍 Checking $TARGET containers..."
|
||||||
|
local missing=0
|
||||||
|
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then
|
||||||
|
echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST"
|
||||||
|
echo " → Deploy $TARGET first: git push to trigger pipeline, or run:"
|
||||||
|
echo " ssh $SSH_HOST \"cd $REMOTE_SITE_DIR && docker compose -p $REMOTE_PROJECT --env-file .env.\$TARGET up -d\""
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then
|
||||||
|
echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
if [ $missing -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "💡 The $TARGET environment hasn't been deployed yet."
|
||||||
|
echo " Push to the '$TARGET' branch or run the pipeline first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ All $TARGET containers running."
|
||||||
|
}
|
||||||
|
|
||||||
# ── PUSH: local → remote ──────────────────────────────────────────────────
|
# ── PUSH: local → remote ──────────────────────────────────────────────────
|
||||||
do_push() {
|
do_push() {
|
||||||
echo ""
|
echo ""
|
||||||
@@ -171,8 +199,9 @@ do_push() {
|
|||||||
echo ""
|
echo ""
|
||||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||||
|
|
||||||
# 0. Ensure local DB is running
|
# 0. Ensure local DB is running & remote containers exist
|
||||||
ensure_local_db
|
ensure_local_db
|
||||||
|
check_remote_containers
|
||||||
|
|
||||||
# 1. Safety backup of remote
|
# 1. Safety backup of remote
|
||||||
backup_remote_db
|
backup_remote_db
|
||||||
@@ -226,8 +255,9 @@ do_pull() {
|
|||||||
echo ""
|
echo ""
|
||||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||||
|
|
||||||
# 0. Ensure local DB is running
|
# 0. Ensure local DB is running & remote containers exist
|
||||||
ensure_local_db
|
ensure_local_db
|
||||||
|
check_remote_containers
|
||||||
|
|
||||||
# 1. Safety backup of local
|
# 1. Safety backup of local
|
||||||
backup_local_db
|
backup_local_db
|
||||||
|
|||||||
24
scripts/lhci-puppeteer-setup.js
Normal file
24
scripts/lhci-puppeteer-setup.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* LHCI Puppeteer Setup Script
|
||||||
|
* Sets the gatekeeper session cookie before auditing
|
||||||
|
*/
|
||||||
|
module.exports = async (browser, context) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
// Using LHCI_URL or TARGET_URL if available
|
||||||
|
const targetUrl =
|
||||||
|
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.klz-cables.com';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie for ${new URL(targetUrl).hostname}...`);
|
||||||
|
|
||||||
|
await page.setCookie({
|
||||||
|
name: 'klz_gatekeeper_session',
|
||||||
|
value: gatekeeperPassword,
|
||||||
|
domain: new URL(targetUrl).hostname,
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: targetUrl.startsWith('https://'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
};
|
||||||
@@ -12,7 +12,11 @@ import * as path from 'path';
|
|||||||
* 3. Runs Lighthouse CI on those URLs
|
* 3. Runs Lighthouse CI on those URLs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl =
|
||||||
|
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
process.env.LHCI_URL ||
|
||||||
|
'http://localhost:3000';
|
||||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
@@ -76,7 +80,56 @@ async function main() {
|
|||||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
// Detect Chrome path from Puppeteer installation if not provided
|
||||||
|
let chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||||
|
if (!chromePath) {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Attempting to detect Puppeteer Chrome path...');
|
||||||
|
const puppeteerInfo = execSync('npx puppeteer browsers latest chrome', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
console.log(`📦 Puppeteer info: ${puppeteerInfo}`);
|
||||||
|
const match = puppeteerInfo.match(/executablePath: (.*)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
chromePath = match[1].trim();
|
||||||
|
console.log(`✅ Detected Puppeteer Chrome at: ${chromePath}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`⚠️ Could not detect Puppeteer Chrome path via command: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to known paths if still not found
|
||||||
|
if (!chromePath) {
|
||||||
|
const fallbacks = [
|
||||||
|
'/root/.cache/puppeteer/chrome/linux-145.0.7632.77/chrome-linux64/chrome',
|
||||||
|
'/home/runner/.cache/puppeteer/chrome/linux-145.0.7632.77/chrome-linux64/chrome',
|
||||||
|
path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'node_modules',
|
||||||
|
'.puppeteer',
|
||||||
|
'chrome',
|
||||||
|
'linux-145.0.7632.77',
|
||||||
|
'chrome-linux64',
|
||||||
|
'chrome',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const fallback of fallbacks) {
|
||||||
|
if (fs.existsSync(fallback)) {
|
||||||
|
chromePath = fallback;
|
||||||
|
console.log(`✅ Found Puppeteer Chrome at fallback: ${chromePath}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ Using existing Chrome path: ${chromePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chromePath) {
|
||||||
|
console.warn('❌ CHROME_PATH is still undefined. Lighthouse might fail.');
|
||||||
|
}
|
||||||
|
|
||||||
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
||||||
|
|
||||||
// Clean up old reports
|
// Clean up old reports
|
||||||
@@ -85,15 +138,16 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Using a more robust way to execute and capture output
|
// Using a more robust way to execute and capture output
|
||||||
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
|
// We use a puppeteer script to set cookies which is more reliable than extraHeaders for LHCI
|
||||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`;
|
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.puppeteerScript="scripts/lhci-puppeteer-setup.js" --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`;
|
||||||
|
|
||||||
console.log(`💻 Executing LHCI...`);
|
console.log(`💻 Executing LHCI with CHROME_PATH="${chromePath}" and Puppeteer Auth...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(lhciCommand, {
|
execSync(lhciCommand, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, CHROME_PATH: chromePath },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
||||||
|
|||||||
220
src/lib/qdrant.ts
Normal file
220
src/lib/qdrant.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||||
|
import redis from './redis';
|
||||||
|
|
||||||
|
const isDockerContainer =
|
||||||
|
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
||||||
|
const qdrantUrl =
|
||||||
|
process.env.QDRANT_URL ||
|
||||||
|
(isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333');
|
||||||
|
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
||||||
|
|
||||||
|
export const qdrant = new QdrantClient({
|
||||||
|
url: qdrantUrl,
|
||||||
|
apiKey: qdrantApiKey || undefined,
|
||||||
|
// Disable qdrant client's own version check to avoid the warning spam
|
||||||
|
checkCompatibility: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const COLLECTION_NAME = 'klz_products';
|
||||||
|
export const VECTOR_SIZE = 1024; // Mistral mistral-embed
|
||||||
|
|
||||||
|
// Cache TTLs
|
||||||
|
const EMBEDDING_CACHE_TTL = 60 * 60 * 24; // 24h — embeddings are deterministic
|
||||||
|
const SEARCH_CACHE_TTL = 60 * 30; // 30 min — product data could change
|
||||||
|
|
||||||
|
// Track collection existence in-memory (don't re-check every request)
|
||||||
|
let collectionVerified = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the collection exists in Qdrant (only checks once per process lifetime).
|
||||||
|
*/
|
||||||
|
export async function ensureCollection() {
|
||||||
|
if (collectionVerified) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collections = await qdrant.getCollections();
|
||||||
|
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
||||||
|
if (!exists) {
|
||||||
|
await qdrant.createCollection(COLLECTION_NAME, {
|
||||||
|
vectors: {
|
||||||
|
size: VECTOR_SIZE,
|
||||||
|
distance: 'Cosine',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
||||||
|
}
|
||||||
|
collectionVerified = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error ensuring Qdrant collection:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash text for cache key
|
||||||
|
*/
|
||||||
|
function hashKey(text: string): string {
|
||||||
|
const { createHash } = require('crypto');
|
||||||
|
return createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding using Mistral API (EU/DSGVO-compliant)
|
||||||
|
*/
|
||||||
|
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||||
|
const cacheKey = `emb:${hashKey(text.toLowerCase().trim())}`;
|
||||||
|
|
||||||
|
// Try Redis cache first
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Redis down — proceed without cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const mistralKey = process.env.MISTRAL_API_KEY;
|
||||||
|
if (!mistralKey) {
|
||||||
|
throw new Error('MISTRAL_API_KEY is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.mistral.ai/v1/embeddings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${mistralKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-embed',
|
||||||
|
input: [text],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const embedding = data.data[0].embedding;
|
||||||
|
|
||||||
|
// Cache the embedding in Redis
|
||||||
|
try {
|
||||||
|
await redis.set(cacheKey, JSON.stringify(embedding), 'EX', EMBEDDING_CACHE_TTL);
|
||||||
|
} catch {
|
||||||
|
// Redis down — proceed without caching
|
||||||
|
}
|
||||||
|
|
||||||
|
return embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a product into Qdrant
|
||||||
|
*/
|
||||||
|
export async function upsertProductVector(
|
||||||
|
id: string | number,
|
||||||
|
text: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await ensureCollection();
|
||||||
|
const vector = await generateEmbedding(text);
|
||||||
|
|
||||||
|
await qdrant.upsert(COLLECTION_NAME, {
|
||||||
|
wait: true,
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
vector,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error writing to Qdrant:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a product from Qdrant
|
||||||
|
*/
|
||||||
|
export async function deleteProductVector(id: string | number) {
|
||||||
|
try {
|
||||||
|
await ensureCollection();
|
||||||
|
await qdrant.delete(COLLECTION_NAME, {
|
||||||
|
wait: true,
|
||||||
|
points: [id] as [string | number],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting from Qdrant:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete knowledge chunks by their source Media ID
|
||||||
|
*/
|
||||||
|
export async function deleteKnowledgeByMediaId(mediaId: string | number) {
|
||||||
|
try {
|
||||||
|
await ensureCollection();
|
||||||
|
await qdrant.delete(COLLECTION_NAME, {
|
||||||
|
wait: true,
|
||||||
|
filter: {
|
||||||
|
must: [
|
||||||
|
{
|
||||||
|
key: 'mediaId',
|
||||||
|
match: {
|
||||||
|
value: mediaId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Successfully deleted Qdrant chunks for Media ID: ${mediaId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting knowledge by Media ID from Qdrant:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search products in Qdrant.
|
||||||
|
* Results are cached in Redis for 30 minutes keyed by query text.
|
||||||
|
*/
|
||||||
|
export async function searchProducts(query: string, limit = 5) {
|
||||||
|
const cacheKey = `search:${hashKey(query.toLowerCase().trim())}:${limit}`;
|
||||||
|
|
||||||
|
// Try Redis cache first
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
console.log(`[Qdrant] Cache HIT for query: "${query.substring(0, 50)}"`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Redis down — proceed without cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureCollection();
|
||||||
|
const vector = await generateEmbedding(query);
|
||||||
|
|
||||||
|
const results = await qdrant.search(COLLECTION_NAME, {
|
||||||
|
vector,
|
||||||
|
limit,
|
||||||
|
with_payload: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache results in Redis
|
||||||
|
try {
|
||||||
|
await redis.set(cacheKey, JSON.stringify(results), 'EX', SEARCH_CACHE_TTL);
|
||||||
|
} catch {
|
||||||
|
// Redis down — proceed without caching
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching in Qdrant:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/lib/redis.ts
Normal file
22
src/lib/redis.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const isDockerContainer =
|
||||||
|
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
||||||
|
const redisUrl =
|
||||||
|
process.env.REDIS_URL ||
|
||||||
|
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
|
||||||
|
|
||||||
|
// Only create a single instance in Node.js
|
||||||
|
const globalForRedis = global as unknown as { redis: Redis };
|
||||||
|
|
||||||
|
export const redis =
|
||||||
|
globalForRedis.redis ||
|
||||||
|
new Redis(redisUrl, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForRedis.redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default redis;
|
||||||
@@ -55,10 +55,29 @@ export async function up({ db }: MigrateUpArgs): Promise<void> {
|
|||||||
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
|
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
|
||||||
EXCEPTION WHEN duplicate_object THEN null; END $$
|
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||||
`);
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "public"."enum_pages_status" AS ENUM('draft', 'published');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||||
|
`);
|
||||||
|
|
||||||
// ── 2. Alter pages table ─────────────────────────────────────────────────────
|
// ── 2. Alter pages table ─────────────────────────────────────────────────────
|
||||||
await db.execute(sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "layout" "enum_pages_layout" DEFAULT 'default'`);
|
await db.execute(
|
||||||
await db.execute(sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "_status" "enum_pages_status" DEFAULT 'draft'`);
|
sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "layout" "enum_pages_layout" DEFAULT 'default'`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "_status" "enum_pages_status" DEFAULT 'draft'`,
|
||||||
|
);
|
||||||
|
|
||||||
// ── 3. Create pages_locales join table ───────────────────────────────────────
|
// ── 3. Create pages_locales join table ───────────────────────────────────────
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
@@ -202,9 +221,63 @@ export async function up({ db }: MigrateUpArgs): Promise<void> {
|
|||||||
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "locale"`);
|
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "locale"`);
|
||||||
|
|
||||||
// ── 12. Version tables (_posts_v, _products_v, _pages_v) locale columns ──────
|
// ── 12. Version tables (_posts_v, _products_v, _pages_v) locale columns ──────
|
||||||
await db.execute(sql`ALTER TABLE "_posts_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__posts_v_published_locale"`);
|
await db.execute(sql`
|
||||||
await db.execute(sql`ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__products_v_published_locale"`);
|
CREATE TABLE IF NOT EXISTS "_posts_v" (
|
||||||
await db.execute(sql`ALTER TABLE "_pages_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__pages_v_published_locale"`);
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"version_title" varchar,
|
||||||
|
"version_slug" varchar,
|
||||||
|
"version_excerpt" varchar,
|
||||||
|
"version_content" jsonb,
|
||||||
|
"version_updated_at" timestamp(3) with time zone,
|
||||||
|
"version_created_at" timestamp(3) with time zone,
|
||||||
|
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"latest" boolean
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "_products_v" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"version_title" varchar,
|
||||||
|
"version_description" varchar,
|
||||||
|
"version_content" jsonb,
|
||||||
|
"version_updated_at" timestamp(3) with time zone,
|
||||||
|
"version_created_at" timestamp(3) with time zone,
|
||||||
|
"version__status" "enum__products_v_version_status" DEFAULT 'draft',
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"latest" boolean
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "_pages_v" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"version_title" varchar,
|
||||||
|
"version_slug" varchar,
|
||||||
|
"version_excerpt" varchar,
|
||||||
|
"version_content" jsonb,
|
||||||
|
"version_updated_at" timestamp(3) with time zone,
|
||||||
|
"version_created_at" timestamp(3) with time zone,
|
||||||
|
"version__status" "enum__pages_v_version_status" DEFAULT 'draft',
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"latest" boolean
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "_posts_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__posts_v_published_locale"`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__products_v_published_locale"`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "_pages_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__pages_v_published_locale"`,
|
||||||
|
);
|
||||||
|
|
||||||
// ── 13. Create _posts_v_locales ──────────────────────────────────────────────
|
// ── 13. Create _posts_v_locales ──────────────────────────────────────────────
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
|
|||||||
@@ -45,4 +45,81 @@ export const Media: CollectionConfig = {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
hooks: {
|
||||||
|
afterChange: [
|
||||||
|
async ({ doc, req }) => {
|
||||||
|
// Only process PDF files
|
||||||
|
if (doc.mimeType === 'application/pdf') {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const pdfParse = require('pdf-parse');
|
||||||
|
const { upsertProductVector, deleteKnowledgeByMediaId } = require('../../lib/qdrant');
|
||||||
|
|
||||||
|
const filePath = path.join(process.cwd(), 'public/media', doc.filename);
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
req.payload.logger.info(`Extracting text from PDF: ${doc.filename}`);
|
||||||
|
|
||||||
|
const dataBuffer = fs.readFileSync(filePath);
|
||||||
|
const data = await pdfParse(dataBuffer);
|
||||||
|
|
||||||
|
// Clear any previously indexed chunks for this file just in case it's an update
|
||||||
|
await deleteKnowledgeByMediaId(doc.id);
|
||||||
|
|
||||||
|
// Chunk the text like we did in the ingest script
|
||||||
|
const chunks = data.text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((c: string) => c.trim())
|
||||||
|
.filter((c: string) => c.length > 50);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
// Generate a deterministic UUID based on doc ID and chunk index
|
||||||
|
const hash = crypto.createHash('md5').update(`${doc.id}-${i}`).digest('hex');
|
||||||
|
// Qdrant strictly requires UUID: 8-4-4-4-12
|
||||||
|
const uuid = [
|
||||||
|
hash.substring(0, 8),
|
||||||
|
hash.substring(8, 12),
|
||||||
|
hash.substring(12, 16),
|
||||||
|
hash.substring(16, 20),
|
||||||
|
hash.substring(20, 32),
|
||||||
|
].join('-');
|
||||||
|
|
||||||
|
await upsertProductVector(uuid, chunks[i], {
|
||||||
|
type: 'knowledge',
|
||||||
|
title: `${doc.filename} - Teil ${i + 1}`,
|
||||||
|
content: chunks[i],
|
||||||
|
source: doc.filename,
|
||||||
|
mediaId: doc.id,
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
req.payload.logger.info(
|
||||||
|
`Successfully ingested ${successCount} chunks from ${doc.filename} into Qdrant`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
req.payload.logger.error(`Error parsing PDF ${doc.filename}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterDelete: [
|
||||||
|
async ({ id, doc, req }) => {
|
||||||
|
if (doc.mimeType === 'application/pdf') {
|
||||||
|
try {
|
||||||
|
const { deleteKnowledgeByMediaId } = require('../../lib/qdrant');
|
||||||
|
await deleteKnowledgeByMediaId(id);
|
||||||
|
req.payload.logger.info(`Removed Qdrant chunks for deleted PDF: ${doc.filename}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
req.payload.logger.error(
|
||||||
|
`Error removing Qdrant chunks for ${doc.filename}: ${e.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const Pages: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: ({ req: { user } }) => {
|
read: ({ req: { user } }) => {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -26,6 +26,66 @@ export const Pages: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [
|
||||||
|
async ({ doc, req }) => {
|
||||||
|
// Run index sync asynchronously to not block the CMS save operation
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
||||||
|
|
||||||
|
// Check if page is published
|
||||||
|
if (doc._status !== 'published') {
|
||||||
|
await deleteProductVector(`page_${doc.id}`);
|
||||||
|
req.payload.logger.info(`Removed drafted page ${doc.slug} from Qdrant`);
|
||||||
|
} else {
|
||||||
|
// Serialize payload
|
||||||
|
const contentText = [
|
||||||
|
`Seite: ${doc.title}`,
|
||||||
|
doc.excerpt ? `Beschreibung: ${doc.excerpt}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: 'knowledge',
|
||||||
|
content: contentText,
|
||||||
|
data: {
|
||||||
|
title: doc.title,
|
||||||
|
slug: doc.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await upsertProductVector(`page_${doc.id}`, contentText, payload);
|
||||||
|
req.payload.logger.info(`Upserted page ${doc.slug} to Qdrant`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error({
|
||||||
|
msg: 'Error syncing page to Qdrant',
|
||||||
|
err: error,
|
||||||
|
pageId: doc.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterDelete: [
|
||||||
|
async ({ id, req }) => {
|
||||||
|
try {
|
||||||
|
const { deleteProductVector } = await import('../../lib/qdrant');
|
||||||
|
await deleteProductVector(`page_${id}`);
|
||||||
|
req.payload.logger.info(`Deleted page ${id} from Qdrant`);
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error({
|
||||||
|
msg: 'Error deleting page from Qdrant',
|
||||||
|
err: error,
|
||||||
|
pageId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const Posts: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: ({ req: { user } }) => {
|
read: ({ req: { user } }) => {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -45,6 +45,67 @@ export const Posts: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [
|
||||||
|
async ({ doc, req }) => {
|
||||||
|
// Run index sync asynchronously to not block the CMS save operation
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
||||||
|
|
||||||
|
// Check if post is published
|
||||||
|
if (doc._status !== 'published') {
|
||||||
|
await deleteProductVector(`post_${doc.id}`);
|
||||||
|
req.payload.logger.info(`Removed drafted post ${doc.slug} from Qdrant`);
|
||||||
|
} else {
|
||||||
|
// Serialize payload
|
||||||
|
const contentText = [
|
||||||
|
`Blog-Artikel: ${doc.title}`,
|
||||||
|
doc.excerpt ? `Zusammenfassung: ${doc.excerpt}` : '',
|
||||||
|
doc.category ? `Kategorie: ${doc.category}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: 'knowledge',
|
||||||
|
content: contentText,
|
||||||
|
data: {
|
||||||
|
title: doc.title,
|
||||||
|
slug: doc.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await upsertProductVector(`post_${doc.id}`, contentText, payload);
|
||||||
|
req.payload.logger.info(`Upserted post ${doc.slug} to Qdrant`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error({
|
||||||
|
msg: 'Error syncing post to Qdrant',
|
||||||
|
err: error,
|
||||||
|
postId: doc.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterDelete: [
|
||||||
|
async ({ id, req }) => {
|
||||||
|
try {
|
||||||
|
const { deleteProductVector } = await import('../../lib/qdrant');
|
||||||
|
await deleteProductVector(`post_${id}`);
|
||||||
|
req.payload.logger.info(`Deleted post ${id} from Qdrant`);
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error({
|
||||||
|
msg: 'Error deleting post from Qdrant',
|
||||||
|
err: error,
|
||||||
|
postId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const Products: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: ({ req: { user } }) => {
|
read: ({ req: { user } }) => {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -37,6 +37,51 @@ export const Products: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [
|
||||||
|
async ({ doc, req, operation }) => {
|
||||||
|
// Run index sync asynchronously to not block the CMS save operation
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
||||||
|
|
||||||
|
// Check if product is published
|
||||||
|
if (doc._status !== 'published') {
|
||||||
|
await deleteProductVector(doc.id);
|
||||||
|
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
|
||||||
|
} else {
|
||||||
|
// Serialize payload
|
||||||
|
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
|
||||||
|
const payload = {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
sku: doc.sku,
|
||||||
|
slug: doc.slug,
|
||||||
|
description: doc.description,
|
||||||
|
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
|
||||||
|
};
|
||||||
|
await upsertProductVector(doc.id, contentText, payload);
|
||||||
|
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterDelete: [
|
||||||
|
async ({ id, req }) => {
|
||||||
|
try {
|
||||||
|
const { deleteProductVector } = await import('../../lib/qdrant');
|
||||||
|
await deleteProductVector(id as string | number);
|
||||||
|
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
64
src/scripts/ingest-pdf.ts
Normal file
64
src/scripts/ingest-pdf.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
// Override Qdrant URL for local script execution outside docker
|
||||||
|
process.env.QDRANT_URL = process.env.QDRANT_URL || 'http://localhost:6333';
|
||||||
|
|
||||||
|
import { upsertProductVector } from '../lib/qdrant';
|
||||||
|
|
||||||
|
// Ingests the extracted Kabelhandbuch text into Qdrant as distinct knowledge topics.
|
||||||
|
async function ingestPDF(txtPath: string) {
|
||||||
|
if (!fs.existsSync(txtPath)) {
|
||||||
|
console.error(`File not found: ${txtPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(txtPath, 'utf8');
|
||||||
|
|
||||||
|
// Simple sentence/paragraph chunking
|
||||||
|
// We split by standard paragraph breaks (double newline) or large content blocks.
|
||||||
|
const chunks = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter((c) => c.length > 50);
|
||||||
|
|
||||||
|
console.log(`Extracted ${text.length} characters from PDF.`);
|
||||||
|
console.log(`Generated ${chunks.length} chunks for vector ingestion.\n`);
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
// We limit chuck sizes to ensure Openrouter embedding models don't timeout/fail,
|
||||||
|
// stringing multiple paragraphs if they are short, or cutting them if too long.
|
||||||
|
// For baseline, we'll index every chunk individually mapped as 'knowledge' with a unique ID
|
||||||
|
|
||||||
|
const chunkText = chunks[i];
|
||||||
|
|
||||||
|
// Generate a synthetic ID that won't collide with Payload Product IDs
|
||||||
|
// Qdrant strictly requires UUID or unsigned int.
|
||||||
|
const syntheticId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const payloadData = {
|
||||||
|
type: 'knowledge', // Custom flag to differentiate from 'product'
|
||||||
|
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
||||||
|
content: chunkText,
|
||||||
|
source: 'Kabelhandbuch KLZ.pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the existing upsert function since it just embeds the text and stores the payload
|
||||||
|
await upsertProductVector(syntheticId, chunkText, payloadData);
|
||||||
|
console.log(`✅ Upserted chunk ${i + 1}/${chunks.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 PDF Ingestion Complete!');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse PDF:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run mapping
|
||||||
|
const targetTxt = '/Users/marcmintel/Downloads/kabelhandbuch.txt';
|
||||||
|
ingestPDF(targetTxt);
|
||||||
58
src/scripts/upload-pdfs.ts
Normal file
58
src/scripts/upload-pdfs.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
|
async function uploadPDFs() {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const downloadDir = '/Users/marcmintel/Downloads';
|
||||||
|
const files = fs.readdirSync(downloadDir).filter((f) => f.endsWith('.pdf'));
|
||||||
|
|
||||||
|
console.log(`Found ${files.length} PDFs in Downloads folder.`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(downloadDir, file);
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
|
// Check if it already exists
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: 'media',
|
||||||
|
where: {
|
||||||
|
filename: {
|
||||||
|
equals: file,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.docs.length > 0) {
|
||||||
|
console.log(`Skipping ${file} - already exists in CMS`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Uploading ${file}...`);
|
||||||
|
await payload.create({
|
||||||
|
collection: 'media',
|
||||||
|
data: {
|
||||||
|
alt: file,
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
data: fs.readFileSync(filePath),
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
name: file,
|
||||||
|
size: stats.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Uploaded ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to upload ${file}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done uploading PDFs to Payload CMS. Payload hooks have synced them to Qdrant.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadPDFs();
|
||||||
20
test-chat2.mjs
Normal file
20
test-chat2.mjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'user', content: 'Ich will einen Windpark bauen' }
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Sending message:', messages[0].content);
|
||||||
|
|
||||||
|
const res = await fetch('http://localhost:3000/api/ai-search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('\nAI Response:', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
16
test-simple.mjs
Normal file
16
test-simple.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { generateText } from 'ai';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
|
||||||
|
const openrouter = createOpenAI({
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: openrouter('mistralai/mistral-large-2407'),
|
||||||
|
prompt: 'Hello world! Reply in one word.',
|
||||||
|
});
|
||||||
|
console.log('Result:', text);
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
|
||||||
const BASE_URL = process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const BASE_URL =
|
||||||
|
process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
describe('OG Image Generation', () => {
|
describe('OG Image Generation', () => {
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
@@ -18,7 +19,9 @@ describe('OG Image Generation', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`\n⚠️ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`);
|
console.log(
|
||||||
|
`\n⚠️ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isServerUp = false;
|
isServerUp = false;
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ describe('OG Image Generation', () => {
|
|||||||
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||||
expect(bytes[0]).toBe(0x89);
|
expect(bytes[0]).toBe(0x89);
|
||||||
expect(bytes[1]).toBe(0x50);
|
expect(bytes[1]).toBe(0x50);
|
||||||
expect(bytes[2]).toBe(0x4E);
|
expect(bytes[2]).toBe(0x4e);
|
||||||
expect(bytes[3]).toBe(0x47);
|
expect(bytes[3]).toBe(0x47);
|
||||||
|
|
||||||
// Check that the image is not empty and has a reasonable size
|
// Check that the image is not empty and has a reasonable size
|
||||||
@@ -49,7 +52,9 @@ describe('OG Image Generation', () => {
|
|||||||
await verifyImageResponse(response);
|
await verifyImageResponse(response);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({ skip }) => {
|
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({
|
||||||
|
skip,
|
||||||
|
}) => {
|
||||||
if (!isServerUp) skip();
|
if (!isServerUp) skip();
|
||||||
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
|
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -64,11 +69,38 @@ describe('OG Image Generation', () => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate blog OG image', async ({ skip }) => {
|
it('should generate static blog overview OG image', async ({ skip }) => {
|
||||||
if (!isServerUp) skip();
|
if (!isServerUp) skip();
|
||||||
const url = `${BASE_URL}/de/blog/opengraph-image`;
|
const url = `${BASE_URL}/de/blog/opengraph-image`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
await verifyImageResponse(response);
|
await verifyImageResponse(response);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
|
||||||
|
|
||||||
|
it('should generate dynamic blog post OG image with featured photo', async ({ skip }) => {
|
||||||
|
if (!isServerUp) skip();
|
||||||
|
|
||||||
|
// Discover a real blog slug from the sitemap
|
||||||
|
const sitemapRes = await fetch(`${BASE_URL}/sitemap.xml`);
|
||||||
|
const sitemapXml = await sitemapRes.text();
|
||||||
|
const blogMatch = sitemapXml.match(/<loc>[^<]*\/de\/blog\/([^<]+)<\/loc>/);
|
||||||
|
const slug = blogMatch ? blogMatch[1] : null;
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
console.log('⚠️ No blog post found in sitemap, skipping dynamic OG test');
|
||||||
|
skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/de/blog/${slug}/opengraph-image`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
await verifyImageResponse(response);
|
||||||
|
|
||||||
|
// Verify the image is substantially large (>50KB) to confirm it actually
|
||||||
|
// contains the featured photo and isn't just a tiny fallback/text-only image
|
||||||
|
const buffer = await response.clone().arrayBuffer();
|
||||||
|
expect(
|
||||||
|
buffer.byteLength,
|
||||||
|
`OG image for "${slug}" is suspiciously small (${buffer.byteLength} bytes) — likely missing featured photo`,
|
||||||
|
).toBeGreaterThan(50000);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,10 +43,6 @@
|
|||||||
"check:spell": {
|
"check:spell": {
|
||||||
"inputs": ["content/**/*.{md,mdx}", "app/**/*.tsx", "components/**/*.tsx", "cspell.json"],
|
"inputs": ["content/**/*.{md,mdx}", "app/**/*.tsx", "components/**/*.tsx", "cspell.json"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
|
||||||
"check:mdx": {
|
|
||||||
"inputs": ["content/**/*.{md,mdx}", "scripts/validate-mdx.mjs"],
|
|
||||||
"outputs": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user