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:
co-authored by
Cursor
parent
14ca6b39ea
commit
051939c6e2
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user