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:
Marco
2026-07-30 22:39:38 +00:00
parent 8c5e284b6c
commit 2eda211a29
3 changed files with 222 additions and 0 deletions
+76
View File
@@ -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 });
}