feat(ai-search): optimize dev server, add qdrant boot sync, fix orb overflow
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
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
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-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 AIOrb from './AIOrb';
|
||||
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;
|
||||
@@ -20,12 +27,13 @@ interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
products?: ProductMatch[];
|
||||
timestamp: number;
|
||||
}
|
||||
interface ComponentProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialQuery?: string;
|
||||
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
|
||||
triggerSearch?: boolean;
|
||||
}
|
||||
|
||||
export function AISearchResults({
|
||||
@@ -41,20 +49,41 @@ export function AISearchResults({
|
||||
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';
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
|
||||
if (triggerSearch && initialQuery && messages.length === 0) {
|
||||
setQuery(initialQuery);
|
||||
// Trigger initial search only once
|
||||
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
|
||||
hasTriggeredRef.current = true;
|
||||
handleSearch(initialQuery);
|
||||
} else if (!triggerSearch) {
|
||||
setQuery('');
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
@@ -62,6 +91,7 @@ export function AISearchResults({
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
hasTriggeredRef.current = false;
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
@@ -69,46 +99,81 @@ export function AISearchResults({
|
||||
}, [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]);
|
||||
|
||||
// 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 };
|
||||
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
|
||||
const newMessagesContext = [...messages, newUserMessage];
|
||||
|
||||
setMessages(newMessagesContext);
|
||||
setQuery('');
|
||||
setIsLoading(true);
|
||||
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_search',
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch search results');
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
@@ -117,21 +182,41 @@ export function AISearchResults({
|
||||
role: 'assistant',
|
||||
content: data.answerText,
|
||||
products: data.products,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
// 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.');
|
||||
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_search_results',
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,14 +225,36 @@ export function AISearchResults({
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
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;
|
||||
|
||||
// Handle clicking outside to close
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
@@ -156,168 +263,384 @@ export function AISearchResults({
|
||||
|
||||
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"
|
||||
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-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"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
{/* ── 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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" />
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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',
|
||||
});
|
||||
{/* 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`,
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-transparent rounded-2xl p-2 w-24 flex justify-center">
|
||||
<AIOrb isThinking={true} />
|
||||
{/* 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>
|
||||
)}
|
||||
)}
|
||||
|
||||
{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 ref={messagesEndRef} />
|
||||
</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>
|
||||
|
||||
{/* ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user