Files
einfach-produktiv/app/components/SearchOverlay.tsx
T
Marco d018d87e25 Fix account-nav feedback, blog filter position, and small icon polish
- Remove Merkliste from AccountNav — the Navbar's own wishlist icon already covers it, a second nav entry was redundant.
- Fix duplicate "Mein Profil" heading (ProfileForm already renders its own; KontoShell no longer adds a second one).
- "Eingeloggt als ..." now renders once in KontoShell, consistently on every /konto/* page, instead of only on the orders page.
- Blog category filter chips moved into the hero's own text column (was a separate bar below the entire hero including the photo) — visible immediately on every width instead of requiring a scroll past the hero image first.
- ArrowRightIcon's arrowhead wings shortened (looked like a generic oversized chevron at full length).
- Search overlay's "Esc" text button replaced with an X icon; the actual Escape keyboard shortcut is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 22:13:08 +00:00

147 lines
5.8 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { SearchResult } from "../api/search/route";
const DEBOUNCE_MS = 250;
export function SearchButton() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Suche öffnen"
className="flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
{open && <SearchOverlay onClose={() => setOpen(false)} />}
</>
);
}
function SearchOverlay({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (query.trim().length < 2) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query.trim())}`, { cache: "no-store" });
const data: { results?: SearchResult[] } = await res.json().catch(() => ({ results: [] }));
setResults(data.results ?? []);
setLoading(false);
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
const products = results.filter((r) => r.type === "product");
const posts = results.filter((r) => r.type === "post");
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 border-b-2 border-border focus-within:border-brand transition-colors pb-3">
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-muted shrink-0" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Produkte, Blogbeiträge…"
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
/>
{/* X icon, not the old "Esc" text — the keyboard shortcut still
works (see the Escape keydown handler above), this button is
just the mouse/touch affordance, and a close icon reads
faster than a text label at a glance. */}
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 h-8 w-8 flex items-center justify-center text-text-muted hover:text-brand transition-colors">
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M2 2L14 14M14 2L2 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="mt-6 flex flex-col gap-6 max-h-[55vh] overflow-y-auto">
{loading && <p className="text-body-sm text-text-muted">Suche</p>}
{!loading && query.trim().length >= 2 && results.length === 0 && (
<p className="text-body-sm text-text-muted">Keine Treffer für {query}.</p>
)}
{products.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Produkte</p>
{products.map((r) => (
<SearchResultRow key={r.id} result={r} onClose={onClose} />
))}
</div>
)}
{posts.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Blog</p>
{posts.map((r) => (
<SearchResultRow key={r.id} result={r} onClose={onClose} />
))}
</div>
)}
</div>
</div>
</div>
);
}
function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) {
return (
<Link
href={result.href}
onClick={onClose}
className="flex items-center gap-3 p-2 rounded-sm hover:bg-bg-muted transition-colors"
>
<div className="relative h-12 w-12 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
{result.thumbnail && <Image alt="" src={result.thumbnail} fill sizes="48px" className="object-cover" />}
</div>
<span className="text-body text-text-primary">{result.title}</span>
</Link>
);
}