Initial commit: Hoomat storefront with Meshkee API and static-image slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Article = {
|
||||
title: string;
|
||||
date: string;
|
||||
image: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export function ArticleCarousel({
|
||||
articles,
|
||||
ariaLabel,
|
||||
}: {
|
||||
articles: Article[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollCarousel = (direction: "next" | "previous") => {
|
||||
const amount = carouselRef.current
|
||||
? carouselRef.current.clientWidth * 0.7
|
||||
: 320;
|
||||
carouselRef.current?.scrollBy({
|
||||
left: direction === "next" ? -amount : amount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-carousel-shell article-carousel-shell">
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-left"
|
||||
type="button"
|
||||
aria-label="مقالات بعدی"
|
||||
onClick={() => scrollCarousel("next")}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div ref={carouselRef} className="carousel article-carousel" aria-label={ariaLabel}>
|
||||
{articles.map((article) => (
|
||||
<Link
|
||||
className="article-card"
|
||||
key={article.href || article.title}
|
||||
href={article.href || "/blog"}
|
||||
aria-label={article.title}
|
||||
>
|
||||
<div className="article-media">
|
||||
<Image
|
||||
src={article.image}
|
||||
alt={article.title}
|
||||
fill
|
||||
sizes="(max-width: 960px) 50vw, 28vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="article-body">
|
||||
<time className="article-date" dateTime={article.date}>
|
||||
{article.date}
|
||||
</time>
|
||||
<h3>{article.title}</h3>
|
||||
<p>ادامه</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-right"
|
||||
type="button"
|
||||
aria-label="مقالات قبلی"
|
||||
onClick={() => scrollCarousel("previous")}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCart } from "@/app/providers/cart-provider";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { formatPrice } from "@/lib/meshkee/format";
|
||||
import { customerContinueCheckoutUrl } from "@/lib/meshkee/urls";
|
||||
|
||||
export function CartDrawer() {
|
||||
const { cart, open, setOpen, updateQuantity, removeItem, guestItems, loading } =
|
||||
useCart();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const continueUrl = customerContinueCheckoutUrl(
|
||||
isAuthenticated,
|
||||
isAuthenticated ? [] : guestItems,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="cart-drawer-root">
|
||||
<button
|
||||
type="button"
|
||||
className="cart-drawer-backdrop"
|
||||
aria-label="بستن سبد خرید"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<aside className="cart-drawer" role="dialog" aria-modal="true" aria-label="سبد خرید">
|
||||
<div className="cart-drawer-head">
|
||||
<div>
|
||||
<h2>سبد خرید</h2>
|
||||
<p>
|
||||
{cart.items.length
|
||||
? `${new Intl.NumberFormat("fa-IR").format(
|
||||
cart.items.reduce((sum, line) => sum + line.quantity, 0),
|
||||
)} کالا`
|
||||
: "سبد خرید خالی است"}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="icon-btn" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer-body">
|
||||
{cart.items.length === 0 ? (
|
||||
<p className="empty-note">هنوز محصولی به سبد اضافه نشده است.</p>
|
||||
) : (
|
||||
cart.items.map((item) => (
|
||||
<article key={item.id} className="cart-line">
|
||||
<div className="cart-line-media">
|
||||
{item.image ? (
|
||||
<Image src={item.image} alt={item.name} width={72} height={72} />
|
||||
) : (
|
||||
<div className="cart-line-fallback" />
|
||||
)}
|
||||
</div>
|
||||
<div className="cart-line-info">
|
||||
<h3>{item.name}</h3>
|
||||
{item.optionLabel ? <p>{item.optionLabel}</p> : null}
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(item.price)}
|
||||
</strong>
|
||||
<div className="cart-line-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateQuantity(item.id, item.quantity - 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateQuantity(item.id, item.quantity + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-btn"
|
||||
onClick={() => void removeItem(item.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer-foot">
|
||||
<div className="cart-total">
|
||||
<span>جمع</span>
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(cart.total)}
|
||||
</strong>
|
||||
</div>
|
||||
<a className="primary-btn cart-continue" href={continueUrl}>
|
||||
ادامه
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { submitContact } from "@/lib/meshkee/api";
|
||||
import { useToast } from "@/app/providers/toast-provider";
|
||||
|
||||
export function ContactForm() {
|
||||
const { showToast } = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [cellNumber, setCellNumber] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [successText, setSuccessText] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSuccessText(null);
|
||||
try {
|
||||
await submitContact({
|
||||
title: "تماس از وبسایت هومت",
|
||||
name: name.trim(),
|
||||
cellNumber: cellNumber.trim() || undefined,
|
||||
text: text.trim(),
|
||||
});
|
||||
setName("");
|
||||
setCellNumber("");
|
||||
setText("");
|
||||
setSuccessText("پیام شما با موفقیت ارسال شد. به زودی با شما تماس میگیریم.");
|
||||
showToast("پیام با موفقیت ارسال شد");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "ارسال پیام ناموفق بود");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={(event) => void onSubmit(event)}>
|
||||
<input
|
||||
required
|
||||
aria-label="نام و نام خانوادگی"
|
||||
placeholder="نام و نام خانوادگی"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
aria-label="شماره تماس"
|
||||
placeholder="شماره تماس"
|
||||
className="ltr-number"
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
value={cellNumber}
|
||||
onChange={(event) => setCellNumber(event.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
aria-label="پیام"
|
||||
placeholder="پیام شما"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
<button className="primary-btn" type="submit" disabled={submitting}>
|
||||
{submitting ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
{successText ? <p className="form-success">{successText}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { navItems, site } from "@/lib/site-data";
|
||||
|
||||
const addressBlocks = [
|
||||
{
|
||||
title: "دفتر مرکزی",
|
||||
text: "تهران _ شهرک غرب _ مجتمع تجاری پلاتین _ طبقه چهارم _ مبلمان هومت",
|
||||
},
|
||||
{
|
||||
title: "نماینده فروش",
|
||||
text: "قم _ بازار مبل گنجوی _ بلوار نظامی گنجوی _ بین کوی 5 و 7 _ مبلمان هومت",
|
||||
},
|
||||
{
|
||||
title: "کارخانه",
|
||||
text: "قم _ بلوار حضرت معصومه _ کوی 35 _ جنب قالیشویی امیرکبیر _ کارخانه مبلمان هومت",
|
||||
},
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container footer-main">
|
||||
<div className="footer-brand">
|
||||
<Link href="/" className="footer-logo" aria-label={site.name}>
|
||||
<Image
|
||||
src={site.logo}
|
||||
alt={site.name}
|
||||
width={220}
|
||||
height={40}
|
||||
className="footer-logo-img"
|
||||
/>
|
||||
</Link>
|
||||
<p>{site.description}</p>
|
||||
<a className="footer-phone" href={`tel:${site.phone.replaceAll("-", "")}`}>
|
||||
<span className="footer-phone-icon" aria-hidden="true">
|
||||
☎
|
||||
</span>
|
||||
<span>
|
||||
تماس با فروشگاه
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</strong>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="footer-col">
|
||||
<h4>پیوندها</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/">صفحه اصلی</Link>
|
||||
</li>
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="footer-col footer-addresses">
|
||||
<h4>نشانی ما</h4>
|
||||
<ul>
|
||||
{addressBlocks.map((item) => (
|
||||
<li key={item.title}>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom">
|
||||
<div className="container footer-bottom-bar">
|
||||
<div className="footer-bottom-site">{site.name}</div>
|
||||
<a
|
||||
href="https://meshkee.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
dir="ltr"
|
||||
className="meshkee-credit"
|
||||
>
|
||||
<span className="meshkee-credit__frame" aria-hidden="true">
|
||||
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||
</span>
|
||||
<Image
|
||||
src="/assets/meshkee-logo-white.png"
|
||||
alt="Meshkee"
|
||||
className="meshkee-credit__logo"
|
||||
width={42}
|
||||
height={42}
|
||||
/>
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CartDrawer } from "@/app/components/cart-drawer";
|
||||
import { ProductsMegaMenu } from "@/app/components/products-mega-menu";
|
||||
import { UserMenu } from "@/app/components/user-menu";
|
||||
import { useCart } from "@/app/providers/cart-provider";
|
||||
import { site } from "@/lib/site-data";
|
||||
import type { Category } from "@/lib/meshkee/types";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "درباره ما", href: "/about-us" },
|
||||
{ label: "تماس با ما", href: "/contactus" },
|
||||
{ label: "مجله دکوراسیون", href: "/blog" },
|
||||
];
|
||||
|
||||
export function Header({ categories }: { categories: Category[] }) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const { itemCount, setOpen } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeader = () => setIsScrolled(window.scrollY > 12);
|
||||
updateHeader();
|
||||
window.addEventListener("scroll", updateHeader, { passive: true });
|
||||
return () => window.removeEventListener("scroll", updateHeader);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={`header ${isScrolled ? "is-scrolled" : ""}`}>
|
||||
<div className="container header-inner">
|
||||
<Link className="brand" href="/" aria-label={site.name}>
|
||||
<Image src={site.logo} alt={site.name} width={291} height={50} priority />
|
||||
</Link>
|
||||
<nav className="nav" aria-label="ناوبری اصلی">
|
||||
<ProductsMegaMenu categories={categories} />
|
||||
{navLinks.map((item) => (
|
||||
<Link key={item.href} href={item.href}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="nav-icons">
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn cart-trigger"
|
||||
aria-label="سبد خرید"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<CartIcon />
|
||||
{itemCount > 0 ? (
|
||||
<span className="cart-badge ltr-number" dir="ltr" lang="en">
|
||||
{itemCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</nav>
|
||||
<div className="header-actions">
|
||||
<a
|
||||
className="phone-pill"
|
||||
href={`tel:${site.phone.replaceAll("-", "")}`}
|
||||
aria-label={`شماره تماس ${site.phone}`}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<CartDrawer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CartIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6.4 8.25h11.2l-.72 10.1a1.6 1.6 0 0 1-1.6 1.5H8.72a1.6 1.6 0 0 1-1.6-1.5L6.4 8.25Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.1 8.25V7a2.9 2.9 0 0 1 5.8 0v1.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8.2 3.8h2.3l1.1 3.3-1.5 1.1a11.4 11.4 0 0 0 4.7 4.7l1.1-1.5 3.3 1.1v2.3a1.8 1.8 0 0 1-1.8 1.8A13.8 13.8 0 0 1 3.8 5.6 1.8 1.8 0 0 1 5.6 3.8Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { heroSlides as fallbackSlides, site } from "@/lib/site-data";
|
||||
|
||||
const SLIDE_INTERVAL = 5200;
|
||||
|
||||
export type HeroSlide = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
linkUrl?: string | null;
|
||||
};
|
||||
|
||||
export function HeroSlider({ slides }: { slides?: HeroSlide[] }) {
|
||||
const items = slides?.length ? slides : fallbackSlides;
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const activeSlide = items[activeIndex] ?? items[0];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused || items.length <= 1) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setActiveIndex((current) => (current + 1) % items.length);
|
||||
}, SLIDE_INTERVAL);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isPaused, items.length]);
|
||||
|
||||
if (!activeSlide) return null;
|
||||
|
||||
const media = (
|
||||
<div
|
||||
key={activeSlide.image}
|
||||
className="hero-image-layer"
|
||||
style={{ backgroundImage: `url(${activeSlide.image})` }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<div
|
||||
className="hero-card"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
{activeSlide.linkUrl ? (
|
||||
<a href={activeSlide.linkUrl} className="hero-image-link" aria-label={activeSlide.title}>
|
||||
{media}
|
||||
</a>
|
||||
) : (
|
||||
media
|
||||
)}
|
||||
<div key={`${activeIndex}-${activeSlide.title}`} className="hero-content">
|
||||
<p className="eyebrow sequence-item sequence-eyebrow">{activeSlide.eyebrow}</p>
|
||||
<h1 className="sequence-item sequence-title">{activeSlide.title}</h1>
|
||||
<p className="sequence-item sequence-copy">{activeSlide.description}</p>
|
||||
<div className="hero-actions sequence-item sequence-actions">
|
||||
<Link className="primary-btn" href={activeSlide.linkUrl || "/store"}>
|
||||
مشاهده محصولات
|
||||
</Link>
|
||||
<Link className="hero-phone-link" href={`tel:${site.phone.replaceAll("-", "")}`}>
|
||||
<span className="hero-phone-icon" aria-hidden="true">
|
||||
☎
|
||||
</span>
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-dots" aria-label="انتخاب اسلاید">
|
||||
{items.map((slide, index) => (
|
||||
<button
|
||||
key={`${slide.image}-${index}`}
|
||||
className={index === activeIndex ? "active" : ""}
|
||||
type="button"
|
||||
aria-label={`اسلاید ${index + 1}`}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Product = {
|
||||
title: string;
|
||||
enName: string;
|
||||
price: string;
|
||||
image: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type SaleBanner = {
|
||||
image?: string | null;
|
||||
linkUrl?: string | null;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
function ProductPrice({ price }: { price: string }) {
|
||||
const trimmed = price.trim();
|
||||
const match = trimmed.match(/^([\d٬,.\s]+)\s*(تومان)$/);
|
||||
|
||||
if (match) {
|
||||
return (
|
||||
<p className="price price-with-amount" dir="ltr">
|
||||
<span className="price-amount ltr-number" lang="en">
|
||||
{match[1].trim()}
|
||||
</span>
|
||||
<span className="price-unit">تومان</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="price price-note">{trimmed}</p>;
|
||||
}
|
||||
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<Link
|
||||
href={product.href || "/store"}
|
||||
className="product-card"
|
||||
aria-label={product.title}
|
||||
>
|
||||
<Image src={product.image} alt={product.title} width={560} height={560} />
|
||||
<div className="product-body">
|
||||
<p className="product-en-name">{product.enName}</p>
|
||||
<h3>{product.title}</h3>
|
||||
<ProductPrice price={product.price} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SaleBannerCard({ banner }: { banner?: SaleBanner | null }) {
|
||||
const style = banner?.image
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(136, 63, 63, 0.55), rgba(0, 35, 75, 0.5)), url(${banner.image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined;
|
||||
const className = "sale sale-promo-card";
|
||||
const label = banner?.ariaLabel || "فروش ویژه";
|
||||
|
||||
if (banner?.linkUrl) {
|
||||
return (
|
||||
<a href={banner.linkUrl} className={className} style={style} aria-label={label} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="/store" className={className} style={style} aria-label={label} />
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCarousel({
|
||||
products,
|
||||
ariaLabel,
|
||||
withSaleBanner = false,
|
||||
saleBanner,
|
||||
}: {
|
||||
products: Product[];
|
||||
ariaLabel: string;
|
||||
withSaleBanner?: boolean;
|
||||
saleBanner?: SaleBanner | null;
|
||||
}) {
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollCarousel = (direction: "next" | "previous") => {
|
||||
const amount = 300;
|
||||
carouselRef.current?.scrollBy({
|
||||
left: direction === "next" ? -amount : amount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-carousel-shell">
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-left"
|
||||
type="button"
|
||||
aria-label="محصولات بعدی"
|
||||
onClick={() => scrollCarousel("next")}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className={`carousel ${withSaleBanner ? "sale-carousel" : ""}`}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{withSaleBanner ? <SaleBannerCard banner={saleBanner} /> : null}
|
||||
{products.map((product) => (
|
||||
<ProductCard key={`${ariaLabel}-${product.title}`} product={product} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-right"
|
||||
type="button"
|
||||
aria-label="محصولات قبلی"
|
||||
onClick={() => scrollCarousel("previous")}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { buildCategoryTree, categoryLabel } from "@/lib/meshkee/format";
|
||||
import type { Category } from "@/lib/meshkee/types";
|
||||
import { storeCategoryHref } from "@/lib/meshkee/urls";
|
||||
|
||||
export function ProductsMegaMenu({ categories }: { categories: Category[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tree = buildCategoryTree(categories).filter(
|
||||
(node) => node.children.length > 0 || !node.parentId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mega-menu ${open ? "is-open" : ""}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<Link
|
||||
href="/store"
|
||||
className="mega-menu-trigger"
|
||||
onFocus={() => setOpen(true)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>محصولات</span>
|
||||
<svg
|
||||
className="mega-menu-chevron"
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.5 5.75 8 10.25l4.5-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
{open ? (
|
||||
<div className="mega-menu-panel" role="menu">
|
||||
{tree.length === 0 ? (
|
||||
<p className="empty-note">دستهبندیای برای نمایش وجود ندارد.</p>
|
||||
) : (
|
||||
<div className="mega-menu-grid">
|
||||
{tree.map((parent) => (
|
||||
<div key={parent.id} className="mega-menu-column">
|
||||
<Link
|
||||
href={storeCategoryHref(parent.id, categoryLabel(parent))}
|
||||
className="mega-menu-parent"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{categoryLabel(parent)}
|
||||
</Link>
|
||||
{parent.children.length > 0 ? (
|
||||
<ul className="mega-menu-children">
|
||||
{parent.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
href={storeCategoryHref(child.id, categoryLabel(child))}
|
||||
className="mega-menu-child"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{categoryLabel(child)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Header } from "@/app/components/header";
|
||||
import { Footer } from "@/app/components/footer";
|
||||
import { AppProviders } from "@/app/providers";
|
||||
import type { Category } from "@/lib/meshkee/types";
|
||||
|
||||
export function SiteShell({
|
||||
categories,
|
||||
children,
|
||||
}: {
|
||||
categories: Category[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppProviders>
|
||||
<div className="site-shell">
|
||||
<Header categories={categories} />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { StaticImageItem } from "@/lib/meshkee/types";
|
||||
import {
|
||||
imageSubtext,
|
||||
imageTitleEn,
|
||||
imageTitleFa,
|
||||
} from "@/lib/meshkee/static-slots";
|
||||
|
||||
export function SlotBanner({
|
||||
image,
|
||||
fallbackEyebrow,
|
||||
fallbackTitle,
|
||||
fallbackText,
|
||||
className = "banner",
|
||||
}: {
|
||||
image?: StaticImageItem | null;
|
||||
fallbackEyebrow?: string;
|
||||
fallbackTitle: string;
|
||||
fallbackText: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const titleFa = imageTitleFa(image) || fallbackTitle;
|
||||
const titleEn = imageTitleEn(image) || fallbackEyebrow || "";
|
||||
const subtext = imageSubtext(image) || fallbackText;
|
||||
const style = image?.url
|
||||
? {
|
||||
backgroundImage: `linear-gradient(135deg, rgba(0, 35, 75, 0.72), rgba(136, 63, 63, 0.62)), url(${image.url})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{titleEn ? <p className="eyebrow">{titleEn}</p> : null}
|
||||
<h3>{titleFa}</h3>
|
||||
<p>{subtext}</p>
|
||||
</>
|
||||
);
|
||||
|
||||
if (image?.linkUrl) {
|
||||
return (
|
||||
<a href={image.linkUrl} className={className} style={style}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlotLinkedImage({
|
||||
image,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
}: {
|
||||
image?: StaticImageItem | null;
|
||||
fallbackSrc: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const src = image?.url || fallbackSrc;
|
||||
const resolvedAlt = imageTitleFa(image) || alt;
|
||||
const node = (
|
||||
<Image
|
||||
src={src}
|
||||
alt={resolvedAlt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
if (image?.linkUrl) {
|
||||
return (
|
||||
<a href={image.linkUrl} aria-label={resolvedAlt}>
|
||||
{node}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCart } from "@/app/providers/cart-provider";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { useToast } from "@/app/providers/toast-provider";
|
||||
import { submitComment } from "@/lib/meshkee/api";
|
||||
import {
|
||||
formatPrice,
|
||||
storeItemName,
|
||||
storeItemPrice,
|
||||
} from "@/lib/meshkee/format";
|
||||
import type {
|
||||
Comment,
|
||||
Product,
|
||||
StoreItemVariant,
|
||||
TechnicalInfo,
|
||||
} from "@/lib/meshkee/types";
|
||||
import { customerLoginUrl, storeItemHref } from "@/lib/meshkee/urls";
|
||||
|
||||
type TabKey = "description" | "technical" | "comments";
|
||||
|
||||
export function StoreDetailClient({
|
||||
variant,
|
||||
product,
|
||||
technical,
|
||||
comments: initialComments,
|
||||
similar,
|
||||
}: {
|
||||
variant: StoreItemVariant;
|
||||
product: Product;
|
||||
technical: TechnicalInfo;
|
||||
comments: Comment[];
|
||||
similar: StoreItemVariant[];
|
||||
}) {
|
||||
const name = storeItemName(variant);
|
||||
const price = storeItemPrice(variant);
|
||||
const images = useMemo(() => {
|
||||
const list = (product.images ?? [])
|
||||
.map((image) => image.url)
|
||||
.filter(Boolean);
|
||||
const fallback = variant.productImage || product.thumbnailUrl || product.image;
|
||||
if (fallback && !list.includes(fallback)) list.unshift(fallback);
|
||||
return list;
|
||||
}, [product, variant]);
|
||||
|
||||
const [activeImage, setActiveImage] = useState(images[0] || "");
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [tab, setTab] = useState<TabKey>("description");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { addItem } = useCart();
|
||||
const { isAuthenticated, user, getAccessToken } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const techRows = useMemo(() => {
|
||||
const fields = technical.form?.fields ?? [];
|
||||
if (!fields.length || !technical.values?.length) return [];
|
||||
return [...fields]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((field) => {
|
||||
const value = technical.values.find((entry) => entry.fieldId === field.id);
|
||||
if (!value) return null;
|
||||
let display = value.textValue ?? "";
|
||||
if (value.optionId) {
|
||||
display =
|
||||
field.options?.find((option) => option.id === value.optionId)?.labelFa ||
|
||||
field.options?.find((option) => option.id === value.optionId)?.label ||
|
||||
display;
|
||||
}
|
||||
if (value.optionIds?.length) {
|
||||
display = value.optionIds
|
||||
.map(
|
||||
(id) =>
|
||||
field.options?.find((option) => option.id === id)?.labelFa ||
|
||||
field.options?.find((option) => option.id === id)?.label ||
|
||||
id,
|
||||
)
|
||||
.join("، ");
|
||||
}
|
||||
if (!display) return null;
|
||||
return {
|
||||
label: field.labelFa || field.label || field.key || "ویژگی",
|
||||
value: display,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
}, [technical]);
|
||||
|
||||
async function handleAddToCart() {
|
||||
await addItem({
|
||||
storeItemVariantId: variant.id,
|
||||
name,
|
||||
price,
|
||||
quantity,
|
||||
image: activeImage || variant.productImage,
|
||||
slug: product.slug || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCommentSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!isAuthenticated || !user) return;
|
||||
const authorName =
|
||||
[user.firstName, user.lastName].filter(Boolean).join(" ").trim() || "کاربر";
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitComment(
|
||||
{
|
||||
entityType: "product",
|
||||
entityId: product.id,
|
||||
authorName,
|
||||
authorEmail: user.email || undefined,
|
||||
text: commentText.trim(),
|
||||
},
|
||||
getAccessToken(),
|
||||
);
|
||||
setComments((prev) => [
|
||||
{
|
||||
id: `local-${Date.now()}`,
|
||||
authorName,
|
||||
text: commentText.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setCommentText("");
|
||||
showToast("نظر شما ارسال شد و پس از تایید نمایش داده میشود");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "ارسال نظر ناموفق بود");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="store-detail">
|
||||
<div className="store-detail-gallery">
|
||||
<button
|
||||
type="button"
|
||||
className="store-main-image zoomable"
|
||||
onClick={() => activeImage && setLightboxOpen(true)}
|
||||
>
|
||||
{activeImage ? (
|
||||
<Image src={activeImage} alt={name} width={900} height={900} priority />
|
||||
) : (
|
||||
<div className="store-card-fallback large" />
|
||||
)}
|
||||
</button>
|
||||
{images.length > 1 ? (
|
||||
<div className="store-thumbs">
|
||||
{images.map((image) => (
|
||||
<button
|
||||
key={image}
|
||||
type="button"
|
||||
className={image === activeImage ? "is-active" : ""}
|
||||
onClick={() => setActiveImage(image)}
|
||||
>
|
||||
<Image src={image} alt={name} width={120} height={120} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="store-detail-info">
|
||||
<p className="eyebrow">{variant.label || product.categoryNameFa || "Store Item"}</p>
|
||||
<h1>{name}</h1>
|
||||
<strong className="store-detail-price ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(price)}
|
||||
</strong>
|
||||
{variant.selections?.length ? (
|
||||
<ul className="store-selections">
|
||||
{variant.selections.map((selection, index) => (
|
||||
<li key={`${selection.optionId}-${index}`}>
|
||||
{selection.variationName}: {selection.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="store-buy-row">
|
||||
<label>
|
||||
تعداد
|
||||
<input
|
||||
className="ltr-number"
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(event) =>
|
||||
setQuantity(Math.max(1, Number(event.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="primary-btn" onClick={() => void handleAddToCart()}>
|
||||
افزودن به سبد خرید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-detail-tabs">
|
||||
<div className="tab-list" role="tablist">
|
||||
{(
|
||||
[
|
||||
["description", "توضیحات"],
|
||||
["technical", "اطلاعات فنی"],
|
||||
["comments", "نظرات"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={tab === key ? "is-active" : ""}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tab-panel">
|
||||
{tab === "description" ? (
|
||||
product.descriptionHtml ? (
|
||||
<div
|
||||
className="rich-html"
|
||||
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="empty-note">توضیحاتی ثبت نشده است.</p>
|
||||
)
|
||||
) : null}
|
||||
{tab === "technical" ? (
|
||||
techRows.length ? (
|
||||
<table className="tech-table">
|
||||
<tbody>
|
||||
{techRows.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<th>{row.label}</th>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="empty-note">اطلاعات فنی موجود نیست.</p>
|
||||
)
|
||||
) : null}
|
||||
{tab === "comments" ? (
|
||||
<div className="comments-block">
|
||||
{isAuthenticated ? (
|
||||
<form className="form" onSubmit={(event) => void handleCommentSubmit(event)}>
|
||||
<textarea
|
||||
required
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="نظر خود را بنویسید"
|
||||
/>
|
||||
<button className="primary-btn" type="submit" disabled={submitting}>
|
||||
ارسال نظر
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="login-box">
|
||||
<p>برای ثبت نظر وارد حساب کاربری شوید.</p>
|
||||
<a className="primary-btn" href={customerLoginUrl(`/store/${variant.id}`)}>
|
||||
ورود / ثبتنام
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="comments-list">
|
||||
{comments.length === 0 ? (
|
||||
<p className="empty-note">هنوز نظری ثبت نشده است.</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<article key={comment.id}>
|
||||
<h3>{comment.authorName || "کاربر"}</h3>
|
||||
<p>{comment.text}</p>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{similar.length > 0 ? (
|
||||
<section className="section similar-section">
|
||||
<h2>محصولات مشابه</h2>
|
||||
<div className="store-grid">
|
||||
{similar.map((item) => {
|
||||
const itemName = storeItemName(item);
|
||||
return (
|
||||
<Link key={item.id} href={storeItemHref(item.id, itemName)} className="store-card">
|
||||
<div className="store-card-media">
|
||||
{item.productImage ? (
|
||||
<Image src={item.productImage} alt={itemName} width={320} height={320} />
|
||||
) : (
|
||||
<div className="store-card-fallback" />
|
||||
)}
|
||||
</div>
|
||||
<div className="store-card-body">
|
||||
<h3>{itemName}</h3>
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(storeItemPrice(item))}
|
||||
</strong>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{lightboxOpen && activeImage ? (
|
||||
<div className="lightbox" role="dialog" aria-modal="true">
|
||||
<button
|
||||
type="button"
|
||||
className="lightbox-backdrop"
|
||||
aria-label="بستن"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
/>
|
||||
<div className="lightbox-content">
|
||||
<Image src={activeImage} alt={name} width={1200} height={1200} />
|
||||
<button type="button" className="icon-btn" onClick={() => setLightboxOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { createAuthHandoff } from "@/lib/meshkee/api";
|
||||
import { displayName } from "@/lib/meshkee/format";
|
||||
import {
|
||||
businessDashboardUrl,
|
||||
customerLoginUrl,
|
||||
customerProfileUrl,
|
||||
} from "@/lib/meshkee/urls";
|
||||
|
||||
export function UserMenu() {
|
||||
const { user, isAuthenticated, isWebsiteAdmin, logout, getAccessToken } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [handingOff, setHandingOff] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<a
|
||||
className="header-icon-btn"
|
||||
href={customerLoginUrl("/")}
|
||||
aria-label="ورود"
|
||||
title="ورود"
|
||||
>
|
||||
<UserIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const name = displayName(user);
|
||||
|
||||
async function openBusinessAdmin(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.button !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const target = businessDashboardUrl();
|
||||
const popup = window.open("about:blank", "_blank");
|
||||
setHandingOff(true);
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const { ticket } = await createAuthHandoff(token || "");
|
||||
const url = new URL(target);
|
||||
if (ticket) url.searchParams.set("handoff", ticket);
|
||||
if (popup) {
|
||||
popup.opener = null;
|
||||
popup.location.replace(url.toString());
|
||||
} else {
|
||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch {
|
||||
if (popup) popup.location.replace(target);
|
||||
else window.open(target, "_blank", "noopener,noreferrer");
|
||||
} finally {
|
||||
setHandingOff(false);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-menu" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`${name} عزیز`}
|
||||
title={`${name} عزیز`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<UserIcon />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="user-menu-dropdown" role="menu">
|
||||
<p className="user-menu-greeting">{name} عزیز</p>
|
||||
<a role="menuitem" href={customerProfileUrl()} onClick={() => setOpen(false)}>
|
||||
پروفایل من
|
||||
</a>
|
||||
{isWebsiteAdmin ? (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={businessDashboardUrl()}
|
||||
onClick={(event) => void openBusinessAdmin(event)}
|
||||
>
|
||||
{handingOff ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
logout();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M5.75 18.5c1.55-2.45 3.7-3.7 6.25-3.7s4.7 1.25 6.25 3.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user