Product pages emit crawler-readable meta, public URLs use id plus Farsi slugs, and the homepage catalog matches the dashboard slot keys. Co-authored-by: Cursor <cursoragent@cursor.com>
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import type { Product } from "@/lib/data";
|
|
import { formatPrice } from "@/lib/format";
|
|
import { IconSearch } from "./Icons";
|
|
import { Media } from "./Media";
|
|
|
|
type Props = {
|
|
className?: string;
|
|
onNavigate?: () => void;
|
|
};
|
|
|
|
export function SearchBox({ className = "", onNavigate }: Props) {
|
|
const listId = useId();
|
|
const root = useRef<HTMLDivElement>(null);
|
|
const [query, setQuery] = useState("");
|
|
const [items, setItems] = useState<Product[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const term = query.trim();
|
|
if (term.length < 2) {
|
|
setItems([]);
|
|
setLoading(false);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timer = window.setTimeout(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { signal: controller.signal });
|
|
const data = (await response.json()) as { items?: Product[] };
|
|
setItems(data.items ?? []);
|
|
setOpen(true);
|
|
} catch (error) {
|
|
if ((error as { name?: string }).name !== "AbortError") {
|
|
setItems([]);
|
|
setOpen(true);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, 160);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
const onPointer = (event: MouseEvent) => {
|
|
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onPointer);
|
|
return () => document.removeEventListener("mousedown", onPointer);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={root} className={`relative ${className}`}>
|
|
<form action="/products" className="flex w-full items-center">
|
|
<input
|
|
name="q"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
onFocus={() => {
|
|
if (query.trim().length >= 2) setOpen(true);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") setOpen(false);
|
|
}}
|
|
placeholder="جستجو ..."
|
|
autoComplete="off"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-controls={listId}
|
|
className="w-full min-w-0 flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-zinc-400"
|
|
/>
|
|
<button type="submit" aria-label="جستجو" className="text-zinc-500 hover:text-brand">
|
|
<IconSearch />
|
|
</button>
|
|
</form>
|
|
|
|
{open && (
|
|
<div
|
|
id={listId}
|
|
className="absolute top-[calc(100%+8px)] right-0 left-0 z-50 overflow-hidden rounded-2xl border border-black/8 bg-white shadow-xl"
|
|
>
|
|
{loading && !items.length ? (
|
|
<p className="px-4 py-3 text-sm text-zinc-400">در حال جستجو...</p>
|
|
) : items.length ? (
|
|
<ul>
|
|
{items.map((product) => {
|
|
const unavailable = product.stock === 0 || product.price === 0;
|
|
return (
|
|
<li key={product.id}>
|
|
<Link
|
|
href={product.href || `/products?q=${encodeURIComponent(query)}`}
|
|
onClick={onNavigate}
|
|
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
|
|
>
|
|
<Media src={product.image} alt={product.name} className="size-12 shrink-0 rounded-lg" sizes="48px" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-[13px] leading-6">{product.name}</p>
|
|
{unavailable ? (
|
|
<p className="text-xs text-zinc-400">ناموجود</p>
|
|
) : (
|
|
<p className="text-xs text-zinc-500">
|
|
{formatPrice(product.price)} <span>تومان</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<p className="px-4 py-3 text-sm text-zinc-400">نتیجهای پیدا نشد.</p>
|
|
)}
|
|
<Link
|
|
href={`/products?q=${encodeURIComponent(query.trim())}`}
|
|
onClick={onNavigate}
|
|
className="block border-t border-black/5 px-4 py-2.5 text-center text-sm text-brand"
|
|
>
|
|
مشاهده همه نتایج
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|