e49dacf057
SearchOverlay was rendered as a <header> descendant, so the header's conditional backdrop-blur-md (once scrolled) made it the containing block for the overlay's fixed positioning, clipping the opaque background to the header's height and letting page content show through underneath. Now rendered as a header sibling, same pattern already used for NewsletterModal. Blog category chips are now a client component gating navigation behind useTransition, disabling the chips while a navigation is pending so rapid clicks can't fire overlapping RSC navigations that commit out of order and briefly show an empty result.
154 lines
6.4 KiB
TypeScript
154 lines
6.4 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;
|
|
|
|
// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to
|
|
// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be
|
|
// rendered as a <header> *sibling* instead of a descendant. Rendering it
|
|
// inside <header> put it under the header's conditional `backdrop-blur-md`
|
|
// (applied once `scrolled` or `mobileOpen` is true), and per spec a
|
|
// `backdrop-filter` makes its element a new containing block for
|
|
// `position: fixed` descendants — the overlay's `fixed inset-0` then
|
|
// resolved against the ~100px header instead of the viewport, clipping its
|
|
// opaque background to that band while the input/results overflowed past
|
|
// it, letting the page content underneath show through.
|
|
export function SearchButton({ onOpen }: { onOpen: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onOpen}
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function SearchOverlay({ open, onClose }: { open: boolean; 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(() => {
|
|
if (!open) return;
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") onClose();
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (open) inputRef.current?.focus();
|
|
}, [open]);
|
|
|
|
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");
|
|
|
|
if (!open) return null;
|
|
|
|
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>
|
|
);
|
|
}
|