diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 9423afba..478f4ac2 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -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"
diff --git a/app/api/ai-search/route.ts b/app/api/ai-search/route.ts
new file mode 100644
index 00000000..d434ea8a
--- /dev/null
+++ b/app/api/ai-search/route.ts
@@ -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 });
+ }
+}
diff --git a/components/Footer.tsx b/components/Footer.tsx
index cbb24205..068f16d8 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -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() {
+
+ {/* Brand & Quality Sub-Footer */}
+
+
+
+
+
+ SSL Secured
+
+
+
+ Green Hosting
+
+
+
+ DSGVO Compliant
+
+
+
+
+ PageSpeed 90+
+
+
+
);
diff --git a/components/Header.tsx b/components/Header.tsx
index 83595e87..a2ec2e09 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -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(null);
// Extract locale from pathname
@@ -274,6 +277,19 @@ export default function Header() {
+
+
+
+
+
+ setIsSearchOpen(false)}
+ />
>
);
}
diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx
index 033aa31d..5f2c1ea4 100644
--- a/components/home/Hero.tsx
+++ b/components/home/Hero.tsx
@@ -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 (
-
-
-
-
-
- {data?.title ? (
- /g, '').replace(/<\/green>/g, '
') }} />
- ) : (
- t.rich('title', {
- green: (chunks) => (
-
- {chunks}
-
-
-
-
- ),
- })
- )}
-
-
-
-
- {data?.subtitle || t('subtitle')}
-
-
-
+ <>
+
+
+
+
+ {data?.title ? (
+ /g, '').replace(/<\/green>/g, '
') }} />
+ ) : (
+ t.rich('title', {
+ green: (chunks) => (
+
+ {chunks}
+
+
+
+
+ ),
+ })
+ )}
+
+
+
+
+ {data?.subtitle || t('subtitle')}
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
+
+
+
+
setIsSearchOpen(false)}
+ initialQuery={searchQuery}
+ triggerSearch={true}
+ />
+ >
);
}
diff --git a/components/search/AISearchResults.tsx b/components/search/AISearchResults.tsx
new file mode 100644
index 00000000..023a2d86
--- /dev/null
+++ b/components/search/AISearchResults.tsx
@@ -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(null);
+ const [error, setError] = useState(null);
+ const inputRef = useRef(null);
+ const modalRef = useRef(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) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSearch();
+ }
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+ {/* Header - Search Bar */}
+
+
+
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"
+ />
+
setHoneypot(e.target.value)}
+ tabIndex={-1}
+ autoComplete="off"
+ aria-hidden="true"
+ />
+ {isLoading ? (
+
+ ) : query ? (
+
+ ) : null}
+
+
+
+
+ {/* Content Area */}
+
+ {!response && !isLoading && !error && (
+
+
+
Describe what you need, and our AI will find it.
+
+ )}
+
+ {error && (
+
+
+
+
Encountered an error
+
{error}
+
+
+ )}
+
+ {response && (
+
+ {/* AI Answer */}
+
+
+
+
AI Assistant
+
+ {response.answerText}
+
+
+
+ {/* Product Matches */}
+ {response.products && response.products.length > 0 && (
+
+
Matching Products
+
+ {response.products.map((product, idx) => (
+
{
+ 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"
+ >
+
+
{product.sku}
+
{product.title}
+
+
+ Details
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 13c8aa66..b5ffc484 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -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:
diff --git a/docker-compose.yml b/docker-compose.yml
index d7f3ba28..5a30cb3a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/next-env.d.ts b/next-env.d.ts
index 9edff1c7..c4b7818f 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/package.json b/package.json
index fd643417..ac15d7aa 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1d588d60..cef71b2e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/lib/qdrant.ts b/src/lib/qdrant.ts
new file mode 100644
index 00000000..f5ec584b
--- /dev/null
+++ b/src/lib/qdrant.ts
@@ -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 {
+ 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) {
+ 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 [];
+ }
+}
diff --git a/src/lib/redis.ts b/src/lib/redis.ts
new file mode 100644
index 00000000..0f14c745
--- /dev/null
+++ b/src/lib/redis.ts
@@ -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;
diff --git a/src/payload/collections/Products.ts b/src/payload/collections/Products.ts
index 78387f39..0c6dadd3 100644
--- a/src/payload/collections/Products.ts
+++ b/src/payload/collections/Products.ts
@@ -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',