Add instant-search overlay (products + blog posts)
Separate commit on purpose so this can be reverted independently if needed. Search icon in the Navbar (hidden below sm:, same reasoning as WishlistLink — Account+Cart are the only always-visible icons on true mobile) opens a debounced (250ms) overlay searching both collections at once via a new /api/search route. Plain Payload `where[...][contains]` queries (Postgres ILIKE), not a real search index (Meilisearch/Algolia) — matches the catalog's current small size, see [[project-ecommerce-sota-gaps]]'s own "search becomes necessary past ~20 products" note. Worth upgrading later without touching the overlay component itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export type SearchResult = {
|
||||
type: "product" | "post";
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
// Lightweight instant search — plain `where[...][contains]` queries
|
||||
// against Payload (Postgres ILIKE under the hood) rather than a real
|
||||
// search index (Meilisearch/Algolia). Fine at this catalog size (a
|
||||
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
|
||||
// own "search becomes necessary past ~20 products" note) — worth
|
||||
// upgrading only once the catalog actually grows into that range, this
|
||||
// route can be swapped out later without touching the frontend overlay.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
if (q.length < 2) return NextResponse.json({ results: [] });
|
||||
|
||||
const productParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
"where[name][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
const postParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[status][equals]": "published",
|
||||
"where[title][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
|
||||
const [productsRes, postsRes] = await Promise.all([
|
||||
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
|
||||
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
|
||||
]);
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (productsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
|
||||
await productsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "product",
|
||||
id: `product-${doc.id}`,
|
||||
title: doc.name,
|
||||
href: doc.detailHref || "/shop",
|
||||
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (postsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "post",
|
||||
id: `post-${doc.id}`,
|
||||
title: doc.title,
|
||||
href: `/blog/${doc.slug}`,
|
||||
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -544,6 +545,7 @@ export function Navbar({ singleActiveProduct, wishlistEnabled }: { singleActiveP
|
||||
built-in padding read as too much space on mobile, where
|
||||
these two icons are the only always-visible controls. */}
|
||||
<div className="flex items-center">
|
||||
<SearchButton />
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"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 (
|
||||
<>
|
||||
{/* hidden sm: — same reasoning as Navbar's WishlistLink: Account+Cart
|
||||
are the only always-visible icons on true mobile, a 3rd icon
|
||||
there risks the same computed nav-overflow class of bug the
|
||||
figma-to-nextjs skill documents. sm+ has real room to spare. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Suche öffnen"
|
||||
className="hidden sm: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"
|
||||
/>
|
||||
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
Esc
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user