Compare commits
29 Commits
ec0abffc55
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d75a83ccf2 | |||
| 5991bd8392 | |||
| 6207e04bf5 | |||
| 8ffb5967d3 | |||
| 8ba1c7ea38 | |||
| a546ffe69c | |||
| 15740db51e | |||
| 13ab755857 | |||
| 1a68af0eec | |||
| 275784745d | |||
| 4aef49cf2c | |||
| 8ad3abb6f3 | |||
| 1d75b60236 | |||
| 3dff19eca2 | |||
| 07b01c622a | |||
| 50de18c09c | |||
| dbee0cd8bc | |||
| f30f8ddd8d | |||
| bb9fd65dbb | |||
| 036fba8b53 | |||
| 3e8d5ad8b6 | |||
| 70ad2e3041 | |||
| 5376b939d5 | |||
| 6f80e72c1d | |||
| d9334f558d | |||
| cb436d31d0 | |||
| 4b3ef49522 | |||
| 301e112488 | |||
| 2d4919cc1f |
38
.env
Normal file
38
.env
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=postmaster@mg.mintel.me
|
||||||
|
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||||
|
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||||
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Payload Infrastructure (Dockerized)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
|
# manually write the connection strings here.
|
||||||
|
PAYLOAD_DB_NAME=payload
|
||||||
|
PAYLOAD_DB_USER=payload
|
||||||
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Hetzner S3 Object Storage
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
|
S3_BUCKET=mintel
|
||||||
|
S3_REGION=fsn1
|
||||||
|
S3_PREFIX=klz-cables
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.branch-${SLUG}"
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
TRAEFIK_HOST="${SLUG}.branch.${DOMAIN}"
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
@@ -261,12 +261,6 @@ jobs:
|
|||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
# Search & AI
|
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
|
||||||
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
|
|
||||||
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
|
||||||
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -325,12 +319,6 @@ jobs:
|
|||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
echo ""
|
echo ""
|
||||||
echo "# Search & AI"
|
|
||||||
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
|
||||||
echo "QDRANT_URL=$QDRANT_URL"
|
|
||||||
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
|
||||||
echo "REDIS_URL=$REDIS_URL"
|
|
||||||
echo ""
|
|
||||||
echo "TARGET=$TARGET"
|
echo "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
@@ -588,6 +576,11 @@ jobs:
|
|||||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||||
$URL"
|
$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 }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
-F "message=$MESSAGE" \
|
-F "message=$MESSAGE" \
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
name: Nightly QA
|
name: Nightly QA
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 3 * * *'
|
- cron: '0 3 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -227,6 +225,11 @@ jobs:
|
|||||||
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||||
${{ env.TARGET_URL }}"
|
${{ 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 }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
-F "message=$MESSAGE" \
|
-F "message=$MESSAGE" \
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,5 +28,3 @@ html-errors*.json
|
|||||||
reference/
|
reference/
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
.env
|
|
||||||
17
Dockerfile
17
Dockerfile
@@ -1,5 +1,9 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base
|
FROM node:20-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat curl
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
@@ -52,12 +56,17 @@ ENV UV_THREADPOOL_SIZE=3
|
|||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
USER root
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
RUN chown -R nextjs:nodejs /app
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect, permanentRedirect } from 'next/navigation';
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -62,6 +62,15 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
|
||||||
|
if (pageData.redirectUrl) {
|
||||||
|
if (pageData.redirectPermanent) {
|
||||||
|
permanentRedirect(pageData.redirectUrl);
|
||||||
|
} else {
|
||||||
|
redirect(pageData.redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect if accessed via a different locale's slug
|
// Redirect if accessed via a different locale's slug
|
||||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getAdjacentPosts,
|
getAdjacentPosts,
|
||||||
getReadingTime,
|
getReadingTime,
|
||||||
extractLexicalHeadings,
|
extractLexicalHeadings,
|
||||||
|
getPostSlugs,
|
||||||
} from '@/lib/blog';
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -33,12 +34,21 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
|
|
||||||
|
const slugs = await getPostSlugs(slug, locale);
|
||||||
|
const deSlug = slugs?.de || post.slug;
|
||||||
|
const enSlug = slugs?.en || post.slug;
|
||||||
|
|
||||||
const description = post.frontmatter.excerpt || '';
|
const description = post.frontmatter.excerpt || '';
|
||||||
return {
|
return {
|
||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
|
languages: {
|
||||||
|
de: `${SITE_URL}/de/blog/${deSlug}`,
|
||||||
|
en: `${SITE_URL}/en/blog/${enSlug}`,
|
||||||
|
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
|
|||||||
@@ -35,13 +35,6 @@ export async function generateMetadata(props: {
|
|||||||
},
|
},
|
||||||
metadataBase: new URL(baseUrl),
|
metadataBase: new URL(baseUrl),
|
||||||
manifest: '/manifest.webmanifest',
|
manifest: '/manifest.webmanifest',
|
||||||
alternates: {
|
|
||||||
canonical: `${baseUrl}/${locale}`,
|
|
||||||
languages: {
|
|
||||||
de: `${baseUrl}/de`,
|
|
||||||
en: `${baseUrl}/en`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.ico', sizes: 'any' },
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
@@ -132,11 +125,7 @@ export default async function Layout(props: {
|
|||||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
|
||||||
lang={safeLocale}
|
|
||||||
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
|
||||||
data-scroll-behavior="smooth"
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
|
||||||
import { searchProducts } from '../../../src/lib/qdrant';
|
|
||||||
import redis from '../../../src/lib/redis';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import * as Sentry from '@sentry/nextjs';
|
|
||||||
// Config and constants
|
|
||||||
const RATE_LIMIT_POINTS = 5; // 5 requests
|
|
||||||
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
|
|
||||||
|
|
||||||
// Removed requestSchema as it's replaced by direct parsing
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
// Changed req type to NextRequest
|
|
||||||
try {
|
|
||||||
const { messages, visitorId, honeypot } = await req.json();
|
|
||||||
|
|
||||||
// 1. Basic Validation
|
|
||||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestMessage = messages[messages.length - 1].content;
|
|
||||||
const isBot = honeypot && honeypot.length > 0;
|
|
||||||
|
|
||||||
// Check if the input itself is obviously spam/too long
|
|
||||||
if (latestMessage.length > 500) {
|
|
||||||
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Honeypot check
|
|
||||||
if (isBot) {
|
|
||||||
console.warn('Honeypot triggered in AI search');
|
|
||||||
// Tarpit the bot
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
return NextResponse.json({
|
|
||||||
answerText: 'Vielen Dank für Ihre Anfrage.',
|
|
||||||
products: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Rate Limiting via Redis
|
|
||||||
try {
|
|
||||||
if (visitorId) {
|
|
||||||
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
|
|
||||||
if (requestCount === 1) {
|
|
||||||
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION); // Use constant
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestCount > RATE_LIMIT_POINTS) {
|
|
||||||
// Use constant
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Rate limit exceeded. Please try again later.',
|
|
||||||
},
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (redisError) {
|
|
||||||
// Renamed variable for clarity
|
|
||||||
console.error('Redis Rate Limiting Error:', redisError); // Changed to error for consistency
|
|
||||||
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
|
||||||
// Fail open if Redis is down
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Fetch Context from Qdrant based on the latest message
|
|
||||||
let contextStr = '';
|
|
||||||
let foundProducts: any[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchResults = await searchProducts(latestMessage, 5);
|
|
||||||
|
|
||||||
if (searchResults && searchResults.length > 0) {
|
|
||||||
const productDescriptions = searchResults
|
|
||||||
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
|
||||||
.map((p: any) => p.payload?.content)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
const knowledgeDescriptions = searchResults
|
|
||||||
.filter((p) => p.payload?.type === 'knowledge')
|
|
||||||
.map((p: any) => p.payload?.content)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
|
|
||||||
|
|
||||||
foundProducts = searchResults
|
|
||||||
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
|
||||||
.map((p: any) => p.payload?.data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Qdrant Search Error:', e);
|
|
||||||
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
|
|
||||||
// We can still proceed without context if Qdrant fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
|
||||||
const systemPrompt = `Du bist ein professioneller und extrem kompetenter Sales-Engineer / Consultant der Firma "KLZ Cables".
|
|
||||||
Deine Aufgabe ist es, Kunden und Interessenten bei der Auswahl von Mittelspannungskabeln, Starkstromkabeln und Infrastrukturausrüstung beratend zur Seite zu stehen.
|
|
||||||
|
|
||||||
WICHTIGE REGELN:
|
|
||||||
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch.
|
|
||||||
2. Wenn der Kunde vage ist (z.B. "Ich will einen Windpark bauen"), würge ihn NICHT ab. Stelle stattdessen gezielte, professionelle Rückfragen als Berater (z.B. "Für einen Windpark benötigen wir einige Rahmendaten: Reden wir über die Parkverkabelung (Mittelspannung, z.B. 20kV oder 33kV) oder die Netzanbindung? Welche Querschnitte oder Ströme erwarten Sie?").
|
|
||||||
3. Nutze das bereitgestellte KABELWISSEN und KATALOG-Gedächtnis unten, um deine Antworten zu fundieren.
|
|
||||||
4. Bleibe stets professionell, lösungsorientiert und leicht technisch (Industrial Aesthetic). Du kannst humorvoll sein, wenn der Nutzer offensichtlich Quatsch fragt, aber lenke es immer elegant zurück zu Kabeln oder Energieinfrastruktur.
|
|
||||||
5. Antworte in reinem Text (kein Markdown für die Antwort, es sei denn es sind einfache Absätze oder Listen).
|
|
||||||
6. Wenn genügend Informationen vorhanden sind, präsentiere passende Kabel aus dem Katalog.
|
|
||||||
7. Oute dich als Berater von KLZ Cables.
|
|
||||||
|
|
||||||
VERFÜGBARER KONTEXT:
|
|
||||||
${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage gefunden.'}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
|
||||||
if (!openRouterKey) {
|
|
||||||
throw new Error('OPENROUTER_API_KEY is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${openRouterKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
|
||||||
'X-Title': 'KLZ Cables Search AI',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'mistralai/mistral-large-2407',
|
|
||||||
temperature: 0.3,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
...messages.map((m: any) => ({
|
|
||||||
role: m.role,
|
|
||||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fetchRes.ok) {
|
|
||||||
const errBody = await fetchRes.text();
|
|
||||||
throw new Error(`OpenRouter API Error: ${errBody}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchRes.json();
|
|
||||||
const text = data.choices[0].message.content;
|
|
||||||
|
|
||||||
// Return the AI's answer along with any found products
|
|
||||||
return NextResponse.json({
|
|
||||||
answerText: text,
|
|
||||||
products: foundProducts,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('AI Search API Error:', error);
|
|
||||||
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
|
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
{ status: hasErrors ? 503 : 200 },
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
|||||||
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
@@ -277,48 +276,6 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand & Quality Sub-Footer */}
|
|
||||||
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://mintel.me"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
||||||
target: 'mintel_agency',
|
|
||||||
location: 'sub_footer',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
Website entwickelt von Marc Mintel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
|
|
||||||
<div className="flex items-center gap-1.5" title="SSL Secured">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
<span>SSL Secured</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="Green Hosting">
|
|
||||||
<Leaf className="w-3.5 h-3.5" />
|
|
||||||
<span>Green Hosting</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
|
|
||||||
<Lock className="w-3.5 h-3.5" />
|
|
||||||
<span>DSGVO Compliant</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="WCAG">
|
|
||||||
<Accessibility className="w-3.5 h-3.5" />
|
|
||||||
<span>WCAG</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
|
|
||||||
<Zap className="w-3.5 h-3.5" />
|
|
||||||
<span>PageSpeed 90+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
import { AISearchResults } from './search/AISearchResults';
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
@@ -18,7 +16,6 @@ export default function Header() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
||||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
@@ -276,19 +273,6 @@ export default function Header() {
|
|||||||
<div
|
<div
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSearchOpen(true)}
|
|
||||||
className="hover:text-accent transition-colors p-2"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 md:w-6 md:h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
|
||||||
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
@@ -483,8 +467,6 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'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,6 +37,7 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|||||||
import GallerySection from '@/components/home/GallerySection';
|
import GallerySection from '@/components/home/GallerySection';
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
import VideoSection from '@/components/home/VideoSection';
|
||||||
import CTA from '@/components/home/CTA';
|
import CTA from '@/components/home/CTA';
|
||||||
|
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits a text string on \n and intersperses <br /> elements.
|
* Splits a text string on \n and intersperses <br /> elements.
|
||||||
@@ -429,6 +430,12 @@ const jsxConverters: JSXConverters = {
|
|||||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||||
</ProductTabs>
|
</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 ───────────────────────────────────────────
|
// ─── New Page Blocks ───────────────────────────────────────────
|
||||||
heroSection: ({ node }: any) => {
|
heroSection: ({ node }: any) => {
|
||||||
const f = node.fields;
|
const f = node.fields;
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
import AIOrb from '../search/AIOrb';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { AISearchResults } from '../search/AISearchResults';
|
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero({ data }: { data?: any }) {
|
export default function Hero({ data }: { data?: any }) {
|
||||||
@@ -17,148 +12,87 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
setIsSearchOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
<div className="max-w-5xl mx-auto md:mx-0">
|
<div>
|
||||||
<div>
|
<Heading
|
||||||
<Heading
|
level={1}
|
||||||
level={1}
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
|
||||||
>
|
|
||||||
{data?.title ? (
|
|
||||||
<span
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: data.title
|
|
||||||
.replace(
|
|
||||||
/<green>/g,
|
|
||||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/<\/green>/g,
|
|
||||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.rich('title', {
|
|
||||||
green: (chunks) => (
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic inline-block">
|
|
||||||
{chunks}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '500ms' }}
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Heading>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
|
||||||
{data?.subtitle || t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSearchSubmit}
|
|
||||||
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
|
|
||||||
>
|
>
|
||||||
<div className="absolute left-2 w-12 h-12 flex items-center justify-center opacity-80 pointer-events-none">
|
{data?.title ? (
|
||||||
<AIOrb isThinking={false} />
|
<span
|
||||||
</div>
|
dangerouslySetInnerHTML={{
|
||||||
<input
|
__html: data.title
|
||||||
type="text"
|
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||||
value={searchQuery}
|
.replace(/<\/green>/g, '</span>'),
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
}}
|
||||||
placeholder="Projekt beschreiben oder Kabel suchen..."
|
/>
|
||||||
className="flex-1 bg-transparent border-none text-white pl-12 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
|
) : (
|
||||||
/>
|
t.rich('title', {
|
||||||
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
|
{data?.subtitle || t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
href="/contact"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.ctaLabel || t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Fragen
|
{data?.ctaLabel || t('cta')}
|
||||||
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data?.secondaryCtaLabel || t('exploreProducts')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
href="/contact"
|
|
||||||
variant="white"
|
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: data?.ctaLabel || t('cta'),
|
|
||||||
location: 'home_hero_primary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data?.ctaLabel || t('cta')}
|
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
|
||||||
location: 'home_hero_secondary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data?.secondaryCtaLabel || t('exploreProducts')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
|
||||||
|
|
||||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
|
||||||
<HeroIllustration />
|
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<div
|
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
<HeroIllustration />
|
||||||
style={{ animationDelay: '2000ms' }}
|
</div>
|
||||||
>
|
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
<div
|
||||||
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
||||||
</div>
|
style={{ animationDelay: '2000ms' }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
|
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
<AISearchResults
|
</Section>
|
||||||
isOpen={isSearchOpen}
|
|
||||||
onClose={() => setIsSearchOpen(false)}
|
|
||||||
initialQuery={searchQuery}
|
|
||||||
triggerSearch={true}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
/* eslint-disable react/no-unknown-property */
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
|
||||||
import { Canvas, useFrame } from '@react-three/fiber';
|
|
||||||
import { Sphere, MeshDistortMaterial, Environment, Float } from '@react-three/drei';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
|
|
||||||
interface AIOrbProps {
|
|
||||||
isThinking: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Orb({ isThinking }: AIOrbProps) {
|
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
|
||||||
const materialRef = useRef<any>(null);
|
|
||||||
|
|
||||||
// Dynamic properties based on state
|
|
||||||
const targetDistort = isThinking ? 0.6 : 0.3;
|
|
||||||
const targetSpeed = isThinking ? 5 : 2;
|
|
||||||
const color = isThinking ? '#00FF88' : '#00A3FF'; // Green/Blue based on thinking state
|
|
||||||
|
|
||||||
useFrame((state) => {
|
|
||||||
if (!materialRef.current) return;
|
|
||||||
|
|
||||||
// Smoothly interpolate material properties
|
|
||||||
materialRef.current.distort = THREE.MathUtils.lerp(
|
|
||||||
materialRef.current.distort,
|
|
||||||
targetDistort,
|
|
||||||
0.1,
|
|
||||||
);
|
|
||||||
materialRef.current.speed = THREE.MathUtils.lerp(materialRef.current.speed, targetSpeed, 0.1);
|
|
||||||
|
|
||||||
// Smooth color transition
|
|
||||||
const currentColor = materialRef.current.color;
|
|
||||||
const targetColorObj = new THREE.Color(color);
|
|
||||||
currentColor.lerp(targetColorObj, 0.05);
|
|
||||||
|
|
||||||
// Slow rotation
|
|
||||||
if (meshRef.current) {
|
|
||||||
meshRef.current.rotation.x = state.clock.getElapsedTime() * 0.2;
|
|
||||||
meshRef.current.rotation.y = state.clock.getElapsedTime() * 0.3;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Float
|
|
||||||
speed={isThinking ? 4 : 2}
|
|
||||||
rotationIntensity={isThinking ? 2 : 1}
|
|
||||||
floatIntensity={isThinking ? 2 : 1}
|
|
||||||
>
|
|
||||||
<Sphere ref={meshRef} args={[1, 64, 64]} scale={1.5}>
|
|
||||||
<MeshDistortMaterial
|
|
||||||
ref={materialRef}
|
|
||||||
color="#00A3FF"
|
|
||||||
envMapIntensity={2}
|
|
||||||
clearcoat={1}
|
|
||||||
clearcoatRoughness={0}
|
|
||||||
metalness={0.8}
|
|
||||||
roughness={0.1}
|
|
||||||
distort={0.3}
|
|
||||||
speed={2}
|
|
||||||
/>
|
|
||||||
</Sphere>
|
|
||||||
</Float>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AIOrb({ isThinking = false }: AIOrbProps) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full min-w-[32px] min-h-[32px] relative flex items-center justify-center">
|
|
||||||
{/* Ambient glow effect behind the orb */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 rounded-full blur-xl opacity-50 transition-colors duration-1000 ${isThinking ? 'bg-[#00FF88]/50' : 'bg-[#00A3FF]/40'}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Canvas
|
|
||||||
camera={{ position: [0, 0, 4], fov: 45 }}
|
|
||||||
className="w-full h-full cursor-pointer z-10 block"
|
|
||||||
>
|
|
||||||
<ambientLight intensity={0.5} />
|
|
||||||
<directionalLight position={[10, 10, 5]} intensity={1.5} />
|
|
||||||
<directionalLight position={[-10, -10, -5]} intensity={0.5} color="#00FF88" />
|
|
||||||
<Orb isThinking={isThinking} />
|
|
||||||
<Environment preset="city" />
|
|
||||||
</Canvas>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
|
||||||
import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import AIOrb from './AIOrb';
|
|
||||||
|
|
||||||
interface ProductMatch {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
sku: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
products?: ProductMatch[];
|
|
||||||
}
|
|
||||||
interface ComponentProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
initialQuery?: string;
|
|
||||||
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AISearchResults({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
initialQuery = '',
|
|
||||||
triggerSearch = false,
|
|
||||||
}: ComponentProps) {
|
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [honeypot, setHoneypot] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
|
|
||||||
if (triggerSearch && initialQuery && messages.length === 0) {
|
|
||||||
setQuery(initialQuery);
|
|
||||||
handleSearch(initialQuery);
|
|
||||||
} else if (!triggerSearch) {
|
|
||||||
setQuery('');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = 'unset';
|
|
||||||
setQuery('');
|
|
||||||
setMessages([]);
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = 'unset';
|
|
||||||
};
|
|
||||||
}, [isOpen, triggerSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && initialQuery && messages.length === 0) {
|
|
||||||
setQuery(initialQuery);
|
|
||||||
}
|
|
||||||
}, [initialQuery, isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Auto-scroll to bottom of chat
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages, isLoading]);
|
|
||||||
|
|
||||||
const handleSearch = async (searchQuery: string = query) => {
|
|
||||||
if (!searchQuery.trim() || isLoading) return;
|
|
||||||
|
|
||||||
const newUserMessage: Message = { role: 'user', content: searchQuery };
|
|
||||||
const newMessagesContext = [...messages, newUserMessage];
|
|
||||||
|
|
||||||
setMessages(newMessagesContext);
|
|
||||||
setQuery('');
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
|
||||||
type: 'ai_search',
|
|
||||||
query: searchQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
messages: newMessagesContext,
|
|
||||||
_honeypot: honeypot,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || 'Failed to fetch search results');
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: data.answerText,
|
|
||||||
products: data.products,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Re-focus input after response so user can continue typing easily
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message || 'An error occurred while chatting. Please try again.');
|
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
|
||||||
location: 'ai_search_results',
|
|
||||||
message: err.message,
|
|
||||||
query: searchQuery,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
// Handle clicking outside to close
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in"
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={modalRef}
|
|
||||||
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 md:p-6 flex items-center justify-between border-b border-white/10 relative z-10 bg-[#001c30]">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Sparkles className="w-5 h-5 text-accent mr-3" />
|
|
||||||
<h2 className="text-white font-bold tracking-widest uppercase text-sm">
|
|
||||||
KLZ AI Consultant
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white/50 hover:text-white transition-colors p-2"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat History Area */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
|
|
||||||
{messages.length === 0 && !isLoading && !error && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
<AIOrb isThinking={false} />
|
|
||||||
<p className="text-xl md:text-2xl font-bold mt-6">I am your technical consultant.</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Describe your project, ask for specific cables, or tell me your requirements.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((msg, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`max-w-[85%] rounded-2xl p-5 ${msg.role === 'user' ? 'bg-accent text-primary rounded-tr-sm' : 'bg-white/10 border border-white/10 text-white rounded-tl-sm'}`}
|
|
||||||
>
|
|
||||||
{msg.role === 'assistant' && (
|
|
||||||
<h3 className="text-xs font-bold tracking-widest uppercase text-accent/80 mb-2 flex items-center">
|
|
||||||
<Sparkles className="w-3 h-3 mr-1" />
|
|
||||||
AI Assistant
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
<div className="text-base md:text-lg leading-relaxed font-medium prose prose-invert prose-p:leading-relaxed prose-pre:bg-black/50 prose-a:text-accent prose-strong:text-accent prose-ul:list-disc prose-ol:list-decimal">
|
|
||||||
{msg.role === 'assistant' ? (
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
|
||||||
) : (
|
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product Matches inside Assistant Message */}
|
|
||||||
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
|
||||||
<div className="mt-6 space-y-3 border-t border-white/10 pt-4">
|
|
||||||
<h4 className="text-xs font-bold tracking-widest uppercase text-white/50">
|
|
||||||
Empfohlene Produkte
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{msg.products.map((product, idx) => (
|
|
||||||
<Link
|
|
||||||
key={idx}
|
|
||||||
href={`/produkte/${product.slug}`}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
target: product.slug,
|
|
||||||
location: 'ai_search_results',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="group flex flex-col justify-between bg-white text-primary rounded-lg p-4 hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-bold text-primary/50 tracking-wider mb-1">
|
|
||||||
{product.sku}
|
|
||||||
</p>
|
|
||||||
<h5 className="text-sm font-extrabold mb-2 group-hover:text-accent transition-colors line-clamp-2">
|
|
||||||
{product.title}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end text-[10px] font-bold tracking-widest uppercase mt-2">
|
|
||||||
<span className="group-hover:text-accent transition-colors">
|
|
||||||
Details
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="w-3 h-3 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="bg-transparent rounded-2xl p-2 w-24 flex justify-center">
|
|
||||||
<AIOrb isThinking={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-4 rounded-xl mt-4">
|
|
||||||
<MessageSquareWarning className="w-6 h-6 text-red-400 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-red-200">System Error</h3>
|
|
||||||
<p className="text-xs text-red-300 mt-1">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<div className="p-4 md:p-6 bg-[#001c30] border-t border-white/10">
|
|
||||||
<div className="relative flex items-center bg-white/5 border border-white/10 rounded-xl focus-within:border-accent/50 focus-within:bg-white/10 transition-all">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder="Type your question or requirements..."
|
|
||||||
className="flex-1 bg-transparent border-none text-white text-base md:text-lg p-4 focus:outline-none placeholder:text-white/30"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="hidden"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSearch()}
|
|
||||||
disabled={!query.trim() || isLoading}
|
|
||||||
className="p-4 text-white/50 hover:text-accent disabled:opacity-50 disabled:hover:text-white/50 transition-colors shrink-0 cursor-pointer"
|
|
||||||
aria-label="Send message"
|
|
||||||
>
|
|
||||||
<Search className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-center mt-3">
|
|
||||||
<span className="text-[10px] uppercase tracking-widest font-bold text-white/30">
|
|
||||||
Press Enter to send • Esc to close
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
cpus: '4'
|
cpus: '4'
|
||||||
memory: 8G
|
memory: 8G
|
||||||
command: >
|
command: >
|
||||||
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0"
|
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||||
@@ -75,24 +75,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "54322:5432"
|
- "54322:5432"
|
||||||
|
|
||||||
klz-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
|
|
||||||
klz-qdrant:
|
|
||||||
image: qdrant/qdrant:v1.13.2
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- klz_qdrant_data:/qdrant/storage
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
ports:
|
|
||||||
- "6333:6333"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -102,8 +84,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
klz_db_data:
|
klz_db_data:
|
||||||
external: false
|
external: false
|
||||||
klz_qdrant_data:
|
|
||||||
external: false
|
|
||||||
klz_node_modules:
|
klz_node_modules:
|
||||||
klz_next_cache:
|
klz_next_cache:
|
||||||
klz_turbo_cache:
|
klz_turbo_cache:
|
||||||
|
|||||||
@@ -100,25 +100,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
klz-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
|
|
||||||
klz-qdrant:
|
|
||||||
image: qdrant/qdrant:v1.13.2
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "6333:6333"
|
|
||||||
environment:
|
|
||||||
QDRANT__SERVICE__HTTP_PORT: 6333
|
|
||||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
|
||||||
volumes:
|
|
||||||
- klz_qdrant_data:/qdrant/storage
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -130,5 +111,3 @@ volumes:
|
|||||||
external: false
|
external: false
|
||||||
klz_media_data:
|
klz_media_data:
|
||||||
external: false
|
external: false
|
||||||
klz_qdrant_data:
|
|
||||||
external: false
|
|
||||||
|
|||||||
54
lib/blog.ts
54
lib/blog.ts
@@ -136,6 +136,60 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPostSlugs(slug: string, locale: string): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// First, find the post in the current locale to get its ID
|
||||||
|
let { docs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
// Fallback: search across all locales
|
||||||
|
const { docs: crossLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
docs = crossLocaleDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docs || docs.length === 0) return {};
|
||||||
|
|
||||||
|
const postId = docs[0].id;
|
||||||
|
|
||||||
|
// Fetch the post with locale 'all' to get all localized fields
|
||||||
|
const { docs: allLocalesDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
id: { equals: postId },
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allLocalesDocs || allLocalesDocs.length === 0) return {};
|
||||||
|
return (allLocalesDocs[0].slug as unknown as Record<string, string>) || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Payload] getPostSlugs failed for ${slug}:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|||||||
@@ -11,10 +11,21 @@ export async function getOgFonts() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||||
const boldFont = readFileSync(boldFontPath);
|
const boldFontBuffer = readFileSync(boldFontPath);
|
||||||
const regularFont = readFileSync(regularFontPath);
|
const regularFontBuffer = readFileSync(regularFontPath);
|
||||||
|
|
||||||
|
// Satori (Vercel OG) strictly requires an ArrayBuffer, not a Node Buffer view.
|
||||||
|
const boldFont = boldFontBuffer.buffer.slice(
|
||||||
|
boldFontBuffer.byteOffset,
|
||||||
|
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
|
||||||
|
);
|
||||||
|
const regularFont = regularFontBuffer.buffer.slice(
|
||||||
|
regularFontBuffer.byteOffset,
|
||||||
|
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface PageFrontmatter {
|
|||||||
|
|
||||||
export interface PageData {
|
export interface PageData {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
redirectPermanent?: boolean;
|
||||||
frontmatter: PageFrontmatter;
|
frontmatter: PageFrontmatter;
|
||||||
content: any; // Lexical AST Document
|
content: any; // Lexical AST Document
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,8 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
slug: doc.slug,
|
slug: doc.slug,
|
||||||
|
redirectUrl: doc.redirectUrl,
|
||||||
|
redirectPermanent: doc.redirectPermanent ?? true,
|
||||||
frontmatter: {
|
frontmatter: {
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
excerpt: doc.excerpt || '',
|
excerpt: doc.excerpt || '',
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||||
Document,
|
|
||||||
Page,
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Image,
|
|
||||||
StyleSheet,
|
|
||||||
Font,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
// Register fonts (using system fonts for now, can be customized)
|
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||||
Font.register({
|
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||||
family: 'Helvetica',
|
|
||||||
fonts: [
|
|
||||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
|
||||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
@@ -302,10 +288,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
return labels[locale];
|
return labels[locale];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||||
product,
|
|
||||||
locale,
|
|
||||||
}) => {
|
|
||||||
const labels = getLabels(locale);
|
const labels = getLabels(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,9 +300,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View>
|
<View>
|
||||||
<Text style={styles.logoText}>KLZ</Text>
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.docTitle}>
|
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||||
{labels.productDatasheet}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.productRow}>
|
<View style={styles.productRow}>
|
||||||
@@ -328,7 +309,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View style={styles.categories}>
|
<View style={styles.categories}>
|
||||||
{product.categories.map((cat, index) => (
|
{product.categories.map((cat, index) => (
|
||||||
<Text key={index} style={styles.productMeta}>
|
<Text key={index} style={styles.productMeta}>
|
||||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
{cat.name}
|
||||||
|
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -337,12 +319,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.productImageCol}>
|
<View style={styles.productImageCol}>
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
<Image
|
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||||
src={product.featuredImage}
|
|
||||||
style={styles.heroImage}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -356,7 +334,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<Text style={styles.description}>
|
<Text style={styles.description}>
|
||||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
{stripHtml(
|
||||||
|
product.applicationHtml ||
|
||||||
|
product.shortDescriptionHtml ||
|
||||||
|
product.descriptionHtml,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -372,17 +354,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
styles.specsTableRow,
|
styles.specsTableRow,
|
||||||
index === product.attributes.length - 1 &&
|
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||||
styles.specsTableRowLast,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.specsTableLabelCell}>
|
<View style={styles.specsTableLabelCell}>
|
||||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.specsTableValueCell}>
|
<View style={styles.specsTableValueCell}>
|
||||||
<Text style={styles.specsTableValueText}>
|
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||||
{attr.options.join(', ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
328
lib/pdf-page.tsx
Normal file
328
lib/pdf-page.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||||
|
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||||
|
|
||||||
|
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
navy: '#001a4d',
|
||||||
|
navyDeep: '#000d26',
|
||||||
|
accent: '#82ed20',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
offWhite: '#f8f9fa',
|
||||||
|
gray100: '#f3f4f6',
|
||||||
|
gray200: '#e5e7eb',
|
||||||
|
gray300: '#d1d5db',
|
||||||
|
gray400: '#9ca3af',
|
||||||
|
gray600: '#4b5563',
|
||||||
|
gray900: '#111827',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARGIN = 72;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
color: C.gray900,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
backgroundColor: C.white,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 100,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hero-style header
|
||||||
|
hero: {
|
||||||
|
backgroundColor: C.white,
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
|
marginBottom: 20,
|
||||||
|
position: 'relative',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: C.gray200,
|
||||||
|
},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
logoText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
docTitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navy,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
productHero: {
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginBottom: 0,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
accentBar: {
|
||||||
|
width: 30,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: C.accent,
|
||||||
|
marginTop: 8,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content Area
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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.accent,
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
listItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.gray600,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: C.accent,
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
textBold: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'Helvetica-Bold',
|
||||||
|
color: C.navyDeep,
|
||||||
|
},
|
||||||
|
textItalic: {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Footer — matches brochure style
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 40,
|
||||||
|
left: MARGIN,
|
||||||
|
right: MARGIN,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 24,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: C.gray200,
|
||||||
|
},
|
||||||
|
|
||||||
|
footerText: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: C.gray400,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
footerBrand: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 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 style={styles.productHero}>
|
||||||
|
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||||
|
<View style={styles.accentBar} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"pages": {
|
"pages": {
|
||||||
"impressum": "impressum",
|
"impressum": "impressum",
|
||||||
"datenschutz": "datenschutz",
|
"datenschutz": "datenschutz",
|
||||||
"agbs": "agbs",
|
"agbs": "terms",
|
||||||
"kontakt": "contact",
|
"kontakt": "contact",
|
||||||
"team": "team",
|
"team": "team",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"privacyPolicy": "Datenschutz",
|
"privacyPolicy": "Datenschutz",
|
||||||
"privacyPolicySlug": "datenschutz",
|
"privacyPolicySlug": "datenschutz",
|
||||||
"terms": "AGB",
|
"terms": "AGB",
|
||||||
"termsSlug": "agbs",
|
"termsSlug": "terms",
|
||||||
"products": "Produkte",
|
"products": "Produkte",
|
||||||
"lowVoltage": "Niederspannungskabel",
|
"lowVoltage": "Niederspannungskabel",
|
||||||
"mediumVoltage": "Mittelspannungskabel",
|
"mediumVoltage": "Mittelspannungskabel",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"pages": {
|
"pages": {
|
||||||
"legal-notice": "impressum",
|
"legal-notice": "impressum",
|
||||||
"privacy-policy": "datenschutz",
|
"privacy-policy": "datenschutz",
|
||||||
"terms": "agbs",
|
"terms": "terms",
|
||||||
"contact": "contact",
|
"contact": "contact",
|
||||||
"team": "team",
|
"team": "team",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
@@ -396,4 +396,4 @@
|
|||||||
"cta": "Back to Safety"
|
"cta": "Back to Safety"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ const nextConfig = {
|
|||||||
maxInactiveAge: 60 * 1000,
|
maxInactiveAge: 60 * 1000,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
|
staleTimes: {
|
||||||
|
dynamic: 0,
|
||||||
|
static: 30,
|
||||||
|
},
|
||||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||||
cpus: 3,
|
cpus: 3,
|
||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
|
serverActions: {
|
||||||
|
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
swcMinify: true,
|
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
@@ -458,10 +464,6 @@ const nextConfig = {
|
|||||||
source: '/en/datenschutz',
|
source: '/en/datenschutz',
|
||||||
destination: '/en/privacy-policy',
|
destination: '/en/privacy-policy',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
source: '/en/agbs',
|
|
||||||
destination: '/en/terms',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
afterFiles: [],
|
afterFiles: [],
|
||||||
fallback: [],
|
fallback: [],
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -4,25 +4,19 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.31",
|
"@mintel/mail": "^1.8.21",
|
||||||
"@ai-sdk/openai": "^3.0.36",
|
"@mintel/next-config": "^1.8.21",
|
||||||
"@mintel/mail": "^1.9.0",
|
"@mintel/next-feedback": "^1.8.21",
|
||||||
"@mintel/next-config": "^1.9.0",
|
"@mintel/next-utils": "^1.8.21",
|
||||||
"@mintel/next-feedback": "^1.9.0",
|
|
||||||
"@mintel/next-utils": "^1.9.0",
|
|
||||||
"@payloadcms/db-postgres": "^3.77.0",
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||||
"@payloadcms/ui": "^3.77.0",
|
"@payloadcms/ui": "^3.77.0",
|
||||||
"@qdrant/js-client-rest": "^1.17.0",
|
|
||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "^1.0.7",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@react-three/drei": "^10.7.7",
|
|
||||||
"@react-three/fiber": "^9.5.0",
|
|
||||||
"@sentry/nextjs": "^10.39.0",
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"@types/recharts": "^2.0.1",
|
"@types/recharts": "^2.0.1",
|
||||||
"ai": "^6.0.101",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
@@ -30,7 +24,6 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
"import-in-the-middle": "^1.11.0",
|
"import-in-the-middle": "^1.11.0",
|
||||||
"ioredis": "^5.9.3",
|
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -45,17 +38,13 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"require-in-the-middle": "^8.0.1",
|
"require-in-the-middle": "^8.0.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svg-to-pdfkit": "^0.1.8",
|
"svg-to-pdfkit": "^0.1.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.183.1",
|
|
||||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
@@ -64,8 +53,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@cspell/dict-de-de": "^4.1.2",
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "^1.9.0",
|
"@mintel/eslint-config": "1.8.21",
|
||||||
"@mintel/tsconfig": "^1.9.0",
|
"@mintel/tsconfig": "^1.8.21",
|
||||||
"@next/bundle-analyzer": "^16.1.6",
|
"@next/bundle-analyzer": "^16.1.6",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -91,7 +80,6 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pa11y-ci": "^4.0.1",
|
"pa11y-ci": "^4.0.1",
|
||||||
"pdf-parse": "^2.4.5",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.37.3",
|
"puppeteer": "^24.37.3",
|
||||||
@@ -151,7 +139,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.2.12",
|
"version": "2.2.14",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export interface Config {
|
|||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
pages: PagesSelect<false> | PagesSelect<true>;
|
pages: PagesSelect<false> | PagesSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<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-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
@@ -98,6 +100,9 @@ export interface Config {
|
|||||||
globals: {};
|
globals: {};
|
||||||
globalsSelect: {};
|
globalsSelect: {};
|
||||||
locale: 'de' | 'en';
|
locale: 'de' | 'en';
|
||||||
|
widgets: {
|
||||||
|
collections: CollectionsWidget;
|
||||||
|
};
|
||||||
user: User;
|
user: User;
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: unknown;
|
tasks: unknown;
|
||||||
@@ -328,6 +333,14 @@ export interface Page {
|
|||||||
layout?: ('default' | 'fullBleed') | null;
|
layout?: ('default' | 'fullBleed') | null;
|
||||||
excerpt?: string | null;
|
excerpt?: string | null;
|
||||||
featuredImage?: (number | null) | Media;
|
featuredImage?: (number | null) | Media;
|
||||||
|
/**
|
||||||
|
* If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).
|
||||||
|
*/
|
||||||
|
redirectUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.
|
||||||
|
*/
|
||||||
|
redirectPermanent?: boolean | null;
|
||||||
content: {
|
content: {
|
||||||
root: {
|
root: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -574,6 +587,8 @@ export interface PagesSelect<T extends boolean = true> {
|
|||||||
layout?: T;
|
layout?: T;
|
||||||
excerpt?: T;
|
excerpt?: T;
|
||||||
featuredImage?: T;
|
featuredImage?: T;
|
||||||
|
redirectUrl?: T;
|
||||||
|
redirectPermanent?: T;
|
||||||
content?: T;
|
content?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
@@ -619,6 +634,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "StatsBlock".
|
* via the `definition` "StatsBlock".
|
||||||
@@ -957,7 +982,6 @@ export interface Auth {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
declare module 'payload' {
|
declare module 'payload' {
|
||||||
export interface GeneratedTypes extends Config {}
|
export interface GeneratedTypes extends Config {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
prodMigrations: migrations,
|
prodMigrations: migrations,
|
||||||
|
migrationDir:
|
||||||
|
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
|
||||||
pool: {
|
pool: {
|
||||||
connectionString:
|
connectionString:
|
||||||
process.env.DATABASE_URI ||
|
process.env.DATABASE_URI ||
|
||||||
|
|||||||
4789
pnpm-lock.yaml
generated
4789
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@ fi
|
|||||||
|
|
||||||
DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
||||||
DB_USER="${PAYLOAD_DB_USER:-payload}"
|
DB_USER="${PAYLOAD_DB_USER:-payload}"
|
||||||
DB_CONTAINER="klz-2026-klz-db-1"
|
|
||||||
BACKUP_DIR="./backups"
|
BACKUP_DIR="./backups"
|
||||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
||||||
@@ -21,20 +20,21 @@ BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
|||||||
# Ensure backup directory exists
|
# Ensure backup directory exists
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
# Check if container is running
|
# Check if database container is running
|
||||||
if ! docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then
|
if ! docker compose ps --services --filter "status=running" | grep -qx "klz-db"; then
|
||||||
echo "❌ Database container '$DB_CONTAINER' is not running."
|
echo "⚠️ Database container 'klz-db' is not running. Starting it..."
|
||||||
echo " Start it with: docker compose up -d klz-db"
|
docker compose up -d klz-db
|
||||||
exit 1
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
sleep 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Backing up Payload database..."
|
echo "📦 Backing up Payload database..."
|
||||||
echo " Container: $DB_CONTAINER"
|
echo " Service: klz-db"
|
||||||
echo " Database: $DB_NAME"
|
echo " Database: $DB_NAME"
|
||||||
echo " Output: $BACKUP_FILE"
|
echo " Output: $BACKUP_FILE"
|
||||||
|
|
||||||
# Run pg_dump inside the container and compress
|
# Run pg_dump inside the container and compress
|
||||||
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
docker compose exec -T klz-db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
||||||
|
|
||||||
# Show result
|
# Show result
|
||||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||||
|
|||||||
@@ -38,11 +38,21 @@ function getExpectedTranslation(
|
|||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
sourceLocale: string,
|
sourceLocale: string,
|
||||||
targetLocale: string,
|
targetLocale: string,
|
||||||
): string {
|
alternates: { hreflang: string; href: string }[],
|
||||||
|
): string | null {
|
||||||
const segments = sourcePath.split('/').filter(Boolean);
|
const segments = sourcePath.split('/').filter(Boolean);
|
||||||
// First segment is locale
|
|
||||||
segments[0] = targetLocale;
|
segments[0] = targetLocale;
|
||||||
|
|
||||||
|
// Blog posts have dynamic slugs. If it's a blog post, trust the alternate tag
|
||||||
|
// if the href is present in the sitemap.
|
||||||
|
// The Smoke Test's primary job is ensuring the alternate links point to valid pages.
|
||||||
|
if (segments[1] === (targetLocale === 'de' ? 'blog' : 'blog') && segments.length > 2) {
|
||||||
|
const altLink = alternates.find((a) => a.hreflang === targetLocale);
|
||||||
|
if (altLink) {
|
||||||
|
return new URL(altLink.href).pathname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +60,7 @@ function getExpectedTranslation(
|
|||||||
segments
|
segments
|
||||||
.map((seg, i) => {
|
.map((seg, i) => {
|
||||||
if (i === 0) return seg; // locale
|
if (i === 0) return seg; // locale
|
||||||
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
return map[seg] || seg; // translate or keep
|
||||||
})
|
})
|
||||||
.join('/')
|
.join('/')
|
||||||
);
|
);
|
||||||
@@ -118,7 +128,7 @@ async function main() {
|
|||||||
if (alt.hreflang === locale) continue; // Same locale, skip
|
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||||
|
|
||||||
// 1. Check slug translation is correct
|
// 1. Check slug translation is correct
|
||||||
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang, alternates);
|
||||||
const actualPath = new URL(alt.href).pathname;
|
const actualPath = new URL(alt.href).pathname;
|
||||||
|
|
||||||
if (actualPath !== expectedPath) {
|
if (actualPath !== expectedPath) {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
|
||||||
|
|
||||||
const isDockerContainer =
|
|
||||||
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
|
||||||
const qdrantUrl =
|
|
||||||
process.env.QDRANT_URL ||
|
|
||||||
(isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333');
|
|
||||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
|
||||||
|
|
||||||
export const qdrant = new QdrantClient({
|
|
||||||
url: qdrantUrl,
|
|
||||||
apiKey: qdrantApiKey || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const COLLECTION_NAME = 'klz_products';
|
|
||||||
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the collection exists in Qdrant.
|
|
||||||
*/
|
|
||||||
export async function ensureCollection() {
|
|
||||||
try {
|
|
||||||
const collections = await qdrant.getCollections();
|
|
||||||
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
|
||||||
if (!exists) {
|
|
||||||
await qdrant.createCollection(COLLECTION_NAME, {
|
|
||||||
vectors: {
|
|
||||||
size: VECTOR_SIZE,
|
|
||||||
distance: 'Cosine',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error ensuring Qdrant collection:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
|
|
||||||
*/
|
|
||||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
|
||||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
|
||||||
if (!openRouterKey) {
|
|
||||||
throw new Error('OPENROUTER_API_KEY is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${openRouterKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
|
||||||
'X-Title': 'KLZ Cables Search AI',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'openai/text-embedding-3-small',
|
|
||||||
input: text,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.data[0].embedding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsert a product into Qdrant
|
|
||||||
*/
|
|
||||||
export async function upsertProductVector(
|
|
||||||
id: string | number,
|
|
||||||
text: string,
|
|
||||||
payload: Record<string, any>,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await ensureCollection();
|
|
||||||
const vector = await generateEmbedding(text);
|
|
||||||
|
|
||||||
await qdrant.upsert(COLLECTION_NAME, {
|
|
||||||
wait: true,
|
|
||||||
points: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
vector,
|
|
||||||
payload,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error writing to Qdrant:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a product from Qdrant
|
|
||||||
*/
|
|
||||||
export async function deleteProductVector(id: string | number) {
|
|
||||||
try {
|
|
||||||
await ensureCollection();
|
|
||||||
await qdrant.delete(COLLECTION_NAME, {
|
|
||||||
wait: true,
|
|
||||||
points: [id] as [string | number],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting from Qdrant:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search products in Qdrant
|
|
||||||
*/
|
|
||||||
export async function searchProducts(query: string, limit = 5) {
|
|
||||||
try {
|
|
||||||
await ensureCollection();
|
|
||||||
const vector = await generateEmbedding(query);
|
|
||||||
|
|
||||||
const results = await qdrant.search(COLLECTION_NAME, {
|
|
||||||
vector,
|
|
||||||
limit,
|
|
||||||
with_payload: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching in Qdrant:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import Redis from 'ioredis';
|
|
||||||
|
|
||||||
const isDockerContainer =
|
|
||||||
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
|
||||||
const redisUrl =
|
|
||||||
process.env.REDIS_URL ||
|
|
||||||
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
|
|
||||||
|
|
||||||
// Only create a single instance in Node.js
|
|
||||||
const globalForRedis = global as unknown as { redis: Redis };
|
|
||||||
|
|
||||||
export const redis =
|
|
||||||
globalForRedis.redis ||
|
|
||||||
new Redis(redisUrl, {
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
globalForRedis.redis = redis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default redis;
|
|
||||||
52
src/migrations/20260305_215000_products_featured_image.ts
Normal file
52
src/migrations/20260305_215000_products_featured_image.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
// Add featured_image_id to products and _products_v
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "products" ADD COLUMN IF NOT EXISTS "featured_image_id" integer;
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "version_featured_image_id" integer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add foreign key constraints
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "products" ADD CONSTRAINT "products_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS "products_featured_image_idx" ON "products" USING btree ("featured_image_id");
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS "_products_v_version_version_featured_image_idx" ON "_products_v" USING btree ("version_featured_image_id");
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_featured_image_id_media_id_fk";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "_products_v" DROP CONSTRAINT IF EXISTS "_products_v_version_featured_image_id_media_id_fk";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP INDEX IF EXISTS "products_featured_image_idx";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP INDEX IF EXISTS "_products_v_version_version_featured_image_idx";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "products" DROP COLUMN IF EXISTS "featured_image_id";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "_products_v" DROP COLUMN IF EXISTS "version_featured_image_id";
|
||||||
|
`);
|
||||||
|
}
|
||||||
22
src/migrations/20260312_120000_pages_redirect_fields.ts
Normal file
22
src/migrations/20260312_120000_pages_redirect_fields.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
// redirect_permanent is a non-localized checkbox → stored on the main pages table
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "redirect_permanent" boolean DEFAULT true;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// redirect_url is a localized text field → stored on the pages_locales table
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages_locales" ADD COLUMN IF NOT EXISTS "redirect_url" varchar;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages" DROP COLUMN IF EXISTS "redirect_permanent";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages_locales" DROP COLUMN IF EXISTS "redirect_url";
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import * as migration_20260223_195005_products_collection from './20260223_19500
|
|||||||
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
||||||
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
|
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
|
||||||
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
|
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
|
||||||
|
import * as migration_20260305_215000_products_featured_image from './20260305_215000_products_featured_image';
|
||||||
|
import * as migration_20260312_120000_pages_redirect_fields from './20260312_120000_pages_redirect_fields';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -24,4 +26,14 @@ export const migrations = [
|
|||||||
down: migration_20260225_175000_native_localization.down,
|
down: migration_20260225_175000_native_localization.down,
|
||||||
name: '20260225_175000_native_localization',
|
name: '20260225_175000_native_localization',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260305_215000_products_featured_image.up,
|
||||||
|
down: migration_20260305_215000_products_featured_image.down,
|
||||||
|
name: '20260305_215000_products_featured_image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260312_120000_pages_redirect_fields.up,
|
||||||
|
down: migration_20260312_120000_pages_redirect_fields.down,
|
||||||
|
name: '20260312_120000_pages_redirect_fields',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
30
src/payload/blocks/PDFDownload.ts
Normal file
30
src/payload/blocks/PDFDownload.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Block } from 'payload';
|
||||||
|
|
||||||
|
export const PDFDownload: Block = {
|
||||||
|
slug: 'pdfDownload',
|
||||||
|
labels: {
|
||||||
|
singular: 'PDF Download',
|
||||||
|
plural: 'PDF Downloads',
|
||||||
|
},
|
||||||
|
admin: {},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'label',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Button Beschriftung',
|
||||||
|
required: true,
|
||||||
|
localized: true,
|
||||||
|
defaultValue: 'Als PDF herunterladen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'primary',
|
||||||
|
options: [
|
||||||
|
{ label: 'Primary', value: 'primary' },
|
||||||
|
{ label: 'Secondary', value: 'secondary' },
|
||||||
|
{ label: 'Outline', value: 'outline' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ import { StickyNarrative } from './StickyNarrative';
|
|||||||
import { TeamProfile } from './TeamProfile';
|
import { TeamProfile } from './TeamProfile';
|
||||||
import { TechnicalGrid } from './TechnicalGrid';
|
import { TechnicalGrid } from './TechnicalGrid';
|
||||||
import { VisualLinkPreview } from './VisualLinkPreview';
|
import { VisualLinkPreview } from './VisualLinkPreview';
|
||||||
|
import { PDFDownload } from './PDFDownload';
|
||||||
import { homeBlocksArray } from './HomeBlocks';
|
import { homeBlocksArray } from './HomeBlocks';
|
||||||
|
|
||||||
export const payloadBlocks = [
|
export const payloadBlocks = [
|
||||||
@@ -38,4 +39,5 @@ export const payloadBlocks = [
|
|||||||
TeamProfile,
|
TeamProfile,
|
||||||
TechnicalGrid,
|
TechnicalGrid,
|
||||||
VisualLinkPreview,
|
VisualLinkPreview,
|
||||||
|
PDFDownload,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -72,6 +72,33 @@ export const Pages: CollectionConfig = {
|
|||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'collapsible',
|
||||||
|
label: 'Redirect Settings',
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'redirectUrl',
|
||||||
|
type: 'text',
|
||||||
|
localized: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'redirectPermanent',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'content',
|
name: 'content',
|
||||||
type: 'richText',
|
type: 'richText',
|
||||||
|
|||||||
@@ -37,51 +37,6 @@ export const Products: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
|
||||||
afterChange: [
|
|
||||||
async ({ doc, req, operation }) => {
|
|
||||||
// Run index sync asynchronously to not block the CMS save operation
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
|
||||||
|
|
||||||
// Check if product is published
|
|
||||||
if (doc._status !== 'published') {
|
|
||||||
await deleteProductVector(doc.id);
|
|
||||||
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
|
|
||||||
} else {
|
|
||||||
// Serialize payload
|
|
||||||
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
|
|
||||||
const payload = {
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
sku: doc.sku,
|
|
||||||
slug: doc.slug,
|
|
||||||
description: doc.description,
|
|
||||||
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
|
|
||||||
};
|
|
||||||
await upsertProductVector(doc.id, contentText, payload);
|
|
||||||
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
return doc;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
afterDelete: [
|
|
||||||
async ({ id, req }) => {
|
|
||||||
try {
|
|
||||||
const { deleteProductVector } = await import('../../lib/qdrant');
|
|
||||||
await deleteProductVector(id as string | number);
|
|
||||||
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
|
|
||||||
} catch (error) {
|
|
||||||
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
// Override Qdrant URL for local script execution outside docker
|
|
||||||
process.env.QDRANT_URL = process.env.QDRANT_URL || 'http://localhost:6333';
|
|
||||||
|
|
||||||
import { upsertProductVector } from '../lib/qdrant';
|
|
||||||
|
|
||||||
// Ingests the extracted Kabelhandbuch text into Qdrant as distinct knowledge topics.
|
|
||||||
async function ingestPDF(txtPath: string) {
|
|
||||||
if (!fs.existsSync(txtPath)) {
|
|
||||||
console.error(`File not found: ${txtPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = fs.readFileSync(txtPath, 'utf8');
|
|
||||||
|
|
||||||
// Simple sentence/paragraph chunking
|
|
||||||
// We split by standard paragraph breaks (double newline) or large content blocks.
|
|
||||||
const chunks = text
|
|
||||||
.split(/\n\s*\n/)
|
|
||||||
.map((c) => c.trim())
|
|
||||||
.filter((c) => c.length > 50);
|
|
||||||
|
|
||||||
console.log(`Extracted ${text.length} characters from PDF.`);
|
|
||||||
console.log(`Generated ${chunks.length} chunks for vector ingestion.\n`);
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
|
||||||
// We limit chuck sizes to ensure Openrouter embedding models don't timeout/fail,
|
|
||||||
// stringing multiple paragraphs if they are short, or cutting them if too long.
|
|
||||||
// For baseline, we'll index every chunk individually mapped as 'knowledge' with a unique ID
|
|
||||||
|
|
||||||
const chunkText = chunks[i];
|
|
||||||
|
|
||||||
// Generate a synthetic ID that won't collide with Payload Product IDs
|
|
||||||
// Qdrant strictly requires UUID or unsigned int.
|
|
||||||
const syntheticId = crypto.randomUUID();
|
|
||||||
|
|
||||||
const payloadData = {
|
|
||||||
type: 'knowledge', // Custom flag to differentiate from 'product'
|
|
||||||
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
|
||||||
content: chunkText,
|
|
||||||
source: 'Kabelhandbuch KLZ.pdf',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use the existing upsert function since it just embeds the text and stores the payload
|
|
||||||
await upsertProductVector(syntheticId, chunkText, payloadData);
|
|
||||||
console.log(`✅ Upserted chunk ${i + 1}/${chunks.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🎉 PDF Ingestion Complete!');
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse PDF:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run mapping
|
|
||||||
const targetTxt = '/Users/marcmintel/Downloads/kabelhandbuch.txt';
|
|
||||||
ingestPDF(targetTxt);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import fetch from 'node-fetch';
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
const messages = [
|
|
||||||
{ role: 'user', content: 'Ich will einen Windpark bauen' }
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('Sending message:', messages[0].content);
|
|
||||||
|
|
||||||
const res = await fetch('http://localhost:3000/api/ai-search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ messages })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
console.log('\nAI Response:', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
test().catch(console.error);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { generateText } from 'ai';
|
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
|
||||||
|
|
||||||
const openrouter = createOpenAI({
|
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
|
||||||
apiKey: process.env.OPENROUTER_API_KEY,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const { text } = await generateText({
|
|
||||||
model: openrouter('mistralai/mistral-large-2407'),
|
|
||||||
prompt: 'Hello world! Reply in one word.',
|
|
||||||
});
|
|
||||||
console.log('Result:', text);
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
Reference in New Issue
Block a user