Connect the storefront to Meshkee catalog, blogs, and static image slots.

Wire homepage specials, product/blog pages, instant search, and the customer portal so the live pharmacy site can be managed from the dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 23:51:41 +03:30
co-authored by Cursor
parent cd2f9dfc9d
commit 66e604c426
39 changed files with 1900 additions and 188 deletions
+24 -2
View File
@@ -1,12 +1,34 @@
"use client";
import { useEffect, useRef, useState } from "react";
type PlaceholderProps = {
src: string;
fallbackSrc?: string;
alt?: string;
className?: string;
};
export function Placeholder({ src, alt = "", className }: PlaceholderProps) {
export function Placeholder({ src, fallbackSrc, alt = "", className }: PlaceholderProps) {
const [current, setCurrent] = useState(src);
const failed = useRef(false);
useEffect(() => {
failed.current = false;
setCurrent(src);
}, [src]);
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className={className} />
<img
src={current}
alt={alt}
className={className}
onError={() => {
if (failed.current || !fallbackSrc || fallbackSrc === current) return;
failed.current = true;
setCurrent(fallbackSrc);
}}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Placeholder } from "@/components/Placeholder";
import { parseAspectRatio } from "@/lib/static-images";
type SlotMediaProps = {
src: string;
alt?: string;
href?: string;
fallbackSrc?: string;
aspectRatio?: string;
className?: string;
imgClassName?: string;
fill?: boolean;
};
function wrap(href: string | undefined, children: ReactNode, className?: string) {
if (!href) return <div className={className}>{children}</div>;
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} className={className}>
{children}
</a>
);
}
export function SlotMedia({
src,
alt = "",
href,
fallbackSrc,
aspectRatio,
className = "",
imgClassName = "absolute inset-0 h-full w-full object-cover",
fill = false,
}: SlotMediaProps) {
const image = <Placeholder src={src} fallbackSrc={fallbackSrc} alt={alt} className={imgClassName} />;
if (fill) {
return wrap(href, image, `absolute inset-0 ${className}`.trim());
}
const ratio = parseAspectRatio(aspectRatio);
return wrap(
href,
<span className="relative block h-full w-full overflow-hidden" style={ratio ? { aspectRatio: ratio } : undefined}>
{image}
</span>,
className,
);
}
+12 -3
View File
@@ -1,14 +1,23 @@
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotMedia } from "@/components/SlotMedia";
import { about } from "@/lib/data";
import type { SlotMediaItem } from "@/lib/static-images";
export function AboutDoctor({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
const src = image?.image ?? "/placeholders/doctor.svg";
export function AboutDoctor() {
return (
<section id="about" className="pt-10">
<Container>
<div className="grid items-center gap-8 overflow-hidden rounded-2xl bg-white p-5 sm:p-8 lg:grid-cols-[0.9fr_1.1fr]">
<div className="overflow-hidden rounded-2xl bg-sky">
<Placeholder src="/placeholders/doctor.svg" className="aspect-[4/5] w-full object-cover lg:aspect-[4/4.4]" />
<SlotMedia
src={src}
href={image?.href}
fallbackSrc={image?.fallbackSrc ?? "/placeholders/doctor.svg"}
aspectRatio={aspectRatio}
className={!aspectRatio ? "aspect-[4/5] lg:aspect-[4/4.4]" : ""}
/>
</div>
<div>
<p className="text-[13px] font-medium text-primary">{about.subtitle}</p>
+20 -9
View File
@@ -1,18 +1,29 @@
import Link from "next/link";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotMedia } from "@/components/SlotMedia";
import type { SlotMediaItem } from "@/lib/static-images";
type Banner = { id: string; image: string; href: string };
export function BannerRow({ banners, columns }: { banners: Banner[]; columns: 2 | 3 }) {
export function BannerRow({
banners,
columns,
aspectRatio,
}: {
banners?: SlotMediaItem[];
columns: 2 | 3;
aspectRatio?: string;
}) {
return (
<section className="pt-8">
<Container>
<div className={`grid gap-3 ${columns === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
{banners.map((banner) => (
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
<Placeholder src={banner.image} className="h-[100px] w-full object-cover sm:h-[118px]" />
</Link>
{(banners ?? []).map((banner) => (
<SlotMedia
key={banner.id}
src={banner.image}
href={banner.href}
fallbackSrc={banner.fallbackSrc}
aspectRatio={aspectRatio}
className={`rounded-xl bg-sky ${!aspectRatio ? "h-[100px] sm:h-[118px]" : ""}`}
/>
))}
</div>
</Container>
+25 -6
View File
@@ -5,10 +5,26 @@ import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { blogs } from "@/lib/data";
import { blogs as sampleBlogs } from "@/lib/data";
import { blogHref, blogImage, type BlogPost } from "@/lib/blog";
import { formatFaDate } from "@/lib/format";
export function BlogCarousel() {
export function BlogCarousel({ posts }: { posts?: BlogPost[] }) {
const scroller = useRef<HTMLDivElement>(null);
const list =
posts && posts.length
? posts.map((post) => ({
id: post.id,
title: post.title,
excerpt: post.abstract || "",
date: formatFaDate(post.publishedAt || post.createdAt),
image: blogImage(post),
href: blogHref(post),
}))
: sampleBlogs.map((post) => ({
...post,
href: "/blog",
}));
return (
<section id="blog" className="pt-10">
@@ -18,7 +34,7 @@ export function BlogCarousel() {
<h2 className="text-[18px] font-bold text-navy">آخرین مطالب مجله سلامت</h2>
<p className="mt-1 text-[13px] text-muted">نکته‌های کاربردی از داروسازان داروخانه</p>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => scroller.current?.scrollBy({ left: 320, behavior: "smooth" })}
@@ -35,18 +51,21 @@ export function BlogCarousel() {
>
<ChevronLeft className="size-4" />
</button>
<Link href="/blog" className="hidden text-[13px] text-primary sm:block">
مشاهده همه
</Link>
</div>
</div>
<div ref={scroller} className="no-scrollbar flex snap-x gap-4 overflow-x-auto">
{blogs.map((post) => (
{list.map((post) => (
<article key={post.id} className="w-[280px] shrink-0 snap-start overflow-hidden rounded-2xl bg-white sm:w-[320px]">
<Link href="#" className="block overflow-hidden bg-sky">
<Link href={post.href} className="block overflow-hidden bg-sky">
<Placeholder src={post.image} className="aspect-[16/10] w-full object-cover" />
</Link>
<div className="p-4">
<p className="text-[12px] text-muted">{post.date}</p>
<h3 className="mt-2 text-[14px] font-bold leading-7 text-navy">
<Link href="#">{post.title}</Link>
<Link href={post.href}>{post.title}</Link>
</h3>
<p className="mt-2 line-clamp-2 text-[12.5px] leading-6 text-muted">{post.excerpt}</p>
</div>
+53 -13
View File
@@ -1,9 +1,45 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotMedia } from "@/components/SlotMedia";
import { categories } from "@/lib/data";
import type { SlotMediaItem } from "@/lib/static-images";
function CardWrap({ href, children }: { href?: string; children: ReactNode }) {
const className =
"group flex flex-col items-center gap-2 rounded-2xl bg-white p-3 text-center transition hover:-translate-y-0.5";
if (!href) return <div className={className}>{children}</div>;
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} className={className}>
{children}
</a>
);
}
export function FeaturedCategories({
items,
aspectRatio,
}: {
items?: SlotMediaItem[];
aspectRatio?: string;
}) {
const list: SlotMediaItem[] =
items && items.length > 0
? items
: categories.map((category) => ({
id: category.id,
image: category.image,
href: category.href,
title: category.title,
}));
export function FeaturedCategories() {
return (
<section id="categories" className="pt-10">
<Container>
@@ -14,19 +50,23 @@ export function FeaturedCategories() {
</div>
</div>
<div className="grid grid-cols-4 gap-3 md:grid-cols-8">
{categories.map((category) => (
<Link
key={category.id}
href={category.href}
className="group flex flex-col items-center gap-2 rounded-2xl bg-white p-3 text-center transition hover:-translate-y-0.5"
>
{list.map((category) => (
<CardWrap key={category.id} href={category.href}>
<span className="grid size-[72px] place-items-center overflow-hidden rounded-2xl bg-sky sm:size-[84px]">
<Placeholder src={category.image} className="size-full object-cover" />
<SlotMedia
src={category.image}
fallbackSrc={category.fallbackSrc}
aspectRatio={aspectRatio}
className="size-full overflow-hidden rounded-2xl"
imgClassName="absolute inset-0 size-full object-cover"
/>
</span>
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
{category.title}
</span>
</Link>
{category.title ? (
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
{category.title}
</span>
) : null}
</CardWrap>
))}
</div>
</Container>
+38 -24
View File
@@ -1,15 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { heroBanners } from "@/lib/data";
import type { Slide } from "@/lib/media";
import { SlotMedia } from "@/components/SlotMedia";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
export function Hero({ slides }: { slides: Slide[] }) {
type HeroProps = {
slides: SlotMediaItem[];
banners: SlotMediaItem[];
sliderAspectRatio?: string;
bannerAspectRatio?: string;
};
export function Hero({ slides = [], banners = [], sliderAspectRatio, bannerAspectRatio }: HeroProps) {
const [index, setIndex] = useState(0);
const sliderRatio = parseAspectRatio(sliderAspectRatio);
useEffect(() => {
if (slides.length <= 1) return;
@@ -27,24 +34,26 @@ export function Hero({ slides }: { slides: Slide[] }) {
return (
<section className="pt-5">
<Container>
<div className="relative overflow-hidden rounded-2xl bg-sky">
<div className="relative aspect-[16/6] sm:aspect-[16/5.2]">
{slides.map((slide, i) => (
<Link
key={slide.id}
href={slide.href}
className={`absolute inset-0 transition-opacity duration-500 ${i === index ? "opacity-100" : "pointer-events-none opacity-0"}`}
>
<Placeholder src={slide.image} className="h-full w-full object-cover" />
</Link>
))}
</div>
<div
className={`relative overflow-hidden rounded-2xl bg-sky ${sliderRatio ? "" : "aspect-[16/6] sm:aspect-[16/5.2]"}`}
style={sliderRatio ? { aspectRatio: sliderRatio } : undefined}
>
{slides.map((slide, i) => (
<SlotMedia
key={slide.id}
fill
src={slide.image}
href={slide.href}
fallbackSrc={slide.fallbackSrc}
className={`transition-opacity duration-500 ${i === index ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
))}
{slides.length > 1 ? (
<>
<button
type="button"
onClick={() => go(-1)}
className="absolute top-1/2 right-3 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
className="absolute top-1/2 right-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
aria-label="اسلاید قبلی"
>
<ChevronRight className="size-5" />
@@ -52,12 +61,12 @@ export function Hero({ slides }: { slides: Slide[] }) {
<button
type="button"
onClick={() => go(1)}
className="absolute top-1/2 left-3 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
className="absolute top-1/2 left-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
aria-label="اسلاید بعدی"
>
<ChevronLeft className="size-5" />
</button>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5">
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
{slides.map((slide, i) => (
<button
key={slide.id}
@@ -73,10 +82,15 @@ export function Hero({ slides }: { slides: Slide[] }) {
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{heroBanners.map((banner) => (
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
<Placeholder src={banner.image} className="h-[92px] w-full object-cover sm:h-[110px]" />
</Link>
{banners.map((banner) => (
<SlotMedia
key={banner.id}
src={banner.image}
href={banner.href}
fallbackSrc={banner.fallbackSrc}
aspectRatio={bannerAspectRatio}
className={`overflow-hidden rounded-xl bg-sky ${!bannerAspectRatio ? "h-[92px] sm:h-[110px]" : ""}`}
/>
))}
</div>
</Container>
+1 -1
View File
@@ -20,7 +20,7 @@ export function ProductCarousel({
id,
title,
subtitle,
href = "#",
href = "/products",
products,
accent = "default",
}: Props) {
+32 -31
View File
@@ -6,7 +6,6 @@ import {
ChevronDown,
Heart,
Menu,
Search,
ShoppingBag,
UserRound,
X,
@@ -14,9 +13,12 @@ import {
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { MegaMenu } from "@/components/layout/MegaMenu";
import { categories, navLinks, site } from "@/lib/data";
import { InstantSearch } from "@/components/layout/InstantSearch";
import { customerPortalUrl } from "@/lib/config";
import { navLinks, site } from "@/lib/data";
import type { NavCategory } from "@/lib/categories";
export function Header({ logoSrc }: { logoSrc: string }) {
export function Header({ logoSrc, categories }: { logoSrc: string; categories: NavCategory[] }) {
const [megaOpen, setMegaOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileCats, setMobileCats] = useState(false);
@@ -37,32 +39,22 @@ export function Header({ logoSrc }: { logoSrc: string }) {
<Placeholder src={logoSrc} alt={site.name} className="h-11 w-auto object-contain" />
</Link>
<form className="relative hidden min-w-0 flex-1 md:block">
<input
type="search"
placeholder="جستجوی دارو، مکمل و محصولات بهداشتی..."
className="h-11 w-full rounded-xl border border-line bg-mist pr-4 pl-11 text-[13px] outline-none transition focus:border-primary focus:bg-white"
/>
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted" />
</form>
<InstantSearch />
<div className="ms-auto flex items-center gap-1">
<Link
href="#"
<a
href={customerPortalUrl}
className="hidden items-center gap-2 rounded-xl px-3 py-2 text-[13px] text-ink hover:bg-mist lg:flex"
>
<UserRound className="size-5 text-navy" />
ورود / ثبت‌نام
</Link>
<Link href="#" className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقه‌مندی">
</a>
<a href={customerPortalUrl} className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقه‌مندی">
<Heart className="size-5 text-navy" />
</Link>
<Link href="#" className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
</a>
<a href={customerPortalUrl} className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
<ShoppingBag className="size-5 text-navy" />
<span className="absolute top-1.5 left-1.5 grid size-4 place-items-center rounded-full bg-primary text-[10px] text-white">
۰
</span>
</Link>
</a>
</div>
</Container>
@@ -81,7 +73,7 @@ export function Header({ logoSrc }: { logoSrc: string }) {
دسته‌بندی‌ها
<ChevronDown className={`size-4 transition ${megaOpen ? "rotate-180" : ""}`} />
</button>
<MegaMenu open={megaOpen} />
<MegaMenu open={megaOpen} categories={categories} />
</div>
{navLinks.map((link) => (
@@ -111,18 +103,14 @@ export function Header({ logoSrc }: { logoSrc: string }) {
<X className="size-5" />
</button>
</div>
<form className="px-4 pt-4">
<input
type="search"
placeholder="جستجو..."
className="h-11 w-full rounded-xl border border-line bg-mist px-3 text-[13px] outline-none"
/>
</form>
<div className="px-4 pt-4">
<InstantSearch compact />
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3">
<button
type="button"
className="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-right text-[14px] font-medium text-navy"
onClick={() => setMobileCats((v) => !v)}
onClick={() => setMobileCats((value) => !value)}
>
دسته‌بندی‌ها
<ChevronDown className={`size-4 ${mobileCats ? "rotate-180" : ""}`} />
@@ -131,9 +119,19 @@ export function Header({ logoSrc }: { logoSrc: string }) {
<ul className="mb-2 space-y-1 pr-3">
{categories.map((cat) => (
<li key={cat.id}>
<Link href={cat.href} className="block rounded-lg px-3 py-1.5 text-[13px] text-muted">
<Link href={cat.href} className="block rounded-lg px-3 py-1.5 text-[13px] font-medium text-navy" onClick={() => setMobileOpen(false)}>
{cat.title}
</Link>
{cat.children.map((child) => (
<Link
key={child.id}
href={child.href}
className="block rounded-lg px-3 py-1.5 pr-5 text-[13px] text-muted"
onClick={() => setMobileOpen(false)}
>
{child.title}
</Link>
))}
</li>
))}
</ul>
@@ -148,6 +146,9 @@ export function Header({ logoSrc }: { logoSrc: string }) {
{link.title}
</Link>
))}
<a href={customerPortalUrl} className="mt-2 block rounded-xl px-3 py-2.5 text-[14px] text-primary">
ورود / ثبت‌نام
</a>
</nav>
</aside>
</div>
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Search } from "lucide-react";
import { Placeholder } from "@/components/Placeholder";
import { formatPrice } from "@/lib/format";
import { variantTitle, type StoreVariant } from "@/lib/store";
export function InstantSearch({ compact = false }: { compact?: boolean }) {
const [query, setQuery] = useState("");
const [items, setItems] = useState<StoreVariant[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (event: MouseEvent) => {
if (!box.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
useEffect(() => {
const value = query.trim();
if (value.length <= 2) {
setItems([]);
setOpen(false);
setLoading(false);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
const data = (await response.json()) as { items?: StoreVariant[] };
setItems(data.items ?? []);
setOpen(true);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, 250);
return () => clearTimeout(timer);
}, [query]);
return (
<div ref={box} className={`relative ${compact ? "w-full" : "hidden min-w-0 flex-1 md:block"}`}>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => {
if (query.trim().length > 2) setOpen(true);
}}
placeholder={compact ? "جستجو..." : "جستجوی دارو، مکمل و محصولات بهداشتی..."}
className="h-11 w-full rounded-xl border border-line bg-mist pr-4 pl-11 text-[13px] outline-none transition focus:border-primary focus:bg-white"
/>
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted" />
{open ? (
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-50 overflow-hidden rounded-2xl border border-line bg-white shadow-[0_16px_40px_rgba(0,38,133,0.12)]">
{loading ? (
<p className="px-4 py-3 text-[13px] text-muted">در حال جستجو...</p>
) : items.length === 0 ? (
<p className="px-4 py-3 text-[13px] text-muted">نتیجه‌ای پیدا نشد.</p>
) : (
<ul className="max-h-[360px] overflow-y-auto py-2">
{items.map((item) => {
const price = item.discountedPrice ?? item.price;
return (
<li key={item.id}>
<Link
href={`/products/${encodeURIComponent(item.id)}`}
className="flex items-center gap-3 px-3 py-2 hover:bg-mist"
onClick={() => setOpen(false)}
>
<span className="grid size-12 shrink-0 overflow-hidden rounded-lg bg-sky">
<Placeholder
src={item.productImage || "/placeholders/product.svg"}
className="size-full object-cover"
/>
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-1 text-[13px] text-ink">{variantTitle(item)}</span>
<span className="mt-0.5 block text-[12px] text-muted">
{price != null ? `${formatPrice(price)} تومان` : "تماس بگیرید"}
</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
) : null}
</div>
);
}
+37 -29
View File
@@ -1,41 +1,49 @@
"use client";
import Link from "next/link";
import { categories } from "@/lib/data";
import { Placeholder } from "@/components/Placeholder";
import type { NavCategory } from "@/lib/categories";
export function MegaMenu({ open }: { open: boolean }) {
export function MegaMenu({ open, categories }: { open: boolean; categories: NavCategory[] }) {
if (!open) return null;
const list = categories.length ? categories : [];
return (
<div className="absolute top-full right-0 z-50 mt-2 w-[min(920px,calc(100vw-2rem))] rounded-2xl border border-line bg-white p-5 shadow-[0_16px_40px_rgba(0,38,133,0.12)]">
<div className="grid grid-cols-2 gap-5 md:grid-cols-4">
{categories.map((category) => (
<div key={category.id}>
<Link
href={category.href}
className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-navy"
>
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
<Placeholder src={category.image} className="size-full object-cover" />
</span>
{category.title}
</Link>
<ul className="space-y-1.5 pr-1">
{category.children.map((child) => (
<li key={child.title}>
<Link
href={child.href}
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
>
{child.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
{list.length === 0 ? (
<p className="text-[13px] text-muted">دسته‌بندی‌ها به‌زودی اضافه می‌شوند.</p>
) : (
<div className="grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4">
{list.map((category) => (
<div key={category.id}>
<Link
href={category.href}
className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-navy"
>
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
<Placeholder src={category.image || "/placeholders/category.svg"} className="size-full object-cover" />
</span>
{category.title}
</Link>
{category.children.length ? (
<ul className="space-y-1.5 pr-1">
{category.children.map((child) => (
<li key={child.id}>
<Link
href={child.href}
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
>
{child.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Minus, Plus, ShoppingBag } from "lucide-react";
import { customerPortalUrl } from "@/lib/config";
export function AddToCart({ variantId, maxQuantity }: { variantId: string; maxQuantity?: number | null }) {
const [quantity, setQuantity] = useState(1);
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const cap = maxQuantity && maxQuantity > 0 ? maxQuantity : 99;
const change = (delta: number) => {
setQuantity((current) => Math.min(cap, Math.max(1, current + delta)));
};
const add = async () => {
const token =
window.localStorage.getItem("accessToken") ||
window.localStorage.getItem("meshkeeToken") ||
window.localStorage.getItem("token");
if (!token) {
window.location.href = customerPortalUrl;
return;
}
setStatus("loading");
try {
const response = await fetch("/api/cart/items", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ storeItemVariantId: variantId, quantity }),
});
if (response.status === 401) {
window.location.href = customerPortalUrl;
return;
}
setStatus(response.ok ? "ok" : "error");
} catch {
setStatus("error");
}
};
return (
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex h-12 items-center rounded-xl border border-line bg-white">
<button type="button" className="grid size-12 place-items-center text-navy" onClick={() => change(-1)} aria-label="کاهش">
<Minus className="size-4" />
</button>
<span className="min-w-[2rem] text-center text-[15px] font-bold text-navy">{quantity}</span>
<button type="button" className="grid size-12 place-items-center text-navy" onClick={() => change(1)} aria-label="افزایش">
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={add}
disabled={status === "loading"}
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-primary text-[14px] font-bold text-white transition hover:bg-primary-hover disabled:opacity-70"
>
<ShoppingBag className="size-4" />
{status === "loading" ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
{status === "ok" ? <p className="text-[12px] text-ok">به سبد اضافه شد.</p> : null}
{status === "error" ? <p className="text-[12px] text-sale">افزودن به سبد انجام نشد.</p> : null}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { X } from "lucide-react";
import { Placeholder } from "@/components/Placeholder";
export function ProductGallery({ images, alt }: { images: string[]; alt: string }) {
const gallery = images.length ? images : ["/placeholders/product.svg"];
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
const [origin, setOrigin] = useState("50% 50%");
const [zoomed, setZoomed] = useState(false);
const current = gallery[active] ?? gallery[0];
return (
<>
<div>
<button
type="button"
className="relative block w-full overflow-hidden rounded-2xl bg-sky"
onClick={() => setLightbox(true)}
onMouseEnter={() => setZoomed(true)}
onMouseLeave={() => {
setZoomed(false);
setOrigin("50% 50%");
}}
onMouseMove={(event) => {
const box = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - box.left) / box.width) * 100;
const y = ((event.clientY - box.top) / box.height) * 100;
setOrigin(`${x}% ${y}%`);
}}
>
<Placeholder
src={current}
fallbackSrc="/placeholders/product.svg"
alt={alt}
className="aspect-square w-full object-cover transition-transform duration-150"
/>
<span
className="pointer-events-none absolute inset-0 bg-cover bg-no-repeat"
style={{
backgroundImage: `url(${current})`,
transform: zoomed ? "scale(1.85)" : "scale(1)",
transformOrigin: origin,
opacity: zoomed ? 1 : 0,
}}
/>
</button>
{gallery.length > 1 ? (
<div className="mt-3 flex gap-2 overflow-x-auto">
{gallery.map((image, index) => (
<button
key={`${image}-${index}`}
type="button"
onClick={() => setActive(index)}
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-primary" : "border-line"}`}
>
<Placeholder src={image} className="size-full object-cover" />
</button>
))}
</div>
) : null}
</div>
{lightbox ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-navy/80 p-4" onClick={() => setLightbox(false)}>
<button type="button" className="absolute top-4 left-4 grid size-10 place-items-center rounded-full bg-white text-navy" aria-label="بستن">
<X className="size-5" />
</button>
<img
src={current}
alt={alt}
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
onClick={(event) => event.stopPropagation()}
/>
</div>
) : null}
</>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { customerPortalUrl } from "@/lib/config";
import { formatFaDate } from "@/lib/format";
import type { CommentItem } from "@/lib/comments";
export function ProductTabs({
descriptionHtml,
comments,
showDescription = true,
}: {
descriptionHtml?: string | null;
comments: CommentItem[];
showDescription?: boolean;
}) {
const [tab, setTab] = useState<"description" | "comments">(showDescription ? "description" : "comments");
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
setToken(
window.localStorage.getItem("accessToken") ||
window.localStorage.getItem("meshkeeToken") ||
window.localStorage.getItem("token"),
);
}, []);
return (
<section className="mt-10 rounded-2xl bg-white p-5 sm:p-7">
{showDescription ? (
<div className="flex gap-2 border-b border-line pb-3">
<button
type="button"
onClick={() => setTab("description")}
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "description" ? "bg-sky text-navy" : "text-muted"}`}
>
توضیحات
</button>
<button
type="button"
onClick={() => setTab("comments")}
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "comments" ? "bg-sky text-navy" : "text-muted"}`}
>
نظرات
</button>
</div>
) : (
<h2 className="text-[16px] font-bold text-navy">نظرات</h2>
)}
{tab === "description" ? (
descriptionHtml ? (
<div className="rich-text mt-5 text-[14px] leading-8 text-ink" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : (
<p className="mt-5 text-[13px] text-muted">توضیحاتی برای این محصول ثبت نشده است.</p>
)
) : (
<div className="mt-5 space-y-4">
{comments.length === 0 ? (
<p className="text-[13px] text-muted">نظری ثبت نشده است.</p>
) : (
comments.map((comment, index) => (
<article key={comment.id ?? index} className="rounded-xl bg-mist p-4">
<p className="text-[13px] font-bold text-navy">{comment.authorName || "کاربر"}</p>
{comment.createdAt ? <p className="mt-1 text-[11px] text-muted">{formatFaDate(comment.createdAt)}</p> : null}
<p className="mt-2 text-[13px] leading-7 text-ink">{comment.text}</p>
</article>
))
)}
{token ? (
<p className="text-[13px] text-muted">برای مدیریت نظرات از حساب کاربری خود استفاده کنید.</p>
) : (
<div className="rounded-2xl bg-sky p-5">
<p className="text-[14px] font-bold text-navy">برای ثبت نظر وارد شوید</p>
<p className="mt-1 text-[13px] text-muted">نظرات فقط برای کاربران واردشده فعال است.</p>
<a
href={customerPortalUrl}
className="mt-4 inline-flex h-11 items-center rounded-xl bg-primary px-5 text-[13px] font-bold text-white"
>
ورود / ثبت‌نام
</a>
</div>
)}
</div>
)}
</section>
);
}
+22 -10
View File
@@ -5,19 +5,25 @@ import { faDigits, formatPrice } from "@/lib/format";
import type { Product } from "@/lib/data";
export function ProductCard({ product }: { product: Product }) {
const href = product.href ?? `/products/${encodeURIComponent(product.id)}`;
return (
<article className="group flex h-full flex-col rounded-2xl bg-white p-3">
<Link href="#" className="relative overflow-hidden rounded-xl bg-sky">
<Link href={href} className="relative overflow-hidden rounded-xl bg-sky">
{product.discount ? (
<span className="absolute top-2 right-2 z-10 rounded-md bg-sale px-1.5 py-0.5 text-[11px] font-bold text-white">
{faDigits(product.discount)}٪
</span>
) : null}
<Placeholder src={product.image} className="aspect-square w-full object-cover transition group-hover:scale-[1.03]" />
<Placeholder src={product.image} fallbackSrc="/placeholders/product.svg" className="aspect-square w-full object-cover transition group-hover:scale-[1.03]" />
</Link>
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{product.brand}</p>
{product.brand ? (
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{product.brand}</p>
) : (
<p className="mt-3 h-[16px]" />
)}
<h3 className="mt-1 line-clamp-2 min-h-[40px] text-[13px] leading-5 text-ink">
<Link href="#">{product.title}</Link>
<Link href={href}>{product.title}</Link>
</h3>
<div className="mt-auto pt-3">
{product.oldPrice ? (
@@ -27,16 +33,22 @@ export function ProductCard({ product }: { product: Product }) {
)}
<div className="mt-1 flex items-end justify-between gap-2">
<p className="text-[14px] font-bold text-navy">
{formatPrice(product.price)}
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
{product.price != null ? (
<>
{formatPrice(product.price)}
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
</>
) : (
<span className="text-[12px] font-medium text-muted">تماس بگیرید</span>
)}
</p>
<button
type="button"
<Link
href={href}
className="grid size-9 place-items-center rounded-xl bg-primary text-white transition hover:bg-primary-hover"
aria-label="افزودن به سبد خرید"
aria-label="مشاهده محصول"
>
<ShoppingBag className="size-4" />
</button>
</Link>
</div>
</div>
</article>