diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..a9a56dc --- /dev/null +++ b/app/api/search/route.ts @@ -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 }); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index d24292c..0640568 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -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. */}
+ {wishlistEnabled && } diff --git a/app/components/SearchOverlay.tsx b/app/components/SearchOverlay.tsx new file mode 100644 index 0000000..bc5a67b --- /dev/null +++ b/app/components/SearchOverlay.tsx @@ -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. */} + + {open && setOpen(false)} />} + + ); +} + +function SearchOverlay({ onClose }: { onClose: () => void }) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef | 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 ( +
+
e.stopPropagation()}> +
+ + 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" + /> + +
+ +
+ {loading &&

Suche…

} + {!loading && query.trim().length >= 2 && results.length === 0 && ( +

Keine Treffer für „{query}“.

+ )} + + {products.length > 0 && ( +
+

Produkte

+ {products.map((r) => ( + + ))} +
+ )} + + {posts.length > 0 && ( +
+

Blog

+ {posts.map((r) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) { + return ( + +
+ {result.thumbnail && } +
+ {result.title} + + ); +}