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:
Alireza Hassani
2026-09-27 14:09:15 +03:30
co-authored by Cursor
parent 8aeba175db
commit c78abfa1de
9 changed files with 317 additions and 55 deletions
-2
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import HeroSlider from "@/components/HeroSlider"; import HeroSlider from "@/components/HeroSlider";
import CategoryShowcase from "@/components/CategoryShowcase";
import CategoryProducts from "@/components/CategoryProducts"; import CategoryProducts from "@/components/CategoryProducts";
import HomeContactStrip from "@/components/HomeContactStrip"; import HomeContactStrip from "@/components/HomeContactStrip";
import Features from "@/components/Features"; import Features from "@/components/Features";
@@ -41,7 +40,6 @@ export default async function Home() {
categoryNameEn="Livestock" categoryNameEn="Livestock"
className="py-14 sm:py-16 bg-surface" className="py-14 sm:py-16 bg-surface"
/> />
<CategoryShowcase />
<HomeContactStrip /> <HomeContactStrip />
<CategoryProducts <CategoryProducts
categoryNameEn="Poultry" categoryNameEn="Poultry"
+16 -27
View File
@@ -1,14 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound, permanentRedirect } from "next/navigation"; import { notFound, permanentRedirect } from "next/navigation";
import Container from "@/components/Container"; import Container from "@/components/Container";
import PageBanner from "@/components/PageBanner"; import PageBanner from "@/components/PageBanner";
import ProductCard from "@/components/ProductCard"; import ProductCard from "@/components/ProductCard";
import ProductGallery from "@/components/ProductGallery";
import CategoryShowcase from "@/components/CategoryShowcase";
import HomeContactStrip from "@/components/HomeContactStrip";
import { import {
getProductById, getProductById,
getProducts, getProducts,
productImage, productImages,
productPriceLabel, productPriceLabel,
stripHtml, stripHtml,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
@@ -49,18 +51,18 @@ export default async function ProductDetailPage({
permanentRedirect(productPath(product)); permanentRedirect(productPath(product));
} }
const image = productImage(product); const images = productImages(product);
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160); const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
const related = product.categoryId 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) .filter((p) => p.id !== product.id)
.slice(0, 3) .slice(0, 4)
: []; : [];
return ( return (
<> <>
<PageBanner title={product.nameFa} subtitle={shortDesc} breadcrumb={product.nameFa} /> <PageBanner title={product.nameFa} breadcrumb={product.nameFa} />
<section className="py-16 sm:py-20"> <section className="py-16 sm:py-20">
<Container> <Container>
@@ -68,7 +70,7 @@ export default async function ProductDetailPage({
href="/products" href="/products"
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-dark transition-colors" 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 <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" 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" fill="currentColor"
@@ -78,27 +80,11 @@ export default async function ProductDetailPage({
</Link> </Link>
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-12"> <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"> <ProductGallery
{image ? ( images={images}
<Image
src={image}
alt={product.nameFa} alt={product.nameFa}
fill categoryNameFa={product.categoryNameFa}
priority
className="object-cover"
sizes="(min-width: 1024px) 45vw, 90vw"
/> />
) : (
<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> <div>
{shortDesc && ( {shortDesc && (
@@ -152,7 +138,7 @@ export default async function ProductDetailPage({
<h2 className="text-2xl font-extrabold text-primary"> <h2 className="text-2xl font-extrabold text-primary">
محصولات مرتبط محصولات مرتبط
</h2> </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) => ( {related.map((p) => (
<ProductCard key={p.id} product={p} /> <ProductCard key={p.id} product={p} />
))} ))}
@@ -160,6 +146,9 @@ export default async function ProductDetailPage({
</Container> </Container>
</section> </section>
)} )}
<CategoryShowcase />
<HomeContactStrip />
</> </>
); );
} }
+49 -8
View File
@@ -10,6 +10,7 @@ import {
getCategoryById, getCategoryById,
getCategoryDescendantIds, getCategoryDescendantIds,
getProducts, getProducts,
type Category,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo"; import { pageMetadata } from "@/lib/seo";
import { categoryPath, categorySlug } from "@/lib/urls"; import { categoryPath, categorySlug } from "@/lib/urls";
@@ -17,7 +18,7 @@ import { categoryPath, categorySlug } from "@/lib/urls";
export const revalidate = 300; export const revalidate = 300;
export const dynamicParams = true; export const dynamicParams = true;
const PAGE_SIZE = 6; const PAGE_SIZE = 8;
export async function generateMetadata({ export async function generateMetadata({
params, 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({ export default async function CategoryProductsPage({
params, params,
searchParams, searchParams,
@@ -65,6 +72,17 @@ export default async function CategoryProductsPage({
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(requestedPage, totalPages); 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 ( return (
<> <>
<PageBanner <PageBanner
@@ -73,16 +91,39 @@ export default async function CategoryProductsPage({
breadcrumb={category.nameFa} breadcrumb={category.nameFa}
/> />
<section className="py-16 sm:py-20"> <section className="pt-6 sm:pt-8 pb-16 sm:pb-20">
<Container> <Container>
<div className="mb-8 flex items-center justify-between flex-wrap gap-3 rounded-2xl bg-surface px-5 py-4"> <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="text-sm text-foreground"> <span className="shrink-0 text-sm text-foreground">
نمایش محصولات دسته{" "} محصولات دسته{" "}
<strong className="text-primary">{category.nameFa}</strong> <strong className="text-primary">{filterRoot.nameFa}</strong>
</span> </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 <Link
href="/products" 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> </Link>
@@ -90,7 +131,7 @@ export default async function CategoryProductsPage({
{items.length > 0 ? ( {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) => ( {items.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard key={product.id} product={product} />
))} ))}
+7 -2
View File
@@ -3,6 +3,8 @@ import Container from "@/components/Container";
import PageBanner from "@/components/PageBanner"; import PageBanner from "@/components/PageBanner";
import ProductCard from "@/components/ProductCard"; import ProductCard from "@/components/ProductCard";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import CategoryShowcase from "@/components/CategoryShowcase";
import HomeContactStrip from "@/components/HomeContactStrip";
import { getCategoryById, getProducts } from "@/lib/meshkee"; import { getCategoryById, getProducts } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo"; import { pageMetadata } from "@/lib/seo";
import { categoryPath } from "@/lib/urls"; import { categoryPath } from "@/lib/urls";
@@ -47,11 +49,11 @@ export default async function ProductsPage({
breadcrumb="محصولات" breadcrumb="محصولات"
/> />
<section className="py-16 sm:py-20"> <section className="pt-6 sm:pt-8 pb-16 sm:pb-20">
<Container> <Container>
{items.length > 0 ? ( {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) => ( {items.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard key={product.id} product={product} />
))} ))}
@@ -70,6 +72,9 @@ export default async function ProductsPage({
)} )}
</Container> </Container>
</section> </section>
<CategoryShowcase />
<HomeContactStrip />
</> </>
); );
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export default async function CategoryShowcase() {
<div className="text-center max-w-xl mx-auto"> <div className="text-center max-w-xl mx-auto">
<span className="text-sm font-bold text-accent">دسته‌بندی محصولات</span> <span className="text-sm font-bold text-accent">دسته‌بندی محصولات</span>
<h2 className="mt-3 text-2xl sm:text-3xl font-extrabold text-primary"> <h2 className="mt-3 text-2xl sm:text-3xl font-extrabold text-primary">
محصول مورد نظر خود را پیدا کنید دسته‌بندی محصولات آراد اَریسمان
</h2> </h2>
</div> </div>
+4 -4
View File
@@ -11,7 +11,7 @@ export default function PageBanner({
breadcrumb: string; breadcrumb: string;
}) { }) {
return ( 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 <div
aria-hidden aria-hidden
className="absolute inset-0 text-primary/[0.07]" className="absolute inset-0 text-primary/[0.07]"
@@ -30,11 +30,11 @@ export default function PageBanner({
/> />
<Container className="relative"> <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 && ( {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 href="/" className="hover:text-primary transition-colors">
خانه خانه
</Link> </Link>
+5 -3
View File
@@ -14,22 +14,24 @@ export default function ProductCard({ product }: { product: Product }) {
href={productPath(product)} href={productPath(product)}
className="group block rounded-2xl border border-black/12 bg-white overflow-hidden transition-colors hover:border-black" 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 ? ( {image ? (
<div className="relative h-full w-full">
<Image <Image
src={image} src={image}
alt={product.nameFa} alt={product.nameFa}
fill 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" 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 className="flex h-full items-center justify-center text-primary/20 text-4xl font-bold">
آ آ
</div> </div>
)} )}
{product.categoryNameFa && ( {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} {product.categoryNameFa}
</span> </span>
)} )}
+215
View File
@@ -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>
)}
</>
);
}
+12
View File
@@ -200,6 +200,18 @@ export function productImage(product: Product): string | null {
return product.thumbnail || product.image || product.thumbnailUrl || 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 { export function productPriceLabel(product: Product): string {
if (product.store?.minPrice) { if (product.store?.minPrice) {
const formatted = new Intl.NumberFormat("fa-IR").format(product.store.minPrice); const formatted = new Intl.NumberFormat("fa-IR").format(product.store.minPrice);