Connect storefront to backend catalog, search, and home slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2f857f1a90
commit
0074ab6629
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { categoryPath } from "@/lib/category-path";
|
||||
import { productPath } from "@/lib/product-path";
|
||||
import { API_URL, STORE_SLUG } from "@/lib/public-api";
|
||||
|
||||
type SearchHit = {
|
||||
id: number | string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
imageUrl?: string | null;
|
||||
price?: number;
|
||||
};
|
||||
|
||||
type CategoryHit = {
|
||||
id: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
};
|
||||
|
||||
export default function HeaderSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [products, setProducts] = useState<SearchHit[]>([]);
|
||||
const [categories, setCategories] = useState<CategoryHit[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
inputRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const query = q.trim();
|
||||
if (query.length < 2) {
|
||||
setProducts([]);
|
||||
setCategories([]);
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
storeSlug: STORE_SLUG,
|
||||
q: query,
|
||||
limit: "8",
|
||||
});
|
||||
const res = await fetch(`${API_URL}/public/search?${params}`);
|
||||
if (!res.ok) throw new Error("search failed");
|
||||
const data = (await res.json()) as {
|
||||
products?: SearchHit[];
|
||||
categories?: CategoryHit[];
|
||||
};
|
||||
setProducts(data.products ?? []);
|
||||
setCategories(data.categories ?? []);
|
||||
} catch {
|
||||
setProducts([]);
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 280);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q, open]);
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
setOpen(false);
|
||||
window.location.href = `/products?q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="جستجو"
|
||||
aria-expanded={open}
|
||||
className="transition-opacity hover:opacity-70"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Image
|
||||
src="/images/home/icons/search.svg"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-ink/40"
|
||||
aria-label="بستن جستجو"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute inset-x-0 top-0 bg-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mx-auto flex max-w-[900px] items-center gap-3 px-4 py-4 sm:px-6"
|
||||
dir="rtl"
|
||||
>
|
||||
<Image
|
||||
src="/images/home/icons/search.svg"
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
unoptimized
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="جستجوی محصول یا دستهبندی..."
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm text-muted hover:text-ink"
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mx-auto max-w-[900px] border-t border-line px-4 py-4 sm:px-6">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted">در حال جستجو...</p>
|
||||
) : q.trim().length < 2 ? (
|
||||
<p className="text-sm text-muted">
|
||||
حداقل ۲ حرف وارد کنید.
|
||||
</p>
|
||||
) : !products.length && !categories.length ? (
|
||||
<p className="text-sm text-muted">نتیجهای یافت نشد.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{categories.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">دستهبندیها</p>
|
||||
<ul className="space-y-1">
|
||||
{categories.map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link
|
||||
href={categoryPath(c)}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block border border-transparent px-2 py-2 text-sm hover:border-line hover:bg-[#f7f7f5]"
|
||||
>
|
||||
{c.nameFa}
|
||||
{c.nameEn ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="ms-2 font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{c.nameEn}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{products.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">محصولات</p>
|
||||
<ul className="space-y-1">
|
||||
{products.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={productPath(p)}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-3 border border-transparent px-2 py-2 hover:border-line hover:bg-[#f7f7f5]"
|
||||
>
|
||||
<span className="relative h-12 w-10 shrink-0 overflow-hidden bg-[#f3f3f1]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={
|
||||
p.imageUrl ||
|
||||
"/images/home/products/coat-blue.jpg"
|
||||
}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-ink">
|
||||
{p.nameFa}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="block truncate font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{p.nameEn}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user