Vitron-Front/app/components/desktop/DesktopHeader.tsx
fazli a405f6ffc4 feat(desktop): add search autocomplete to desktop header
The desktop header search only navigated on submit — the autocomplete
suggestions dropdown existed on mobile (explore SearchSection) but was
never wired into the desktop chrome. Reuse the same useSearchAutocomplete
hook (debounced) and render a suggestions dropdown under the search box:
product titles navigate to /search, sellers navigate to /seller/<id>.
Closes on outside-click / Escape / selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:23:12 +00:00

232 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Link, useLocation, useNavigate } from "@remix-run/react";
import { useState, useRef, useEffect, type FormEvent } from "react";
import { Search, Heart, ShoppingBag, User, Store } from "lucide-react";
import { useRootData } from "~/hooks/use-root-data";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useSearchAutocomplete } from "~/requestHandler/use-search-hooks";
import { useDebounce } from "~/hooks/useDebounce";
import { SunMoonToggle } from "~/components/SunMoonToggle";
import { AutocompleteSuggestionTypeEnum } from "../../../src/api/types";
import logo from "~/assets/logo/SVG-07.svg";
const NAV = [
{ label: "خانه", to: "/" },
{ label: "کاوش", to: "/explore" },
{ label: "برندها", to: "/sellers" },
{ label: "کالکشن‌ها", to: "/collections" },
];
/**
* Desktop-only top chrome (sticky header with brand, nav, search and actions).
* Hidden below the `lg` breakpoint; the mobile experience is unchanged.
*/
const DesktopHeader = () => {
const location = useLocation();
const navigate = useNavigate();
const { user, theme } = useRootData();
const cartCount = useCartCount();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const isLoggedIn = !!user?.access_token;
const debouncedQ = useDebounce(q.trim(), 400);
const { data: autocompleteData, isLoading: isSuggesting } =
useSearchAutocomplete(debouncedQ);
const suggestions = autocompleteData?.suggestions ?? [];
const showSuggestions = open && q.trim().length > 0;
// Close the suggestions dropdown when clicking outside the search box.
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const isActive = (to: string) =>
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to);
const goToSearch = (term: string) => {
const t = term.trim();
if (!t) return;
setOpen(false);
navigate(`/search/${encodeURIComponent(t)}`);
};
const onSearch = (e: FormEvent) => {
e.preventDefault();
goToSearch(q);
};
const onSelectSuggestion = (sug: {
type: AutocompleteSuggestionTypeEnum;
text?: string;
name?: string;
id?: string;
}) => {
if (sug.type === AutocompleteSuggestionTypeEnum.Seller && sug.id) {
setOpen(false);
navigate(`/seller/${sug.id}`);
return;
}
goToSearch(sug.text || sug.name || "");
};
return (
<header className="hidden lg:block sticky top-0 z-40 bg-WHITE/85 backdrop-blur-xl border-b border-WHITE3">
<div className="max-w-[1320px] mx-auto h-[72px] px-8 flex items-center gap-7">
<Link to="/" className="flex items-center gap-2.5 shrink-0">
<img src={logo} alt="ویترون" className="w-7 h-9 object-contain" />
<b className="text-[23px] font-extrabold tracking-tight text-VITROWN_BLUE">
ویترون
</b>
</Link>
<nav className="flex items-center gap-1">
{NAV.map((n) => (
<Link
key={n.to}
to={n.to}
prefetch="intent"
className={`px-3.5 py-2 rounded-[10px] text-[14.5px] transition-colors ${
isActive(n.to)
? "text-BLACK font-bold"
: "text-BLACK2 font-semibold hover:bg-WHITE3 hover:text-BLACK"
}`}
>
{n.label}
</Link>
))}
</nav>
<div ref={searchRef} className="flex-1 max-w-[460px] mx-auto relative">
<form
onSubmit={onSearch}
className="flex items-center gap-2.5 bg-WHITE3 border border-transparent rounded-xl px-3.5 h-11 transition-colors focus-within:bg-WHITE focus-within:border-GRAY3"
>
<Search size={20} className="text-GRAY shrink-0" />
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
name="q"
autoComplete="off"
placeholder="جستجوی محصول، برند یا فروشگاه…"
aria-label="جستجو"
className="w-full bg-transparent outline-none text-[14.5px] text-BLACK placeholder:text-GRAY"
/>
</form>
{showSuggestions ? (
<div
dir="rtl"
className="absolute top-full inset-x-0 mt-2 max-h-[360px] overflow-y-auto rounded-xl bg-WHITE border border-WHITE3 shadow-xl py-1.5 z-50"
>
{isSuggesting && suggestions.length === 0 ? (
<p className="px-4 py-3 text-[13px] text-GRAY">در حال جستجو...</p>
) : suggestions.length > 0 ? (
suggestions.map((sug, index) => {
const isSeller =
sug.type === AutocompleteSuggestionTypeEnum.Seller;
const label = isSeller ? sug.name : sug.text;
if (!label) return null;
return (
<button
key={`${sug.type}-${sug.id ?? sug.text ?? index}`}
type="button"
onClick={() => onSelectSuggestion(sug)}
className="w-full flex items-center gap-2.5 px-4 h-11 text-right hover:bg-WHITE2 transition-colors"
>
{isSeller ? (
<Store size={17} className="text-GRAY shrink-0" />
) : (
<Search size={17} className="text-GRAY shrink-0" />
)}
<span className="text-[14px] text-BLACK truncate">
{label}
</span>
{isSeller ? (
<span className="mr-auto text-[11px] text-GRAY shrink-0">
فروشگاه
</span>
) : null}
</button>
);
})
) : (
<button
type="button"
onClick={() => goToSearch(q)}
className="w-full flex items-center gap-2.5 px-4 h-11 text-right hover:bg-WHITE2 transition-colors"
>
<Search size={17} className="text-GRAY shrink-0" />
<span className="text-[14px] text-BLACK truncate">{q}</span>
</button>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2.5 shrink-0">
<SunMoonToggle theme={theme} />
{isLoggedIn ? (
<>
<Link
to="/profile/bookmarks"
aria-label="نشان‌شده‌ها"
prefetch="intent"
className="w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
<Heart size={21} />
</Link>
<Link
to="/cart"
aria-label="سبد خرید"
prefetch="intent"
className="relative w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
{cartCount > 0 ? (
<span className="absolute top-1 left-1 min-w-[17px] h-[17px] bg-RED text-white rounded-full text-[10.5px] font-bold grid place-items-center px-1 border-2 border-WHITE">
{cartCount}
</span>
) : null}
<ShoppingBag size={21} />
</Link>
<Link
to="/profile"
aria-label="پروفایل"
prefetch="intent"
className="w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
<span className="w-9 h-9 rounded-full bg-WHITE3 grid place-items-center text-GRAY overflow-hidden">
<User size={20} />
</span>
</Link>
</>
) : (
<Link
to="/login"
prefetch="intent"
className="inline-flex items-center h-10 px-4 rounded-xl bg-VITROWN_BLUE text-white text-[14px] font-bold hover:bg-[#012f6b] transition-colors whitespace-nowrap"
>
ورود / ثبت نام
</Link>
)}
</div>
</div>
</header>
);
};
export default DesktopHeader;