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

This commit is contained in:
2026-03-06 22:35:48 +01:00
parent 81ce3a4588
commit 4dcdb717f0
16 changed files with 1981 additions and 380 deletions

View File

@@ -6,11 +6,11 @@ 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 { useState, useEffect, useRef, useCallback } from 'react';
import { ChevronRight } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
export default function Hero({ data }: { data?: any }) {
const t = useTranslations('Home.hero');
@@ -19,6 +19,62 @@ export default function Hero({ data }: { data?: any }) {
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [heroPlaceholder, setHeroPlaceholder] = useState(
'Projekt beschreiben oder Kabel suchen...',
);
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const HERO_PLACEHOLDERS = [
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
'Wie schwer ist NAYY 4x150?',
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
'Mittelspannungskabel für Windkraftanlage', // Windpark
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
];
// Typing animation for the hero search placeholder
useEffect(() => {
if (searchQuery) {
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
return;
}
let textIdx = 0;
let charIdx = 0;
let deleting = false;
const tick = () => {
const fullText = HERO_PLACEHOLDERS[textIdx];
if (deleting) {
charIdx--;
setHeroPlaceholder(fullText.substring(0, charIdx));
} else {
charIdx++;
setHeroPlaceholder(fullText.substring(0, charIdx));
}
let delay = deleting ? 30 : 70;
if (!deleting && charIdx === fullText.length) {
delay = 2500;
deleting = true;
} else if (deleting && charIdx === 0) {
deleting = false;
textIdx = (textIdx + 1) % HERO_PLACEHOLDERS.length;
delay = 400;
}
typingRef.current = setTimeout(tick, delay);
};
typingRef.current = setTimeout(tick, 1500);
return () => {
if (typingRef.current) clearTimeout(typingRef.current);
};
}, [searchQuery]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -79,15 +135,16 @@ export default function Hero({ data }: { data?: any }) {
onSubmit={handleSearchSubmit}
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"
>
<div className="absolute left-2 w-12 h-12 flex items-center justify-center opacity-80 pointer-events-none">
<div className="absolute left-1 w-20 h-20 flex items-center justify-center z-10 overflow-visible">
<AIOrb isThinking={false} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
placeholder={heroPlaceholder}
className="flex-1 bg-transparent border-none text-white pl-20 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
autoFocus
/>
<Button
type="submit"

View File

@@ -74,11 +74,14 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{new Date(post.frontmatter.date).toLocaleDateString(
locale?.length === 2 ? locale : 'de',
{
year: 'numeric',
month: 'short',
day: 'numeric',
},
)}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (

View File

@@ -1,88 +1,371 @@
/* 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';
import React, { useRef, useEffect, useCallback } from 'react';
interface AIOrbProps {
isThinking: boolean;
hasError?: 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;
}
});
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
// Simple noise function for organic movement
function noise(x: number, y: number, t: number): number {
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>
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
);
}
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'}`}
/>
// ── Particle ───────────────────────────────────────────────────
interface Particle {
// Sphere position (target shape)
theta: number;
phi: number;
// Current position
x: number;
y: number;
z: number;
// Velocity
vx: number;
vy: number;
vz: number;
// Properties
size: number;
baseSize: number;
hue: number; // 0=blue, 1=green
brightness: number;
phase: number;
orbitSpeed: number;
noiseScale: number;
}
<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>
function createParticles(count: number): Particle[] {
const particles: Particle[] = [];
for (let i = 0; i < count; i++) {
// Fibonacci sphere distribution for even spacing
const golden = Math.PI * (3 - Math.sqrt(5));
const y = 1 - (i / (count - 1)) * 2;
const radiusAtY = Math.sqrt(1 - y * y);
const theta = golden * i;
const phi = Math.acos(y);
particles.push({
theta,
phi,
x: Math.cos(theta) * radiusAtY,
y,
z: Math.sin(theta) * radiusAtY,
vx: 0,
vy: 0,
vz: 0,
size: 0.4 + Math.random() * 0.8,
baseSize: 0.4 + Math.random() * 0.8,
hue: Math.random() > 0.45 ? 0 : 1,
brightness: 0.5 + Math.random() * 0.5,
phase: Math.random() * Math.PI * 2,
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
noiseScale: 0.5 + Math.random() * 1.5,
});
}
return particles;
}
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const animRef = useRef<number>(0);
const particlesRef = useRef<Particle[]>([]);
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
const state = useRef({
pulse: 0,
hover: 0,
error: 0,
mouseX: 0.5,
mouseY: 0.5,
rotY: 0,
rotX: 0,
breathe: 0,
scatter: 0,
shake: 0,
});
const onMove = useCallback((e: React.PointerEvent) => {
const r = wrapRef.current?.getBoundingClientRect();
if (!r) return;
mouse.current.x = (e.clientX - r.left) / r.width;
mouse.current.y = (e.clientY - r.top) / r.height;
}, []);
const onEnter = useCallback(() => {
mouse.current.hover = true;
}, []);
const onLeave = useCallback(() => {
mouse.current.hover = false;
mouse.current.x = 0.5;
mouse.current.y = 0.5;
}, []);
const draw = useCallback(
function drawStep() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = rect.width * dpr;
const h = rect.height * dpr;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const cx = w / 2;
const cy = h / 2;
const minDim = Math.min(w, h);
// Reduced further to give maximum breathing room for glow + movement
const sphereR = minDim * 0.16;
const time = performance.now() / 1000;
const s = state.current;
const m = mouse.current;
// ── Interpolate state ──
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
s.mouseX = lerp(s.mouseX, m.x, 0.12);
s.mouseY = lerp(s.mouseY, m.y, 0.12);
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
s.shake += 0.15 * s.error;
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
const mouseRotX = (s.mouseY - 0.5) * 0.8;
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
// ── Clear ──
ctx.clearRect(0, 0, w, h);
// ── Subtle core glow ──
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
const glowCX = cx + shakeX;
const glowCY = cy;
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
const glowR = Math.min(
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
minDim * 0.48,
);
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
// Glow color: blue normally, red on error
const glowR1 = Math.round(lerp(20, 255, s.error));
const glowG1 = Math.round(lerp(60, 40, s.error));
const glowB1 = Math.round(lerp(255, 40, s.error));
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
glow.addColorStop(
0.25,
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
);
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
ctx.fill();
// ── Create particles if empty ──
if (particlesRef.current.length === 0) {
particlesRef.current = createParticles(350);
}
// ── Update & draw particles ──
const cosRY = Math.cos(s.rotY + mouseRotY);
const sinRY = Math.sin(s.rotY + mouseRotY);
const cosRX = Math.cos(mouseRotX);
const sinRX = Math.sin(mouseRotX);
// Sort by z for correct layering
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
const projected: ParticleWithScreen[] = [];
for (const p of particlesRef.current) {
// Target position: sphere surface + noise displacement
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
// Orbit: rotate theta — always moving, faster idle
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
// Sphere coordinates to cartesian
const sinPhi = Math.sin(p.phi);
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
// Excitement from hover + pulse + error
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
const tgtY = Math.cos(p.phi) * displacement;
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
// Scatter on hover: push particles outward
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
// Spring physics toward target
const tx = tgtX * scatterMul;
const ty = tgtY * scatterMul;
const tz = tgtZ * scatterMul;
p.vx += (tx - p.x) * 0.08;
p.vy += (ty - p.y) * 0.08;
p.vz += (tz - p.z) * 0.08;
p.vx *= 0.88;
p.vy *= 0.88;
p.vz *= 0.88;
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
// 3D rotation (Y then X)
const rx = p.x * cosRY - p.z * sinRY;
const rz = p.x * sinRY + p.z * cosRY;
const ry = p.y * cosRX - rz * sinRX;
const finalZ = p.y * sinRX + rz * cosRX;
// Project to screen
const perspective = 3;
const scale = perspective / (perspective + finalZ);
const sx = cx + rx * sphereR * scale;
const sy = cy + ry * sphereR * scale;
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
}
// Sort back-to-front
projected.sort((a, b) => a.sz - b.sz);
for (const { p, sx, sy, sz, depth } of projected) {
// Depth-based alpha and size
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
const alpha =
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
const drawSize =
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
// Color — shift to red on error
let r: number, g: number, b: number;
if (s.error > 0.1) {
// Error: red family
if (p.hue === 0) {
r = Math.round(lerp(40 + sz * 30, 255, s.error));
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
b = Math.round(lerp(255, 40, s.error));
} else {
r = Math.round(lerp(100 + sz * 30, 230, s.error));
g = Math.round(lerp(220 + sz * 17, 60, s.error));
b = Math.round(lerp(20, 20, s.error));
}
} else if (p.hue === 0) {
r = 60 + Math.round(sz * 40);
g = 100 + Math.round(sz * 50);
b = 255;
} else {
r = 120 + Math.round(sz * 30);
g = 237 + Math.round(sz * 10);
b = 30;
}
// Thinking: shift toward brighter, more saturated
if (s.pulse > 0.1) {
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
}
// Micro glow — always visible, stronger on front
if (depthAlpha > 0.25) {
const gSize = drawSize * lerp(4, 7, s.hover);
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = pg;
ctx.beginPath();
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
ctx.fill();
}
// Core dot — bright
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
ctx.beginPath();
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
ctx.fill();
}
// ── Loading rings (thinking) ──
if (s.pulse > 0.02) {
ctx.save();
ctx.translate(cx, cy);
// Spinning arc
const spinAngle = time * 2;
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
ctx.rotate(spinAngle);
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
ctx.lineWidth = 1.2 * dpr;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
ctx.stroke();
// Counter-spinning arc
ctx.rotate(-spinAngle * 2);
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
ctx.lineWidth = 0.8 * dpr;
ctx.beginPath();
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
ctx.stroke();
ctx.restore();
// Expanding pulse
const pulsePhase = (time * 0.8) % 1;
const pulseR = sphereR * (1 + pulsePhase * 1.5);
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
ctx.lineWidth = 1 * dpr;
ctx.beginPath();
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
ctx.stroke();
}
animRef.current = requestAnimationFrame(drawStep);
},
[isThinking, hasError],
);
useEffect(() => {
animRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animRef.current);
}, [draw]);
return (
<div
ref={wrapRef}
className="w-full h-full relative overflow-visible"
onPointerMove={onMove}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
style={{ cursor: 'pointer' }}
>
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
</div>
);
}

View File

@@ -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>
);
}