diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 9423afba..478f4ac2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -255,6 +255,12 @@ jobs: # Analytics UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} + + # Search & AI + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }} + QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }} + QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -313,6 +319,12 @@ jobs: echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "" + echo "# Search & AI" + echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY" + echo "QDRANT_URL=$QDRANT_URL" + echo "QDRANT_API_KEY=$QDRANT_API_KEY" + echo "REDIS_URL=$REDIS_URL" + echo "" echo "TARGET=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET" echo "PROJECT_NAME=$PROJECT_NAME" diff --git a/app/api/ai-search/route.ts b/app/api/ai-search/route.ts new file mode 100644 index 00000000..d434ea8a --- /dev/null +++ b/app/api/ai-search/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from 'next/server'; +import { searchProducts } from '../../../src/lib/qdrant'; +import redis from '../../../src/lib/redis'; +import { z } from 'zod'; + +// Config and constants +const RATE_LIMIT_POINTS = 5; // 5 requests +const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute + +const requestSchema = z.object({ + query: z.string().min(1).max(500), + _honeypot: z.string().max(0).optional(), // Honeypot trap: must be empty +}); + +export async function POST(req: Request) { + try { + // 1. IP extraction for Rate Limiting + const forwardedFor = req.headers.get('x-forwarded-for'); + const realIp = req.headers.get('x-real-ip'); + const ip = forwardedFor?.split(',')[0] || realIp || 'anon'; + const rateLimitKey = `rate_limit:ai_search:${ip}`; + + // Redis Rate Limiting + try { + const current = await redis.incr(rateLimitKey); + if (current === 1) { + await redis.expire(rateLimitKey, RATE_LIMIT_DURATION); + } + if (current > RATE_LIMIT_POINTS) { + return NextResponse.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429 }); + } + } catch (redisError) { + console.warn('Redis error during rate limiting:', redisError); + // Fallback: proceed if Redis is down, to maintain availability + } + + // 2. Validate request + const json = await req.json().catch(() => ({})); + const parseResult = requestSchema.safeParse(json); + + if (!parseResult.success) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + + const { query, _honeypot } = parseResult.data; + + // 3. Honeypot check + // If the honeypot field has any content, this is a bot. + if (_honeypot && _honeypot.length > 0) { + // Return a fake success mask + return NextResponse.json({ answer: 'Searching...' }, { status: 200 }); + } + + // 4. Qdrant Context Retrieval + const searchResults = await searchProducts(query, 5); + + // Build context block + const contextText = searchResults.map((res: any) => { + const payload = res.payload; + return `Product ID: ${payload?.id} +Name: ${payload?.title} +SKU: ${payload?.sku} +Description: ${payload?.description} +Slug: ${payload?.slug} +---`; + }).join('\n'); + + // 5. OpenRouter Integration (gemini-3-flash-preview) + const openRouterKey = process.env.OPENROUTER_API_KEY; + if (!openRouterKey) { + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); + } + + const systemPrompt = `You are the KLZ Cables AI Search Assistant, an intelligent, helpful, and highly specialized assistant strictly for the KLZ Cables website. +Your primary goal is to help users find the correct industrial cables and products based ONLY on the context provided. +Follow these strict rules: +1. ONLY answer questions related to products, search queries, cables, or industrial electronics. +2. If the user asks a question entirely unrelated to products or the company (e.g., "What is the capital of France?", "Write a poem", "What is 2+2?"), REFUSE to answer it. Instead, reply with a funny, sarcastic, or humorous comment about how you only know about cables and wires. +3. Base your product answers strictly on the CONTEXT provided below. Do not hallucinate products. +4. Output your response as a valid JSON object matching this schema exactly, do not use Markdown codeblocks, output RAW JSON: +{ + "answerText": "A friendly description or answer based on the search.", + "products": [ + { "id": "Context Product ID", "title": "Product Title", "sku": "Product SKU", "slug": "slug" } + ] +} + +If you find relevant products in the context, add them to the "products" array. If no products match, use an empty array. + +CONTEXT: +${contextText} +`; + + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${openRouterKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com', + 'X-Title': 'KLZ Cables Search AI', + }, + body: JSON.stringify({ + model: 'google/gemini-3-flash-preview', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: query } + ], + response_format: { type: "json_object" } + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`OpenRouter error: ${response.status} ${errorBody}`); + } + + const completion = await response.json(); + const rawContent = completion.choices?.[0]?.message?.content; + + let answerJson; + try { + // Remove any potential markdown json block markers + const sanitizedObjStr = rawContent.replace(/^```json\s*/, '').replace(/\s*```$/, ''); + answerJson = JSON.parse(sanitizedObjStr); + } catch (parseError) { + console.error('Failed to parse AI response:', rawContent); + answerJson = { + answerText: rawContent || "Sorry, I had trouble thinking about cables right now.", + products: [] + }; + } + + return NextResponse.json(answerJson); + } catch (error) { + console.error('AI Search API Error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/Footer.tsx b/components/Footer.tsx index cbb24205..068f16d8 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -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() { + + {/* Brand & Quality Sub-Footer */} +
+
+ + 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 + +
+
+
+ + SSL Secured +
+
+ + Green Hosting +
+
+ + DSGVO Compliant +
+
+ + WCAG +
+
+ + PageSpeed 90+ +
+
+
); diff --git a/components/Header.tsx b/components/Header.tsx index 83595e87..a2ec2e09 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -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(null); // Extract locale from pathname @@ -274,6 +277,19 @@ export default function Header() {
+ +
+ +
+ + setIsSearchOpen(false)} + /> ); } diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 033aa31d..5f2c1ea4 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -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 ( -
- -
-
- - {data?.title ? ( - /g, '').replace(/<\/green>/g, '') }} /> - ) : ( - t.rich('title', { - green: (chunks) => ( - - {chunks} -
- -
-
- ), - }) - )} -
-
-
-

- {data?.subtitle || t('subtitle')} -

-
-
+ <> +
+ +
+ + {data?.title ? ( + /g, '').replace(/<\/green>/g, '') }} /> + ) : ( + t.rich('title', { + green: (chunks) => ( + + {chunks} +
+ +
+
+ ), + }) + )} +
+
+
+

+ {data?.subtitle || t('subtitle')} +

+
+
+ + 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" + /> -
-
- + + +
+
+ +
+
+ +
-
- + -
- -
- -
-
-
+
+
-
-
+ +
+
+
+
+
+ + setIsSearchOpen(false)} + initialQuery={searchQuery} + triggerSearch={true} + /> + ); } diff --git a/components/search/AISearchResults.tsx b/components/search/AISearchResults.tsx new file mode 100644 index 00000000..023a2d86 --- /dev/null +++ b/components/search/AISearchResults.tsx @@ -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(null); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const modalRef = useRef(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) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + if (e.key === 'Escape') { + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+