feat(ai-search): add interactive WebGL Orb, Markdown support, and Sentry tracking
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

This commit is contained in:
2026-02-28 00:03:39 +01:00
parent 7fb1945ce5
commit f1a28b9db2
14 changed files with 1848 additions and 453 deletions

View File

@@ -6,8 +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 AIOrb from '../search/AIOrb';
import { useState } from 'react';
import { Search, Sparkles } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
@@ -76,24 +77,26 @@ export default function Hero({ data }: { data?: any }) {
</div>
<form
onSubmit={handleSearchSubmit}
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-full p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent"
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
>
<Sparkles className="w-6 h-6 text-accent ml-4 hidden sm:block" />
<div className="absolute left-2 w-12 h-12 flex items-center justify-center opacity-80 pointer-events-none">
<AIOrb isThinking={false} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen Sie nach einem Kabel (z.B. N2XY, NYM-J)..."
className="flex-1 bg-transparent border-none text-white px-4 py-3 placeholder:text-white/60 focus:outline-none text-lg"
placeholder="Projekt beschreiben oder Kabel suchen..."
className="flex-1 bg-transparent border-none text-white pl-12 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
/>
<Button
type="submit"
variant="accent"
size="lg"
className="rounded-full px-8 py-3 shrink-0"
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
>
<Search className="w-5 h-5 mr-2 -ml-2" />
Suchen
Fragen
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
</Button>
</form>
@@ -103,7 +106,7 @@ export default function Hero({ data }: { data?: any }) {
href="/contact"
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),

View File

@@ -0,0 +1,88 @@
/* eslint-disable react/no-unknown-property */
'use client';
import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Sphere, MeshDistortMaterial, Environment, Float } from '@react-three/drei';
import * as THREE from 'three';
interface AIOrbProps {
isThinking: boolean;
}
function Orb({ isThinking }: AIOrbProps) {
const meshRef = useRef<THREE.Mesh>(null);
const materialRef = useRef<any>(null);
// Dynamic properties based on state
const targetDistort = isThinking ? 0.6 : 0.3;
const targetSpeed = isThinking ? 5 : 2;
const color = isThinking ? '#00FF88' : '#00A3FF'; // Green/Blue based on thinking state
useFrame((state) => {
if (!materialRef.current) return;
// Smoothly interpolate material properties
materialRef.current.distort = THREE.MathUtils.lerp(
materialRef.current.distort,
targetDistort,
0.1,
);
materialRef.current.speed = THREE.MathUtils.lerp(materialRef.current.speed, targetSpeed, 0.1);
// Smooth color transition
const currentColor = materialRef.current.color;
const targetColorObj = new THREE.Color(color);
currentColor.lerp(targetColorObj, 0.05);
// Slow rotation
if (meshRef.current) {
meshRef.current.rotation.x = state.clock.getElapsedTime() * 0.2;
meshRef.current.rotation.y = state.clock.getElapsedTime() * 0.3;
}
});
return (
<Float
speed={isThinking ? 4 : 2}
rotationIntensity={isThinking ? 2 : 1}
floatIntensity={isThinking ? 2 : 1}
>
<Sphere ref={meshRef} args={[1, 64, 64]} scale={1.5}>
<MeshDistortMaterial
ref={materialRef}
color="#00A3FF"
envMapIntensity={2}
clearcoat={1}
clearcoatRoughness={0}
metalness={0.8}
roughness={0.1}
distort={0.3}
speed={2}
/>
</Sphere>
</Float>
);
}
export default function AIOrb({ isThinking = false }: AIOrbProps) {
return (
<div className="w-full h-full min-w-[32px] min-h-[32px] relative flex items-center justify-center">
{/* Ambient glow effect behind the orb */}
<div
className={`absolute inset-0 rounded-full blur-xl opacity-50 transition-colors duration-1000 ${isThinking ? 'bg-[#00FF88]/50' : 'bg-[#00A3FF]/40'}`}
/>
<Canvas
camera={{ position: [0, 0, 4], fov: 45 }}
className="w-full h-full cursor-pointer z-10 block"
>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1.5} />
<directionalLight position={[-10, -10, -5]} intensity={0.5} color="#00FF88" />
<Orb isThinking={isThinking} />
<Environment preset="city" />
</Canvas>
</div>
);
}

View File

@@ -1,230 +1,323 @@
'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 { 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 Image from 'next/image';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import AIOrb from './AIOrb';
interface ProductMatch {
id: string;
title: string;
sku: string;
slug: string;
id: string;
title: string;
sku: string;
slug: string;
}
interface AIResponse {
answerText: string;
products: ProductMatch[];
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
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();
export function AISearchResults({
isOpen,
onClose,
initialQuery = '',
triggerSearch = false,
}: ComponentProps) {
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);
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';
// Slight delay to allow animation to start before focus
setTimeout(() => inputRef.current?.focus(), 100);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
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(() => {
if (triggerSearch && initialQuery && messages.length === 0) {
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);
}
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]);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'Escape') {
onClose();
}
};
useEffect(() => {
if (isOpen && initialQuery && messages.length === 0) {
setQuery(initialQuery);
}
}, [initialQuery, isOpen]);
if (!isOpen) return null;
useEffect(() => {
// Auto-scroll to bottom of chat
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
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"
/>
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim() || isLoading) return;
<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>
const newUserMessage: Message = { role: 'user', content: searchQuery };
const newMessagesContext = [...messages, newUserMessage];
{/* 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>
)}
setMessages(newMessagesContext);
setQuery('');
setIsLoading(true);
setError(null);
{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>
)}
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
type: 'ai_search',
query: searchQuery,
});
{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>
try {
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: newMessagesContext,
_honeypot: honeypot,
}),
});
{/* 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>
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>
);
}