Files
klz-cables.com/components/search/AISearchResults.tsx
Marc Mintel 4dcdb717f0
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
feat(ai-search): optimize dev server, add qdrant boot sync, fix orb overflow
2026-03-06 22:35:48 +01:00

647 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { ArrowUp, X, Sparkles, ChevronRight, RotateCcw, Copy, Check } from 'lucide-react';
import Link from 'next/link';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import dynamic from 'next/dynamic';
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
const LOADING_TEXTS = [
'Durchsuche das Kabelhandbuch... 📖',
'Frage den Senior-Ingenieur... 👴🔧',
'Frage ChatGPTs Cousin 2. Grades... 🤖',
];
interface ProductMatch {
id: string;
title: string;
sku: string;
slug: string;
}
interface Message {
role: 'user' | 'assistant';
content: string;
products?: ProductMatch[];
timestamp: number;
}
interface ComponentProps {
isOpen: boolean;
onClose: () => void;
initialQuery?: string;
triggerSearch?: boolean;
}
export function AISearchResults({
isOpen,
onClose,
initialQuery = '',
triggerSearch = false,
}: ComponentProps) {
const { trackEvent } = useAnalytics();
const [query, setQuery] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const loadingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hasTriggeredRef = useRef(false);
// Dedicated focus effect — polls until the input actually has focus
useEffect(() => {
if (!isOpen) return;
let attempts = 0;
const focusTimer = setInterval(() => {
const el = inputRef.current;
if (el && document.activeElement !== el) {
el.focus({ preventScroll: true });
}
attempts++;
if (attempts >= 15 || document.activeElement === el) {
clearInterval(focusTimer);
}
}, 100);
return () => clearInterval(focusTimer);
}, [isOpen]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// Trigger initial search only once
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
hasTriggeredRef.current = true;
handleSearch(initialQuery);
}
} else {
document.body.style.overflow = 'unset';
setQuery('');
setMessages([]);
setError(null);
setIsLoading(false);
hasTriggeredRef.current = false;
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen, triggerSearch]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
// Global ESC handler
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
const activeElement = document.activeElement;
const isInputFocused = activeElement === inputRef.current;
if (query.trim()) {
// If there's text, clear it but keep chat open
setQuery('');
inputRef.current?.focus();
} else if (!isInputFocused) {
// If no text and input is not focused, focus it
inputRef.current?.focus();
} else {
// If no text and input IS focused, close the chat
onClose();
}
}
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose, query]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim() || isLoading) return;
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
const newMessagesContext = [...messages, newUserMessage];
setMessages(newMessagesContext);
setQuery(''); // Always clear input after send
setError(null);
// Give the user message animation 400ms to arrive before showing "thinking"
setTimeout(() => {
setIsLoading(true);
// Start rotating loading texts
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
setLoadingText(LOADING_TEXTS[textIdx]);
loadingIntervalRef.current = setInterval(() => {
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
setLoadingText(LOADING_TEXTS[textIdx]);
}, 2500);
}, 400);
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
type: 'ai_chat',
query: searchQuery,
});
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
messages: newMessagesContext,
_honeypot: honeypot,
}),
});
clearTimeout(timeout);
const data = await res.json().catch(() => null);
if (!res.ok || !data) {
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
}
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: data.answerText,
products: data.products,
timestamp: Date.now(),
},
]);
setTimeout(() => inputRef.current?.focus(), 100);
} catch (err: any) {
console.error(err);
const msg =
err.name === 'AbortError'
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
: err.message || 'Ein Fehler ist aufgetreten.';
// Show error as a system message in the chat instead of a separate error banner
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `⚠️ ${msg}`,
timestamp: Date.now(),
},
]);
trackEvent(AnalyticsEvents.ERROR, {
location: 'ai_chat',
message: err.message,
query: searchQuery,
});
} finally {
setIsLoading(false);
if (loadingIntervalRef.current) {
clearInterval(loadingIntervalRef.current);
loadingIntervalRef.current = null;
}
// Always re-focus the input
setTimeout(() => inputRef.current?.focus(), 50);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'ArrowUp' && !query) {
// Find the last user message and put it into the input
const lastUserNav = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserNav) {
e.preventDefault();
setQuery(lastUserNav.content);
}
}
};
const handleCopy = (content: string, index?: number) => {
navigator.clipboard.writeText(content);
if (index !== undefined) {
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} else {
setCopiedAll(true);
setTimeout(() => setCopiedAll(false), 2000);
}
};
const handleCopyChat = () => {
const fullChat = messages
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
.join('\n\n');
handleCopy(fullChat);
};
if (!isOpen) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-6 md:pt-12 px-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
style={{ animation: 'chatBackdropIn 0.4s ease-out forwards' }}
>
{/* Animated backdrop */}
<div
className="absolute inset-0 bg-[#000a18]/90 backdrop-blur-2xl"
style={{ animation: 'chatFadeIn 0.3s ease-out' }}
/>
<div
ref={modalRef}
className="relative w-full max-w-3xl flex flex-col"
style={{
height: 'min(90vh, 900px)',
animation: 'chatSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
}}
>
{/* ── Glassmorphism container ── */}
<div className="flex flex-col h-full rounded-3xl overflow-hidden border border-white/[0.08] bg-gradient-to-b from-white/[0.06] to-white/[0.02] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.6)]">
{/* ── Header ── */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 overflow-hidden rounded-full">
<AIOrb isThinking={isLoading} hasError={!!error} />
</div>
<div>
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{messages.length > 0 && (
<button
onClick={handleCopyChat}
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
title="gesamten Chat kopieren"
>
{copiedAll ? (
<Check className="w-3.5 h-3.5 text-accent" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
</button>
)}
<button
onClick={onClose}
className="text-white/30 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-xl p-2 cursor-pointer"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Chat Area ── */}
<div className="flex-1 overflow-y-auto px-5 py-6 space-y-5 scroll-smooth chat-scrollbar">
{/* Empty state */}
{messages.length === 0 && !isLoading && !error && (
<div
className="flex flex-col items-center justify-center h-full text-center space-y-5"
style={{ animation: 'chatFadeIn 0.6s ease-out 0.3s both' }}
>
<div className="w-24 h-24 mb-2">
<AIOrb isThinking={false} hasError={false} />
</div>
<div>
<p className="text-xl md:text-2xl font-bold text-white/80">
Wie kann ich helfen?
</p>
<p className="text-sm text-white/30 mt-2 max-w-md">
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
Anforderungen.
</p>
</div>
{/* Quick prompts */}
<div className="flex flex-wrap justify-center gap-2 mt-4">
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
(prompt) => (
<button
key={prompt}
onClick={() => handleSearch(prompt)}
className="text-xs text-white/40 hover:text-white/80 border border-white/10 hover:border-white/20 hover:bg-white/5 rounded-full px-4 py-2 transition-all duration-200 cursor-pointer"
>
{prompt}
</button>
),
)}
</div>
</div>
)}
{/* Messages */}
{messages.map((msg, index) => (
<div
key={index}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
style={{
animation: `chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${index * 0.05}s both`,
}}
>
<div
className={`relative group max-w-[85%] rounded-2xl px-5 py-4 ${
msg.role === 'user'
? 'bg-accent text-primary font-semibold rounded-br-lg'
: 'bg-white/[0.05] border border-white/[0.06] text-white/90 rounded-bl-lg'
}`}
>
{/* Copy Button */}
<button
onClick={() => handleCopy(msg.content, index)}
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg cursor-pointer ${
msg.role === 'user'
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
}`}
title="Nachricht kopieren"
>
{copiedIndex === index ? (
<Check className="w-3.5 h-3.5" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-3 h-3 text-accent/60" />
<span className="text-[10px] font-bold tracking-widest uppercase text-accent/50">
Ohm
</span>
</div>
)}
<div
className={`text-sm md:text-[15px] leading-relaxed ${
msg.role === 'assistant'
? 'prose prose-invert prose-sm prose-p:leading-relaxed prose-a:text-accent prose-strong:text-accent/90 prose-ul:list-disc prose-ol:list-decimal'
: ''
}`}
>
{msg.role === 'assistant' ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
</div>
{/* Timestamp */}
{!msg.products?.length && (
<p
className={`text-[9px] mt-2 font-medium tracking-wide ${msg.role === 'user' ? 'text-primary/40' : 'text-white/20'}`}
>
{new Date(msg.timestamp).toLocaleTimeString('de', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
)}
{/* Product cards */}
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
Empfohlene Produkte
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{msg.products.map((product, idx) => (
<Link
key={idx}
href={`/produkte/${product.slug}`}
onClick={() => {
onClose();
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: product.slug,
location: 'ai_chat',
});
}}
className="group flex items-center justify-between bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] hover:border-accent/30 rounded-xl px-4 py-3 transition-all duration-300"
style={{ animation: `chatFadeIn 0.3s ease-out ${idx * 0.1}s both` }}
>
<div className="min-w-0">
<p className="text-[9px] font-bold text-white/25 tracking-wider">
{product.sku}
</p>
<h5 className="text-xs font-bold text-white/70 group-hover:text-accent truncate transition-colors">
{product.title}
</h5>
</div>
<ChevronRight className="w-3.5 h-3.5 text-white/20 group-hover:text-accent shrink-0 ml-3 group-hover:translate-x-0.5 transition-all" />
</Link>
))}
</div>
</div>
)}
</div>
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div
className="flex justify-start"
style={{ animation: 'chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards' }}
>
<div className="flex items-center gap-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl rounded-bl-lg px-5 py-4">
<div className="w-10 h-10 shrink-0">
<AIOrb isThinking={true} hasError={false} />
</div>
<div>
<p
className="text-sm text-white/50 font-medium"
style={{ animation: 'chatTextSwap 0.4s ease-out' }}
key={loadingText}
>
{loadingText}
</p>
<div className="flex gap-1 mt-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-accent/40"
style={{
animation: 'chatDotBounce 1.2s ease-in-out infinite',
animationDelay: `${i * 0.15}s`,
}}
/>
))}
</div>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="flex justify-start" style={{ animation: 'chatShake 0.5s ease-out' }}>
<div className="flex items-center gap-4 bg-red-500/[0.06] border border-red-500/20 rounded-2xl rounded-bl-lg px-5 py-4">
<div className="w-10 h-10 shrink-0">
<AIOrb isThinking={false} hasError={true} />
</div>
<div>
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
<p className="text-xs text-red-300/60 mt-1">{error}</p>
<button
onClick={() => {
setError(null);
inputRef.current?.focus();
}}
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
Nochmal versuchen
</button>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* ── Input Area ── */}
<div className="px-5 pb-5 pt-3 border-t border-white/[0.04]">
<div
className={`relative flex items-center rounded-2xl transition-all duration-300 ${
query.trim()
? 'bg-white/[0.08] border border-accent/30 shadow-[0_0_20px_-4px_rgba(130,237,32,0.1)]'
: 'bg-white/[0.04] border border-white/[0.06]'
}`}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Nachricht eingeben..."
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
disabled={isLoading}
tabIndex={1}
autoFocus
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<button
onClick={() => handleSearch()}
disabled={!query.trim() || isLoading}
className={`mr-2 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 cursor-pointer ${
query.trim()
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
: 'bg-white/5 text-white/20'
}`}
aria-label="Nachricht senden"
>
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
</button>
</div>
<div className="flex items-center justify-center gap-3 mt-2.5">
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
Enter zum Senden · Esc zum Schließen
</span>
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
·
</span>
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
🛡 DSGVO-konform · EU-Server
</span>
</div>
</div>
</div>
</div>
{/* ── Keyframe animations ── */}
<style>{`
@keyframes chatBackdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes chatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(40px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes chatMessageIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chatDotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes chatTextSwap {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chatShake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-6px); }
30% { transform: translateX(5px); }
45% { transform: translateX(-4px); }
60% { transform: translateX(3px); }
75% { transform: translateX(-1px); }
}
/* Custom scrollbar */
.chat-scrollbar::-webkit-scrollbar {
width: 4px;
}
.chat-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.chat-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.chat-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.16);
}
.chat-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
`}</style>
</div>
);
}