- 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>
367 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|