Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
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
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m55s
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
|
import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import { useAnalytics } from '../analytics/useAnalytics';
|
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import AIOrb from './AIOrb';
|
|
|
|
interface ProductMatch {
|
|
id: string;
|
|
title: string;
|
|
sku: string;
|
|
slug: string;
|
|
}
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
products?: ProductMatch[];
|
|
}
|
|
interface ComponentProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
initialQuery?: string;
|
|
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
|
|
}
|
|
|
|
export function AISearchResults({
|
|
isOpen,
|
|
onClose,
|
|
initialQuery = '',
|
|
triggerSearch = false,
|
|
}: ComponentProps) {
|
|
const { trackEvent } = useAnalytics();
|
|
|
|
const [query, setQuery] = useState('');
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [honeypot, setHoneypot] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
|
|
if (triggerSearch && initialQuery && messages.length === 0) {
|
|
setQuery(initialQuery);
|
|
handleSearch(initialQuery);
|
|
} else if (!triggerSearch) {
|
|
setQuery('');
|
|
}
|
|
} else {
|
|
document.body.style.overflow = 'unset';
|
|
setQuery('');
|
|
setMessages([]);
|
|
setError(null);
|
|
setIsLoading(false);
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = 'unset';
|
|
};
|
|
}, [isOpen, triggerSearch]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && initialQuery && messages.length === 0) {
|
|
setQuery(initialQuery);
|
|
}
|
|
}, [initialQuery, isOpen]);
|
|
|
|
useEffect(() => {
|
|
// Auto-scroll to bottom of chat
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, isLoading]);
|
|
|
|
const handleSearch = async (searchQuery: string = query) => {
|
|
if (!searchQuery.trim() || isLoading) return;
|
|
|
|
const newUserMessage: Message = { role: 'user', content: searchQuery };
|
|
const newMessagesContext = [...messages, newUserMessage];
|
|
|
|
setMessages(newMessagesContext);
|
|
setQuery('');
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
|
type: 'ai_search',
|
|
query: searchQuery,
|
|
});
|
|
|
|
try {
|
|
const res = await fetch('/api/ai-search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
messages: newMessagesContext,
|
|
_honeypot: honeypot,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.error || 'Failed to fetch search results');
|
|
}
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: 'assistant',
|
|
content: data.answerText,
|
|
products: data.products,
|
|
},
|
|
]);
|
|
|
|
// Re-focus input after response so user can continue typing easily
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
setError(err.message || 'An error occurred while chatting. Please try again.');
|
|
trackEvent(AnalyticsEvents.ERROR, {
|
|
location: 'ai_search_results',
|
|
message: err.message,
|
|
query: searchQuery,
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleSearch();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// Handle clicking outside to close
|
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in"
|
|
onClick={handleBackdropClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
ref={modalRef}
|
|
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
|
|
>
|
|
{/* Header */}
|
|
<div className="p-4 md:p-6 flex items-center justify-between border-b border-white/10 relative z-10 bg-[#001c30]">
|
|
<div className="flex items-center">
|
|
<Sparkles className="w-5 h-5 text-accent mr-3" />
|
|
<h2 className="text-white font-bold tracking-widest uppercase text-sm">
|
|
KLZ AI Consultant
|
|
</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-white/50 hover:text-white transition-colors p-2"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Chat History Area */}
|
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
|
|
{messages.length === 0 && !isLoading && !error && (
|
|
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<AIOrb isThinking={false} />
|
|
<p className="text-xl md:text-2xl font-bold mt-6">I am your technical consultant.</p>
|
|
<p className="text-sm">
|
|
Describe your project, ask for specific cables, or tell me your requirements.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg, index) => (
|
|
<div
|
|
key={index}
|
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div
|
|
className={`max-w-[85%] rounded-2xl p-5 ${msg.role === 'user' ? 'bg-accent text-primary rounded-tr-sm' : 'bg-white/10 border border-white/10 text-white rounded-tl-sm'}`}
|
|
>
|
|
{msg.role === 'assistant' && (
|
|
<h3 className="text-xs font-bold tracking-widest uppercase text-accent/80 mb-2 flex items-center">
|
|
<Sparkles className="w-3 h-3 mr-1" />
|
|
AI Assistant
|
|
</h3>
|
|
)}
|
|
<div className="text-base md:text-lg leading-relaxed font-medium prose prose-invert prose-p:leading-relaxed prose-pre:bg-black/50 prose-a:text-accent prose-strong:text-accent prose-ul:list-disc prose-ol:list-decimal">
|
|
{msg.role === 'assistant' ? (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
|
) : (
|
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Product Matches inside Assistant Message */}
|
|
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
|
<div className="mt-6 space-y-3 border-t border-white/10 pt-4">
|
|
<h4 className="text-xs font-bold tracking-widest uppercase text-white/50">
|
|
Empfohlene Produkte
|
|
</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{msg.products.map((product, idx) => (
|
|
<Link
|
|
key={idx}
|
|
href={`/produkte/${product.slug}`}
|
|
onClick={() => {
|
|
onClose();
|
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
target: product.slug,
|
|
location: 'ai_search_results',
|
|
});
|
|
}}
|
|
className="group flex flex-col justify-between bg-white text-primary rounded-lg p-4 hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
|
|
>
|
|
<div>
|
|
<p className="text-[10px] font-bold text-primary/50 tracking-wider mb-1">
|
|
{product.sku}
|
|
</p>
|
|
<h5 className="text-sm font-extrabold mb-2 group-hover:text-accent transition-colors line-clamp-2">
|
|
{product.title}
|
|
</h5>
|
|
</div>
|
|
<div className="flex items-center justify-end text-[10px] font-bold tracking-widest uppercase mt-2">
|
|
<span className="group-hover:text-accent transition-colors">
|
|
Details
|
|
</span>
|
|
<ChevronRight className="w-3 h-3 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-transparent rounded-2xl p-2 w-24 flex justify-center">
|
|
<AIOrb isThinking={true} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-4 rounded-xl mt-4">
|
|
<MessageSquareWarning className="w-6 h-6 text-red-400 shrink-0" />
|
|
<div>
|
|
<h3 className="text-sm font-bold text-red-200">System Error</h3>
|
|
<p className="text-xs text-red-300 mt-1">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="p-4 md:p-6 bg-[#001c30] border-t border-white/10">
|
|
<div className="relative flex items-center bg-white/5 border border-white/10 rounded-xl focus-within:border-accent/50 focus-within:bg-white/10 transition-all">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
placeholder="Type your question or requirements..."
|
|
className="flex-1 bg-transparent border-none text-white text-base md:text-lg p-4 focus:outline-none placeholder:text-white/30"
|
|
disabled={isLoading}
|
|
/>
|
|
<input
|
|
type="text"
|
|
className="hidden"
|
|
value={honeypot}
|
|
onChange={(e) => setHoneypot(e.target.value)}
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
aria-hidden="true"
|
|
/>
|
|
<button
|
|
onClick={() => handleSearch()}
|
|
disabled={!query.trim() || isLoading}
|
|
className="p-4 text-white/50 hover:text-accent disabled:opacity-50 disabled:hover:text-white/50 transition-colors shrink-0 cursor-pointer"
|
|
aria-label="Send message"
|
|
>
|
|
<Search className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
<div className="text-center mt-3">
|
|
<span className="text-[10px] uppercase tracking-widest font-bold text-white/30">
|
|
Press Enter to send • Esc to close
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|