first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user