feat: ai search

This commit is contained in:
2026-02-26 03:10:15 +01:00
parent 0487bd8ebe
commit 20fd889751
14 changed files with 963 additions and 76 deletions

View File

@@ -255,6 +255,12 @@ jobs:
# Analytics
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' }}
# 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:
- name: Checkout repository
uses: actions/checkout@v4
@@ -313,6 +319,12 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
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 "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME"

138
app/api/ai-search/route.ts Normal file
View File

@@ -0,0 +1,138 @@
import { NextResponse } from 'next/server';
import { searchProducts } from '../../../src/lib/qdrant';
import redis from '../../../src/lib/redis';
import { z } from 'zod';
// Config and constants
const RATE_LIMIT_POINTS = 5; // 5 requests
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
const requestSchema = z.object({
query: z.string().min(1).max(500),
_honeypot: z.string().max(0).optional(), // Honeypot trap: must be empty
});
export async function POST(req: Request) {
try {
// 1. IP extraction for Rate Limiting
const forwardedFor = req.headers.get('x-forwarded-for');
const realIp = req.headers.get('x-real-ip');
const ip = forwardedFor?.split(',')[0] || realIp || 'anon';
const rateLimitKey = `rate_limit:ai_search:${ip}`;
// Redis Rate Limiting
try {
const current = await redis.incr(rateLimitKey);
if (current === 1) {
await redis.expire(rateLimitKey, RATE_LIMIT_DURATION);
}
if (current > RATE_LIMIT_POINTS) {
return NextResponse.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429 });
}
} catch (redisError) {
console.warn('Redis error during rate limiting:', redisError);
// Fallback: proceed if Redis is down, to maintain availability
}
// 2. Validate request
const json = await req.json().catch(() => ({}));
const parseResult = requestSchema.safeParse(json);
if (!parseResult.success) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const { query, _honeypot } = parseResult.data;
// 3. Honeypot check
// If the honeypot field has any content, this is a bot.
if (_honeypot && _honeypot.length > 0) {
// Return a fake success mask
return NextResponse.json({ answer: 'Searching...' }, { status: 200 });
}
// 4. Qdrant Context Retrieval
const searchResults = await searchProducts(query, 5);
// Build context block
const contextText = searchResults.map((res: any) => {
const payload = res.payload;
return `Product ID: ${payload?.id}
Name: ${payload?.title}
SKU: ${payload?.sku}
Description: ${payload?.description}
Slug: ${payload?.slug}
---`;
}).join('\n');
// 5. OpenRouter Integration (gemini-3-flash-preview)
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
const systemPrompt = `You are the KLZ Cables AI Search Assistant, an intelligent, helpful, and highly specialized assistant strictly for the KLZ Cables website.
Your primary goal is to help users find the correct industrial cables and products based ONLY on the context provided.
Follow these strict rules:
1. ONLY answer questions related to products, search queries, cables, or industrial electronics.
2. If the user asks a question entirely unrelated to products or the company (e.g., "What is the capital of France?", "Write a poem", "What is 2+2?"), REFUSE to answer it. Instead, reply with a funny, sarcastic, or humorous comment about how you only know about cables and wires.
3. Base your product answers strictly on the CONTEXT provided below. Do not hallucinate products.
4. Output your response as a valid JSON object matching this schema exactly, do not use Markdown codeblocks, output RAW JSON:
{
"answerText": "A friendly description or answer based on the search.",
"products": [
{ "id": "Context Product ID", "title": "Product Title", "sku": "Product SKU", "slug": "slug" }
]
}
If you find relevant products in the context, add them to the "products" array. If no products match, use an empty array.
CONTEXT:
${contextText}
`;
const response = 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: 'google/gemini-3-flash-preview',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: query }
],
response_format: { type: "json_object" }
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`OpenRouter error: ${response.status} ${errorBody}`);
}
const completion = await response.json();
const rawContent = completion.choices?.[0]?.message?.content;
let answerJson;
try {
// Remove any potential markdown json block markers
const sanitizedObjStr = rawContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
answerJson = JSON.parse(sanitizedObjStr);
} catch (parseError) {
console.error('Failed to parse AI response:', rawContent);
answerJson = {
answerText: rawContent || "Sorry, I had trouble thinking about cables right now.",
products: []
};
}
return NextResponse.json(answerJson);
} catch (error) {
console.error('AI Search API Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -3,6 +3,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
import { Container } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
@@ -275,6 +276,48 @@ export default function Footer() {
</Link>
</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>
</footer>
);

View File

@@ -9,6 +9,8 @@ import { useEffect, useState, useRef } from 'react';
import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
import { Search } from 'lucide-react';
import { AISearchResults } from './search/AISearchResults';
export default function Header() {
const t = useTranslations('Navigation');
@@ -16,6 +18,7 @@ export default function Header() {
const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
// Extract locale from pathname
@@ -274,6 +277,19 @@ export default function Header() {
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
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
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
@@ -445,6 +461,11 @@ export default function Header() {
</nav>
</div>
</header>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
/>
</>
);
}

View File

@@ -6,6 +6,9 @@ import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import { useState } from 'react';
import { Search, Sparkles } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero({ data }: { data?: any }) {
@@ -13,91 +16,132 @@ export default function Hero({ data }: { data?: any }) {
const locale = useLocale();
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 (
<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">
<div className="max-w-5xl mx-auto md:mx-0">
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<>
<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">
<div className="max-w-5xl mx-auto md:mx-0">
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<form
onSubmit={handleSearchSubmit}
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-full p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent"
>
<Sparkles className="w-6 h-6 text-accent ml-4 hidden sm:block" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen Sie nach einem Kabel (z.B. N2XY, NYM-J)..."
className="flex-1 bg-transparent border-none text-white px-4 py-3 placeholder:text-white/60 focus:outline-none text-lg"
/>
<Button
href="/contact"
type="submit"
variant="accent"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
className="rounded-full px-8 py-3 shrink-0"
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</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')}
<Search className="w-5 h-5 mr-2 -ml-2" />
Suchen
</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-transform"
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">
&rarr;
</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>
</Container>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration />
</div>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
<div 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>
</Section>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
</div>
</div>
</Section>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
initialQuery={searchQuery}
triggerSearch={true}
/>
</>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Search, Loader2, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
import { Button, cn } from '@/components/ui';
import Link from 'next/link';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import Image from 'next/image';
interface ProductMatch {
id: string;
title: string;
sku: string;
slug: string;
}
interface AIResponse {
answerText: 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 t = useTranslations('Search');
const { trackEvent } = useAnalytics();
const [query, setQuery] = useState(initialQuery);
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [response, setResponse] = useState<AIResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// Slight delay to allow animation to start before focus
setTimeout(() => inputRef.current?.focus(), 100);
if (triggerSearch && initialQuery && !response) {
handleSearch(initialQuery);
}
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen, triggerSearch]);
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim()) return;
setIsLoading(true);
setError(null);
setResponse(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({ query: searchQuery, _honeypot: honeypot })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch search results');
}
setResponse(data);
} catch (err: any) {
console.error(err);
setError(err.message || 'An error occurred while searching. Please try again.');
} finally {
setIsLoading(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
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">
<div
className="absolute inset-0"
onClick={onClose}
aria-hidden="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 - Search Bar */}
<div className="p-6 md:p-8 flex items-center border-b border-white/10 relative z-10 bg-gradient-to-r from-primary/80 to-[#00223A]/80">
<Sparkles className="w-6 h-6 text-accent shrink-0 mr-4" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={"What are you looking for?"}
className="w-full bg-transparent border-none text-white text-xl md:text-3xl font-extrabold focus:outline-none placeholder:text-white/30"
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
{isLoading ? (
<Loader2 className="w-8 h-8 text-white/50 animate-spin shrink-0 ml-4" />
) : query ? (
<button
onClick={() => handleSearch()}
className="text-white hover:text-accent transition-colors ml-4 shrink-0"
aria-label="Search"
>
<Search className="w-8 h-8" />
</button>
) : null}
<div className="w-px h-10 bg-white/10 mx-6 hidden md:block" />
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<X className="w-8 h-8 md:w-10 md:h-10" />
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-6 md:p-8 relative">
{!response && !isLoading && !error && (
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4">
<Search className="w-16 h-16" />
<p className="text-xl md:text-2xl font-bold">Describe what you need, and our AI will find it.</p>
</div>
)}
{error && (
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-6 rounded-2xl">
<MessageSquareWarning className="w-8 h-8 text-red-400 shrink-0" />
<div>
<h3 className="text-xl font-bold text-red-200">Encountered an error</h3>
<p className="text-red-300 mt-2">{error}</p>
</div>
</div>
)}
{response && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* AI Answer */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 md:p-8 relative overflow-hidden group">
<div className="absolute top-0 left-0 w-1 h-full bg-accent" />
<Sparkles className="absolute top-4 right-4 w-6h-6 text-accent/20 group-hover:text-accent/40 transition-colors" />
<h3 className="text-sm font-bold tracking-widest uppercase text-accent mb-4">AI Assistant</h3>
<p className="text-lg md:text-xl text-white/90 leading-relaxed font-medium">
{response.answerText}
</p>
</div>
{/* Product Matches */}
{response.products && response.products.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold tracking-widest uppercase text-white/50">Matching Products</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{response.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-xl p-6 hover:shadow-2xl hover:-translate-y-1 transition-all duration-300"
>
<div>
<p className="text-xs font-bold text-primary/50 tracking-wider mb-2">{product.sku}</p>
<h4 className="text-xl md:text-2xl font-extrabold mb-4 group-hover:text-accent transition-colors">{product.title}</h4>
</div>
<div className="flex items-center text-sm font-bold tracking-widest uppercase">
<span className="group-hover:text-accent transition-colors">Details</span>
<ChevronRight className="w-4 h-4 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
</div>
</Link>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -75,6 +75,24 @@ services:
ports:
- "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:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
@@ -84,6 +102,8 @@ networks:
volumes:
klz_db_data:
external: false
klz_qdrant_data:
external: false
klz_node_modules:
klz_next_cache:
klz_turbo_cache:

View File

@@ -88,6 +88,23 @@ services:
networks:
- default
klz-redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- default
klz-qdrant:
image: qdrant/qdrant:v1.13.2
restart: unless-stopped
environment:
QDRANT__SERVICE__HTTP_PORT: 6333
QDRANT__SERVICE__GRPC_PORT: 6334
volumes:
- klz_qdrant_data:/qdrant/storage
networks:
- default
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
@@ -99,3 +116,5 @@ volumes:
external: false
klz_media_data:
external: false
klz_qdrant_data:
external: false

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -4,6 +4,7 @@
"private": true,
"packageManager": "pnpm@10.18.3",
"dependencies": {
"@ai-sdk/google": "^3.0.31",
"@mintel/mail": "1.8.3",
"@mintel/next-config": "1.8.3",
"@mintel/next-feedback": "1.8.10",
@@ -13,10 +14,12 @@
"@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/ui": "^3.77.0",
"@qdrant/js-client-rest": "^1.17.0",
"@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1",
"ai": "^6.0.101",
"axios": "^1.13.5",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
@@ -24,6 +27,7 @@
"gray-matter": "^4.0.3",
"i18next": "^25.7.3",
"import-in-the-middle": "^1.11.0",
"ioredis": "^5.9.3",
"jsdom": "^27.4.0",
"leaflet": "^1.9.4",
"next": "16.1.6",

171
pnpm-lock.yaml generated
View File

@@ -12,6 +12,9 @@ importers:
.:
dependencies:
'@ai-sdk/google':
specifier: ^3.0.31
version: 3.0.31(zod@3.25.76)
'@mintel/mail':
specifier: 1.8.3
version: 1.8.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -39,6 +42,9 @@ importers:
'@payloadcms/ui':
specifier: ^3.77.0
version: 3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
'@qdrant/js-client-rest':
specifier: ^1.17.0
version: 1.17.0(typescript@5.9.3)
'@react-email/components':
specifier: ^1.0.7
version: 1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -51,6 +57,9 @@ importers:
'@types/recharts':
specifier: ^2.0.1
version: 2.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
ai:
specifier: ^6.0.101
version: 6.0.101(zod@3.25.76)
axios:
specifier: ^1.13.5
version: 1.13.5(debug@4.4.3)
@@ -72,6 +81,9 @@ importers:
import-in-the-middle:
specifier: ^1.11.0
version: 1.15.0
ioredis:
specifier: ^5.9.3
version: 5.9.3
jsdom:
specifier: ^27.4.0
version: 27.4.0
@@ -268,6 +280,28 @@ packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@ai-sdk/gateway@3.0.55':
resolution: {integrity: sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/google@3.0.31':
resolution: {integrity: sha512-RVNz8WFSIRbXbYDBE6JvlE2escWPJimBCs22LzKEYH7DNfl/X7cHNa1LFho4PsY6Ib0JmbzB8s2+i0wHs/wNCg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.15':
resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -1563,6 +1597,9 @@ packages:
cpu: [x64]
os: [win32]
'@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -2167,6 +2204,16 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@qdrant/js-client-rest@1.17.0':
resolution: {integrity: sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A==}
engines: {node: '>=18.17.0', pnpm: '>=8'}
peerDependencies:
typescript: '>=4.7'
'@qdrant/openapi-typescript-fetch@1.2.6':
resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==}
engines: {node: '>=18.0.0', pnpm: '>=8'}
'@react-email/body@0.0.11':
resolution: {integrity: sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg==}
peerDependencies:
@@ -3368,6 +3415,10 @@ packages:
cpu: [x64]
os: [win32]
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
'@vitejs/plugin-react@5.1.4':
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3513,6 +3564,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@6.0.101:
resolution: {integrity: sha512-Ur/NgbgOp1rdhyDiKDk6EOpSgd1g5ADlbcD1cjQJtQsnmhEngz3Rf8nK5JetDh0vnbLy2aEBpaQeL+zvLRWuaA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -3997,6 +4054,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@@ -4395,6 +4456,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -4892,6 +4957,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -5488,6 +5557,10 @@ packages:
intl-messageformat@11.1.2:
resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==}
ioredis@5.9.3:
resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==}
engines: {node: '>=12.22.0'}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
@@ -5815,6 +5888,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -6005,6 +6081,12 @@ packages:
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.kebabcase@4.1.1:
resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==}
@@ -7103,6 +7185,14 @@ packages:
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -7453,6 +7543,9 @@ packages:
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
engines: {node: '>=6'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
start-server-and-test@2.1.3:
resolution: {integrity: sha512-k4EcbNjeg0odaDkAMlIeDVDByqX9PIgL4tivgP2tES6Zd8o+4pTq/HgbWCyA3VHIoZopB+wGnNPKYGGSByNriQ==}
engines: {node: '>=16'}
@@ -8383,6 +8476,30 @@ snapshots:
'@acemir/cssom@0.9.31': {}
'@ai-sdk/gateway@3.0.55(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
'@vercel/oidc': 3.1.0
zod: 3.25.76
'@ai-sdk/google@3.0.31(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@4.0.15(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@apidevtools/json-schema-ref-parser@11.9.3':
@@ -9519,6 +9636,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ioredis/commands@1.5.0': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -10495,6 +10614,14 @@ snapshots:
- react-native-b4a
- supports-color
'@qdrant/js-client-rest@1.17.0(typescript@5.9.3)':
dependencies:
'@qdrant/openapi-typescript-fetch': 1.2.6
typescript: 5.9.3
undici: 6.23.0
'@qdrant/openapi-typescript-fetch@1.2.6': {}
'@react-email/body@0.0.11(react@19.2.4)':
dependencies:
react: 19.2.4
@@ -11708,6 +11835,8 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vercel/oidc@3.1.0': {}
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.29.0
@@ -11893,6 +12022,14 @@ snapshots:
agent-base@7.1.4: {}
ai@6.0.101(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.55(zod@3.25.76)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv-formats@2.1.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@@ -12414,6 +12551,8 @@ snapshots:
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@@ -12849,6 +12988,8 @@ snapshots:
delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -13475,6 +13616,8 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.6: {}
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -14145,6 +14288,20 @@ snapshots:
'@formatjs/icu-messageformat-parser': 3.5.1
tslib: 2.8.1
ioredis@5.9.3:
dependencies:
'@ioredis/commands': 1.5.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
@@ -14467,6 +14624,8 @@ snapshots:
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -14670,6 +14829,10 @@ snapshots:
lodash.camelcase@4.3.0: {}
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.kebabcase@4.1.1: {}
lodash.merge@4.6.2: {}
@@ -16019,6 +16182,12 @@ snapshots:
- '@types/react'
- redux
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
@@ -16477,6 +16646,8 @@ snapshots:
dependencies:
type-fest: 0.7.1
standard-as-callback@2.1.0: {}
start-server-and-test@2.1.3:
dependencies:
arg: 5.0.2

124
src/lib/qdrant.ts Normal file
View File

@@ -0,0 +1,124 @@
import { QdrantClient } from '@qdrant/js-client-rest';
const qdrantUrl = process.env.QDRANT_URL || '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 [];
}
}

16
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,16 @@
import Redis from 'ioredis';
const redisUrl = process.env.REDIS_URL || 'redis://klz-redis: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;

View File

@@ -37,6 +37,51 @@ export const Products: CollectionConfig = {
};
},
},
hooks: {
afterChange: [
async ({ doc, req, operation }) => {
// Run index sync asynchronously to not block the CMS save operation
setTimeout(async () => {
try {
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
// Check if product is published
if (doc._status !== 'published') {
await deleteProductVector(doc.id);
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
} else {
// Serialize payload
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
const payload = {
id: doc.id,
title: doc.title,
sku: doc.sku,
slug: doc.slug,
description: doc.description,
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
};
await upsertProductVector(doc.id, contentText, payload);
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
}
} catch (error) {
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
}
}, 0);
return doc;
},
],
afterDelete: [
async ({ id, req }) => {
try {
const { deleteProductVector } = await import('../../lib/qdrant');
await deleteProductVector(id as string | number);
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
} catch (error) {
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
}
},
],
},
fields: [
{
name: 'title',