Tighten storefront cards and rebuild the product page.

Match carousel cards on XL, add gallery zoom/lightbox, quantity and comments, and update the sale banner slot to 2:3.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-18 16:19:29 +03:30
co-authored by Cursor
parent 14ca6b39ea
commit 051939c6e2
18 changed files with 570 additions and 110 deletions
+54
View File
@@ -88,6 +88,42 @@ body {
border: 1px solid rgb(255 255 255 / 0.55);
}
.faq-band {
position: relative;
isolation: isolate;
background-color: #efe7ff;
background-image:
radial-gradient(ellipse 42% 55% at 8% 30%, rgb(255 214 228 / 0.78), transparent 62%),
radial-gradient(ellipse 38% 50% at 94% 72%, rgb(165 243 252 / 0.55), transparent 64%),
radial-gradient(ellipse 50% 40% at 52% 0%, rgb(216 198 255 / 0.7), transparent 70%),
radial-gradient(circle, rgb(109 40 217 / 0.13) 1.15px, transparent 1.25px);
background-size: auto, auto, auto, 26px 26px;
}
.faq-band::before,
.faq-band::after {
content: "";
position: absolute;
inset-inline: 0;
z-index: 1;
height: 20px;
pointer-events: none;
}
.faq-band::before {
top: 0;
background-image: radial-gradient(circle at 50% 0, var(--paper) 11px, transparent 12px);
background-size: 32px 20px;
background-repeat: repeat-x;
}
.faq-band::after {
bottom: 0;
background-image: radial-gradient(circle at 50% 20px, var(--paper) 11px, transparent 12px);
background-size: 32px 20px;
background-repeat: repeat-x;
}
.font-display {
font-family: var(--font-display);
}
@@ -115,6 +151,24 @@ body {
}
}
.carousel-card {
flex-shrink: 0;
scroll-snap-align: start;
width: 280px;
}
@media (min-width: 640px) {
.carousel-card {
width: 300px;
}
}
@media (min-width: 1280px) {
.carousel-card {
width: calc((100% - 5rem) / 6);
}
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-1
View File
@@ -36,7 +36,6 @@ export default async function Home() {
countdown
leadBanner
bannerSlot={slotByKey(slots, HOME_SLOT_KEYS.specialSale)}
href="/special-sale"
/>
<DualBanners slot={slotByKey(slots, HOME_SLOT_KEYS.twoBannerRow)} />
<ProductCarousel
+10 -18
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ProductBuyBox } from "@/components/ProductBuyBox";
import { ProductCarousel } from "@/components/ProductCarousel";
import { ProductGallery } from "@/components/ProductGallery";
import { ProductTabs } from "@/components/ProductTabs";
import { SiteChrome } from "@/components/SiteChrome";
import { getCatalogItem, listCatalog } from "@/lib/api";
import { formatPrice } from "@/lib/format";
@@ -54,12 +56,12 @@ export default async function ProductDetailsPage({ params }: Props) {
<span>{product.title}</span>
</nav>
<div className="grid gap-10 lg:grid-cols-[1.05fr_1fr]">
<div className="glass-box rounded-3xl p-5">
<div className="grid items-start gap-8 lg:grid-cols-12">
<div className="glass-box rounded-3xl p-4 lg:col-span-4">
<ProductGallery images={product.images} title={product.title} />
</div>
<div>
<div className="lg:col-span-8">
<p className="font-display text-sm text-cyan-600">{product.brand}</p>
<h1 className="mt-1 text-2xl font-black leading-10 text-ink sm:text-3xl">{product.title}</h1>
{product.categoryName ? <p className="mt-2 text-sm text-muted">{product.categoryName}</p> : null}
@@ -107,24 +109,14 @@ export default async function ProductDetailsPage({ params }: Props) {
</div>
) : null}
{product.price != null && product.price > 0 ? (
<a
href={CUSTOMER_URL}
className="mt-8 inline-flex rounded-full bg-purple-700 px-8 py-3 text-sm font-bold text-white hover:bg-purple-800"
>
خرید از حساب کاربری
</a>
) : null}
{product.summary ? <p className="mt-6 text-sm leading-8 text-muted">{product.summary}</p> : null}
<ProductBuyBox
href={CUSTOMER_URL}
disabled={product.price == null || product.price <= 0 || product.inStock === false}
/>
</div>
</div>
{product.descriptionHtml ? (
<div className="glass-box mt-10 rounded-3xl p-6">
<h2 className="mb-4 text-lg font-bold text-ink">توضیحات محصول</h2>
<div className="rich-content" dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
</div>
) : null}
<ProductTabs descriptionHtml={product.descriptionHtml} summary={product.summary} />
<ProductCarousel
english="Related"
+1 -1
View File
@@ -39,7 +39,7 @@ export function Carousel({ children }: Props) {
</button>
<div
ref={scroller}
className="hide-scrollbar flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory"
className="hide-scrollbar flex items-stretch gap-4 overflow-x-auto pb-2 snap-x snap-mandatory"
>
{children}
</div>
+36 -13
View File
@@ -1,20 +1,43 @@
import { faqs } from "@/data/content";
const accents = [
"bg-purple-100 text-purple-700",
"bg-cyan-100 text-cyan-700",
"bg-rose-100 text-rose-600",
"bg-purple-50 text-cyan-700",
];
export function Faq() {
return (
<section id="faq" className="container-nn py-10">
<div className="mb-6">
<p className="font-display text-sm text-cyan-600">FAQ</p>
<h2 className="mt-1 text-2xl font-bold text-ink sm:text-3xl">سوالات متداول</h2>
<span className="mt-2 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
</div>
<div className="grid gap-3 md:grid-cols-2">
{faqs.map((item) => (
<article key={item.q} className="glass-box rounded-3xl p-5">
<h3 className="text-base font-bold text-ink">{item.q}</h3>
<p className="mt-2 text-sm leading-7 text-muted">{item.a}</p>
</article>
))}
<section id="faq" className="faq-band my-2 py-16 sm:py-20">
<div className="container-nn relative z-10">
<div className="mb-8">
<p className="font-display text-sm text-cyan-600">FAQ</p>
<h2 className="mt-1 text-2xl font-bold text-ink sm:text-3xl">سوالات متداول</h2>
<span className="mt-2 block h-1.5 w-16 rounded-full bg-linear-to-l from-purple-600 via-cyan-500 to-rose-500" />
</div>
<div className="grid gap-3 md:grid-cols-2">
{faqs.map((item, index) => (
<article
key={item.q}
className="rounded-3xl border border-white/70 bg-white/75 p-5 shadow-soft backdrop-blur-sm"
>
<div className="flex items-start gap-3">
<span
dir="ltr"
lang="en"
className={`font-display grid size-9 shrink-0 place-items-center rounded-2xl text-sm font-semibold ${accents[index % accents.length]}`}
>
{String(index + 1).padStart(2, "0")}
</span>
<div>
<h3 className="text-base font-bold text-ink">{item.q}</h3>
<p className="mt-2 text-sm leading-7 text-muted">{item.a}</p>
</div>
</div>
</article>
))}
</div>
</div>
</section>
);
+17 -5
View File
@@ -1,7 +1,6 @@
import { categories } from "@/data/content";
import { toFaDigits } from "@/lib/format";
import {
imageAlt,
slotHref,
slotImages,
type StaticImageSlot,
@@ -19,11 +18,14 @@ export function FeaturedCategories({ slot }: Props) {
images.length > 0
? images.map((image, index) => {
const fallback = categories[index];
const titleFa = (image.titleFa || image.title || fallback?.title || "").trim();
const titleEn = (image.titleEn || "").trim();
return {
id: image.id,
image,
fallbackSrc: fallback?.image ?? categories[0].image,
title: imageAlt(image, fallback?.title ?? ""),
titleFa,
titleEn: titleFa && titleEn && titleEn !== titleFa ? titleEn : "",
href: slotHref(image) || "#",
count: fallback?.count,
subtext: image.subtext,
@@ -33,7 +35,8 @@ export function FeaturedCategories({ slot }: Props) {
id: category.id,
image: undefined,
fallbackSrc: category.image,
title: category.title,
titleFa: category.title,
titleEn: category.titleEn && category.titleEn !== category.title ? category.titleEn : "",
href: "#",
count: category.count,
subtext: undefined as string | null | undefined,
@@ -52,14 +55,23 @@ export function FeaturedCategories({ slot }: Props) {
<SlotImage
image={item.image}
fallbackSrc={item.fallbackSrc}
fallbackAlt={item.title}
fallbackAlt={item.titleFa}
aspectRatio={slot?.aspectRatio ?? "1:1"}
width={168}
height={168}
rounded="rounded-2xl"
unlinked
/>
<h3 className="mt-3 text-sm font-bold text-ink">{item.title}</h3>
<h3 className="mt-2 text-sm font-bold text-ink">{item.titleFa}</h3>
{item.titleEn ? (
<p
lang="en"
dir="ltr"
className="font-display mt-0.5 text-[11px] leading-none tracking-wide text-cyan-600"
>
{item.titleEn}
</p>
) : null}
{item.subtext ? (
<p className="text-xs text-muted">{item.subtext}</p>
) : item.count != null ? (
+8
View File
@@ -158,6 +158,14 @@ export function IconPlus({ className = "size-5" }: IconProps) {
);
}
export function IconMinus({ className = "size-5" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function IconClose({ className = "size-6" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
+5 -1
View File
@@ -6,6 +6,7 @@ type Props = {
className?: string;
rounded?: string;
fill?: boolean;
fit?: "cover" | "contain";
};
export function Placeholder({
@@ -16,6 +17,7 @@ export function Placeholder({
className = "",
rounded = "rounded-2xl",
fill = false,
fit = "cover",
}: Props) {
return (
<img
@@ -23,7 +25,9 @@ export function Placeholder({
alt={alt}
width={width}
height={height}
className={`block w-full object-cover ${fill ? "h-full" : "h-auto"} ${rounded} ${className}`}
className={`block w-full ${fill ? "h-full" : "h-auto"} ${
fit === "contain" ? "object-contain" : "object-cover"
} ${rounded} ${className}`}
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { toFaDigits } from "@/lib/format";
import { IconBag, IconMinus, IconPlus } from "./Icons";
type Props = {
href: string;
disabled?: boolean;
};
export function ProductBuyBox({ href, disabled = false }: Props) {
const [qty, setQty] = useState(1);
return (
<div className="mt-8 flex flex-wrap items-center gap-3">
<div className="flex h-12 items-center rounded-full border border-line bg-white/80 px-1.5">
<button
type="button"
onClick={() => setQty((value) => Math.max(1, value - 1))}
aria-label="کاهش تعداد"
className="grid size-9 place-items-center rounded-full text-purple-700 hover:bg-purple-50"
>
<IconMinus className="size-4" />
</button>
<span dir="ltr" lang="fa" className="min-w-10 text-center text-sm font-black text-ink">
{toFaDigits(qty)}
</span>
<button
type="button"
onClick={() => setQty((value) => value + 1)}
aria-label="افزایش تعداد"
className="grid size-9 place-items-center rounded-full text-purple-700 hover:bg-purple-50"
>
<IconPlus className="size-4" />
</button>
</div>
{disabled ? (
<button
type="button"
disabled
className="inline-flex h-12 items-center gap-2 rounded-full bg-purple-200 px-6 text-sm font-bold text-white"
>
<IconBag className="size-4" />
افزودن به سبد خرید
</button>
) : (
<a
href={href}
className="inline-flex h-12 items-center gap-2 rounded-full bg-purple-700 px-6 text-sm font-bold text-white hover:bg-purple-800"
>
<IconBag className="size-4" />
افزودن به سبد خرید
</a>
)}
</div>
);
}
+52 -44
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { CUSTOMER_URL } from "@/lib/site";
import type { Product } from "@/lib/catalog";
import { formatPrice } from "@/lib/format";
import { formatDiscountPercent, formatPrice } from "@/lib/format";
import { IconPlus } from "./Icons";
import { Placeholder } from "./Placeholder";
@@ -12,68 +12,76 @@ type Props = {
export function ProductCard({ product, fill = false }: Props) {
const canAdd = product.price != null && product.price > 0;
const discount = formatDiscountPercent(product.price, product.oldPrice);
const labelBadge = product.badge && !product.badge.includes("٪") ? product.badge : null;
return (
<article
data-card
className={`glass-box rounded-3xl p-2.5 shadow-soft transition hover:-translate-y-1 hover:shadow-card ${
fill ? "w-full" : "w-[280px] shrink-0 snap-start sm:w-[300px]"
className={`glass-box flex h-full flex-col rounded-3xl p-2 shadow-soft transition hover:-translate-y-1 hover:shadow-card ${
fill ? "w-full" : "carousel-card"
}`}
>
<Link href={product.href} className="block">
<Placeholder
src={product.image}
alt={product.title}
width={360}
height={420}
rounded="rounded-2xl"
/>
</Link>
<div className="px-1.5 pt-3 pb-1">
<div className="flex items-center justify-between gap-2">
<p className="font-display text-[11px] text-cyan-600">{product.brand}</p>
{product.badge ? (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold text-white ${
product.badge === "یگانه" || product.badge === "نینی"
? "bg-purple-700"
: product.badge === "پرفروش"
? "bg-cyan-600"
: "bg-rose-500"
}`}
>
{product.badge}
</span>
) : null}
<Link href={product.href} className="relative block">
<div className="aspect-square overflow-hidden rounded-2xl bg-white">
<Placeholder
src={product.image}
alt={product.title}
width={360}
height={360}
rounded="rounded-2xl"
fill
fit="contain"
/>
</div>
{discount ? (
<span className="absolute top-2 right-2 rounded-full bg-rose-500 px-2 py-1 text-[11px] font-black leading-none text-white shadow-soft">
{discount}
</span>
) : null}
</Link>
<div className="flex flex-1 flex-col px-1.5 pt-2 pb-0.5">
{labelBadge ? (
<span
className={`mb-1 inline-block w-fit rounded-full px-2 py-0.5 text-[10px] font-bold text-white ${
labelBadge === "یگانه" || labelBadge === "نینی"
? "bg-purple-700"
: labelBadge === "پرفروش"
? "bg-cyan-600"
: "bg-rose-500"
}`}
>
{labelBadge}
</span>
) : null}
<Link href={product.href}>
<h3 className="mt-0.5 line-clamp-2 min-h-11 text-sm font-bold leading-6 text-ink">
<h3 className="line-clamp-2 min-h-10 text-sm font-bold leading-5 text-ink">
{product.title}
</h3>
</Link>
{product.colors.length ? (
<div className="mt-2 flex items-center gap-1.5">
{product.colors.slice(0, 5).map((color) => (
<span
key={color}
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
style={{ backgroundColor: color }}
/>
))}
</div>
) : null}
<div className="mt-3 flex items-end justify-between gap-2">
<div>
<div className="mt-1.5 flex min-h-3.5 items-center gap-1.5">
{product.colors.slice(0, 5).map((color) => (
<span
key={color}
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="mt-auto flex min-h-9 items-center justify-between gap-2 pt-2">
<div className="min-w-0">
{product.oldPrice ? (
<p className="text-[11px] text-muted line-through">{formatPrice(product.oldPrice)}</p>
) : null}
<p className="text-sm font-black text-purple-700">{formatPrice(product.price)}</p>
<p className="text-sm font-black leading-5 text-purple-700">
{formatPrice(product.price)}
</p>
</div>
{canAdd ? (
<a
href={CUSTOMER_URL}
aria-label="افزودن به سبد خرید"
className="grid size-9 place-items-center rounded-full bg-purple-700 text-white hover:bg-purple-800"
className="grid size-9 shrink-0 place-items-center rounded-full bg-purple-700 text-white hover:bg-purple-800"
>
<IconPlus className="size-4" />
</a>
+15 -13
View File
@@ -47,21 +47,23 @@ export function ProductCarousel({
{leadBanner ? (
<div
data-card
className="flex w-[280px] min-h-[320px] shrink-0 snap-start self-stretch overflow-hidden rounded-3xl sm:w-[300px]"
className="carousel-card relative min-h-0 self-stretch overflow-hidden rounded-3xl"
aria-label="بنر فروش ویژه"
>
<SlotImage
image={saleBanner}
fallbackSrc="/images/sale-banner.png"
fallbackAlt="فروش ویژه"
aspectRatio={bannerSlot?.aspectRatio}
width={300}
height={500}
rounded="rounded-3xl"
fill
className="h-full w-full"
fallbackHref="/special-sale"
/>
<div className="absolute inset-0">
<SlotImage
image={saleBanner}
fallbackSrc="/images/sale-banner.png"
fallbackAlt="فروش ویژه"
aspectRatio={bannerSlot?.aspectRatio ?? "2:3"}
width={260}
height={390}
rounded="rounded-3xl"
fill
className="h-full w-full object-cover"
fallbackHref="/special-sale"
/>
</div>
</div>
) : null}
{products.map((product) => (
+107 -4
View File
@@ -1,18 +1,70 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Placeholder } from "@/components/Placeholder";
import { IconChevron, IconClose } from "./Icons";
export function ProductGallery({ images, title }: { images: string[]; title: string }) {
const gallery = images.filter(Boolean);
const [active, setActive] = useState(0);
const [open, setOpen] = useState(false);
const [origin, setOrigin] = useState("50% 50%");
const current = gallery[active] || gallery[0];
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (gallery.length < 2) return;
if (event.key === "ArrowRight") setActive((index) => (index + 1) % gallery.length);
if (event.key === "ArrowLeft") setActive((index) => (index - 1 + gallery.length) % gallery.length);
};
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previous;
window.removeEventListener("keydown", onKey);
};
}, [open, gallery.length]);
if (!current) return null;
const show = (index: number) => {
setActive((index + gallery.length) % gallery.length);
};
return (
<div>
<Placeholder src={current} alt={title} width={720} height={720} rounded="rounded-3xl" />
<button
type="button"
onClick={() => setOpen(true)}
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}%`);
}}
onMouseLeave={() => setOrigin("50% 50%")}
className="group relative block aspect-square w-full cursor-zoom-in overflow-hidden rounded-3xl bg-white"
aria-label="بزرگ‌نمایی تصویر"
>
<span
className="block h-full w-full transition-transform duration-200 ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[1.8]"
style={{ transformOrigin: origin }}
>
<Placeholder
src={current}
alt={title}
width={720}
height={720}
rounded="rounded-3xl"
fill
fit="contain"
className="pointer-events-none"
/>
</span>
</button>
{gallery.length > 1 ? (
<div className="mt-3 flex flex-wrap gap-2">
{gallery.slice(0, 8).map((image, index) => (
@@ -20,15 +72,66 @@ export function ProductGallery({ images, title }: { images: string[]; title: str
key={`${image}-${index}`}
type="button"
onClick={() => setActive(index)}
className={`overflow-hidden rounded-2xl ${
className={`size-[72px] shrink-0 overflow-hidden rounded-2xl ${
index === active ? "ring-2 ring-purple-600" : "ring-1 ring-line"
}`}
>
<Placeholder src={image} alt="" width={72} height={72} rounded="rounded-none" className="size-[72px] object-cover" />
<Placeholder src={image} alt="" width={72} height={72} rounded="rounded-none" fill />
</button>
))}
</div>
) : null}
{open ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[#2a1838]/80 p-4 backdrop-blur-sm"
onClick={() => setOpen(false)}
role="dialog"
aria-modal="true"
aria-label={title}
>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute top-4 left-4 grid size-10 place-items-center rounded-full bg-white text-ink hover:bg-purple-50"
aria-label="بستن"
>
<IconClose className="size-5" />
</button>
{gallery.length > 1 ? (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
show(active - 1);
}}
className="absolute top-1/2 right-4 grid size-10 -translate-y-1/2 place-items-center rounded-full bg-white text-purple-700 hover:bg-purple-50"
aria-label="قبلی"
>
<IconChevron className="size-5 rotate-180" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
show(active + 1);
}}
className="absolute top-1/2 left-4 grid size-10 -translate-y-1/2 place-items-center rounded-full bg-white text-purple-700 hover:bg-purple-50"
aria-label="بعدی"
>
<IconChevron className="size-5" />
</button>
</>
) : null}
<img
src={current}
alt={title}
className="max-h-[88vh] max-w-[min(92vw,960px)] rounded-3xl bg-white object-contain"
onClick={(event) => event.stopPropagation()}
/>
</div>
) : null}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { useAuthUser } from "@/lib/auth";
import { customerLoginUrl } from "@/lib/site";
type Tab = "description" | "comments";
type Props = {
descriptionHtml?: string;
summary?: string;
};
export function ProductTabs({ descriptionHtml, summary }: Props) {
const [tab, setTab] = useState<Tab>("description");
const [loginHref, setLoginHref] = useState(customerLoginUrl());
const { user, ready, isLoggedIn } = useAuthUser();
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.get("tab") === "comments") setTab("comments");
url.searchParams.set("tab", "comments");
setLoginHref(customerLoginUrl(url.toString()));
}, []);
return (
<div className="glass-box mt-10 rounded-3xl p-6 sm:p-8">
<div className="flex justify-center gap-2 border-b border-line pb-3">
<TabButton active={tab === "description"} onClick={() => setTab("description")}>
توضیحات
</TabButton>
<TabButton active={tab === "comments"} onClick={() => setTab("comments")}>
نظرات
</TabButton>
</div>
{tab === "description" ? (
<div className="mt-6">
{descriptionHtml ? (
<div className="rich-content" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : summary ? (
<p className="text-sm leading-8 text-muted">{summary}</p>
) : (
<p className="py-8 text-center text-sm text-muted">توضیحاتی برای این محصول ثبت نشده است.</p>
)}
</div>
) : (
<div className="mx-auto mt-6 max-w-xl">
{!ready ? (
<p className="py-8 text-center text-sm text-muted">در حال بررسی ورود...</p>
) : isLoggedIn ? (
<CommentForm defaultName={[user?.firstName, user?.lastName].filter(Boolean).join(" ")} />
) : (
<div className="py-8 text-center">
<p className="text-sm leading-7 text-muted">
برای ثبت دیدگاه باید وارد حساب کاربری شوید.
</p>
<a
href={loginHref}
className="mt-5 inline-flex h-11 items-center rounded-full bg-purple-700 px-6 text-sm font-bold text-white hover:bg-purple-800"
>
ورود به حساب کاربری
</a>
</div>
)}
</div>
)}
</div>
);
}
function CommentForm({ defaultName }: { defaultName?: string }) {
return (
<form
className="grid gap-3"
onSubmit={(event) => {
event.preventDefault();
}}
>
<input
type="text"
name="name"
defaultValue={defaultName}
placeholder="نام"
className="h-11 rounded-2xl border border-line bg-white/70 px-4 text-sm outline-none focus:border-purple-500"
/>
<textarea
name="comment"
rows={4}
placeholder="نظر شما"
className="rounded-2xl border border-line bg-white/70 px-4 py-3 text-sm outline-none focus:border-purple-500"
/>
<button
type="submit"
className="h-11 rounded-full bg-purple-700 text-sm font-bold text-white hover:bg-purple-800"
>
ارسال دیدگاه
</button>
</form>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full px-5 py-2 text-sm transition ${
active ? "bg-purple-700 text-white" : "text-muted hover:bg-purple-50 hover:text-purple-700"
}`}
>
<span className="font-bold">{children}</span>
</button>
);
}
+8 -8
View File
@@ -78,14 +78,14 @@ export const sideBanners = [
];
export const categories = [
{ id: "c1", image: "/images/category-1.png", title: "لباس نوزاد", count: 86, tone: "purple" as Tone, size: "160 × 160" },
{ id: "c2", image: "/images/category-2.png", title: "دخترانه", count: 124, tone: "rose" as Tone, size: "160 × 160" },
{ id: "c3", image: "/images/category-3.png", title: "پسرانه", count: 118, tone: "cyan" as Tone, size: "160 × 160" },
{ id: "c4", image: "/images/category-4.png", title: "ست و راحتی", count: 64, tone: "purple" as Tone, size: "160 × 160" },
{ id: "c5", image: "/images/category-5.png", title: "کفش و پاپوش", count: 41, tone: "cyan" as Tone, size: "160 × 160" },
{ id: "c6", image: "/images/category-6.png", title: "اکسسوری", count: 37, tone: "rose" as Tone, size: "160 × 160" },
{ id: "c7", image: "/images/category-7.png", title: "حمام و حوله", count: 22, tone: "purple" as Tone, size: "160 × 160" },
{ id: "c8", image: "/images/category-8.png", title: "مهمانی", count: 29, tone: "rose" as Tone, size: "160 × 160" },
{ id: "c1", image: "/images/category-1.png", title: "لباس نوزاد", titleEn: "Baby Clothes", count: 86, tone: "purple" as Tone, size: "160 × 160" },
{ id: "c2", image: "/images/category-2.png", title: "دخترانه", titleEn: "Girls", count: 124, tone: "rose" as Tone, size: "160 × 160" },
{ id: "c3", image: "/images/category-3.png", title: "پسرانه", titleEn: "Boys", count: 118, tone: "cyan" as Tone, size: "160 × 160" },
{ id: "c4", image: "/images/category-4.png", title: "ست و راحتی", titleEn: "Sets & Loungewear", count: 64, tone: "purple" as Tone, size: "160 × 160" },
{ id: "c5", image: "/images/category-5.png", title: "کفش و پاپوش", titleEn: "Shoes & Booties", count: 41, tone: "cyan" as Tone, size: "160 × 160" },
{ id: "c6", image: "/images/category-6.png", title: "اکسسوری", titleEn: "Accessories", count: 37, tone: "rose" as Tone, size: "160 × 160" },
{ id: "c7", image: "/images/category-7.png", title: "حمام و حوله", titleEn: "Bath & Towels", count: 22, tone: "purple" as Tone, size: "160 × 160" },
{ id: "c8", image: "/images/category-8.png", title: "مهمانی", titleEn: "Party", count: 29, tone: "rose" as Tone, size: "160 × 160" },
];
export const features = [
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import { API_BASE } from "@/lib/site";
const TOKEN_KEYS = ["accessToken", "meshkee_access_token", "meshkee.accessToken"];
export type AuthUser = {
firstName?: string;
lastName?: string;
email?: string;
};
function readToken() {
if (typeof window === "undefined") return null;
for (const key of TOKEN_KEYS) {
const value = window.localStorage.getItem(key);
if (value) return value;
}
return null;
}
export function useAuthUser() {
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const token = readToken();
if (!token) {
setUser(null);
setReady(true);
return;
}
fetch(`${API_BASE}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(async (response) => {
if (!response.ok) return null;
const data = (await response.json()) as { user?: AuthUser } & AuthUser;
return data.user ?? data;
})
.then((next) => {
if (!cancelled) setUser(next);
})
.catch(() => {
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, []);
return { user, ready, isLoggedIn: Boolean(user) };
}
+5
View File
@@ -9,6 +9,11 @@ export function formatPrice(value: number | null | undefined) {
return `${toFaDigits(value.toLocaleString("en-US"))} تومان`;
}
export function formatDiscountPercent(price: number | null | undefined, oldPrice?: number | null) {
if (price == null || oldPrice == null || oldPrice <= price) return null;
return `${toFaDigits(Math.round(((oldPrice - price) / oldPrice) * 100))}٪`;
}
export function formatDate(value?: string) {
if (!value) return "";
const date = new Date(value);
+9
View File
@@ -7,5 +7,14 @@ export const API_BASE =
export const CUSTOMER_URL = `https://customer.${SITE_DOMAIN}`;
export function customerLoginUrl(returnTo?: string) {
const url = new URL("/login", CUSTOMER_URL);
if (returnTo) {
url.searchParams.set("redirect", returnTo);
url.searchParams.set("returnUrl", returnTo);
}
return url.toString();
}
export const SITE_NAME = "نینی نادری";
export const SITE_NAME_EN = "Nini Naderi";
+2 -2
View File
@@ -46,9 +46,9 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
label: "Special sale banner",
kind: "single" as const,
pageKey: "home",
aspectRatio: "3:5",
aspectRatio: "2:3",
itemCount: 1,
recommendedWidth: 300,
recommendedWidth: 260,
},
{
key: HOME_SLOT_KEYS.twoBannerRow,