Vitron-Front/app/components/desktop/DesktopHeader.tsx
fazli 877b01f483 feat(desktop-header): surface "ورود به فروشگاه من" CTA for sellers and staff
Mobile profile page already has this action, but the desktop chrome had
no way for a seller or staff member to jump into a store they manage —
they'd have to type the URL. Add a compact "فروشگاه من" pill in the
header action row, gated on useMyStores() so it only renders for users
who actually manage a store. Selection mirrors the mobile flow: owned
first, then the first store they staff.
2026-07-07 10:57:14 +00:00

250 lines
9.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 { useMyStores } from "~/requestHandler/use-seller-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 { data: myStores } = useMyStores();
// Mirror the mobile "ورود به فروشگاه من" flow: prefer a store the user owns,
// fall back to the first store they staff. If they don't manage any store,
// the CTA is hidden.
const targetStore =
myStores?.find((s) => s.role === "owner") ?? myStores?.[0];
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 ? (
<>
{targetStore ? (
<Link
to={`/store/${targetStore.username}`}
aria-label="ورود به فروشگاه من"
prefetch="intent"
className="inline-flex items-center gap-2 h-[42px] px-3.5 rounded-xl bg-WHITE3 text-BLACK text-[13.5px] font-bold hover:bg-WHITE2 transition-colors whitespace-nowrap"
>
<Store size={18} />
<span>فروشگاه من</span>
</Link>
) : null}
<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;