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);
|
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-display {
|
||||||
font-family: var(--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 {
|
.product-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export default async function Home() {
|
|||||||
countdown
|
countdown
|
||||||
leadBanner
|
leadBanner
|
||||||
bannerSlot={slotByKey(slots, HOME_SLOT_KEYS.specialSale)}
|
bannerSlot={slotByKey(slots, HOME_SLOT_KEYS.specialSale)}
|
||||||
href="/special-sale"
|
|
||||||
/>
|
/>
|
||||||
<DualBanners slot={slotByKey(slots, HOME_SLOT_KEYS.twoBannerRow)} />
|
<DualBanners slot={slotByKey(slots, HOME_SLOT_KEYS.twoBannerRow)} />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { ProductBuyBox } from "@/components/ProductBuyBox";
|
||||||
import { ProductCarousel } from "@/components/ProductCarousel";
|
import { ProductCarousel } from "@/components/ProductCarousel";
|
||||||
import { ProductGallery } from "@/components/ProductGallery";
|
import { ProductGallery } from "@/components/ProductGallery";
|
||||||
|
import { ProductTabs } from "@/components/ProductTabs";
|
||||||
import { SiteChrome } from "@/components/SiteChrome";
|
import { SiteChrome } from "@/components/SiteChrome";
|
||||||
import { getCatalogItem, listCatalog } from "@/lib/api";
|
import { getCatalogItem, listCatalog } from "@/lib/api";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
@@ -54,12 +56,12 @@ export default async function ProductDetailsPage({ params }: Props) {
|
|||||||
<span>{product.title}</span>
|
<span>{product.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="grid gap-10 lg:grid-cols-[1.05fr_1fr]">
|
<div className="grid items-start gap-8 lg:grid-cols-12">
|
||||||
<div className="glass-box rounded-3xl p-5">
|
<div className="glass-box rounded-3xl p-4 lg:col-span-4">
|
||||||
<ProductGallery images={product.images} title={product.title} />
|
<ProductGallery images={product.images} title={product.title} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="lg:col-span-8">
|
||||||
<p className="font-display text-sm text-cyan-600">{product.brand}</p>
|
<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>
|
<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}
|
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{product.price != null && product.price > 0 ? (
|
<ProductBuyBox
|
||||||
<a
|
href={CUSTOMER_URL}
|
||||||
href={CUSTOMER_URL}
|
disabled={product.price == null || product.price <= 0 || product.inStock === false}
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.descriptionHtml ? (
|
<ProductTabs descriptionHtml={product.descriptionHtml} summary={product.summary} />
|
||||||
<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}
|
|
||||||
|
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
english="Related"
|
english="Related"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function Carousel({ children }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
ref={scroller}
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+36
-13
@@ -1,20 +1,43 @@
|
|||||||
import { faqs } from "@/data/content";
|
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() {
|
export function Faq() {
|
||||||
return (
|
return (
|
||||||
<section id="faq" className="container-nn py-10">
|
<section id="faq" className="faq-band my-2 py-16 sm:py-20">
|
||||||
<div className="mb-6">
|
<div className="container-nn relative z-10">
|
||||||
<p className="font-display text-sm text-cyan-600">FAQ</p>
|
<div className="mb-8">
|
||||||
<h2 className="mt-1 text-2xl font-bold text-ink sm:text-3xl">سوالات متداول</h2>
|
<p className="font-display text-sm text-cyan-600">FAQ</p>
|
||||||
<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" />
|
<h2 className="mt-1 text-2xl font-bold text-ink sm:text-3xl">سوالات متداول</h2>
|
||||||
</div>
|
<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 className="grid gap-3 md:grid-cols-2">
|
</div>
|
||||||
{faqs.map((item) => (
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<article key={item.q} className="glass-box rounded-3xl p-5">
|
{faqs.map((item, index) => (
|
||||||
<h3 className="text-base font-bold text-ink">{item.q}</h3>
|
<article
|
||||||
<p className="mt-2 text-sm leading-7 text-muted">{item.a}</p>
|
key={item.q}
|
||||||
</article>
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { categories } from "@/data/content";
|
import { categories } from "@/data/content";
|
||||||
import { toFaDigits } from "@/lib/format";
|
import { toFaDigits } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
imageAlt,
|
|
||||||
slotHref,
|
slotHref,
|
||||||
slotImages,
|
slotImages,
|
||||||
type StaticImageSlot,
|
type StaticImageSlot,
|
||||||
@@ -19,11 +18,14 @@ export function FeaturedCategories({ slot }: Props) {
|
|||||||
images.length > 0
|
images.length > 0
|
||||||
? images.map((image, index) => {
|
? images.map((image, index) => {
|
||||||
const fallback = categories[index];
|
const fallback = categories[index];
|
||||||
|
const titleFa = (image.titleFa || image.title || fallback?.title || "").trim();
|
||||||
|
const titleEn = (image.titleEn || "").trim();
|
||||||
return {
|
return {
|
||||||
id: image.id,
|
id: image.id,
|
||||||
image,
|
image,
|
||||||
fallbackSrc: fallback?.image ?? categories[0].image,
|
fallbackSrc: fallback?.image ?? categories[0].image,
|
||||||
title: imageAlt(image, fallback?.title ?? ""),
|
titleFa,
|
||||||
|
titleEn: titleFa && titleEn && titleEn !== titleFa ? titleEn : "",
|
||||||
href: slotHref(image) || "#",
|
href: slotHref(image) || "#",
|
||||||
count: fallback?.count,
|
count: fallback?.count,
|
||||||
subtext: image.subtext,
|
subtext: image.subtext,
|
||||||
@@ -33,7 +35,8 @@ export function FeaturedCategories({ slot }: Props) {
|
|||||||
id: category.id,
|
id: category.id,
|
||||||
image: undefined,
|
image: undefined,
|
||||||
fallbackSrc: category.image,
|
fallbackSrc: category.image,
|
||||||
title: category.title,
|
titleFa: category.title,
|
||||||
|
titleEn: category.titleEn && category.titleEn !== category.title ? category.titleEn : "",
|
||||||
href: "#",
|
href: "#",
|
||||||
count: category.count,
|
count: category.count,
|
||||||
subtext: undefined as string | null | undefined,
|
subtext: undefined as string | null | undefined,
|
||||||
@@ -52,14 +55,23 @@ export function FeaturedCategories({ slot }: Props) {
|
|||||||
<SlotImage
|
<SlotImage
|
||||||
image={item.image}
|
image={item.image}
|
||||||
fallbackSrc={item.fallbackSrc}
|
fallbackSrc={item.fallbackSrc}
|
||||||
fallbackAlt={item.title}
|
fallbackAlt={item.titleFa}
|
||||||
aspectRatio={slot?.aspectRatio ?? "1:1"}
|
aspectRatio={slot?.aspectRatio ?? "1:1"}
|
||||||
width={168}
|
width={168}
|
||||||
height={168}
|
height={168}
|
||||||
rounded="rounded-2xl"
|
rounded="rounded-2xl"
|
||||||
unlinked
|
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 ? (
|
{item.subtext ? (
|
||||||
<p className="text-xs text-muted">{item.subtext}</p>
|
<p className="text-xs text-muted">{item.subtext}</p>
|
||||||
) : item.count != null ? (
|
) : 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) {
|
export function IconClose({ className = "size-6" }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
|
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
rounded?: string;
|
rounded?: string;
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
|
fit?: "cover" | "contain";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Placeholder({
|
export function Placeholder({
|
||||||
@@ -16,6 +17,7 @@ export function Placeholder({
|
|||||||
className = "",
|
className = "",
|
||||||
rounded = "rounded-2xl",
|
rounded = "rounded-2xl",
|
||||||
fill = false,
|
fill = false,
|
||||||
|
fit = "cover",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
@@ -23,7 +25,9 @@ export function Placeholder({
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
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 Link from "next/link";
|
||||||
import { CUSTOMER_URL } from "@/lib/site";
|
import { CUSTOMER_URL } from "@/lib/site";
|
||||||
import type { Product } from "@/lib/catalog";
|
import type { Product } from "@/lib/catalog";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatDiscountPercent, formatPrice } from "@/lib/format";
|
||||||
import { IconPlus } from "./Icons";
|
import { IconPlus } from "./Icons";
|
||||||
import { Placeholder } from "./Placeholder";
|
import { Placeholder } from "./Placeholder";
|
||||||
|
|
||||||
@@ -12,68 +12,76 @@ type Props = {
|
|||||||
|
|
||||||
export function ProductCard({ product, fill = false }: Props) {
|
export function ProductCard({ product, fill = false }: Props) {
|
||||||
const canAdd = product.price != null && product.price > 0;
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
data-card
|
data-card
|
||||||
className={`glass-box rounded-3xl p-2.5 shadow-soft transition hover:-translate-y-1 hover:shadow-card ${
|
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" : "w-[280px] shrink-0 snap-start sm:w-[300px]"
|
fill ? "w-full" : "carousel-card"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link href={product.href} className="block">
|
<Link href={product.href} className="relative block">
|
||||||
<Placeholder
|
<div className="aspect-square overflow-hidden rounded-2xl bg-white">
|
||||||
src={product.image}
|
<Placeholder
|
||||||
alt={product.title}
|
src={product.image}
|
||||||
width={360}
|
alt={product.title}
|
||||||
height={420}
|
width={360}
|
||||||
rounded="rounded-2xl"
|
height={360}
|
||||||
/>
|
rounded="rounded-2xl"
|
||||||
</Link>
|
fill
|
||||||
<div className="px-1.5 pt-3 pb-1">
|
fit="contain"
|
||||||
<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}
|
|
||||||
</div>
|
</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}>
|
<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}
|
{product.title}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
{product.colors.length ? (
|
<div className="mt-1.5 flex min-h-3.5 items-center gap-1.5">
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
{product.colors.slice(0, 5).map((color) => (
|
||||||
{product.colors.slice(0, 5).map((color) => (
|
<span
|
||||||
<span
|
key={color}
|
||||||
key={color}
|
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
|
||||||
className="size-3.5 rounded-full border border-white shadow-[0_0_0_1px_#e8dcef]"
|
style={{ backgroundColor: color }}
|
||||||
style={{ backgroundColor: color }}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="mt-auto flex min-h-9 items-center justify-between gap-2 pt-2">
|
||||||
) : null}
|
<div className="min-w-0">
|
||||||
<div className="mt-3 flex items-end justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
{product.oldPrice ? (
|
{product.oldPrice ? (
|
||||||
<p className="text-[11px] text-muted line-through">{formatPrice(product.oldPrice)}</p>
|
<p className="text-[11px] text-muted line-through">{formatPrice(product.oldPrice)}</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
{canAdd ? (
|
{canAdd ? (
|
||||||
<a
|
<a
|
||||||
href={CUSTOMER_URL}
|
href={CUSTOMER_URL}
|
||||||
aria-label="افزودن به سبد خرید"
|
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" />
|
<IconPlus className="size-4" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -47,21 +47,23 @@ export function ProductCarousel({
|
|||||||
{leadBanner ? (
|
{leadBanner ? (
|
||||||
<div
|
<div
|
||||||
data-card
|
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="بنر فروش ویژه"
|
aria-label="بنر فروش ویژه"
|
||||||
>
|
>
|
||||||
<SlotImage
|
<div className="absolute inset-0">
|
||||||
image={saleBanner}
|
<SlotImage
|
||||||
fallbackSrc="/images/sale-banner.png"
|
image={saleBanner}
|
||||||
fallbackAlt="فروش ویژه"
|
fallbackSrc="/images/sale-banner.png"
|
||||||
aspectRatio={bannerSlot?.aspectRatio}
|
fallbackAlt="فروش ویژه"
|
||||||
width={300}
|
aspectRatio={bannerSlot?.aspectRatio ?? "2:3"}
|
||||||
height={500}
|
width={260}
|
||||||
rounded="rounded-3xl"
|
height={390}
|
||||||
fill
|
rounded="rounded-3xl"
|
||||||
className="h-full w-full"
|
fill
|
||||||
fallbackHref="/special-sale"
|
className="h-full w-full object-cover"
|
||||||
/>
|
fallbackHref="/special-sale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
|
|||||||
@@ -1,18 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
import { IconChevron, IconClose } from "./Icons";
|
||||||
|
|
||||||
export function ProductGallery({ images, title }: { images: string[]; title: string }) {
|
export function ProductGallery({ images, title }: { images: string[]; title: string }) {
|
||||||
const gallery = images.filter(Boolean);
|
const gallery = images.filter(Boolean);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [origin, setOrigin] = useState("50% 50%");
|
||||||
const current = gallery[active] || gallery[0];
|
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;
|
if (!current) return null;
|
||||||
|
|
||||||
|
const show = (index: number) => {
|
||||||
|
setActive((index + gallery.length) % gallery.length);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 ? (
|
{gallery.length > 1 ? (
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{gallery.slice(0, 8).map((image, index) => (
|
{gallery.slice(0, 8).map((image, index) => (
|
||||||
@@ -20,15 +72,66 @@ export function ProductGallery({ images, title }: { images: string[]; title: str
|
|||||||
key={`${image}-${index}`}
|
key={`${image}-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActive(index)}
|
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"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</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 = [
|
export const categories = [
|
||||||
{ id: "c1", image: "/images/category-1.png", title: "لباس نوزاد", count: 86, tone: "purple" 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: "دخترانه", count: 124, tone: "rose" 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: "پسرانه", count: 118, tone: "cyan" 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: "ست و راحتی", count: 64, tone: "purple" 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: "کفش و پاپوش", count: 41, tone: "cyan" 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: "اکسسوری", count: 37, tone: "rose" 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: "حمام و حوله", count: 22, tone: "purple" 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: "مهمانی", count: 29, tone: "rose" 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 = [
|
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"))} تومان`;
|
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) {
|
export function formatDate(value?: string) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|||||||
@@ -7,5 +7,14 @@ export const API_BASE =
|
|||||||
|
|
||||||
export const CUSTOMER_URL = `https://customer.${SITE_DOMAIN}`;
|
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 = "نینی نادری";
|
||||||
export const SITE_NAME_EN = "Nini Naderi";
|
export const SITE_NAME_EN = "Nini Naderi";
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
|
|||||||
label: "Special sale banner",
|
label: "Special sale banner",
|
||||||
kind: "single" as const,
|
kind: "single" as const,
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "3:5",
|
aspectRatio: "2:3",
|
||||||
itemCount: 1,
|
itemCount: 1,
|
||||||
recommendedWidth: 300,
|
recommendedWidth: 260,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: HOME_SLOT_KEYS.twoBannerRow,
|
key: HOME_SLOT_KEYS.twoBannerRow,
|
||||||
|
|||||||
Reference in New Issue
Block a user