From c78abfa1de7a4489a9f1fb9d54b2950c79829e5b Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 27 Sep 2026 14:09:15 +0330 Subject: [PATCH] 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 --- src/app/page.tsx | 2 - src/app/products/[id]/[slug]/page.tsx | 47 ++-- .../products/category/[id]/[slug]/page.tsx | 57 ++++- src/app/products/page.tsx | 9 +- src/components/CategoryShowcase.tsx | 2 +- src/components/PageBanner.tsx | 8 +- src/components/ProductCard.tsx | 20 +- src/components/ProductGallery.tsx | 215 ++++++++++++++++++ src/lib/meshkee.ts | 12 + 9 files changed, 317 insertions(+), 55 deletions(-) create mode 100644 src/components/ProductGallery.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index b813805..13b83c2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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" /> - p.id !== product.id) - .slice(0, 3) + .slice(0, 4) : []; return ( <> - +
@@ -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" > - +
-
- {image ? ( - {product.nameFa} - ) : ( -
- آ -
- )} - {product.categoryNameFa && ( - - {product.categoryNameFa} - - )} -
+
{shortDesc && ( @@ -152,7 +138,7 @@ export default async function ProductDetailPage({

محصولات مرتبط

-
+
{related.map((p) => ( ))} @@ -160,6 +146,9 @@ export default async function ProductDetailPage({
)} + + + ); } diff --git a/src/app/products/category/[id]/[slug]/page.tsx b/src/app/products/category/[id]/[slug]/page.tsx index dd1544e..7517e03 100644 --- a/src/app/products/category/[id]/[slug]/page.tsx +++ b/src/app/products/category/[id]/[slug]/page.tsx @@ -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 ( <> -
+
-
- - نمایش محصولات دسته{" "} - {category.nameFa} +
+ + محصولات دسته{" "} + {filterRoot.nameFa} + + {subcategories.length > 0 && ( +
+ + همه + + {subcategories.map((sub) => ( + + {sub.nameFa} + + ))} +
+ )} + همه محصولات @@ -90,7 +131,7 @@ export default async function CategoryProductsPage({ {items.length > 0 ? ( <> -
+
{items.map((product) => ( ))} diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index f221596..89d3399 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -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="محصولات" /> -
+
{items.length > 0 ? ( <> -
+
{items.map((product) => ( ))} @@ -70,6 +72,9 @@ export default async function ProductsPage({ )}
+ + + ); } diff --git a/src/components/CategoryShowcase.tsx b/src/components/CategoryShowcase.tsx index c13e004..5091e8b 100644 --- a/src/components/CategoryShowcase.tsx +++ b/src/components/CategoryShowcase.tsx @@ -52,7 +52,7 @@ export default async function CategoryShowcase() {
دسته‌بندی محصولات

- محصول مورد نظر خود را پیدا کنید + دسته‌بندی محصولات آراد اَریسمان

diff --git a/src/components/PageBanner.tsx b/src/components/PageBanner.tsx index fdbd569..aeaf16e 100644 --- a/src/components/PageBanner.tsx +++ b/src/components/PageBanner.tsx @@ -11,7 +11,7 @@ export default function PageBanner({ breadcrumb: string; }) { return ( -
+
-

{title}

+

{title}

{subtitle && ( -

{subtitle}

+

{subtitle}

)} -
+
خانه diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index c3e01d4..b00f81c 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -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" > -
+
{image ? ( - {product.nameFa} +
+ {product.nameFa} +
) : (
آ
)} {product.categoryNameFa && ( - + {product.categoryNameFa} )} diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx new file mode 100644 index 0000000..805c17d --- /dev/null +++ b/src/components/ProductGallery.tsx @@ -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 ( +
+
+ آ +
+ {categoryNameFa && ( + + {categoryNameFa} + + )} +
+ ); + } + + return ( + <> +
+ + + {count > 1 && ( +
+ {images.map((src, i) => ( + + ))} +
+ )} +
+ + {open && ( +
+ + + {count > 1 && ( + <> + + + + )} + +
e.stopPropagation()} + > + {alt} +
+ + {count > 1 && ( +
+ {images.map((_, i) => ( +
+ )} +
+ )} + + ); +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 1fcca2e..8fc0b57 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -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(); + 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);