feat: ai search
This commit is contained in:
230
components/search/AISearchResults.tsx
Normal file
230
components/search/AISearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user