feat: ai search

This commit is contained in:
2026-02-26 03:10:15 +01:00
parent 0487bd8ebe
commit 20fd889751
14 changed files with 963 additions and 76 deletions

View File

@@ -3,6 +3,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
import { Container } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
@@ -275,6 +276,48 @@ export default function Footer() {
</Link>
</div>
</div>
{/* Brand & Quality Sub-Footer */}
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
<div>
<a
href="https://mintel.me"
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
target: 'mintel_agency',
location: 'sub_footer',
})
}
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
>
Website entwickelt von Marc Mintel
</a>
</div>
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
<div className="flex items-center gap-1.5" title="SSL Secured">
<ShieldCheck className="w-3.5 h-3.5" />
<span>SSL Secured</span>
</div>
<div className="flex items-center gap-1.5" title="Green Hosting">
<Leaf className="w-3.5 h-3.5" />
<span>Green Hosting</span>
</div>
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
<Lock className="w-3.5 h-3.5" />
<span>DSGVO Compliant</span>
</div>
<div className="flex items-center gap-1.5" title="WCAG">
<Accessibility className="w-3.5 h-3.5" />
<span>WCAG</span>
</div>
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
<Zap className="w-3.5 h-3.5" />
<span>PageSpeed 90+</span>
</div>
</div>
</div>
</Container>
</footer>
);

View File

@@ -9,6 +9,8 @@ import { useEffect, useState, useRef } from 'react';
import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
import { Search } from 'lucide-react';
import { AISearchResults } from './search/AISearchResults';
export default function Header() {
const t = useTranslations('Navigation');
@@ -16,6 +18,7 @@ export default function Header() {
const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
// Extract locale from pathname
@@ -274,6 +277,19 @@ export default function Header() {
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
>
<button
onClick={() => setIsSearchOpen(true)}
className="hover:text-accent transition-colors p-2"
aria-label="Search"
>
<Search className="w-5 h-5 md:w-6 md:h-6" />
</button>
</div>
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
>
<Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
@@ -445,6 +461,11 @@ export default function Header() {
</nav>
</div>
</header>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
/>
</>
);
}

View File

@@ -6,6 +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 { useState } from 'react';
import { Search, Sparkles } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero({ data }: { data?: any }) {
@@ -13,91 +16,132 @@ export default function Hero({ data }: { data?: any }) {
const locale = useLocale();
const { trackEvent } = useAnalytics();
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
setIsSearchOpen(true);
}
};
return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<div className="max-w-5xl mx-auto md:mx-0">
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<>
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<div className="max-w-5xl mx-auto md:mx-0">
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</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"
>
<Sparkles className="w-6 h-6 text-accent ml-4 hidden sm:block" />
<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"
/>
<Button
href="/contact"
type="submit"
variant="accent"
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"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
className="rounded-full px-8 py-3 shrink-0"
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</span>
</Button>
</div>
<div>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
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 md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{data?.secondaryCtaLabel || t('exploreProducts')}
<Search className="w-5 h-5 mr-2 -ml-2" />
Suchen
</Button>
</form>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<div>
<Button
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"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</span>
</Button>
</div>
<div>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="outline"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{data?.secondaryCtaLabel || t('exploreProducts')}
</Button>
</div>
</div>
</div>
</div>
</Container>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration />
</div>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration />
</div>
</div>
</Section>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
</div>
</div>
</Section>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
initialQuery={searchQuery}
triggerSearch={true}
/>
</>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Search, Loader2, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
import { Button, cn } from '@/components/ui';
import Link from 'next/link';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import Image from 'next/image';
interface ProductMatch {
id: string;
title: string;
sku: string;
slug: string;
}
interface AIResponse {
answerText: string;
products: ProductMatch[];
}
interface ComponentProps {
isOpen: boolean;
onClose: () => void;
initialQuery?: string;
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
}
export function AISearchResults({ isOpen, onClose, initialQuery = '', triggerSearch = false }: ComponentProps) {
const t = useTranslations('Search');
const { trackEvent } = useAnalytics();
const [query, setQuery] = useState(initialQuery);
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [response, setResponse] = useState<AIResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// Slight delay to allow animation to start before focus
setTimeout(() => inputRef.current?.focus(), 100);
if (triggerSearch && initialQuery && !response) {
handleSearch(initialQuery);
}
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen, triggerSearch]);
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim()) return;
setIsLoading(true);
setError(null);
setResponse(null);
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
type: 'ai_search',
query: searchQuery
});
try {
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: searchQuery, _honeypot: honeypot })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch search results');
}
setResponse(data);
} catch (err: any) {
console.error(err);
setError(err.message || 'An error occurred while searching. Please try again.');
} finally {
setIsLoading(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in">
<div
className="absolute inset-0"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={modalRef}
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
>
{/* Header - Search Bar */}
<div className="p-6 md:p-8 flex items-center border-b border-white/10 relative z-10 bg-gradient-to-r from-primary/80 to-[#00223A]/80">
<Sparkles className="w-6 h-6 text-accent shrink-0 mr-4" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={"What are you looking for?"}
className="w-full bg-transparent border-none text-white text-xl md:text-3xl font-extrabold focus:outline-none placeholder:text-white/30"
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
{isLoading ? (
<Loader2 className="w-8 h-8 text-white/50 animate-spin shrink-0 ml-4" />
) : query ? (
<button
onClick={() => handleSearch()}
className="text-white hover:text-accent transition-colors ml-4 shrink-0"
aria-label="Search"
>
<Search className="w-8 h-8" />
</button>
) : null}
<div className="w-px h-10 bg-white/10 mx-6 hidden md:block" />
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<X className="w-8 h-8 md:w-10 md:h-10" />
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-6 md:p-8 relative">
{!response && !isLoading && !error && (
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4">
<Search className="w-16 h-16" />
<p className="text-xl md:text-2xl font-bold">Describe what you need, and our AI will find it.</p>
</div>
)}
{error && (
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-6 rounded-2xl">
<MessageSquareWarning className="w-8 h-8 text-red-400 shrink-0" />
<div>
<h3 className="text-xl font-bold text-red-200">Encountered an error</h3>
<p className="text-red-300 mt-2">{error}</p>
</div>
</div>
)}
{response && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* AI Answer */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 md:p-8 relative overflow-hidden group">
<div className="absolute top-0 left-0 w-1 h-full bg-accent" />
<Sparkles className="absolute top-4 right-4 w-6h-6 text-accent/20 group-hover:text-accent/40 transition-colors" />
<h3 className="text-sm font-bold tracking-widest uppercase text-accent mb-4">AI Assistant</h3>
<p className="text-lg md:text-xl text-white/90 leading-relaxed font-medium">
{response.answerText}
</p>
</div>
{/* Product Matches */}
{response.products && response.products.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold tracking-widest uppercase text-white/50">Matching Products</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{response.products.map((product, idx) => (
<Link
key={idx}
href={`/produkte/${product.slug}`}
onClick={() => {
onClose();
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: product.slug,
location: 'ai_search_results'
});
}}
className="group flex flex-col justify-between bg-white text-primary rounded-xl p-6 hover:shadow-2xl hover:-translate-y-1 transition-all duration-300"
>
<div>
<p className="text-xs font-bold text-primary/50 tracking-wider mb-2">{product.sku}</p>
<h4 className="text-xl md:text-2xl font-extrabold mb-4 group-hover:text-accent transition-colors">{product.title}</h4>
</div>
<div className="flex items-center text-sm font-bold tracking-widest uppercase">
<span className="group-hover:text-accent transition-colors">Details</span>
<ChevronRight className="w-4 h-4 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
</div>
</Link>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}