Compare commits
1 Commits
feature/ai
...
9fadaf07b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fadaf07b5 |
@@ -226,6 +226,21 @@ jobs:
|
||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
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
|
||||
@@ -256,8 +271,8 @@ jobs:
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
@@ -271,11 +286,9 @@ jobs:
|
||||
|
||||
# Search & AI
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY || vars.MISTRAL_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' }}
|
||||
KABELFACHMANN_MCP_URL: ${{ secrets.KABELFACHMANN_MCP_URL || vars.KABELFACHMANN_MCP_URL || 'http://klz-kabelfachmann:3007/sse' }}
|
||||
# Container Registry (standalone)
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||
@@ -339,11 +352,9 @@ jobs:
|
||||
echo ""
|
||||
echo "# Search & AI"
|
||||
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
||||
echo "MISTRAL_API_KEY=$MISTRAL_API_KEY"
|
||||
echo "QDRANT_URL=$QDRANT_URL"
|
||||
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
||||
echo "REDIS_URL=$REDIS_URL"
|
||||
echo "KABELFACHMANN_MCP_URL=$KABELFACHMANN_MCP_URL"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
@@ -393,10 +404,6 @@ jobs:
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
SLUG: ${{ needs.prepare.outputs.slug }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
@@ -422,48 +429,47 @@ jobs:
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||
|
||||
# Branch Seeding Logic (Production -> Branch)
|
||||
if [[ "$TARGET" == "branch" ]]; then
|
||||
echo "🌱 Seeding Branch Environment from Production Database..."
|
||||
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 klz-db"
|
||||
|
||||
# Wait for DB to be healthy with a 60s timeout
|
||||
echo "⏳ Waiting for branch database to be ready..."
|
||||
ssh root@alpha.mintel.me "
|
||||
for i in {1..30}; do
|
||||
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo '❌ Database failed to become ready after 60 seconds'
|
||||
exit 1
|
||||
" || exit 1
|
||||
|
||||
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
|
||||
echo "📦 Syncing Production DB into Branch DB..."
|
||||
ssh root@alpha.mintel.me "
|
||||
set -e -o pipefail
|
||||
docker exec klz-cablescom-klz-db-1 pg_dump -U payload -d payload --clean --if-exists | docker exec -i $DB_CONTAINER psql -U payload -d payload --quiet
|
||||
rsync -a --delete /var/lib/docker/volumes/klz-cablescom_klz_media_data/_data/ /var/lib/docker/volumes/${{ needs.prepare.outputs.project_name }}_klz_media_data/_data/
|
||||
" || exit 1
|
||||
|
||||
echo "✅ Branch database and media synced successfully."
|
||||
fi
|
||||
|
||||
# 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 && 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.
|
||||
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||
echo "⏳ Waiting for database container to be ready..."
|
||||
for i in $(seq 1 15); do
|
||||
if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then
|
||||
echo "✅ Database is ready."
|
||||
break
|
||||
fi
|
||||
echo " Attempt $i/15..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
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_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_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
|
||||
# Run Payload migrations via a temporary container before restarting the app.
|
||||
# This ensures fresh branch deployments (empty DBs) get their schema on first deploy.
|
||||
echo "🔄 Running Payload migrations..."
|
||||
MIGRATOR_IMAGE="registry.infra.mintel.me/mintel/klz-2026:migrate-$IMAGE_TAG"
|
||||
|
||||
ssh root@alpha.mintel.me "
|
||||
echo '${{ steps.auth.outputs.token }}' | docker login registry.infra.mintel.me -u '${{ steps.auth.outputs.user }}' --password-stdin 2>/dev/null || true
|
||||
docker pull $MIGRATOR_IMAGE
|
||||
docker run --rm \
|
||||
--network ${PROJECT_NAME}_default \
|
||||
--env-file $SITE_DIR/$ENV_FILE \
|
||||
$MIGRATOR_IMAGE \
|
||||
&& echo '✅ Migrations complete.' \
|
||||
|| echo '⚠️ Migrations failed or already up-to-date — continuing.'
|
||||
"
|
||||
|
||||
# Restart app to pick up clean migration state
|
||||
APP_CONTAINER="${PROJECT_NAME}-klz-app-1"
|
||||
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||
|
||||
# Generate Excel Datasheets
|
||||
echo "📊 Generating Excel Datasheets on live container..."
|
||||
ssh root@alpha.mintel.me "docker exec $APP_CONTAINER pnpm run excel:datasheets" || echo "⚠️ Excel generation failed (non-blocking)"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
@@ -476,7 +482,7 @@ jobs:
|
||||
post_deploy_checks:
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success' && true
|
||||
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -569,15 +575,6 @@ jobs:
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
- name: 📊 Excel Datasheet Accessibility Check
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: |
|
||||
echo "Checking if datasheets directory is reachable..."
|
||||
# This checks if the /datasheets/ directory returns a valid response (200, 403, or 404 is technically reachable, but we'd prefer 200/403)
|
||||
# Since the files are in public/datasheets/products/, we check that path.
|
||||
curl -I -L -s -o /dev/null -w "%{http_code}" "$TEST_URL/datasheets/products/" | grep -E "200|301|302|403|404"
|
||||
|
||||
- name: 📝 E2E Form Submission Test
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
@@ -632,14 +629,7 @@ jobs:
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
|
||||
MESSAGE="$STATUS_LINE
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
MESSAGE="$STATUS_LINE | Deploy: $DEPLOY | Smoke: $SMOKE | $URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -198,7 +200,7 @@ jobs:
|
||||
notify:
|
||||
name: 🔔 Notify
|
||||
needs: [static, a11y, lighthouse, links]
|
||||
if: failure()
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -225,11 +227,6 @@ jobs:
|
||||
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -29,8 +29,4 @@ reference/
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
.env
|
||||
|
||||
# Payload CMS auto-generated
|
||||
# Knowledge base source files
|
||||
kabelhandbuch.txt
|
||||
.env
|
||||
@@ -18,7 +18,6 @@ ENV CI=true
|
||||
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
COPY patches* ./patches/
|
||||
|
||||
# Configure private registry and install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
@@ -38,11 +37,6 @@ FROM base AS development
|
||||
ENV NODE_ENV=development
|
||||
CMD ["pnpm", "dev:local"]
|
||||
|
||||
# Stage: Migrator
|
||||
FROM base AS migrator
|
||||
ENV NODE_ENV=production
|
||||
CMD ["pnpm", "cms:migrate"]
|
||||
|
||||
# Build application
|
||||
# Stage 3: Builder (Production)
|
||||
FROM base AS builder
|
||||
@@ -54,7 +48,6 @@ ENV RAYON_NUM_THREADS=3
|
||||
ENV UV_THREADPOOL_SIZE=3
|
||||
|
||||
RUN pnpm build
|
||||
# Excel generation moved to post-deploy
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
@@ -71,4 +64,3 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
|
||||
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
|
||||
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
|
||||
|
||||
export const importMap = {
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'/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,
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { notFound, redirect } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
@@ -14,7 +14,7 @@ interface BlogIndexProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: BlogIndexProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Container, Heading, Section } from '@/components/ui';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } from 'react';
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||
|
||||
@@ -7,8 +7,10 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
@@ -59,7 +61,6 @@ export const viewport: Viewport = {
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -76,7 +77,7 @@ export default async function Layout(props: {
|
||||
let messages: Record<string, any> = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
messages = {};
|
||||
}
|
||||
|
||||
@@ -90,7 +91,6 @@ export default async function Layout(props: {
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
@@ -160,8 +160,6 @@ export default async function Layout(props: {
|
||||
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
|
||||
<AutoBrochureModal />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default async function NotFound() {
|
||||
}
|
||||
suggestedUrl = '/' + pathParts.join('/');
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore Payload errors in 404
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
@@ -277,7 +278,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||
const excelPath = getExcelDatasheetPath(productSlug, locale);
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||
@@ -343,7 +343,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
productName={product.frontmatter.title}
|
||||
productImage={product.frontmatter.images?.[0]}
|
||||
datasheetPath={datasheetPath}
|
||||
excelPath={excelPath}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -497,15 +496,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap items-center gap-4 max-w-2xl">
|
||||
<DatasheetDownload
|
||||
datasheetPath={datasheetPath}
|
||||
className="mt-0 w-full sm:w-auto"
|
||||
/>
|
||||
{excelPath && (
|
||||
<ExcelDownload excelPath={excelPath} className="mt-0 w-full sm:w-auto" />
|
||||
)}
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Section, Container, Heading, Badge } from '@/components/ui';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import Image from 'next/image';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
_email: email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -25,14 +25,6 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
@@ -62,7 +54,6 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
productName: productName || undefined,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
logger.info('Successfully saved form submission to Payload CMS', {
|
||||
|
||||
@@ -3,11 +3,6 @@ import { searchProducts } from '../../../src/lib/qdrant';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import { z } from 'zod';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
// @ts-expect-error - Local version of @mintel/payload-ai/tools/mcpAdapter might not have types published yet
|
||||
import { createMcpTools } from '@mintel/payload-ai/tools/mcpAdapter';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60; // Max allowed duration (Vercel)
|
||||
|
||||
@@ -113,9 +108,12 @@ Das ECHTE KLZ Team:
|
||||
.map((p: any) => p.payload?.content)
|
||||
.join('\n\n');
|
||||
|
||||
if (productDescriptions) {
|
||||
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}`;
|
||||
}
|
||||
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)
|
||||
@@ -147,14 +145,12 @@ DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
|
||||
- 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."
|
||||
- Wenn technisches Wissen aus dem Kabelhandbuch benötigt wird, NUTZE UNBEDINGT eines der "kabelfachmann_*" Tools, anstatt zu raten oder zu behaupten du wüsstest es nicht! Das Tool weiss alles.
|
||||
|
||||
VORGEHEN:
|
||||
1. Prüfe den KONTEXT auf passende Katalog-Kabel für das Kundenprojekt.
|
||||
2. Wenn du tiefgehendes Wissen zu einem Kabeltyp brauchst (z.B. Biegeradius, Normen, Querschnitte), rufe das Kabelfachmann-Tool auf.
|
||||
3. Nenne direkt 1-2 passende Produktserien aus dem Kontext oder der Tool-Abfrage, die für diesen Fall Sinn machen.
|
||||
4. 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?).
|
||||
5. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
|
||||
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.
|
||||
@@ -165,42 +161,51 @@ ${contextStr || 'Kein Katalogkontext verfügbar.'}
|
||||
${teamContextStr}
|
||||
`;
|
||||
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openrouterApiKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
const mistralKey = process.env.MISTRAL_API_KEY;
|
||||
if (!mistralKey) {
|
||||
throw new Error('MISTRAL_API_KEY is not set');
|
||||
}
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: openrouterApiKey,
|
||||
// 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),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
let mcpTools: Record<string, any> = {};
|
||||
const mcpUrl = process.env.KABELFACHMANN_MCP_URL || 'http://host.docker.internal:3007/sse';
|
||||
try {
|
||||
const { tools } = await createMcpTools({
|
||||
name: 'kabelfachmann',
|
||||
url: mcpUrl,
|
||||
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' },
|
||||
});
|
||||
mcpTools = tools;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load MCP tools', e);
|
||||
Sentry.captureException(e, { tags: { context: 'ai-search-mcp' } });
|
||||
|
||||
// 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 { text } = await generateText({
|
||||
model: openrouter('openai/gpt-4o-mini'),
|
||||
system: systemPrompt,
|
||||
messages: cappedMessages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
})),
|
||||
tools: mcpTools,
|
||||
// @ts-expect-error - maxSteps might be missing in some versions of generateText types
|
||||
maxSteps: 5,
|
||||
temperature: 0.3,
|
||||
maxTokens: MAX_RESPONSE_TOKENS,
|
||||
});
|
||||
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({
|
||||
|
||||
@@ -16,14 +16,6 @@ export async function GET() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
checks.init = 'ok';
|
||||
|
||||
// Ensure migrations are applied on startup (reliable for standalone builds)
|
||||
try {
|
||||
await payload.db.migrate();
|
||||
} catch (e: any) {
|
||||
console.error('Migration failed:', e.message);
|
||||
// We continue to check the collections even if migration fails
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { PDFPage } from '@/lib/pdf-page';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Get Payload App
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Fetch the page
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (pages.totalDocs === 0) {
|
||||
return new NextResponse('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const page = pages.docs[0];
|
||||
|
||||
// Determine locale from searchParams or default to 'de'
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||
|
||||
// Render the React-PDF document into a stream
|
||||
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||
|
||||
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
(stream as any).destroy?.();
|
||||
},
|
||||
});
|
||||
|
||||
const filename = `${slug}.pdf`;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
// Cache control if needed, skip for now.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -106,47 +106,8 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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`,
|
||||
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
export default function AutoBrochureModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already seen or interacted with the modal
|
||||
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||
|
||||
if (!hasSeenModal) {
|
||||
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
// Mark as seen so it doesn't bother them again on next page load
|
||||
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BrochureCTA — Shows a button that opens a modal asking for an email address.
|
||||
* The full-catalog PDF is ONLY revealed after email submission.
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||
PDF Katalog
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}
|
||||
>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Close on escape + lock scroll + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Auto-focus input when opened
|
||||
const firstInput = document.getElementById('brochure-email');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 50);
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
|
||||
if (e.key === 'Tab' && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
if (focusable.length > 0) {
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
last.focus();
|
||||
e.preventDefault();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
first.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Strict overflow lock on mobile as well
|
||||
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success) {
|
||||
setState('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
|
||||
>
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
@@ -138,20 +138,7 @@ export default function ContactForm() {
|
||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||
{t('form.title')}
|
||||
</Heading>
|
||||
<form
|
||||
id="contact-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"
|
||||
>
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||
<Input
|
||||
|
||||
@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface ExcelDownloadProps {
|
||||
excelPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={excelPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: excelPath.split('/').pop(),
|
||||
file_path: excelPath,
|
||||
file_type: 'excel',
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{/* Spreadsheet/Table Icon */}
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||
Excel Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||
{t('downloadExcel')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadExcelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import FooterBrochureForm from './FooterBrochureForm';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
@@ -245,10 +244,6 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-12 md:mb-16">
|
||||
<FooterBrochureForm />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="flex gap-8">
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FooterBrochureForm({ className }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success) {
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'footer_inline',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'success') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-bold mb-1">
|
||||
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">
|
||||
{locale === 'de'
|
||||
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
|
||||
: 'We have just sent the catalog to your email.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 max-w-xl">
|
||||
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
|
||||
{t('ctaTitle')}
|
||||
</h4>
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
|
||||
>
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
className={cn(
|
||||
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
|
||||
phase === 'loading'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{phase === 'error' && err && (
|
||||
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,6 @@ export default function Header() {
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus trap logic
|
||||
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||
@@ -84,8 +83,7 @@ export default function Header() {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (photoParam !== null) {
|
||||
const index = parseInt(photoParam, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
setCurrentIndex(index);
|
||||
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}
|
||||
}, [searchParams, images.length]);
|
||||
@@ -125,17 +125,13 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
const originalBodyStyle = window.getComputedStyle(document.body).overflow;
|
||||
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow;
|
||||
|
||||
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.overflow = originalHtmlStyle;
|
||||
document.body.style.overflow = originalBodyStyle;
|
||||
document.body.style.overflow = originalStyle;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||
@@ -143,7 +139,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}>
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function ObfuscatedEmail({ email, className = '', children }: Obf
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ export default function ObfuscatedPhone({ phone, className = '', children }: Obf
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,7 +37,6 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
||||
import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||
|
||||
/**
|
||||
* Splits a text string on \n and intersperses <br /> elements.
|
||||
@@ -430,12 +429,6 @@ const jsxConverters: JSXConverters = {
|
||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||
</ProductTabs>
|
||||
),
|
||||
pdfDownload: ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
'block-pdfDownload': ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
// ─── New Page Blocks ───────────────────────────────────────────
|
||||
heroSection: ({ node }: any) => {
|
||||
const f = node.fields;
|
||||
@@ -793,8 +786,8 @@ const jsxConverters: JSXConverters = {
|
||||
</Section>
|
||||
);
|
||||
},
|
||||
imageGallery: () => <Gallery />,
|
||||
'block-imageGallery': () => <Gallery />,
|
||||
imageGallery: ({ node }: any) => <Gallery />,
|
||||
'block-imageGallery': ({ node }: any) => <Gallery />,
|
||||
categoryGrid: ({ node }: any) => {
|
||||
const cats = node.fields.categories || [];
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,6 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -12,7 +11,6 @@ interface ProductSidebarProps {
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
datasheetPath?: string | null;
|
||||
excelPath?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +18,6 @@ export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
excelPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
@@ -73,9 +70,6 @@ export default function ProductSidebar({
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
|
||||
{/* Excel Download – right below datasheet */}
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface KeyValueItem {
|
||||
label: string;
|
||||
@@ -46,40 +45,22 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 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) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<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">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<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) => (
|
||||
<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">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -164,17 +164,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="quote-request-form" onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
<div className="space-y-2 !mt-0">
|
||||
<div className="space-y-1 !mt-0">
|
||||
<label htmlFor={emailId} className="sr-only">
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function TrackedLink({
|
||||
}: TrackedLinkProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
try {
|
||||
trackEvent(eventName, {
|
||||
href,
|
||||
...eventProperties,
|
||||
});
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
||||
}
|
||||
if (onClick) onClick();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface TechnicalGridItem {
|
||||
label: string;
|
||||
@@ -18,44 +18,25 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||
<span className="relative inline-block">
|
||||
{title}
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((item, index) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr KLZ Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your KLZ Cable Catalog',
|
||||
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from klz-cables.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#000d26',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #4da612',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#4da612',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
@@ -75,7 +75,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
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(
|
||||
['en', 'de'].includes(locale) ? locale : 'de',
|
||||
locale?.length === 2 ? locale : 'de',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -112,20 +112,9 @@ services:
|
||||
- klz_qdrant_data:/qdrant/storage
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "16333:6333"
|
||||
|
||||
klz-kabelfachmann:
|
||||
image: registry.infra.mintel.me/mintel/kabelfachmann-mcp:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
QDRANT_URL: http://klz-qdrant:6333
|
||||
depends_on:
|
||||
- klz-qdrant
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
||||
volumes:
|
||||
- klz_media_data:/app/public/media
|
||||
- klz_datasheets:/app/public/datasheets
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
@@ -120,18 +119,6 @@ services:
|
||||
networks:
|
||||
- default
|
||||
|
||||
klz-kabelfachmann:
|
||||
image: registry.infra.mintel.me/mintel/kabelfachmann-mcp:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
QDRANT_URL: http://klz-qdrant:6333
|
||||
depends_on:
|
||||
- klz-qdrant
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||
@@ -145,5 +132,3 @@ volumes:
|
||||
external: false
|
||||
klz_qdrant_data:
|
||||
external: false
|
||||
klz_datasheets:
|
||||
external: false
|
||||
|
||||
@@ -23,9 +23,7 @@ export default [
|
||||
"tests/**",
|
||||
"next-env.d.ts",
|
||||
"reference/**",
|
||||
"data/**",
|
||||
"remotion/**",
|
||||
"components/record-mode/**"
|
||||
"data/**"
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
19157
kabelhandbuch.txt
19157
kabelhandbuch.txt
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import path from 'path';
|
||||
*/
|
||||
export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,21 +16,16 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
|
||||
// Subdirectories to search in
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
// List of patterns to try for the current locale
|
||||
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
|
||||
const patterns = [
|
||||
`${slug}-${locale}.pdf`,
|
||||
`${slug}-2-${locale}.pdf`,
|
||||
`${slug}-3-${locale}.pdf`,
|
||||
`${slug}-mv-${locale}.pdf`,
|
||||
`${slug}-hv-${locale}.pdf`,
|
||||
`${normalizedSlug}-${locale}.pdf`,
|
||||
`${normalizedSlug}-2-${locale}.pdf`,
|
||||
`${normalizedSlug}-3-${locale}.pdf`,
|
||||
`${normalizedSlug}-mv-${locale}.pdf`,
|
||||
`${normalizedSlug}-hv-${locale}.pdf`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
@@ -49,70 +44,9 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
`${slug}-en.pdf`,
|
||||
`${slug}-2-en.pdf`,
|
||||
`${slug}-3-en.pdf`,
|
||||
`${slug}-mv-en.pdf`,
|
||||
`${slug}-hv-en.pdf`,
|
||||
`${normalizedSlug}-en.pdf`,
|
||||
`${normalizedSlug}-2-en.pdf`,
|
||||
`${normalizedSlug}-3-en.pdf`,
|
||||
`${normalizedSlug}-mv-en.pdf`,
|
||||
`${normalizedSlug}-hv-en.pdf`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the datasheet Excel path for a given product slug and locale.
|
||||
* Checks public/datasheets for matching .xlsx files.
|
||||
*/
|
||||
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
const patterns = [
|
||||
`${slug}-${locale}.xlsx`,
|
||||
`${slug}-2-${locale}.xlsx`,
|
||||
`${slug}-3-${locale}.xlsx`,
|
||||
`${normalizedSlug}-${locale}.xlsx`,
|
||||
`${normalizedSlug}-2-${locale}.xlsx`,
|
||||
`${normalizedSlug}-3-${locale}.xlsx`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of patterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enPatterns = [
|
||||
`${slug}-en.xlsx`,
|
||||
`${slug}-2-en.xlsx`,
|
||||
`${slug}-3-en.xlsx`,
|
||||
`${normalizedSlug}-en.xlsx`,
|
||||
`${normalizedSlug}-2-en.xlsx`,
|
||||
`${normalizedSlug}-3-en.xlsx`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
|
||||
1436
lib/pdf-brochure.tsx
1436
lib/pdf-brochure.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,220 +1,287 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
import type { DatasheetVoltageTable, KeyValueItem } from '../scripts/pdf/model/types';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// Standard built-in fonts are used.
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 56;
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingHorizontal: MARGIN,
|
||||
paddingBottom: 80,
|
||||
paddingTop: 40,
|
||||
color: '#111827', // Text Primary
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: 0,
|
||||
paddingBottom: 100,
|
||||
fontFamily: 'Helvetica',
|
||||
backgroundColor: C.white,
|
||||
color: C.gray900,
|
||||
},
|
||||
hero: { paddingBottom: 20, marginBottom: 10 },
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: 72,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 22,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 2,
|
||||
color: '#000d26',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.green,
|
||||
color: '#001a4d',
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
productRow: { flexDirection: 'row', alignItems: 'center', gap: 20 },
|
||||
productInfoCol: { flex: 1, justifyContent: 'center' },
|
||||
|
||||
productRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
productInfoCol: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
productImageCol: {
|
||||
flex: 1,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: C.gray200,
|
||||
backgroundColor: C.white,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
// Product Hero Info
|
||||
productHero: {
|
||||
marginTop: 0,
|
||||
},
|
||||
|
||||
productName: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
color: '#000d26',
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
|
||||
productMeta: {
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
color: '#4b5563',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 2,
|
||||
},
|
||||
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
|
||||
noImage: { fontSize: 8, color: C.gray400, textAlign: 'center' },
|
||||
|
||||
content: {},
|
||||
section: { marginBottom: 20 },
|
||||
sectionTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: C.green,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.5,
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
|
||||
noImage: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: 72,
|
||||
},
|
||||
|
||||
// Content sections
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: '#000d26', // Primary Dark
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
|
||||
sectionAccent: {
|
||||
width: 30,
|
||||
height: 2,
|
||||
backgroundColor: C.green,
|
||||
height: 3,
|
||||
backgroundColor: '#82ed20', // Accent Green
|
||||
marginBottom: 8,
|
||||
borderRadius: 1,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
description: { fontSize: 10, lineHeight: 1.7, color: C.gray600 },
|
||||
|
||||
categories: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
categoryTag: {
|
||||
backgroundColor: C.offWhite,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 0.5,
|
||||
borderColor: C.gray200,
|
||||
borderRadius: 3,
|
||||
description: {
|
||||
fontSize: 11,
|
||||
lineHeight: 1.7,
|
||||
color: '#4b5563', // Text Secondary
|
||||
},
|
||||
|
||||
// Technical data table
|
||||
specsTable: {
|
||||
marginTop: 8,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
specsTableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
specsTableRowLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
|
||||
specsTableLabelCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
specsTableValueCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
|
||||
specsTableLabelText: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
specsTableValueText: {
|
||||
fontSize: 10,
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
// Categories
|
||||
categories: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
categoryTag: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 100,
|
||||
},
|
||||
|
||||
categoryText: {
|
||||
fontSize: 7,
|
||||
color: C.gray600,
|
||||
fontSize: 8,
|
||||
color: '#4b5563',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 28,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
bottom: 40,
|
||||
left: 72,
|
||||
right: 72,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: C.green,
|
||||
paddingTop: 24,
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 400,
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 9,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
color: '#000d26',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.5,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
kvGrid: { width: '100%', borderWidth: 1, borderColor: C.gray200 },
|
||||
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: C.gray200 },
|
||||
kvRowAlt: { backgroundColor: C.offWhite },
|
||||
kvRowLast: { borderBottomWidth: 0 },
|
||||
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
|
||||
kvMidDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
|
||||
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: C.gray600 },
|
||||
kvValueText: { fontSize: 9.5, color: C.gray900 },
|
||||
|
||||
tableWrap: { width: '100%', borderWidth: 1, borderColor: C.gray200, marginBottom: 14 },
|
||||
tableHeader: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: C.white,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.gray200,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 4,
|
||||
fontSize: 6.6,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
},
|
||||
tableHeaderCellCfg: { paddingHorizontal: 6 },
|
||||
tableHeaderCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
|
||||
tableRow: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.gray200,
|
||||
},
|
||||
tableRowAlt: { backgroundColor: C.offWhite },
|
||||
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: C.gray900 },
|
||||
tableCellCfg: { paddingHorizontal: 6 },
|
||||
tableCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
|
||||
});
|
||||
|
||||
interface ProductData {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
categoriesLine?: string;
|
||||
descriptionText?: string;
|
||||
heroSrc?: string | null;
|
||||
productUrl?: string;
|
||||
shortDescriptionHtml?: string;
|
||||
descriptionHtml?: string;
|
||||
shortDescriptionHtml: string;
|
||||
descriptionHtml: string;
|
||||
applicationHtml?: string;
|
||||
images?: string[];
|
||||
featuredImage?: string | null;
|
||||
logoDataUrl?: string | null;
|
||||
categories?: Array<{ name: string }>;
|
||||
attributes?: Array<{ name: string; options: string[] }>;
|
||||
images: string[];
|
||||
featuredImage: string | null;
|
||||
sku: string;
|
||||
categories: Array<{ name: string }>;
|
||||
attributes: Array<{
|
||||
name: string;
|
||||
options: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PDFDatasheetProps {
|
||||
interface PDFDatasheetProps {
|
||||
product: ProductData;
|
||||
locale: 'en' | 'de';
|
||||
logoDataUrl?: string | null;
|
||||
technicalItems?: KeyValueItem[];
|
||||
voltageTables?: DatasheetVoltageTable[];
|
||||
legendItems?: KeyValueItem[];
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '');
|
||||
// Helper to strip HTML tags
|
||||
const stripHtml = (html: string): string => {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
};
|
||||
|
||||
const getLabels = (locale: 'en' | 'de') =>
|
||||
({
|
||||
// Helper to get translated labels
|
||||
const getLabels = (locale: 'en' | 'de') => {
|
||||
const labels = {
|
||||
en: {
|
||||
productDatasheet: 'Technical Datasheet',
|
||||
description: 'APPLICATION',
|
||||
@@ -222,9 +289,6 @@ const getLabels = (locale: 'en' | 'de') =>
|
||||
categories: 'CATEGORIES',
|
||||
sku: 'SKU',
|
||||
noImage: 'No image available',
|
||||
crossSection: 'Configurations',
|
||||
slug_cs: 'Cores & CS',
|
||||
abbreviations: 'ABBREVIATIONS',
|
||||
},
|
||||
de: {
|
||||
productDatasheet: 'Technisches Datenblatt',
|
||||
@@ -233,283 +297,52 @@ const getLabels = (locale: 'en' | 'de') =>
|
||||
categories: 'KATEGORIEN',
|
||||
sku: 'ARTIKELNUMMER',
|
||||
noImage: 'Kein Bild verfügbar',
|
||||
crossSection: 'Konfigurationen',
|
||||
slug_cs: 'Adern & QS',
|
||||
abbreviations: 'ABKÜRZUNGEN',
|
||||
},
|
||||
})[locale];
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
function normTextForMeasure(v: unknown) {
|
||||
return String(v ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
function textLen(v: unknown) {
|
||||
return normTextForMeasure(v).length;
|
||||
}
|
||||
|
||||
function distributeWithMinMax(
|
||||
weights: number[],
|
||||
total: number,
|
||||
minEach: number,
|
||||
maxEach: number,
|
||||
): number[] {
|
||||
const n = weights.length;
|
||||
if (!n) return [];
|
||||
const mins = Array.from({ length: n }, () => minEach);
|
||||
const maxs = Array.from({ length: n }, () => maxEach);
|
||||
const minSum = mins.reduce((a, b) => a + b, 0);
|
||||
if (minSum > total) return mins.map((m) => m * (total / minSum));
|
||||
|
||||
const result = mins.slice();
|
||||
let remaining = total - minSum;
|
||||
let remainingIdx = Array.from({ length: n }, (_, i) => i);
|
||||
|
||||
while (remaining > 1e-9 && remainingIdx.length) {
|
||||
const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
|
||||
if (wSum <= 1e-9) {
|
||||
const even = remaining / remainingIdx.length;
|
||||
for (const i of remainingIdx) result[i] += even;
|
||||
remaining = 0;
|
||||
break;
|
||||
}
|
||||
const nextIdx: number[] = [];
|
||||
for (const i of remainingIdx) {
|
||||
const w = Math.max(0, weights[i] || 0);
|
||||
const add = (w / wSum) * remaining;
|
||||
const capped = Math.min(result[i] + add, maxs[i]);
|
||||
const used = capped - result[i];
|
||||
result[i] = capped;
|
||||
remaining -= used;
|
||||
if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i);
|
||||
}
|
||||
remainingIdx = nextIdx;
|
||||
}
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
const drift = total - sum;
|
||||
if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift;
|
||||
return result;
|
||||
}
|
||||
|
||||
function KeyValueGrid({ items }: { items: KeyValueItem[] }) {
|
||||
const filtered = (items || []).filter((i) => i.label && i.value);
|
||||
if (!filtered.length) return null;
|
||||
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
|
||||
for (let i = 0; i < filtered.length; i += 2) rows.push([filtered[i], filtered[i + 1] || null]);
|
||||
|
||||
return (
|
||||
<View style={styles.kvGrid}>
|
||||
{rows.map(([left, right], rowIndex) => {
|
||||
const isLast = rowIndex === rows.length - 1;
|
||||
const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value;
|
||||
const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : '';
|
||||
return (
|
||||
<View
|
||||
key={`${left.label}-${rowIndex}`}
|
||||
style={[
|
||||
styles.kvRow,
|
||||
rowIndex % 2 === 0 ? styles.kvRowAlt : null,
|
||||
isLast ? styles.kvRowLast : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
<View style={[styles.kvCell, { width: '23%' }]}>
|
||||
<Text style={styles.kvLabelText}>{left.label}</Text>
|
||||
</View>
|
||||
<View style={[styles.kvCell, styles.kvMidDivider, { width: '27%' }]}>
|
||||
<Text style={styles.kvValueText}>{leftValue}</Text>
|
||||
</View>
|
||||
<View style={[styles.kvCell, { width: '23%' }]}>
|
||||
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
|
||||
</View>
|
||||
<View style={[styles.kvCell, { width: '27%' }]}>
|
||||
<Text style={styles.kvValueText}>{rightValue}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DenseTable({
|
||||
table,
|
||||
firstColLabel,
|
||||
}: {
|
||||
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
|
||||
firstColLabel: string;
|
||||
}) {
|
||||
const cols = table.columns;
|
||||
const rows = table.rows;
|
||||
const headerText = (label: string) =>
|
||||
String(label || '')
|
||||
.replace(/\s+/g, '\u00A0')
|
||||
.trim();
|
||||
|
||||
const cfgMin = 0.14,
|
||||
cfgMax = 0.23;
|
||||
const cfgContentLen = Math.max(
|
||||
textLen(firstColLabel),
|
||||
...rows.map((r) => textLen(r.configuration)),
|
||||
8,
|
||||
);
|
||||
const dataContentLens = cols.map((c, ci) => {
|
||||
const headerL = textLen(c.label);
|
||||
let cellMax = 0;
|
||||
for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci]));
|
||||
return Math.max(headerL * 1.15, cellMax, 3);
|
||||
});
|
||||
|
||||
const cfgWeight = cfgContentLen * 1.05;
|
||||
const dataWeights = dataContentLens.map((l) => l);
|
||||
const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0);
|
||||
const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28;
|
||||
let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax);
|
||||
|
||||
const minDataPct =
|
||||
cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
|
||||
const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
|
||||
if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
|
||||
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
|
||||
|
||||
const dataTotal = Math.max(0, 1 - cfgPct);
|
||||
const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55));
|
||||
const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct);
|
||||
|
||||
const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
|
||||
const dataWs = dataPcts.map((p, idx) => {
|
||||
if (idx === dataPcts.length - 1) {
|
||||
const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0);
|
||||
const remainder = Math.max(0, dataTotal - used);
|
||||
return `${(remainder * 100).toFixed(4)}%`;
|
||||
}
|
||||
return `${(p * 100).toFixed(4)}%`;
|
||||
});
|
||||
|
||||
const headerFontSize =
|
||||
cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6;
|
||||
|
||||
return (
|
||||
<View style={styles.tableWrap} break={false}>
|
||||
<View style={styles.tableHeader} wrap={false}>
|
||||
<View style={{ width: cfgW }}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableHeaderCell,
|
||||
styles.tableHeaderCellCfg,
|
||||
{ fontSize: headerFontSize, paddingHorizontal: 3 },
|
||||
cols.length ? styles.tableHeaderCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{headerText(firstColLabel)}
|
||||
</Text>
|
||||
</View>
|
||||
{cols.map((c, idx) => {
|
||||
const isLast = idx === cols.length - 1;
|
||||
return (
|
||||
<View key={c.key} style={{ width: dataWs[idx] }}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableHeaderCell,
|
||||
{ fontSize: headerFontSize, paddingHorizontal: 3 },
|
||||
!isLast ? styles.tableHeaderCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{headerText(c.label)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{rows.map((r, ri) => (
|
||||
<View
|
||||
key={`${r.configuration}-${ri}`}
|
||||
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
|
||||
wrap={false}
|
||||
minPresenceAhead={16}
|
||||
>
|
||||
<View style={{ width: cfgW }} wrap={false}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableCell,
|
||||
styles.tableCellCfg,
|
||||
{ fontSize: 6.2, paddingHorizontal: 3 },
|
||||
cols.length ? styles.tableCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{r.configuration}
|
||||
</Text>
|
||||
</View>
|
||||
{r.cells.map((cell, ci) => (
|
||||
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataWs[ci] }} wrap={false}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableCell,
|
||||
ci !== r.cells.length - 1 ? styles.tableCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{cell}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
return labels[locale];
|
||||
};
|
||||
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
technicalItems = [],
|
||||
voltageTables = [],
|
||||
legendItems = [],
|
||||
}) => {
|
||||
const labels = getLabels(locale);
|
||||
const description = stripHtml(
|
||||
product.applicationHtml ||
|
||||
product.shortDescriptionHtml ||
|
||||
product.descriptionHtml ||
|
||||
product.descriptionText ||
|
||||
'',
|
||||
);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Hero Header */}
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.header}>
|
||||
<View style={{ width: 80 }}>
|
||||
{product.logoDataUrl || (product as any).logoDataUrl ? (
|
||||
<Image
|
||||
src={product.logoDataUrl || (product as any).logoDataUrl}
|
||||
style={{ width: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
)}
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
<View style={styles.productInfoCol}>
|
||||
<Text style={styles.productMeta}>
|
||||
{product.categoriesLine ||
|
||||
(product.categories || []).map((c) => c.name).join(' • ')}
|
||||
</Text>
|
||||
<Text style={styles.productName}>{product.name}</Text>
|
||||
<View style={styles.productHero}>
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<Text key={index} style={styles.productMeta}>
|
||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.productName}>{product.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||
<Image
|
||||
src={product.featuredImage}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -517,93 +350,65 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{description && (
|
||||
{/* Description section */}
|
||||
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{technicalItems.length > 0 && (
|
||||
{/* Technical specifications */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<KeyValueGrid items={technicalItems} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{voltageTables.map((table, idx) => (
|
||||
<View key={idx} style={styles.section} break={false}>
|
||||
<Text
|
||||
style={styles.sectionTitle}
|
||||
>{`${labels.crossSection} — ${table.voltageLabel}`}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<DenseTable table={table} firstColLabel={labels.slug_cs} />
|
||||
</View>
|
||||
))}
|
||||
|
||||
{legendItems.length > 0 && (
|
||||
<View style={styles.section} break={false}>
|
||||
<Text style={styles.sectionTitle}>{labels.abbreviations}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<KeyValueGrid items={legendItems} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!technicalItems.length &&
|
||||
!voltageTables.length &&
|
||||
product.attributes &&
|
||||
product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={{ borderWidth: 1, borderColor: C.gray200 }}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: index === product.attributes!.length - 1 ? 0 : 1,
|
||||
borderBottomColor: C.gray200,
|
||||
backgroundColor: index % 2 === 0 ? C.offWhite : C.white,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 6,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: C.gray200,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 8.5, fontWeight: 700, color: C.gray600 }}>
|
||||
{attr.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1, padding: 6 }}>
|
||||
<Text style={{ fontSize: 9.5, color: C.gray900 }}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.specsTable}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Categories as clean tags */}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.categories}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryTag}>
|
||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<View style={{ width: 60 }}>
|
||||
{product.logoDataUrl || (product as any).logoDataUrl ? (
|
||||
<Image
|
||||
src={product.logoDataUrl || (product as any).logoDataUrl}
|
||||
style={{ width: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>
|
||||
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
|
||||
329
lib/pdf-page.tsx
329
lib/pdf-page.tsx
@@ -1,329 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
||||
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 56;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 80,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: C.green,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
pageTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginBottom: 8,
|
||||
marginTop: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
|
||||
accentBar: {
|
||||
width: 30,
|
||||
height: 2,
|
||||
backgroundColor: C.green,
|
||||
marginBottom: 20,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
// Lexical Elements
|
||||
paragraph: {
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 20,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
list: {
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 4,
|
||||
},
|
||||
listItemBullet: {
|
||||
width: 12,
|
||||
fontSize: 10,
|
||||
color: C.green,
|
||||
fontWeight: 700,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
},
|
||||
link: {
|
||||
color: C.green,
|
||||
textDecoration: 'none',
|
||||
},
|
||||
textBold: {
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: C.navyDeep,
|
||||
},
|
||||
textItalic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Footer — matches brochure style
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 28,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: C.green,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
||||
|
||||
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'text': {
|
||||
const format = node.format || 0;
|
||||
const isBold = (format & 1) !== 0;
|
||||
const isItalic = (format & 2) !== 0;
|
||||
|
||||
let elementStyle: any = {};
|
||||
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
||||
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
||||
|
||||
return (
|
||||
<Text key={idx} style={elementStyle}>
|
||||
{node.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'heading': {
|
||||
let hStyle = styles.heading3;
|
||||
if (node.tag === 'h1') hStyle = styles.heading1;
|
||||
if (node.tag === 'h2') hStyle = styles.heading2;
|
||||
|
||||
return (
|
||||
<Text key={idx} style={hStyle}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
return (
|
||||
<View key={idx} style={styles.list}>
|
||||
{node.children?.map((child: any, i: number) => {
|
||||
if (child.type === 'listitem') {
|
||||
return (
|
||||
<View key={i} style={styles.listItem}>
|
||||
<Text style={styles.listItemBullet}>
|
||||
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
||||
</Text>
|
||||
<Text style={styles.listItemContent}>
|
||||
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return renderLexicalNode(child, i);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
const href = node.fields?.url || node.url || '#';
|
||||
return (
|
||||
<Link key={idx} src={href} style={styles.link}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
case 'linebreak': {
|
||||
return <Text key={idx}>{'\n'}</Text>;
|
||||
}
|
||||
|
||||
// Ignore payload blocks recursively to avoid crashing
|
||||
case 'block':
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (node.children) {
|
||||
return (
|
||||
<Text key={idx}>
|
||||
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Hero Header */}
|
||||
<View style={styles.hero} fixed>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||
<View style={styles.accentBar} />
|
||||
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>{dateStr}</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Utility for formatting technical data values.
|
||||
* Handles long lists of standards and simplifies repetitive strings.
|
||||
*/
|
||||
|
||||
export interface FormattedTechnicalValue {
|
||||
original: string;
|
||||
isList: boolean;
|
||||
parts: string[];
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a technical value string.
|
||||
* Detects if it's a list (separated by / or ,) and tries to clean it up.
|
||||
*/
|
||||
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
|
||||
if (!value) {
|
||||
return { original: '', isList: false, parts: [], displayValue: '' };
|
||||
}
|
||||
|
||||
const str = String(value).trim();
|
||||
|
||||
// Detect list separators
|
||||
let parts: string[] = [];
|
||||
if (str.includes(' / ')) {
|
||||
parts = str.split(' / ').map((p) => p.trim());
|
||||
} else if (str.includes(' /')) {
|
||||
parts = str.split(' /').map((p) => p.trim());
|
||||
} else if (str.includes('/ ')) {
|
||||
parts = str.split('/ ').map((p) => p.trim());
|
||||
} else if (str.split('/').length > 2) {
|
||||
// Check if it's actually many standards separated by / without spaces
|
||||
// e.g. EN123/EN456/EN789
|
||||
const split = str.split('/');
|
||||
if (split.length > 3) {
|
||||
parts = split.map((p) => p.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// If no parts found yet, try comma
|
||||
if (parts.length === 0 && str.includes(', ')) {
|
||||
parts = str.split(', ').map((p) => p.trim());
|
||||
}
|
||||
|
||||
// Filter out empty parts
|
||||
parts = parts.filter(Boolean);
|
||||
|
||||
// If we have parts, let's see if we can simplify them
|
||||
if (parts.length > 2) {
|
||||
// Find common prefix to condense repetitive standards
|
||||
let commonPrefix = '';
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
let i = 0;
|
||||
while (i < first.length && first.charAt(i) === last.charAt(i)) {
|
||||
i++;
|
||||
}
|
||||
commonPrefix = first.substring(0, i);
|
||||
|
||||
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
|
||||
if (commonPrefix.length > 4) {
|
||||
const suffixParts: string[] = [];
|
||||
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
if (idx === 0) {
|
||||
suffixParts.push(parts[idx]);
|
||||
} else {
|
||||
const suffix = parts[idx].substring(commonPrefix.length).trim();
|
||||
if (suffix) {
|
||||
suffixParts.push(suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
|
||||
// Wait, returning a single string might still wrap badly.
|
||||
// Instead, we return them as chunks or just a condensed string.
|
||||
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false, // Turn off badge rendering to use text block instead
|
||||
parts: [condensedString],
|
||||
displayValue: condensedString,
|
||||
};
|
||||
}
|
||||
|
||||
// If no common prefix, return as list so UI can render badges
|
||||
return {
|
||||
original: str,
|
||||
isList: true,
|
||||
parts,
|
||||
displayValue: parts.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false,
|
||||
parts: [str],
|
||||
displayValue: str,
|
||||
};
|
||||
}
|
||||
@@ -226,10 +226,6 @@
|
||||
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
||||
"downloadDatasheet": "Datenblatt herunterladen",
|
||||
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
||||
"downloadExcel": "Excel herunterladen",
|
||||
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
|
||||
"downloadBrochure": "Produktbroschüre",
|
||||
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
|
||||
"form": {
|
||||
"contactInfo": "Kontaktinformationen",
|
||||
"projectDetails": "Projektdetails",
|
||||
@@ -399,21 +395,5 @@
|
||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Produktkatalog",
|
||||
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
|
||||
"emailPlaceholder": "ihre@email.de",
|
||||
"emailLabel": "E-Mail-Adresse",
|
||||
"submit": "Broschüre erhalten",
|
||||
"submitting": "Wird gesendet...",
|
||||
"successTitle": "Ihre Broschüre ist bereit!",
|
||||
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
|
||||
"download": "Broschüre herunterladen",
|
||||
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
|
||||
"close": "Schließen",
|
||||
"ctaTitle": "Kompletter Produktkatalog",
|
||||
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
|
||||
"ctaButton": "Kostenlose Broschüre erhalten"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,10 +226,6 @@
|
||||
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
||||
"downloadDatasheet": "Download Datasheet",
|
||||
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
||||
"downloadExcel": "Download Excel",
|
||||
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
|
||||
"downloadBrochure": "Product Brochure",
|
||||
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
|
||||
"form": {
|
||||
"contactInfo": "Contact Information",
|
||||
"projectDetails": "Project Details",
|
||||
@@ -399,21 +395,5 @@
|
||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Product Catalog",
|
||||
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"emailLabel": "Email Address",
|
||||
"submit": "Get Brochure",
|
||||
"submitting": "Sending...",
|
||||
"successTitle": "Your brochure is ready!",
|
||||
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
|
||||
"download": "Download Brochure",
|
||||
"privacyNote": "By submitting you agree to our privacy policy.",
|
||||
"close": "Close",
|
||||
"ctaTitle": "Complete Product Catalog",
|
||||
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
|
||||
"ctaButton": "Get Free Brochure"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|xlsx|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||
'/(de|en)/:path*',
|
||||
],
|
||||
};
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -28,9 +28,6 @@ const nextConfig = {
|
||||
workerThreads: false,
|
||||
memoryBasedWorkersCount: true,
|
||||
},
|
||||
serverActions: {
|
||||
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||
},
|
||||
reactStrictMode: false,
|
||||
productionBrowserSourceMaps: false,
|
||||
logging: {
|
||||
@@ -54,7 +51,6 @@ const nextConfig = {
|
||||
}
|
||||
return config;
|
||||
},
|
||||
// Rewrites moved to bottom merged function
|
||||
async headers() {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
||||
@@ -109,7 +105,7 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -456,22 +452,6 @@ const nextConfig = {
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
{
|
||||
source: '/:locale/datasheets/:path*',
|
||||
destination: '/api/datasheets/:path*',
|
||||
},
|
||||
{
|
||||
source: '/:locale/brochures/:path*',
|
||||
destination: '/api/brochures/:path*',
|
||||
},
|
||||
{
|
||||
source: '/datasheets/:path*',
|
||||
destination: '/api/datasheets/:path*',
|
||||
},
|
||||
{
|
||||
source: '/brochures/:path*',
|
||||
destination: '/api/brochures/:path*',
|
||||
},
|
||||
{
|
||||
source: '/de/produkte',
|
||||
destination: '/de/products',
|
||||
|
||||
19
package.json
19
package.json
@@ -17,7 +17,7 @@
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@qdrant/js-client-rest": "^1.17.0",
|
||||
"@react-email/components": "1.0.8",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
@@ -102,8 +102,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.8.10",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.16",
|
||||
"xlsx-cli": "^1.1.3"
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"scripts": {
|
||||
"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'",
|
||||
@@ -115,7 +114,6 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
"test:e2e": "vitest run tests/*.e2e.test.ts",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:a11y": "pa11y-ci",
|
||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||
@@ -128,11 +126,9 @@
|
||||
"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.tsx",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
|
||||
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
|
||||
"cms:migrate": "tsx ./node_modules/payload/bin.js migrate",
|
||||
"cms:migrate": "payload migrate",
|
||||
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
||||
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
|
||||
@@ -142,10 +138,6 @@
|
||||
"assets:pull:prod": "bash ./scripts/assets-sync.sh prod local",
|
||||
"assets:sync:testing-to-staging": "bash ./scripts/assets-sync.sh testing staging",
|
||||
"assets:sync:staging-to-prod": "bash ./scripts/assets-sync.sh staging prod",
|
||||
"qdrant:push:testing": "bash ./scripts/qdrant-sync.sh testing",
|
||||
"qdrant:push:staging": "bash ./scripts/qdrant-sync.sh staging",
|
||||
"qdrant:push:prod": "bash ./scripts/qdrant-sync.sh prod",
|
||||
"qdrant:push:branch": "bash ./scripts/qdrant-sync.sh",
|
||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"pagespeed:audit": "./scripts/audit-local.sh",
|
||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||
@@ -172,9 +164,6 @@
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
"minimatch": ">=10.2.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mintel/payload-ai@1.9.15": "patches/@mintel__payload-ai@1.9.15.patch"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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 (
|
||||
@@ -87,9 +87,7 @@ export interface Config {
|
||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents':
|
||||
| PayloadLockedDocumentsSelect<false>
|
||||
| PayloadLockedDocumentsSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
@@ -100,9 +98,6 @@ export interface Config {
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: 'de' | 'en';
|
||||
widgets: {
|
||||
collections: CollectionsWidget;
|
||||
};
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
@@ -254,7 +249,7 @@ export interface FormSubmission {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
type: 'contact' | 'product_quote' | 'brochure_download';
|
||||
type: 'contact' | 'product_quote';
|
||||
/**
|
||||
* The specific KLZ product the user requested a quote for.
|
||||
*/
|
||||
@@ -624,16 +619,6 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
*/
|
||||
export interface CollectionsWidget {
|
||||
data?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
width: 'full';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "StatsBlock".
|
||||
@@ -972,6 +957,7 @@ export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
}
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -8,11 +8,6 @@ overrides:
|
||||
next: 16.1.6
|
||||
minimatch: '>=10.2.2'
|
||||
|
||||
patchedDependencies:
|
||||
'@mintel/payload-ai@1.9.15':
|
||||
hash: 934315d3f15180552789a94bf76c34624501f601cc7409c24da3dc27af96c8fb
|
||||
path: patches/@mintel__payload-ai@1.9.15.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -37,7 +32,7 @@ importers:
|
||||
version: 1.9.5(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)
|
||||
'@mintel/payload-ai':
|
||||
specifier: ^1.9.15
|
||||
version: 1.9.15(patch_hash=934315d3f15180552789a94bf76c34624501f601cc7409c24da3dc27af96c8fb)(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)
|
||||
version: 1.9.15(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)
|
||||
'@payloadcms/db-postgres':
|
||||
specifier: ^3.77.0
|
||||
version: 3.77.0(@opentelemetry/api@1.9.0)(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))
|
||||
@@ -57,8 +52,8 @@ importers:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0(typescript@5.9.3)
|
||||
'@react-email/components':
|
||||
specifier: 1.0.8
|
||||
version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(react@19.2.4)
|
||||
@@ -309,9 +304,6 @@ importers:
|
||||
vitest:
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
xlsx-cli:
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
|
||||
packages:
|
||||
|
||||
@@ -2463,8 +2455,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
|
||||
'@react-email/components@1.0.8':
|
||||
resolution: {integrity: sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==}
|
||||
'@react-email/components@1.0.7':
|
||||
resolution: {integrity: sha512-mY+v4C1SMaGOKuKp0QWDQLGK+3fvH06ZE10EVavv+T6tQneDHq9cpQ9NdCrvuO1nWZnWrA/0tRpvyqyF0uo93w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
@@ -2639,8 +2631,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
|
||||
'@react-email/tailwind@2.0.5':
|
||||
resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==}
|
||||
'@react-email/tailwind@2.0.4':
|
||||
resolution: {integrity: sha512-cDp8Ss6LJKI8zBLKE+tsXFurn6I2nnQNg1qqjfZuNPNoToN1Uyx3egW0bwSVk1JjrNWx/Xnme7ZxvNLRrU9K0Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@react-email/body': 0.2.1
|
||||
@@ -4407,9 +4399,6 @@ packages:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@2.17.1:
|
||||
resolution: {integrity: sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
@@ -5317,10 +5306,6 @@ packages:
|
||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
exit-on-epipe@1.0.1:
|
||||
resolution: {integrity: sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -9124,17 +9109,6 @@ packages:
|
||||
resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
xlsx-cli@1.1.3:
|
||||
resolution: {integrity: sha512-6yAsnXbuMGxuFny9K4nGUSwhVb5sI6yaZ4cAQhlxuTbbavJkhmbp72Elm3/vi/gxS7yEx6q0JCncPbGsIWqdcw==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
xlsx@https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz:
|
||||
resolution: {tarball: https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz}
|
||||
version: 0.20.3
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -10826,7 +10800,7 @@ snapshots:
|
||||
- sass
|
||||
- typescript
|
||||
|
||||
'@mintel/payload-ai@1.9.15(patch_hash=934315d3f15180552789a94bf76c34624501f601cc7409c24da3dc27af96c8fb)(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)':
|
||||
'@mintel/payload-ai@1.9.15(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)':
|
||||
dependencies:
|
||||
'@ai-sdk/openai': 3.0.41(zod@3.25.76)
|
||||
'@ai-sdk/react': 3.0.118(react@19.2.4)(zod@3.25.76)
|
||||
@@ -11660,7 +11634,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
'@react-email/components@1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@react-email/components@1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@react-email/body': 0.2.1(react@19.2.4)
|
||||
'@react-email/button': 0.2.1(react@19.2.4)
|
||||
@@ -11680,7 +11654,7 @@ snapshots:
|
||||
'@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@react-email/row': 0.0.13(react@19.2.4)
|
||||
'@react-email/section': 0.0.17(react@19.2.4)
|
||||
'@react-email/tailwind': 2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)
|
||||
'@react-email/tailwind': 2.0.4(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)
|
||||
'@react-email/text': 0.1.6(react@19.2.4)
|
||||
react: 19.2.4
|
||||
transitivePeerDependencies:
|
||||
@@ -11811,7 +11785,7 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@react-email/tailwind@2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)':
|
||||
'@react-email/tailwind@2.0.4(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@react-email/text': 0.1.6(react@19.2.4)
|
||||
react: 19.2.4
|
||||
@@ -13718,8 +13692,6 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.17.1: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
@@ -14792,8 +14764,6 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
exit-on-epipe@1.0.1: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
express-rate-limit@8.3.0(express@5.2.1):
|
||||
@@ -19353,14 +19323,6 @@ snapshots:
|
||||
|
||||
xdg-basedir@5.1.0: {}
|
||||
|
||||
xlsx-cli@1.1.3:
|
||||
dependencies:
|
||||
commander: 2.17.1
|
||||
exit-on-epipe: 1.0.1
|
||||
xlsx: https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz
|
||||
|
||||
xlsx@https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user