Polish product pages with subcategory filters, gallery, and denser grids.
Move category showcase to product routes, add clickable subcategory chips on category pages, and tighten banner/card spacing for mobile-to-desktop layouts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8aeba175db
commit
c78abfa1de
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import HeroSlider from "@/components/HeroSlider";
|
||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||
import CategoryProducts from "@/components/CategoryProducts";
|
||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||
import Features from "@/components/Features";
|
||||
@@ -41,7 +40,6 @@ export default async function Home() {
|
||||
categoryNameEn="Livestock"
|
||||
className="py-14 sm:py-16 bg-surface"
|
||||
/>
|
||||
<CategoryShowcase />
|
||||
<HomeContactStrip />
|
||||
<CategoryProducts
|
||||
categoryNameEn="Poultry"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import Container from "@/components/Container";
|
||||
import PageBanner from "@/components/PageBanner";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import ProductGallery from "@/components/ProductGallery";
|
||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||
import {
|
||||
getProductById,
|
||||
getProducts,
|
||||
productImage,
|
||||
productImages,
|
||||
productPriceLabel,
|
||||
stripHtml,
|
||||
} from "@/lib/meshkee";
|
||||
@@ -49,18 +51,18 @@ export default async function ProductDetailPage({
|
||||
permanentRedirect(productPath(product));
|
||||
}
|
||||
|
||||
const image = productImage(product);
|
||||
const images = productImages(product);
|
||||
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
|
||||
|
||||
const related = product.categoryId
|
||||
? (await getProducts({ categoryId: product.categoryId, pageSize: 4 })).items
|
||||
? (await getProducts({ categoryId: product.categoryId, pageSize: 5 })).items
|
||||
.filter((p) => p.id !== product.id)
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageBanner title={product.nameFa} subtitle={shortDesc} breadcrumb={product.nameFa} />
|
||||
<PageBanner title={product.nameFa} breadcrumb={product.nameFa} />
|
||||
|
||||
<section className="py-16 sm:py-20">
|
||||
<Container>
|
||||
@@ -68,7 +70,7 @@ export default async function ProductDetailPage({
|
||||
href="/products"
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="rotate-180">
|
||||
<path
|
||||
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
||||
fill="currentColor"
|
||||
@@ -78,27 +80,11 @@ export default async function ProductDetailPage({
|
||||
</Link>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="relative aspect-square rounded-3xl overflow-hidden shadow-xl bg-surface">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
<ProductGallery
|
||||
images={images}
|
||||
alt={product.nameFa}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="(min-width: 1024px) 45vw, 90vw"
|
||||
categoryNameFa={product.categoryNameFa}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-primary/20 text-6xl font-bold">
|
||||
آ
|
||||
</div>
|
||||
)}
|
||||
{product.categoryNameFa && (
|
||||
<span className="absolute top-4 right-4 rounded-full bg-white/90 backdrop-blur px-4 py-1.5 text-sm font-semibold text-primary">
|
||||
{product.categoryNameFa}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{shortDesc && (
|
||||
@@ -152,7 +138,7 @@ export default async function ProductDetailPage({
|
||||
<h2 className="text-2xl font-extrabold text-primary">
|
||||
محصولات مرتبط
|
||||
</h2>
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
@@ -160,6 +146,9 @@ export default async function ProductDetailPage({
|
||||
</Container>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CategoryShowcase />
|
||||
<HomeContactStrip />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCategoryById,
|
||||
getCategoryDescendantIds,
|
||||
getProducts,
|
||||
type Category,
|
||||
} from "@/lib/meshkee";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { categoryPath, categorySlug } from "@/lib/urls";
|
||||
@@ -17,7 +18,7 @@ import { categoryPath, categorySlug } from "@/lib/urls";
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = true;
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -34,6 +35,12 @@ export async function generateMetadata({
|
||||
});
|
||||
}
|
||||
|
||||
function chipClass(active: boolean) {
|
||||
return active
|
||||
? "rounded-full bg-primary px-3.5 py-1.5 text-sm font-semibold text-white"
|
||||
: "rounded-full bg-white px-3.5 py-1.5 text-sm font-medium text-primary hover:bg-primary/10 transition-colors";
|
||||
}
|
||||
|
||||
export default async function CategoryProductsPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -65,6 +72,17 @@ export default async function CategoryProductsPage({
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.min(requestedPage, totalPages);
|
||||
|
||||
const parent: Category | null = category.parentId
|
||||
? allCategories.find((c) => c.id === category.parentId) ?? null
|
||||
: null;
|
||||
const filterRoot = parent ?? category;
|
||||
const subcategories = allCategories
|
||||
.filter((c) => c.parentId === filterRoot.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.sortOrder - b.sortOrder || a.nameFa.localeCompare(b.nameFa, "fa")
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageBanner
|
||||
@@ -73,16 +91,39 @@ export default async function CategoryProductsPage({
|
||||
breadcrumb={category.nameFa}
|
||||
/>
|
||||
|
||||
<section className="py-16 sm:py-20">
|
||||
<section className="pt-6 sm:pt-8 pb-16 sm:pb-20">
|
||||
<Container>
|
||||
<div className="mb-8 flex items-center justify-between flex-wrap gap-3 rounded-2xl bg-surface px-5 py-4">
|
||||
<span className="text-sm text-foreground">
|
||||
نمایش محصولات دسته{" "}
|
||||
<strong className="text-primary">{category.nameFa}</strong>
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-2xl bg-surface px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-5">
|
||||
<span className="shrink-0 text-sm text-foreground">
|
||||
محصولات دسته{" "}
|
||||
<strong className="text-primary">{filterRoot.nameFa}</strong>
|
||||
</span>
|
||||
|
||||
{subcategories.length > 0 && (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 sm:justify-center">
|
||||
<Link
|
||||
href={categoryPath(filterRoot)}
|
||||
className={chipClass(!parent)}
|
||||
aria-current={!parent ? "page" : undefined}
|
||||
>
|
||||
همه
|
||||
</Link>
|
||||
{subcategories.map((sub) => (
|
||||
<Link
|
||||
key={sub.id}
|
||||
href={categoryPath(sub)}
|
||||
className={chipClass(sub.id === category.id)}
|
||||
aria-current={sub.id === category.id ? "page" : undefined}
|
||||
>
|
||||
{sub.nameFa}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
||||
className="shrink-0 text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
||||
>
|
||||
همه محصولات
|
||||
</Link>
|
||||
@@ -90,7 +131,7 @@ export default async function CategoryProductsPage({
|
||||
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{items.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Container from "@/components/Container";
|
||||
import PageBanner from "@/components/PageBanner";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||
import { getCategoryById, getProducts } from "@/lib/meshkee";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { categoryPath } from "@/lib/urls";
|
||||
@@ -47,11 +49,11 @@ export default async function ProductsPage({
|
||||
breadcrumb="محصولات"
|
||||
/>
|
||||
|
||||
<section className="py-16 sm:py-20">
|
||||
<section className="pt-6 sm:pt-8 pb-16 sm:pb-20">
|
||||
<Container>
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{items.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
@@ -70,6 +72,9 @@ export default async function ProductsPage({
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<CategoryShowcase />
|
||||
<HomeContactStrip />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default async function CategoryShowcase() {
|
||||
<div className="text-center max-w-xl mx-auto">
|
||||
<span className="text-sm font-bold text-accent">دستهبندی محصولات</span>
|
||||
<h2 className="mt-3 text-2xl sm:text-3xl font-extrabold text-primary">
|
||||
محصول مورد نظر خود را پیدا کنید
|
||||
دستهبندی محصولات آراد اَریسمان
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function PageBanner({
|
||||
breadcrumb: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-surface py-14 sm:py-16">
|
||||
<section className="relative overflow-hidden bg-surface py-8 sm:py-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 text-primary/[0.07]"
|
||||
@@ -30,11 +30,11 @@ export default function PageBanner({
|
||||
/>
|
||||
|
||||
<Container className="relative">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-primary">{title}</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-primary">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-3 max-w-2xl text-foreground">{subtitle}</p>
|
||||
<p className="mt-2 max-w-2xl text-sm sm:text-base text-foreground">{subtitle}</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-foreground">
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-foreground">
|
||||
<Link href="/" className="hover:text-primary transition-colors">
|
||||
خانه
|
||||
</Link>
|
||||
|
||||
@@ -14,22 +14,24 @@ export default function ProductCard({ product }: { product: Product }) {
|
||||
href={productPath(product)}
|
||||
className="group block rounded-2xl border border-black/12 bg-white overflow-hidden transition-colors hover:border-black"
|
||||
>
|
||||
<div className="relative aspect-square bg-surface">
|
||||
<div className="relative aspect-square bg-white p-5 sm:p-6">
|
||||
{image ? (
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.nameFa}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
className="object-contain group-hover:scale-105 transition-transform duration-500"
|
||||
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-primary/20 text-4xl font-bold">
|
||||
آ
|
||||
</div>
|
||||
)}
|
||||
{product.categoryNameFa && (
|
||||
<span className="absolute top-3 right-3 rounded-full bg-white/90 backdrop-blur px-3 py-1 text-xs font-semibold text-primary">
|
||||
<span className="absolute top-3 right-3 z-10 rounded-full bg-white/90 backdrop-blur px-3 py-1 text-xs font-semibold text-primary">
|
||||
{product.categoryNameFa}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function ProductGallery({
|
||||
images,
|
||||
alt,
|
||||
categoryNameFa,
|
||||
}: {
|
||||
images: string[];
|
||||
alt: string;
|
||||
categoryNameFa?: string | null;
|
||||
}) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const count = images.length;
|
||||
const current = images[active] ?? null;
|
||||
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
if (count === 0) return;
|
||||
setActive((i + count) % count);
|
||||
},
|
||||
[count]
|
||||
);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
if (e.key === "ArrowLeft") goTo(active + 1);
|
||||
if (e.key === "ArrowRight") goTo(active - 1);
|
||||
}
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, active, close, goTo]);
|
||||
|
||||
if (!current) {
|
||||
return (
|
||||
<div className="relative aspect-square rounded-3xl overflow-hidden shadow-xl bg-white">
|
||||
<div className="flex h-full items-center justify-center text-primary/20 text-6xl font-bold">
|
||||
آ
|
||||
</div>
|
||||
{categoryNameFa && (
|
||||
<span className="absolute top-4 right-4 rounded-full bg-white/90 backdrop-blur px-4 py-1.5 text-sm font-semibold text-primary">
|
||||
{categoryNameFa}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="بزرگنمایی تصویر"
|
||||
className="group relative block w-full aspect-square rounded-3xl overflow-hidden shadow-xl bg-white text-start"
|
||||
>
|
||||
<Image
|
||||
src={current}
|
||||
alt={alt}
|
||||
fill
|
||||
priority
|
||||
className="object-contain p-6 transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
sizes="(min-width: 1024px) 45vw, 90vw"
|
||||
/>
|
||||
{categoryNameFa && (
|
||||
<span className="absolute top-4 right-4 z-10 rounded-full bg-white/90 backdrop-blur px-4 py-1.5 text-sm font-semibold text-primary">
|
||||
{categoryNameFa}
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-4 left-4 z-10 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-3 py-1.5 text-xs font-medium text-white backdrop-blur opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M10 4a6 6 0 1 0 3.5 10.8l4.3 4.3a1 1 0 0 0 1.4-1.4l-4.3-4.3A6 6 0 0 0 10 4Zm-4 6a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
بزرگنمایی
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{count > 1 && (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:thin]">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={src + i}
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
aria-label={`تصویر ${i + 1}`}
|
||||
aria-current={i === active}
|
||||
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-xl border bg-white transition-colors ${
|
||||
i === active
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-black/10 hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
<Image src={src} alt="" fill className="object-contain p-1.5" sizes="64px" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="گالری تصاویر محصول"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/85 p-4 sm:p-8"
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="بستن"
|
||||
className="absolute top-4 end-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.6l3.9-3.9a1 1 0 1 1 1.4 1.4L13.4 12l3.9 3.9a1 1 0 0 1-1.4 1.4L12 13.4l-3.9 3.9a1 1 0 0 1-1.4-1.4L10.6 12 6.7 8.1a1 1 0 0 1 0-1.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{count > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goTo(active - 1);
|
||||
}}
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute start-3 sm:start-6 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="rotate-180" aria-hidden>
|
||||
<path
|
||||
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goTo(active + 1);
|
||||
}}
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute end-3 sm:end-6 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative h-[min(80vh,900px)] w-full max-w-5xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Image
|
||||
src={current}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{count > 1 && (
|
||||
<div className="absolute bottom-5 inset-x-0 flex justify-center gap-2">
|
||||
{images.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActive(i);
|
||||
}}
|
||||
aria-label={`رفتن به تصویر ${i + 1}`}
|
||||
aria-current={i === active}
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
i === active ? "w-8 bg-accent" : "w-2 bg-white/50 hover:bg-white/80"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,18 @@ export function productImage(product: Product): string | null {
|
||||
return product.thumbnail || product.image || product.thumbnailUrl || null;
|
||||
}
|
||||
|
||||
/** Unique gallery URLs: `images[]` first, then the primary thumbnail/image. */
|
||||
export function productImages(product: Product): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const url of [...(product.images ?? []), product.thumbnail, product.image, product.thumbnailUrl]) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function productPriceLabel(product: Product): string {
|
||||
if (product.store?.minPrice) {
|
||||
const formatted = new Intl.NumberFormat("fa-IR").format(product.store.minPrice);
|
||||
|
||||
Reference in New Issue
Block a user