Files
sanihome/src/components/home/Header.tsx
T
amirhosein.ashourlooandClaude Sonnet 5 66f92b634e Add customer auth, real cart/checkout, store-items pages, comments, contact form
- Real in-site login/register (password + SMS OTP) against Meshkee's
  /auth API, with self-healing access-token refresh (15 min lifetime) and
  an in-flight-refresh guard so AuthContext and CartContext don't race
  each other into a failed concurrent refresh.
- Real cart: header cart button/badge, a cart popup, and a working
  "add to cart" on both product cards (where a specific variant id is
  known) and the product/store-item detail buy-box (always resolves a
  purchasable variant, even for single-SKU products with no selectable
  variation).
- /checkout: address + cash/bank-transfer payment, posts to the real
  checkout API and creates an order; refreshes the cart afterward so the
  header badge doesn't go stale.
- New /store-items and /store-items/[id] pages (variant-level browsing,
  separate from the product-level /products catalog), cross-referencing
  the parent product for images/description/technical-info.
- Product gallery: click-to-open fullscreen lightbox alongside the
  existing hover-zoom.
- Comments tab wired to the real comments API, gated to logged-in users
  (a product decision — the API itself doesn't require auth) with a
  login prompt otherwise.
- Similar-products row on product/store-item detail pages.
- Contact page wired to the real contact-submissions API, with a toast
  and inline confirmation on success, and the form resetting.
- CONTEXT.md: a maintained architecture/conventions doc for this repo,
  now auto-loaded via CLAUDE.md, covering the Meshkee integration quirks
  and UI conventions established across this project.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 08:06:30 +03:30

367 lines
13 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Category, CategoryNode, CategoryTreeGroup } from "@/lib/meshkee";
import { EmptyState } from "@/components/ui/EmptyState";
import { CartButton } from "@/components/cart/CartButton";
import { SearchBar } from "./SearchBar";
const navLinks = [
{ label: "فروشگاه", href: "/store-items" },
{ label: "فروش ویژه", href: "#" },
{ label: "درباره ما", href: "#" },
{ label: "سوالات متداول", href: "#" },
{ label: "تماس با ما", href: "/contact" },
];
const LOGIN_URL = "https://customer.sanihome.ir";
function Logo({ large = false }: { large?: boolean }) {
return (
<a href="/" className="flex shrink-0 items-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/logo.png"
alt="سانی‌هوم"
className={
large
? "h-[42px] w-auto object-contain md:h-[46px]"
: "h-8 w-auto md:h-9"
}
/>
</a>
);
}
function ChevronDownIcon({ className = "" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
<path
d="m6 9 6 6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function flattenLevel2(categoryTree: CategoryTreeGroup[]): CategoryNode[] {
return categoryTree.flatMap((group) => group.items);
}
function MobileDrawer({
open,
onClose,
categoryTree,
}: {
open: boolean;
onClose: () => void;
categoryTree: CategoryTreeGroup[];
}) {
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const level2 = flattenLevel2(categoryTree);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]);
if (!mounted) return null;
return createPortal(
<div className="min-[1200px]:hidden" aria-hidden={!open}>
<div
onClick={onClose}
className={`fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity duration-300 ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
<div
className={`fixed top-0 right-0 z-[110] h-dvh w-[85%] max-w-sm overflow-y-auto bg-surface shadow-2xl transition-transform duration-300 ease-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-border p-5">
<Logo />
<button
type="button"
onClick={onClose}
aria-label="بستن منو"
className="flex h-9 w-9 items-center justify-center rounded-lg text-xl text-muted transition-colors hover:bg-red-light hover:text-red"
>
✕
</button>
</div>
<nav className="flex flex-col text-sm">
<a href="/" className="border-b border-border px-5 py-4 transition-colors hover:bg-red-light/40 hover:text-red">
خانه
</a>
<div className="border-b border-border">
<button
type="button"
onClick={() => setCategoriesOpen((v) => !v)}
aria-expanded={categoriesOpen}
className="flex w-full items-center justify-between px-5 py-4 text-right transition-colors hover:bg-red-light/40 hover:text-red"
>
دسته‌بندی‌ها
<ChevronDownIcon
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
categoriesOpen ? "rotate-180" : ""
}`}
/>
</button>
<div
className={`grid transition-all duration-300 ease-out ${
categoriesOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
}`}
>
<div className="overflow-hidden">
{level2.length === 0 ? (
<div className="bg-background px-5 pb-4">
<EmptyState message="هنوز دسته‌بندی‌ای ثبت نشده است." />
</div>
) : (
<ul className="space-y-1 bg-background px-5 pb-4">
{level2.map((node) => (
<li key={node.id}>
<a
href={`/products?categoryId=${node.id}`}
className="block rounded-lg px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-red-light hover:text-red"
>
{node.nameFa}
</a>
{node.children.length > 0 && (
<ul className="mr-3 space-y-1 border-r border-border pr-3">
{node.children.map((child) => (
<li key={child.id}>
<a
href={`/products?categoryId=${child.id}`}
className="block rounded-lg px-3 py-1 text-muted transition-colors hover:bg-red-light hover:text-red"
>
{child.nameFa}
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</div>
</div>
</div>
{navLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="border-b border-border px-5 py-4 transition-colors hover:bg-red-light/40 hover:text-red"
>
{link.label}
</a>
))}
<a
href={LOGIN_URL}
className="flex items-center gap-2 px-5 py-4 text-right font-medium transition-colors hover:bg-red-light/40 hover:text-red"
>
<span aria-hidden>👤</span>
ورود
</a>
</nav>
</div>
</div>,
document.body,
);
}
export function Header({
categoryTree,
categories,
}: {
categoryTree: CategoryTreeGroup[];
categories: Category[];
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const level2 = flattenLevel2(categoryTree);
useEffect(() => {
const onClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
return (
<header className="sticky top-0 z-50 border-b border-border bg-surface/95 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-4">
<div className="grid grid-cols-3 items-center gap-4 py-4 min-[1200px]:flex min-[1200px]:justify-between">
<div className="flex items-center justify-start">
<button
type="button"
onClick={() => setDrawerOpen(true)}
aria-label="منو"
className="flex h-10 w-10 items-center justify-center rounded-lg border border-border text-xl transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
>
<span aria-hidden>☰</span>
</button>
<div className="hidden min-[1200px]:block">
<Logo />
</div>
</div>
<div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
<div className="min-[1200px]:hidden">
<Logo large />
</div>
<div className="hidden w-full min-[1200px]:block">
<SearchBar />
</div>
</div>
<div className="flex items-center justify-end gap-3 text-sm">
<a
href={LOGIN_URL}
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex"
>
<span>👤</span>
<span>ورود</span>
</a>
<CartButton />
</div>
</div>
<div className="border-t border-border py-3 min-[1200px]:hidden">
<SearchBar />
</div>
<nav className="relative hidden items-center gap-6 overflow-visible border-t border-border py-3 text-sm min-[1200px]:flex">
<div
ref={menuRef}
className="relative"
onMouseEnter={() => setMenuOpen(true)}
onMouseLeave={() => setMenuOpen(false)}
>
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-haspopup="true"
className="flex shrink-0 items-center gap-2 rounded-lg bg-red px-4 py-2 font-medium text-white transition-colors hover:bg-red-dark"
>
<span aria-hidden>☰</span>
دسته‌بندی‌ها
</button>
{menuOpen && (
<div className="absolute top-full right-0 z-50 mt-0 max-h-[70vh] w-[min(92vw,900px)] animate-fade-up overflow-y-auto rounded-b-2xl border border-t-0 border-border bg-surface p-5 shadow-xl">
<div className="mb-4 flex items-end justify-between gap-3 border-b border-border pb-3">
<div>
<p className="font-bold text-red-dark">همه دسته‌بندی‌ها</p>
<p className="font-display text-[11px] tracking-wide text-muted uppercase">
Shop by Category
</p>
</div>
<a
href="/products"
className="text-xs font-medium text-red hover:text-red-dark"
>
مشاهده همه ←
</a>
</div>
{level2.length === 0 ? (
<EmptyState message="هنوز دسته‌بندی‌ای ثبت نشده است." />
) : (
<div className="columns-1 gap-6 sm:columns-2 lg:columns-4">
{level2.map((node) => (
<div key={node.id} className="mb-4 break-inside-avoid">
<a
href={`/products?categoryId=${node.id}`}
className="text-sm font-bold text-red-dark transition-colors hover:text-red"
>
{node.nameFa}
</a>
{node.children.length > 0 && (
<ul className="mt-1.5 space-y-1.5">
{node.children.map((child) => (
<li key={child.id}>
<a
href={`/products?categoryId=${child.id}`}
className="text-sm text-muted transition-colors hover:text-red"
>
{child.nameFa}
</a>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
{categories.length > 0 && (
<div className="mt-5 grid grid-cols-2 gap-2 border-t border-border pt-4 sm:grid-cols-4">
{categories.slice(0, 4).map((cat) => (
<a
key={cat.id}
href={`/products?categoryId=${cat.id}`}
className="rounded-lg bg-red-light/50 px-3 py-2 text-center text-xs font-medium text-red-dark transition-colors hover:bg-red-light"
>
{cat.name}
</a>
))}
</div>
)}
</div>
)}
</div>
{navLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="shrink-0 text-muted transition-colors hover:text-red"
>
{link.label}
</a>
))}
</nav>
</div>
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
categoryTree={categoryTree}
/>
</header>
);
}