first commit

This commit is contained in:
amirhosein.ashourloo
2026-09-02 17:03:23 +03:30
commit 1bd131e9d8
91 changed files with 11211 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, UserIcon } from "./icons";
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { displayName, isAdmin } from "@/lib/authUser";
import type { AuthUser } from "@/lib/types";
// Desktop header account control. Anonymous: a plain link to the customer
// portal's login/register. Signed in: "نام عزیز" + a dropdown with پروفایل
// من / خروج, plus مدیریت وبسایت when the session looks like an admin's.
export default function AccountMenu({ user }: { user: AuthUser | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
if (!user) {
return (
<Link
href={CUSTOMER_PORTAL_URL}
className="flex h-11 items-center gap-2 rounded-xl border border-line px-3 text-[13px] transition hover:border-brand hover:text-brand"
>
<UserIcon className="size-5" />
<span className="hidden sm:inline">ورود | ثبت نام</span>
</Link>
);
}
return (
<div ref={ref} className="relative h-11">
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex h-11 items-center gap-2 rounded-xl border border-line px-3 text-[13px] text-ink transition hover:border-brand hover:text-brand"
>
<UserIcon className="size-5" />
<span className="hidden max-w-[120px] truncate sm:inline">{displayName(user)}</span>
<ChevronDown className={`size-4 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-44 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl">
<Link
href={`${CUSTOMER_PORTAL_URL}/profile`}
className="block px-4 py-2.5 text-[13px] text-ink-soft transition hover:bg-surface hover:text-brand"
>
پروفایل من
</Link>
{isAdmin(user) && (
<Link
href={BUSINESS_PORTAL_URL}
className="block px-4 py-2.5 text-[13px] text-ink-soft transition hover:bg-surface hover:text-brand"
>
مدیریت وبسایت
</Link>
)}
<Link
href={`${CUSTOMER_PORTAL_URL}/logout`}
className="block px-4 py-2.5 text-[13px] text-ink-soft transition hover:bg-surface hover:text-brand"
>
خروج
</Link>
</div>
)}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import type { Swiper as SwiperType } from "swiper";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import SectionHeader from "./ui/SectionHeader";
import CarouselNav from "./ui/CarouselNav";
export interface BlogCardData {
id: string;
href: string;
title: string;
image: string | null;
category?: string;
}
export default function BlogCarousel({ posts }: { posts: BlogCardData[] }) {
const [swiper, setSwiper] = useState<SwiperType | null>(null);
if (posts.length === 0) return null;
return (
<section className="boxed mt-10">
<SectionHeader
title="مجله معلم"
desc="تازه‌ترین راهنماهای خرید و اخبار دنیای دیجیتال"
href="/blog"
nav={<CarouselNav swiper={swiper} />}
/>
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
onSwiper={setSwiper}
slidesPerView={1.3}
spaceBetween={16}
breakpoints={{
640: { slidesPerView: 2.3 },
1024: { slidesPerView: 4 },
}}
>
{posts.map((post) => (
<SwiperSlide key={post.id} className="h-auto">
<Link
href={post.href}
className="flex h-full flex-col overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative block aspect-[16/9] w-full bg-surface">
{post.image && (
<Image
src={post.image}
alt={post.title}
fill
sizes="(max-width: 1024px) 60vw, 280px"
className="object-cover"
/>
)}
{post.category && (
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{post.category}
</span>
)}
</span>
<span className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{post.title}</span>
</Link>
</SwiperSlide>
))}
</Swiper>
<div className="mt-4 flex justify-center lg:hidden">
<CarouselNav swiper={swiper} />
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { ChevronLeft } from "./icons";
export interface Crumb {
label: string;
href?: string;
}
export default function Breadcrumbs({ items }: { items: Crumb[] }) {
return (
<nav className="mb-5 flex flex-wrap items-center gap-1.5 text-[12.5px] text-body" aria-label="مسیر صفحه">
{items.map((item, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
{item.href ? (
<Link href={item.href} className="transition hover:text-brand">
{item.label}
</Link>
) : (
<span className="text-ink-soft">{item.label}</span>
)}
</span>
))}
</nav>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import { categoryHref } from "@/lib/slug";
import type { CategoryNode, StaticImageSlot } from "@/lib/types";
// Circular category thumbnails. The "categories" static-image slot has no
// per-category id, so its images (kind: list, duplicatable) are matched
// positionally to the category list, in the same order the business fills
// them in — each still links to its real category page, only the art swaps.
// A category past the end of the slot, or the whole thing when the slot is
// empty, keeps its placeholder at public/images/categories/{id}.svg.
export default function CategoryCarousel({
categories,
slot,
}: {
categories: CategoryNode[];
slot?: StaticImageSlot;
}) {
if (categories.length === 0) return null;
const slotImages = slot?.images ?? [];
return (
<section className="boxed mt-8" aria-label="دسته‌بندی محصولات">
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
slidesPerView={3.5}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 4.5 },
768: { slidesPerView: 6.5 },
1024: { slidesPerView: 9 },
}}
>
{categories.map((cat, i) => {
const label = cat.nameFa || cat.name;
const art = slotImages[i]?.url || `/images/categories/${cat.id}.svg`;
return (
<SwiperSlide key={cat.id}>
<Link href={categoryHref(cat.id, cat.nameFa, cat.name)} className="group flex flex-col items-center gap-2">
<span className="relative size-[90px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft">
<Image src={art} alt={label} fill sizes="90px" className="object-contain p-2" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
{label}
</span>
</Link>
</SwiperSlide>
);
})}
</Swiper>
</section>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { postComment } from "@/lib/api";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { displayName } from "@/lib/authUser";
import type { AuthUser, EntityComment } from "@/lib/types";
// Comment list + form. The API's POST /comments doesn't itself require
// auth, but the brief asks the storefront to gate the form: signed-in
// visitors get a name-less textarea, anonymous visitors get a login prompt
// instead of the form.
export default function CommentSection({
entityType,
entityId,
initialComments,
user,
}: {
entityType: "product" | "blog";
entityId: string;
initialComments: EntityComment[];
user: AuthUser | null;
}) {
const [comments, setComments] = useState(initialComments);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!user || !text.trim()) return;
setSubmitting(true);
setError(null);
try {
const { comment } = await postComment({
entityType,
entityId,
authorName: displayName(user).replace(/ عزیز$/, ""),
text: text.trim(),
});
setComments((prev) => [comment, ...prev]);
setText("");
} catch {
setError("ثبت دیدگاه ناموفق بود. لطفاً دوباره تلاش کنید.");
} finally {
setSubmitting(false);
}
}
return (
<div>
{user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="دیدگاه خود را بنویسید…"
rows={4}
className="w-full rounded-xl border border-line p-3 text-sm outline-none transition focus:border-brand"
/>
<div className="mt-2 flex items-center justify-between">
{error && <span className="text-[12px] text-brand">{error}</span>}
<button
type="submit"
disabled={submitting || !text.trim()}
className="mr-auto flex h-10 items-center rounded-lg bg-brand px-5 text-[13px] font-bold text-white transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "در حال ارسال…" : "ثبت دیدگاه"}
</button>
</div>
</form>
) : (
<div className="mb-8 flex flex-col items-center gap-3 rounded-xl border border-dashed border-line bg-surface p-6 text-center">
<p className="text-sm text-body">برای ثبت دیدگاه ابتدا وارد حساب کاربری خود شوید.</p>
<Link
href={CUSTOMER_PORTAL_URL}
className="rounded-lg bg-brand px-5 py-2 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
ورود | ثبت‌نام
</Link>
</div>
)}
{comments.length > 0 ? (
<ul className="space-y-4">
{comments.map((c) => (
<li key={c.id} className="rounded-xl border border-line p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-ink">{c.authorName || "کاربر"}</span>
{c.createdAt && (
<span className="en text-xs text-body/60">{c.createdAt.slice(0, 10)}</span>
)}
</div>
{c.text && <p className="mt-2 text-sm leading-7 text-body">{c.text}</p>}
</li>
))}
</ul>
) : (
<p className="text-sm text-body">هنوز دیدگاهی ثبت نشده است.</p>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import Link from "next/link";
import Image from "next/image";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE } from "@/data/site";
import { categoryHref } from "@/lib/slug";
import type { CategoryNode } from "@/lib/types";
import { PhoneIcon } from "./icons";
import { toFaDigits } from "@/lib/format";
export default function Footer({ categories }: { categories: CategoryNode[] }) {
return (
<footer className="mt-14 border-t border-line bg-surface">
<div className="boxed grid gap-8 py-10 md:grid-cols-4">
<div className="md:col-span-2">
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} />
<p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p>
<a
href={PHONE_HREF}
className="mt-4 inline-flex items-center gap-2 text-[13px] font-bold text-ink transition hover:text-brand"
>
<PhoneIcon className="size-4" />
{PHONE}
</a>
</div>
<div>
<h3 className="mb-3 text-[14px]">دسته‌بندی‌ها</h3>
<ul className="space-y-2">
{categories.slice(0, 6).map((cat) => (
<li key={cat.id}>
<Link
href={categoryHref(cat.id, cat.nameFa, cat.name)}
className="text-[13px] text-body transition hover:text-brand"
>
{cat.nameFa || cat.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="mb-3 text-[14px]">دسترسی سریع</h3>
<ul className="space-y-2">
{MAIN_NAV.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-[13px] text-body transition hover:text-brand">
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-line py-4 text-center text-[12px] text-body">
© {toFaDigits(1404)} {SITE_NAME} — تمامی حقوق محفوظ است.
</div>
</footer>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import SearchBox from "./SearchBox";
import AccountMenu from "./AccountMenu";
import { CartIcon, ChevronDown, GridIcon, PhoneIcon } from "./icons";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug";
import type { AuthUser, CategoryNode } from "@/lib/types";
// Two rows: logo + search + account/cart, then the product mega menu and the
// main nav — the same split the reference storefront uses.
export default function Header({ menu, user }: { menu: CategoryNode[]; user: AuthUser | null }) {
const [megaOpen, setMegaOpen] = useState(false);
return (
<header className="sticky top-0 z-40 w-full border-b border-line bg-white">
<div className="boxed">
{/* Mobile keeps the header down to the search box alone — account,
cart and browsing live in the fixed bottom bar instead. */}
<div className="flex h-[70px] items-center gap-4">
<Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}>
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} priority />
</Link>
<div className="flex-1">
<SearchBox />
</div>
<div className="mr-auto hidden items-center gap-2 lg:flex">
<AccountMenu user={user} />
<Link
href={`${CUSTOMER_PORTAL_URL}/cart`}
className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark"
aria-label="سبد خرید"
>
<CartIcon className="size-5" />
<span className="font-bold">۰</span>
</Link>
</div>
</div>
<nav className="hidden h-12 items-center gap-6 text-[13px] lg:flex">
<div
className="relative h-full"
onMouseEnter={() => setMegaOpen(true)}
onMouseLeave={() => setMegaOpen(false)}
>
<button
className="flex h-full items-center gap-2 font-bold text-ink transition hover:text-brand"
aria-expanded={megaOpen}
>
<GridIcon className="size-5" />
منو محصولات
<ChevronDown className="size-4" />
</button>
{megaOpen && menu.length > 0 && (
<div className="absolute right-0 top-full z-50 w-[900px] rounded-b-2xl border border-line bg-white p-6 shadow-xl">
<div className="grid grid-cols-4 gap-x-6 gap-y-5">
{menu.map((cat) => (
<div key={cat.id}>
<Link
href={categoryHref(cat.id, cat.nameFa, cat.name)}
className="mb-2 block font-bold text-ink transition hover:text-brand"
>
{cat.nameFa || cat.name}
</Link>
<ul className="space-y-1.5">
{cat.children.slice(0, 6).map((child) => (
<li key={child.id}>
<Link
href={categoryHref(child.id, child.nameFa, child.name)}
className="text-[12.5px] text-body transition hover:text-brand"
>
{child.nameFa || child.name}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
</div>
{MAIN_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className={`transition hover:text-brand ${
item.highlight ? "font-bold text-brand" : "text-ink-soft"
}`}
>
{item.label}
</Link>
))}
<a
href={PHONE_HREF}
className="mr-auto flex items-center gap-2 text-ink-soft transition hover:text-brand"
>
<PhoneIcon className="size-4" />
<span className="font-bold">{PHONE}</span>
</a>
</nav>
</div>
</header>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/pagination";
import { ChevronLeft, ChevronRight } from "./icons";
import { toCssAspectRatio } from "@/lib/format";
import type { StaticImageSlot } from "@/lib/types";
export interface Slide {
id: string;
image: string;
mobileImage?: string;
href: string;
alt: string;
}
// Default aspect ratios match the fallback banner assets: 700x400 mobile art,
// 1296x366 desktop, so neither crop loses the headline. Kept in sync with the
// "slider" entry in src/app/meshkee/static-image-slots/route.ts — see the
// note there: if this ratio ever changes, that entry must change with it.
const FALLBACK_ASPECT_CLASSES = "aspect-[7/4] md:aspect-[1296/366]";
// `slot` is the "slider" static-image slot (kind: list, duplicatable) — when
// it has images they win over the `slides` fallback prop entirely. A CMS
// slide is one image for every breakpoint (not a separate mobile crop), and
// slot.aspectRatio (if set) drives the frame instead of the built-in default.
export default function HeroSlider({ slot, slides = [] }: { slot?: StaticImageSlot; slides?: Slide[] }) {
const fromCms = (slot?.images.length ?? 0) > 0;
const items: Slide[] = fromCms
? slot!.images.map((img, i) => ({
id: img.id,
image: img.url,
href: img.linkUrl || "/products",
alt: img.titleFa || img.titleEn || `بنر فروشگاه موبایل معلم ${i + 1}`,
}))
: slides;
if (items.length === 0) return null;
const cssAspect = fromCms ? toCssAspectRatio(slot?.aspectRatio) : undefined;
const aspectStyle = cssAspect ? ({ aspectRatio: cssAspect } as CSSProperties) : undefined;
return (
<section className="boxed mt-5" aria-label="بنرهای اصلی">
<div className="relative overflow-hidden rounded-2xl">
<Swiper
modules={[Pagination, Navigation, Autoplay]}
dir="rtl"
loop={items.length > 1}
autoplay={{ delay: 5000, disableOnInteraction: false }}
pagination={{ clickable: true, el: ".hero-dots" }}
navigation={{ prevEl: ".hero-prev", nextEl: ".hero-next" }}
className={`w-full ${aspectStyle ? "" : FALLBACK_ASPECT_CLASSES}`}
style={aspectStyle}
>
{items.map((slide) => (
<SwiperSlide key={slide.id}>
<Link href={slide.href} className="relative block size-full">
{fromCms ? (
<Image src={slide.image} alt={slide.alt} fill priority sizes="100vw" className="object-cover" />
) : (
<>
<Image
src={slide.mobileImage || slide.image}
alt={slide.alt}
fill
priority
sizes="100vw"
className="object-cover md:hidden"
/>
<Image
src={slide.image}
alt={slide.alt}
fill
priority
sizes="1200px"
className="hidden object-cover md:block"
/>
</>
)}
</Link>
</SwiperSlide>
))}
</Swiper>
<div className="absolute bottom-4 left-4 z-10 flex items-center gap-2">
<button
className="hero-prev flex size-9 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
aria-label="اسلاید قبلی"
>
<ChevronRight className="size-5" />
</button>
<button
className="hero-next flex size-9 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
aria-label="اسلاید بعدی"
>
<ChevronLeft className="size-5" />
</button>
</div>
{/* Swiper's own .swiper-pagination rules set left:0/width:100% on this
element, so it is centred with justify-content rather than a
translate — a translate would fight those and push it off-canvas. */}
<div className="hero-dots absolute bottom-5 z-10 flex w-full justify-center gap-1.5" />
</div>
</section>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { Swiper as SwiperType } from "swiper";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import ProductCard from "./ui/ProductCard";
import CarouselNav from "./ui/CarouselNav";
import { ChevronLeft, FireIcon } from "./icons";
import { toFaDigits } from "@/lib/format";
import type { ProductCardData } from "@/lib/types";
const UNITS: { key: keyof Remaining; label: string }[] = [
{ key: "seconds", label: "ثانیه" },
{ key: "minutes", label: "دقیقه" },
{ key: "hours", label: "ساعت" },
{ key: "days", label: "روز" },
];
interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function remainingUntil(target: number): Remaining {
const diff = Math.max(0, target - Date.now());
const total = Math.floor(diff / 1000);
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
};
}
// The countdown starts only after mount: rendering a live clock on the server
// would hydrate against a different second every time. The first value is set
// from a frame callback rather than the effect body so the state update stays
// out of the render pass.
function Countdown({ endsAt }: { endsAt: number }) {
const [left, setLeft] = useState<Remaining | null>(null);
useEffect(() => {
const tick = () => setLeft(remainingUntil(endsAt));
const frame = requestAnimationFrame(tick);
const id = setInterval(tick, 1000);
return () => {
cancelAnimationFrame(frame);
clearInterval(id);
};
}, [endsAt]);
return (
<div className="flex items-center gap-1.5" dir="ltr">
{UNITS.map(({ key, label }) => (
<div
key={key}
className="flex size-12 flex-col items-center justify-center rounded-lg bg-white/15 text-white"
>
<span className="text-[15px] font-bold leading-none">
{left ? toFaDigits(String(left[key]).padStart(2, "0")) : "--"}
</span>
<span className="mt-0.5 text-[9px] opacity-80">{label}</span>
</div>
))}
</div>
);
}
export default function HotDeals({
products,
endsAt,
href = "/products?special=1",
}: {
products: ProductCardData[];
endsAt: number;
href?: string;
}) {
const [swiper, setSwiper] = useState<SwiperType | null>(null);
if (products.length === 0) return null;
return (
<section className="boxed mt-10">
<div className="relative overflow-hidden rounded-2xl bg-brand p-5 lg:p-8">
{/* Faint dot grid so the flat brand block does not read as a plain box. */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.13]"
style={{
backgroundImage: "radial-gradient(#fff 1.5px, transparent 1.5px)",
backgroundSize: "22px 22px",
}}
aria-hidden
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-stretch">
<div className="flex shrink-0 flex-col justify-between gap-4 lg:w-[230px]">
<div>
<h2 className="flex items-center gap-2 text-[26px] font-black leading-tight text-white lg:text-[32px]">
<FireIcon className="size-7 shrink-0" />
تخفیف‌های داغ
</h2>
<p className="mt-1 text-[13px] text-white/85">تا پایان تایمر، بعدش قیمت‌ها برمی‌گرده</p>
</div>
<Countdown endsAt={endsAt} />
<div className="flex items-center justify-between gap-2">
<Link
href={href}
className="flex items-center gap-1 rounded-full bg-white px-4 py-2 text-[13px] font-bold text-brand transition hover:bg-white/90"
>
مشاهده همه
<ChevronLeft className="size-4" />
</Link>
<div className="hidden lg:block">
<CarouselNav swiper={swiper} tone="onBrand" />
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
onSwiper={setSwiper}
slidesPerView={2.1}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 2.6 },
768: { slidesPerView: 3.5 },
1024: { slidesPerView: 5 },
}}
>
{products.map((product) => (
<SwiperSlide key={product.id} className="h-auto">
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
</section>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { CartIcon, ChevronDown, GridIcon, UserIcon } from "./icons";
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug";
import { displayName, isAdmin } from "@/lib/authUser";
import type { AuthUser, CategoryNode } from "@/lib/types";
const HomeIcon = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden
>
<path d="M3 10.2 12 3l9 7.2V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
</svg>
);
// Mobile navigation lives here instead of a hamburger in the header: a fixed
// bar with the four destinations that matter, and the category tree as a sheet
// so browsing never leaves the page.
export default function MobileBottomNav({
categories,
user,
}: {
categories: CategoryNode[];
user: AuthUser | null;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const [accountOpen, setAccountOpen] = useState(false);
const accountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = sheetOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [sheetOpen]);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!accountRef.current?.contains(e.target as Node)) setAccountOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px]";
return (
<>
{sheetOpen && (
<div className="fixed inset-0 z-50 lg:hidden" role="dialog" aria-label="دسته‌بندی محصولات">
<button
className="absolute inset-0 bg-black/40"
onClick={() => setSheetOpen(false)}
aria-label="بستن"
/>
<div className="absolute inset-x-0 bottom-0 max-h-[75vh] overflow-auto rounded-t-2xl bg-white pb-[76px]">
<div className="sticky top-0 flex items-center justify-between border-b border-line bg-white px-4 py-3">
<h2 className="text-[15px] font-bold">دسته‌بندی محصولات</h2>
<button
onClick={() => setSheetOpen(false)}
className="text-[13px] text-body"
aria-label="بستن"
>
بستن
</button>
</div>
<ul className="divide-y divide-line">
{categories.map((cat) => (
<li key={cat.id}>
<div className="flex items-center">
<Link
href={categoryHref(cat.id, cat.nameFa, cat.name)}
onClick={() => setSheetOpen(false)}
className="flex-1 px-4 py-3 text-[14px] text-ink"
>
{cat.nameFa || cat.name}
</Link>
{cat.children.length > 0 && (
<button
onClick={() => setOpenId(openId === cat.id ? null : cat.id)}
className="px-4 py-3 text-body"
aria-expanded={openId === cat.id}
aria-label={`زیرشاخه‌های ${cat.nameFa || cat.name}`}
>
<ChevronDown
className={`size-5 transition ${openId === cat.id ? "rotate-180" : ""}`}
/>
</button>
)}
</div>
{openId === cat.id && cat.children.length > 0 && (
<ul className="bg-surface px-4 py-2">
{cat.children.map((child) => (
<li key={child.id}>
<Link
href={categoryHref(child.id, child.nameFa, child.name)}
onClick={() => setSheetOpen(false)}
className="block py-2 text-[13px] text-body"
>
{child.nameFa || child.name}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
</div>
)}
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-white lg:hidden">
<div className="flex items-stretch">
<Link href="/" className={`${item} text-ink`}>
<HomeIcon className="size-6" />
خانه
</Link>
<Link href={`${CUSTOMER_PORTAL_URL}/cart`} className={`${item} text-ink`}>
<CartIcon className="size-6" />
سبد خرید
</Link>
<button
onClick={() => setSheetOpen((v) => !v)}
className={`${item} ${sheetOpen ? "text-brand" : "text-ink"}`}
aria-expanded={sheetOpen}
>
<GridIcon className="size-6" />
دسته‌بندی‌ها
</button>
{user ? (
<div ref={accountRef} className="relative flex-1">
{accountOpen && (
<div className="absolute inset-x-2 bottom-full mb-2 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl">
<Link
href={`${CUSTOMER_PORTAL_URL}/profile`}
className="block px-4 py-2.5 text-[13px] text-ink-soft"
onClick={() => setAccountOpen(false)}
>
پروفایل من
</Link>
{isAdmin(user) && (
<Link
href={BUSINESS_PORTAL_URL}
className="block px-4 py-2.5 text-[13px] text-ink-soft"
onClick={() => setAccountOpen(false)}
>
مدیریت وبسایت
</Link>
)}
<Link
href={`${CUSTOMER_PORTAL_URL}/logout`}
className="block px-4 py-2.5 text-[13px] text-ink-soft"
onClick={() => setAccountOpen(false)}
>
خروج
</Link>
</div>
)}
<button
onClick={() => setAccountOpen((v) => !v)}
className={`${item} w-full ${accountOpen ? "text-brand" : "text-ink"}`}
aria-expanded={accountOpen}
>
<UserIcon className="size-6" />
<span className="max-w-[64px] truncate">{displayName(user)}</span>
</button>
</div>
) : (
<Link href={CUSTOMER_PORTAL_URL} className={`${item} text-ink`}>
<UserIcon className="size-6" />
ورود
</Link>
)}
</div>
</nav>
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
import { formatPrice } from "@/lib/format";
import { AUTH_TOKEN_COOKIE, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing";
import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types";
const trustPoints = [
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" },
{ icon: TruckIcon, title: "ارسال سریع به سراسر کشور" },
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" },
];
export default function ProductBuyPanel({
product,
items = [],
user,
}: {
product: ApiProduct;
items?: StoreItem[];
user: AuthUser | null;
}) {
const [selectedId, setSelectedId] = useState(items[0]?.id);
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState(false);
const [error, setError] = useState<string | null>(null);
const selected = items.find((i) => i.id === selectedId) ?? items[0];
const title = product.nameFa || product.title;
const variationName = selected?.selections[0]?.variationName;
let price: number | null = null;
let oldPrice: number | null = null;
if (selected) {
price = selected.discountedPrice ?? selected.price;
oldPrice = selected.discountedPrice != null ? selected.price : null;
} else if (product.store.minPrice !== null) {
price = product.store.minPrice;
} else if (DEMO_PRICING) {
const demo = demoPrice(product);
price = demo.price;
oldPrice = demo.oldPrice;
}
const percent = price !== null && oldPrice !== null && oldPrice > price
? Math.round(((oldPrice - price) / oldPrice) * 100)
: 0;
// Purchasing needs a real store-item variant (cart items are added by
// storeItemVariantId, not productId) — until this product has one, there
// is nothing a bearer token could add to a cart, logged in or not.
const purchasable = items.length > 0 && Boolean(selected);
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
async function handleAddToCart() {
if (!selected || !user) return;
setAdding(true);
setError(null);
try {
const token = document.cookie
.split("; ")
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
?.split("=")[1];
if (!token) throw new Error("no-token");
const res = await fetch(
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ storeItemVariantId: selected.storeItemId, quantity: 1 }),
}
);
if (!res.ok) throw new Error(String(res.status));
setAdded(true);
setTimeout(() => setAdded(false), 1800);
} catch {
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
} finally {
setAdding(false);
}
}
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10">
<div>
<h1 className="text-xl font-bold text-ink md:text-2xl">{title}</h1>
{product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
{product.summary && (
<div className="mt-5">
<p className="mb-1.5 text-sm font-bold text-ink">توضیح کوتاه:</p>
<p className="text-sm leading-7 text-body">{product.summary}</p>
</div>
)}
{items.length > 0 && (
<div className="mt-5">
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
<div className="flex flex-wrap gap-2">
{items.map((item) => {
const rawValue = item.selections[0]?.value;
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
disabled={item.stockQuantity <= 0}
title={item.label}
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
colorHex
? `size-9 rounded-full border-2 ${item.id === selectedId ? "border-brand" : "border-line"}`
: `h-10 rounded-lg border px-4 text-sm font-bold ${
item.id === selectedId
? "border-brand bg-brand text-white"
: "border-line text-ink hover:border-brand hover:text-brand"
}`
}`}
>
{colorHex ? (
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
) : (
item.label
)}
</button>
);
})}
</div>
</div>
)}
<div className="mt-5 space-y-2 text-sm">
{product.categoryNameFa && (
<p className="text-body">
دسته‌بندی: <span className="font-bold text-ink">{product.categoryNameFa}</span>
</p>
)}
{product.tags.length > 0 && <p className="text-body">تگ‌ها: {product.tags.join("، ")}</p>}
</div>
</div>
<div className="h-fit space-y-4 lg:sticky lg:top-24">
<div className="rounded-2xl border border-line bg-white p-5">
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${
inStock ? "bg-brand-soft text-brand" : "bg-surface text-body"
}`}
>
{inStock ? "آماده ارسال" : "ناموجود"}
</span>
<TruckIcon className="size-5 text-body" />
</div>
<div className="mb-1.5 flex items-center justify-between text-sm text-body">
<span>قیمت:</span>
{percent > 0 && (
<span className="rounded-md bg-brand-soft px-1.5 py-0.5 text-xs font-bold text-brand">
%{percent}
</span>
)}
</div>
<div className="mb-5 flex items-baseline justify-between gap-2">
{oldPrice !== null && oldPrice > (price ?? 0) && (
<span className="text-xs text-body line-through">{formatPrice(oldPrice)}</span>
)}
<span className="mr-auto text-xl font-bold text-ink md:text-2xl">
{price !== null ? (
<>
{formatPrice(price)}
<span className="mr-1 text-xs font-normal text-body">تومان</span>
</>
) : (
"تماس بگیرید"
)}
</span>
</div>
{!purchasable ? (
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
این محصول هنوز برای خرید آنلاین فعال نشده است.
</p>
) : !user ? (
<Link
href={CUSTOMER_PORTAL_URL}
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-brand text-sm font-bold text-white transition hover:bg-brand-dark"
>
برای خرید وارد شوید
</Link>
) : (
<button
type="button"
onClick={handleAddToCart}
disabled={!inStock || adding}
className={`flex h-12 w-full items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
{added ? (
<>
<CheckIcon className="size-4.5" />
افزوده شد
</>
) : (
<>
<CartIcon className="size-4.5" />
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
</>
)}
</button>
)}
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
</div>
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5">
{trustPoints.map((p) => (
<div key={p.title} className="flex flex-col items-center gap-2 text-center">
<p.icon className="size-6 text-brand" />
<span className="text-[11px] leading-5 text-body">{p.title}</span>
</div>
))}
</div>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import type { Swiper as SwiperType } from "swiper";
import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/free-mode";
import ProductCard from "./ui/ProductCard";
import SectionHeader from "./ui/SectionHeader";
import CarouselNav from "./ui/CarouselNav";
import type { ProductCardData } from "@/lib/types";
export default function ProductCarousel({
title,
desc,
href,
products,
}: {
title: string;
desc?: string;
href: string;
products: ProductCardData[];
}) {
const [swiper, setSwiper] = useState<SwiperType | null>(null);
if (products.length === 0) return null;
return (
<section className="boxed mt-10">
<SectionHeader title={title} desc={desc} href={href} nav={<CarouselNav swiper={swiper} />} />
<div className="relative">
<Swiper
modules={[FreeMode]}
dir="rtl"
freeMode
onSwiper={setSwiper}
slidesPerView={2.2}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 3.2 },
768: { slidesPerView: 4.2 },
1024: { slidesPerView: 6 },
}}
>
{products.map((product) => (
<SwiperSlide key={product.id} className="h-auto">
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
<div className="mt-4 flex justify-center lg:hidden">
<CarouselNav swiper={swiper} />
</div>
</div>
</section>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, CloseIcon, ZoomIcon } from "./icons";
// Product/workshop-style media gallery: a square main image that magnifies
// under the cursor on hover (desktop only — this is a hover interaction, it
// simply does nothing on touch), plus a click-to-open full-screen lightbox
// with its own prev/next and thumbnail strip.
export default function ProductGallery({ images, title }: { images: string[]; title: string }) {
const gallery = images.length > 0 ? images : ["/images/products/placeholder.svg"];
const [active, setActive] = useState(0);
const [zoomPos, setZoomPos] = useState<{ x: number; y: number } | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const prev = () => setActive((i) => (i - 1 + gallery.length) % gallery.length);
const next = () => setActive((i) => (i + 1) % gallery.length);
useEffect(() => {
if (!lightboxOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setLightboxOpen(false);
if (e.key === "ArrowLeft") next(); // RTL: visual left = next
if (e.key === "ArrowRight") prev();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lightboxOpen]);
return (
<div>
<div
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl border border-line bg-white"
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setZoomPos({
x: ((e.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100,
});
}}
onMouseLeave={() => setZoomPos(null)}
onClick={() => setLightboxOpen(true)}
>
<Image src={gallery[active]} alt={title} fill sizes="500px" className="object-contain p-4" />
{zoomPos && (
<div
className="pointer-events-none absolute inset-0 hidden bg-no-repeat lg:block"
style={{
backgroundImage: `url(${gallery[active]})`,
backgroundSize: "220%",
backgroundPosition: `${zoomPos.x}% ${zoomPos.y}%`,
}}
aria-hidden
/>
)}
<span className="absolute left-3 top-3 flex size-9 items-center justify-center rounded-full bg-white/90 text-ink opacity-0 shadow transition group-hover:opacity-100">
<ZoomIcon className="size-5" />
</span>
{gallery.length > 1 && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="تصویر قبلی"
className="absolute top-1/2 start-3 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
>
<ChevronRight className="size-4.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="تصویر بعدی"
className="absolute top-1/2 end-3 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
>
<ChevronLeft className="size-4.5" />
</button>
</>
)}
</div>
{gallery.length > 1 && (
<div className="mt-3 flex flex-wrap gap-2.5">
{gallery.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setActive(i)}
className={`relative size-16 shrink-0 overflow-hidden rounded-lg border-2 transition md:size-20 ${
i === active ? "border-brand" : "border-line"
}`}
>
<Image src={src} alt="" fill sizes="80px" className="object-contain p-1" />
</button>
))}
</div>
)}
{lightboxOpen && (
<div
className="fixed inset-0 z-[60] flex flex-col bg-black/95"
role="dialog"
aria-modal
aria-label={`گالری تصاویر ${title}`}
>
<div className="flex items-center justify-between p-4">
<span className="text-[13px] text-white/70">
{active + 1} / {gallery.length}
</span>
<button
onClick={() => setLightboxOpen(false)}
className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="بستن"
>
<CloseIcon className="size-5" />
</button>
</div>
<div className="relative flex-1">
<Image src={gallery[active]} alt={title} fill sizes="90vw" className="object-contain" />
{gallery.length > 1 && (
<>
<button
onClick={prev}
aria-label="تصویر قبلی"
className="absolute top-1/2 start-4 flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<ChevronRight className="size-5" />
</button>
<button
onClick={next}
aria-label="تصویر بعدی"
className="absolute top-1/2 end-4 flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<ChevronLeft className="size-5" />
</button>
</>
)}
</div>
{gallery.length > 1 && (
<div className="flex justify-center gap-2 overflow-x-auto p-4">
{gallery.map((src, i) => (
<button
key={src + i}
onClick={() => setActive(i)}
className={`relative size-14 shrink-0 overflow-hidden rounded-lg border-2 transition ${
i === active ? "border-brand" : "border-white/20"
}`}
>
<Image src={src} alt="" fill sizes="56px" className="object-contain" />
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import CommentSection from "./CommentSection";
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
type Tab = "description" | "technical" | "reviews";
export default function ProductInfoTabs({
title,
descriptionHtml,
specRows,
comments,
productId,
user,
}: {
title: string;
descriptionHtml?: string;
specRows: SpecRow[];
comments: EntityComment[];
productId: string;
user: AuthUser | null;
}) {
const [tab, setTab] = useState<Tab>("description");
const tabs: { key: Tab; label: string }[] = [
{ key: "description", label: "توضیحات" },
{ key: "technical", label: "مشخصات فنی" },
{ key: "reviews", label: `دیدگاه کاربران${comments.length > 0 ? ` (${comments.length})` : ""}` },
];
return (
<div className="mt-14 md:mt-20">
<div className="flex flex-wrap gap-2 border-b border-line pb-px">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`-mb-px h-11 rounded-t-lg border-b-2 px-5 text-sm font-bold transition ${
tab === t.key ? "border-brand text-brand" : "border-transparent text-body hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="pt-8">
{tab === "description" && (
<div>
<h2 className="mb-4 text-lg font-bold text-ink md:text-xl">معرفی {title}</h2>
{descriptionHtml ? (
<div
className="rich-text text-sm leading-8 text-body"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "technical" && (
<div>
{specRows.length > 0 ? (
<dl className="divide-y divide-line overflow-hidden rounded-xl border border-line">
{specRows.map((row) => (
<div key={row.key} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
<dt className="text-body">{row.label}</dt>
<dd className="font-bold text-ink">{row.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-body">مشخصات فنی این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "reviews" && (
<CommentSection entityType="product" entityId={productId} initialComments={comments} user={user} />
)}
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import { toCssAspectRatio } from "@/lib/format";
import type { StaticImageSlot } from "@/lib/types";
// Shared renderer for the "one-banner-bg" / "two-banner-bg" / "three-banner-bg"
// static-image slots: url -> <img src>, linkUrl -> wrap in <a>, slot.aspectRatio
// drives the frame. An empty or failed slot keeps a flat, labelled placeholder
// (this site's usual stand-in for undesigned art, see
// public/images/categories/{id}.svg) instead of a fake photo, sized so the
// business/designer can see exactly what to fill in.
function BannerGrid({
slot,
count,
defaultAspect,
recommendedWidth,
cols,
}: {
slot?: StaticImageSlot;
count: number;
defaultAspect: string;
recommendedWidth: number;
cols: string;
}) {
const images = (slot?.images ?? []).slice(0, count);
const cssAspect = toCssAspectRatio(slot?.aspectRatio) ?? defaultAspect;
const aspectStyle = { aspectRatio: cssAspect } as CSSProperties;
const cells = Array.from({ length: count }, (_, i) => images[i]);
return (
<section className={`boxed mt-10`} aria-label="بنرهای تبلیغاتی">
<div className={`grid gap-3 ${cols}`}>
{cells.map((img, i) => {
if (!img) {
return (
<div
key={i}
className="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-line bg-surface text-body"
style={aspectStyle}
>
<span className="text-[12px]">بنر تبلیغاتی</span>
<span className="text-[11px] opacity-70" dir="ltr">
{recommendedWidth}px · {cssAspect.replace("/", ":")}
</span>
</div>
);
}
const media = (
<Image
src={img.url}
alt={img.titleFa || img.titleEn || "بنر تبلیغاتی"}
fill
sizes={`(max-width: 768px) 100vw, ${recommendedWidth}px`}
className="object-cover"
/>
);
const frameClass = "relative block overflow-hidden rounded-xl bg-surface";
// Only wrap in <a> when the slot actually carries a link — an
// undesignated banner shouldn't become a dead click target.
return img.linkUrl ? (
<Link key={img.id} href={img.linkUrl} className={frameClass} style={aspectStyle}>
{media}
</Link>
) : (
<div key={img.id} className={frameClass} style={aspectStyle}>
{media}
</div>
);
})}
</div>
</section>
);
}
export function OneBanner({ slot }: { slot?: StaticImageSlot }) {
return <BannerGrid slot={slot} count={1} defaultAspect="16/5" recommendedWidth={1200} cols="grid-cols-1" />;
}
export function TwoBanners({ slot }: { slot?: StaticImageSlot }) {
return (
<BannerGrid slot={slot} count={2} defaultAspect="16/9" recommendedWidth={580} cols="grid-cols-1 md:grid-cols-2" />
);
}
export function ThreeBanners({ slot }: { slot?: StaticImageSlot }) {
return (
<BannerGrid slot={slot} count={3} defaultAspect="4/3" recommendedWidth={380} cols="grid-cols-1 sm:grid-cols-3" />
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { SearchIcon } from "./icons";
import { formatPrice } from "@/lib/format";
import { productHref } from "@/lib/slug";
import type { ApiProduct } from "@/lib/types";
const API = "https://api.meshkee.com/api/v1";
const DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "moalem.shop";
// Instant search: fires once the query passes 2 characters, debounced, and
// every in-flight request is abortable so a fast typist never sees an older
// response overwrite a newer one.
export default function SearchBox() {
const [query, setQuery] = useState("");
const [items, setItems] = useState<ApiProduct[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
// Clearing on a short query happens in the change handler, not here: the
// effect body must not call setState synchronously.
const onChange = (value: string) => {
setQuery(value);
if (value.trim().length < 3) {
setItems([]);
setLoading(false);
setOpen(false);
}
};
useEffect(() => {
const term = query.trim();
if (term.length < 3) return;
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
try {
const url = new URL(`${API}/tenants/${DOMAIN}/products`);
url.searchParams.set("name", term);
url.searchParams.set("pageSize", "6");
const res = await fetch(url, { signal: controller.signal });
const data = (await res.json()) as { items: ApiProduct[] };
setItems(data.items ?? []);
setOpen(true);
} catch {
/* aborted or offline — keep the previous list */
} finally {
setLoading(false);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div ref={boxRef} className="relative w-full">
<div className="flex h-12 items-center gap-2 rounded-xl bg-surface px-4 text-sm">
<SearchIcon className="size-5 shrink-0 text-body" />
<input
value={query}
onChange={(e) => onChange(e.target.value)}
onFocus={() => items.length > 0 && setOpen(true)}
placeholder="جستجو براساس نام یا کد محصول"
className="h-full w-full bg-transparent outline-none placeholder:text-body"
aria-label="جستجوی محصولات"
/>
{loading && (
<span className="size-4 shrink-0 animate-spin rounded-full border-2 border-line border-t-brand" />
)}
</div>
{open && query.trim().length >= 3 && (
<div className="absolute inset-x-0 top-14 z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl">
{items.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-body">محصولی پیدا نشد.</p>
) : (
<ul className="max-h-96 overflow-auto">
{items.map((p) => (
<li key={p.id}>
<Link
href={productHref(p.id, p.nameFa, p.title)}
onClick={() => setOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 transition hover:bg-surface"
>
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
{p.thumbnailUrl && (
<Image
src={p.thumbnailUrl}
alt={p.nameFa || p.title}
fill
sizes="48px"
className="object-contain"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="clamp-2 text-[13px] leading-6">{p.nameFa || p.title}</span>
</span>
{p.store.minPrice !== null && (
<span className="shrink-0 text-[12px] font-bold text-ink">
{formatPrice(p.store.minPrice)}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { PROMO } from "@/data/site";
// Slim promo strip above the header — the reference storefront's
// `.header_banner_wrapper`, rebuilt as type instead of a baked GIF so the
// copy stays editable.
export default function TopBar() {
return (
<div className="w-full bg-brand text-white">
<div className="boxed flex h-10 items-center justify-center gap-3 text-[13px]">
<span className="truncate">{PROMO.text}</span>
<Link
href={PROMO.href}
className="hidden shrink-0 rounded-full bg-white/15 px-3 py-1 text-[12px] font-bold transition hover:bg-white/25 sm:inline-block"
>
{PROMO.cta}
</Link>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
// Single-stroke line icons in the Iconsax style the reference storefront uses.
type P = { className?: string };
const base = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
viewBox: "0 0 24 24",
};
export const SearchIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3-3" />
</svg>
);
export const UserIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-3.3 3.6-5.5 8-5.5s8 2.2 8 5.5" />
</svg>
);
export const CartIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M3 4h2l2.2 10.2A2 2 0 0 0 9.2 16h7.7a2 2 0 0 0 2-1.6L20.5 7H6" />
<circle cx="9.5" cy="20" r="1.4" />
<circle cx="17.5" cy="20" r="1.4" />
</svg>
);
export const GridIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2" />
</svg>
);
export const PhoneIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M21 16.5v2.2a2 2 0 0 1-2.2 2 18.6 18.6 0 0 1-8.1-2.9 18.3 18.3 0 0 1-5.6-5.6A18.6 18.6 0 0 1 2.2 4.1 2 2 0 0 1 4.2 2h2.2a2 2 0 0 1 2 1.7c.1.9.3 1.8.7 2.6a2 2 0 0 1-.5 2.1l-1 1a15 15 0 0 0 5.6 5.6l1-1a2 2 0 0 1 2.1-.5c.8.3 1.7.6 2.6.7a2 2 0 0 1 1.7 2z" />
</svg>
);
export const ChevronLeft = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M14.5 6l-6 6 6 6" />
</svg>
);
export const ChevronRight = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M9.5 6l6 6-6 6" />
</svg>
);
export const ChevronDown = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M6 9.5l6 6 6-6" />
</svg>
);
export const FireIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 3s5 3.6 5 8.4A5 5 0 0 1 12 21a5 5 0 0 1-5-4.9c0-2 1-3.4 1-3.4s.4 1.4 1.6 1.9c0-2.6 1-5.4 2.4-6.6 0 0-.3 2.4 1.3 3.5.5-1.6-.3-3.5-.3-3.5z" />
</svg>
);
export const CheckIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M4.5 12.5l5 5 10-11" />
</svg>
);
export const CloseIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M5 5l14 14M19 5L5 19" />
</svg>
);
export const ZoomIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M15.2 15.2L20 20M8 10.5h5M10.5 8v5" />
</svg>
);
export const ShieldIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 3l7 3v5.2c0 4.6-3 7.7-7 9.8-4-2.1-7-5.2-7-9.8V6z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
export const TruckIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M3 6h10v9H3zM13 10h4l3 3v2h-7z" />
<circle cx="7" cy="17.5" r="1.7" />
<circle cx="17" cy="17.5" r="1.7" />
</svg>
);
export const RotateIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3" />
<path d="M17.5 3.5V7H14M6.5 20.5V17H10" />
</svg>
);
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { Swiper as SwiperType } from "swiper";
import { ChevronLeft, ChevronRight } from "../icons";
// Prev/next controls that drive a Swiper instance held by the parent, so a
// section can place them wherever its layout needs them.
export default function CarouselNav({
swiper,
tone = "light",
}: {
swiper: SwiperType | null;
tone?: "light" | "onBrand";
}) {
const style =
tone === "onBrand"
? "bg-white/20 text-white hover:bg-white/35"
: "border border-line bg-white text-ink hover:border-brand hover:text-brand";
return (
<div className="flex items-center gap-2">
<button
onClick={() => swiper?.slidePrev()}
className={`flex size-9 items-center justify-center rounded-full transition ${style}`}
aria-label="قبلی"
>
<ChevronRight className="size-5" />
</button>
<button
onClick={() => swiper?.slideNext()}
className={`flex size-9 items-center justify-center rounded-full transition ${style}`}
aria-label="بعدی"
>
<ChevronLeft className="size-5" />
</button>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import { toFaDigits } from "@/lib/format";
import { ChevronLeft, ChevronRight } from "../icons";
// Simple numbered pager. `buildHref` turns a page number into the URL for
// that page — callers own how query params are preserved (search, filters).
export default function Pagination({
page,
pageSize,
total,
buildHref,
}: {
page: number;
pageSize: number;
total: number;
buildHref: (page: number) => string;
}) {
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (pageCount <= 1) return null;
// A short window around the current page, plus first/last, keeps the
// control usable even with a few hundred pages.
const pages = new Set<number>([1, pageCount, page - 1, page, page + 1].filter((p) => p >= 1 && p <= pageCount));
const sorted = [...pages].sort((a, b) => a - b);
const linkClass = (active: boolean) =>
`flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-[13px] transition ${
active ? "bg-brand text-white" : "border border-line text-ink-soft hover:border-brand hover:text-brand"
}`;
return (
<nav className="mt-8 flex items-center justify-center gap-1.5" aria-label="صفحه‌بندی">
<Link
href={buildHref(Math.max(1, page - 1))}
aria-disabled={page <= 1}
className={`${linkClass(false)} ${page <= 1 ? "pointer-events-none opacity-40" : ""}`}
aria-label="صفحه قبل"
>
<ChevronRight className="size-4" />
</Link>
{sorted.map((p, i) => (
<span key={p} className="flex items-center gap-1.5">
{i > 0 && p - sorted[i - 1] > 1 && <span className="px-1 text-body">…</span>}
<Link href={buildHref(p)} className={linkClass(p === page)}>
{toFaDigits(p)}
</Link>
</span>
))}
<Link
href={buildHref(Math.min(pageCount, page + 1))}
aria-disabled={page >= pageCount}
className={`${linkClass(false)} ${page >= pageCount ? "pointer-events-none opacity-40" : ""}`}
aria-label="صفحه بعد"
>
<ChevronLeft className="size-4" />
</Link>
</nav>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Image from "next/image";
import Link from "next/link";
import { formatPrice, toFaDigits } from "@/lib/format";
import type { ProductCardData } from "@/lib/types";
export default function ProductCard({ product }: { product: ProductCardData }) {
const { href, title, image, price, oldPrice, percent, inStock } = product;
const hasOldPrice = price !== null && oldPrice !== null && oldPrice > price;
return (
<Link
href={href}
className="flex h-full flex-col rounded-lg border border-line bg-white p-2 transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative mb-2 block aspect-square w-full overflow-hidden rounded-md bg-white">
{image ? (
<Image
src={image}
alt={title}
fill
sizes="(max-width: 768px) 45vw, 180px"
className="object-contain p-1"
/>
) : (
<span className="flex size-full items-center justify-center text-[11px] text-body">
بدون تصویر
</span>
)}
{percent > 0 && (
<span className="absolute right-1.5 top-1.5 rounded-full bg-brand px-2 py-0.5 text-[11px] font-bold text-white">
{toFaDigits(percent)}٪
</span>
)}
</span>
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
{title}
</span>
{/* The old-price line always occupies its row — rendered empty when there
is no discount — so a discounted card is exactly as tall as the rest
and every card in a carousel lines up. */}
<span className="mt-auto flex h-[44px] flex-col items-start justify-end px-1 pb-1">
{price !== null && inStock ? (
<>
<span
className={`h-[17px] text-[11px] text-body ${hasOldPrice ? "line-through" : ""}`}
>
{hasOldPrice ? formatPrice(oldPrice) : ""}
</span>
<span className="text-[15px] font-bold text-ink">
{formatPrice(price)}
<span className="mr-1 text-[11px] font-normal text-body">تومان</span>
</span>
</>
) : (
<>
<span className="h-[17px]" />
<span className="text-[12px] text-body">ناموجود</span>
</>
)}
</span>
</Link>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft } from "../icons";
// Title + one-line description on the right; on the left the "مشاهده همه" pill
// with the carousel arrows beside it (desktop only — on mobile the arrows move
// under the track, where the thumb is).
export default function SectionHeader({
title,
desc,
href,
nav,
}: {
title: string;
desc?: string;
href: string;
nav?: ReactNode;
}) {
return (
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-ink">{title}</h2>
{desc && <p className="mt-1 text-[13px] text-body">{desc}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
{nav && <div className="hidden lg:flex">{nav}</div>}
<Link
href={href}
className="flex items-center gap-1 rounded-full border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
مشاهده همه
<ChevronLeft className="size-4" />
</Link>
</div>
</div>
);
}