Polish storefront listing: filters, category directory, and list extras.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 20:46:17 +03:30
co-authored by Cursor
parent 4089247ad3
commit fa248d2f6a
26 changed files with 1274 additions and 274 deletions
+14 -16
View File
@@ -6,6 +6,7 @@ import SiteFooter from "@/components/home/SiteFooter";
import AboutSection from "@/components/home/AboutSection";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import BilingualTitle from "@/components/ui/BilingualTitle";
import { ABOUT_PAGE } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
@@ -54,15 +55,12 @@ export default async function AboutPage() {
<section className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<div className="grid gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
<div>
<h2 className="font-doran text-2xl font-light text-ink">
{ABOUT_PAGE.storyTitleFa}
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{ABOUT_PAGE.storyTitleEn}
</p>
<BilingualTitle
titleFa={ABOUT_PAGE.storyTitleFa}
titleEn={ABOUT_PAGE.storyTitleEn}
size="md"
align="end"
/>
<p className="mt-6 text-sm leading-8 text-ink/80">
{ABOUT_PAGE.story}
</p>
@@ -79,13 +77,13 @@ export default async function AboutPage() {
key={value.titleEn}
className="border border-line bg-[#f7f7f5] p-5"
>
<p className="text-base text-ink">{value.titleFa}</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{value.titleEn}
</p>
<BilingualTitle
titleFa={value.titleFa}
titleEn={value.titleEn}
as="p"
size="md"
align="end"
/>
<p className="mt-3 text-sm leading-7 text-ink/75">
{value.body}
</p>
+117 -19
View File
@@ -8,19 +8,32 @@ import ProductListHero from "@/components/products/ProductListHero";
import ProductFilters from "@/components/products/ProductFilters";
import ProductGrid from "@/components/products/ProductGrid";
import ProductPagination from "@/components/products/ProductPagination";
import ProductEmptyState from "@/components/products/ProductEmptyState";
import ProductListExtras from "@/components/products/ProductListExtras";
import { getCategoryPage } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicWebsiteSlots,
itemsForSlot,
} from "@/lib/website-slots";
import { getStorefrontProducts } from "@/lib/products-api";
import {
categoryAncestry,
fetchPublicCategories,
mapCategoriesToMegaTabs,
resolveHomeCategoryStrip,
type ApiCategoryNode,
} from "@/lib/categories-api";
import { categoryPath, categorySlug } from "@/lib/category-path";
import {
paginateItems,
parseProductListQuery,
type ProductListQuery,
} from "@/lib/products-query";
type PageProps = {
params: Promise<{ path: string[] }>;
searchParams: Promise<ProductListQuery>;
};
function findCategory(
@@ -47,7 +60,8 @@ async function resolveCategory(id: string) {
nameFa: apiCat.nameFa,
nameEn: apiCat.nameEn || apiCat.nameFa,
imageUrl: apiCat.imageUrl,
description: `مجموعه‌ای از محصولات ${apiCat.nameFa} در مانا کالکشن.`,
seoDescription: (apiCat.seoDescription ?? "").trim(),
description: (apiCat.seoDescription ?? "").trim(),
};
}
const staticCat = getCategoryPage(id);
@@ -60,6 +74,7 @@ async function resolveCategory(id: string) {
nameFa: staticCat.nameFa,
nameEn: staticCat.nameEn,
imageUrl: null as string | null,
seoDescription: "",
description: staticCat.description,
};
}
@@ -76,11 +91,13 @@ export async function generateMetadata({
if (!resolved) return { title: "دسته | مانا کالکشن" };
return {
title: `${resolved.nameFa} | مانا کالکشن`,
description: resolved.description,
...(resolved.description
? { description: resolved.description }
: {}),
};
}
export default async function CategoryPage({ params }: PageProps) {
export default async function CategoryPage({ params, searchParams }: PageProps) {
const { path } = await params;
if (!path?.length || path.length > 2) notFound();
@@ -104,10 +121,22 @@ export default async function CategoryPage({ params }: PageProps) {
redirect(canonical);
}
const query = parseProductListQuery(await searchParams);
const [slotItems, products] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontProducts(
resolved.apiCat ? { categoryId: resolved.id } : undefined,
resolved.apiCat
? {
categoryId: resolved.id,
name: query.q || undefined,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
}
: {
name: query.q || undefined,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
},
),
]);
const home = mapHomeSlots(slotItems);
@@ -116,6 +145,35 @@ export default async function CategoryPage({ params }: PageProps) {
home.megaTabs.map((t) => ({ image: t.image })),
);
// Same category strip source as home
const stripCategories = resolveHomeCategoryStrip(
resolved.apiCategories,
home.categories,
itemsForSlot(slotItems, "category-strip").length > 0,
);
const ancestry = categoryAncestry(resolved.apiCategories, resolved.id);
const breadcrumbs = [
{ label: "خانه", href: "/" },
...(ancestry.length
? ancestry.map((node, index) => ({
label: node.nameFa,
...(index < ancestry.length - 1
? { href: categoryPath(node) }
: {}),
}))
: [
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: resolved.nameFa },
]),
];
const { pageItems, totalPages, page, total } = paginateItems(
products,
query.page,
);
const hasExtraFilters = Boolean(query.q || query.min || query.max);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
@@ -128,25 +186,50 @@ export default async function CategoryPage({ params }: PageProps) {
resolved.imageUrl ||
resolved.staticCat?.heroImage
}
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: resolved.nameFa },
]}
breadcrumbs={breadcrumbs}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 pt-8 sm:px-6 lg:px-8">
<p className="max-w-2xl text-sm leading-7 text-ink/75">
{resolved.description}
</p>
</div>
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[48px]">
<ProductFilters />
<ProductFilters
basePath={canonical}
lockedCategory={{ id: resolved.id, nameFa: resolved.nameFa }}
initial={{
q: query.q,
min: query.min,
max: query.max,
}}
/>
<div className="space-y-3 sm:space-y-4">
<ProductGrid products={products} />
<ProductPagination />
{total === 0 ? (
<ProductEmptyState
title={
hasExtraFilters
? "محصولی با این فیلترها پیدا نشد"
: `محصولی در «${resolved.nameFa}» نیست`
}
description={
hasExtraFilters
? "فیلترها را تغییر دهید یا همه را پاک کنید تا محصولات بیشتری ببینید."
: "به‌زودی محصولات این دسته اضافه می‌شوند."
}
/>
) : (
<>
<ProductGrid products={pageItems} />
<ProductPagination
page={page}
totalPages={totalPages}
basePath={canonical}
query={{
q: query.q,
min: query.min,
max: query.max,
}}
lockedCategoryId={resolved.id}
/>
</>
)}
</div>
</div>
@@ -158,6 +241,21 @@ export default async function CategoryPage({ params }: PageProps) {
link={home.leather.link}
/>
<PartnerCta />
<ProductListExtras
stripCategories={stripCategories}
about={home.about}
categorySeo={
resolved.seoDescription
? {
nameFa: resolved.nameFa,
nameEn: resolved.nameEn,
description: resolved.seoDescription,
}
: null
}
/>
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
+19 -40
View File
@@ -1,19 +1,18 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import CategoriesIndexDirectory from "@/components/categories/CategoriesIndexDirectory";
import BilingualTitle from "@/components/ui/BilingualTitle";
import { LIST_PAGE_META } from "@/data/products";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
mapCategoriesToStrip,
} from "@/lib/categories-api";
import { categoryPath } from "@/lib/category-path";
export const metadata: Metadata = {
title: "دسته‌بندی‌ها | مانا کالکشن",
@@ -26,7 +25,6 @@ export default async function CategoriesIndexPage() {
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const list = mapCategoriesToStrip(apiCategories);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
@@ -47,43 +45,24 @@ export default async function CategoriesIndexPage() {
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3 lg:gap-6">
{list.map((cat) => (
<li key={cat.id}>
<Link
href={cat.href || categoryPath(cat)}
className="group block overflow-hidden rounded-[5px] border border-line bg-white transition-colors hover:border-ink"
>
<div className="relative aspect-[4/5] overflow-hidden bg-[#f6f6f4]">
<Image
src={cat.image}
alt={cat.nameFa}
fill
sizes="(max-width: 768px) 50vw, 360px"
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
<div className="flex items-end justify-between gap-3 px-4 py-3">
<div>
<p className="text-base text-ink">{cat.nameFa}</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{cat.nameEn}
</p>
</div>
<span className="text-xs text-muted transition-colors group-hover:text-ink">
مشاهده
</span>
</div>
</Link>
</li>
))}
</ul>
<div className="mx-auto w-full max-w-[1920px] px-4 pt-8 sm:px-6 sm:pt-10 lg:px-[120px]">
<BilingualTitle
titleFa="دسته‌بندی‌ها"
titleEn="Categories"
size="lg"
align="end"
/>
</div>
<CategoriesIndexDirectory categories={apiCategories} />
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
+7 -9
View File
@@ -4,6 +4,7 @@ import SiteFooter from "@/components/home/SiteFooter";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ContactForm from "@/components/contact/ContactForm";
import BilingualTitle from "@/components/ui/BilingualTitle";
import { CONTACT_PAGE } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
@@ -44,15 +45,12 @@ export default async function ContactPage() {
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] lg:gap-14">
<aside className="space-y-6 border border-line bg-[#f7f7f5] p-6 sm:p-8">
<div>
<h2 className="font-doran text-2xl font-light text-ink">
اطلاعات تماس
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Get in touch
</p>
<BilingualTitle
titleFa="اطلاعات تماس"
titleEn="Get in touch"
size="md"
align="end"
/>
</div>
<dl className="space-y-4 text-sm leading-7">
+6 -10
View File
@@ -17,7 +17,7 @@ import { getStorefrontCatalog } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
mapCategoriesToStrip,
resolveHomeCategoryStrip,
} from "@/lib/categories-api";
export default async function HomePage() {
@@ -32,7 +32,6 @@ export default async function HomePage() {
const discountProducts = catalog.promo2;
const autumnProducts = catalog.promo3;
const stripCategories = mapCategoriesToStrip(apiCategories);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({
@@ -42,14 +41,11 @@ export default async function HomePage() {
})),
);
// Prefer Website Images → category-strip slots over catalog categories
const hasWebsiteCategoryStrip =
itemsForSlot(slotItems, "category-strip").length > 0;
const categories = hasWebsiteCategoryStrip
? home.categories
: stripCategories.length
? stripCategories
: home.categories;
const categories = resolveHomeCategoryStrip(
apiCategories,
home.categories,
itemsForSlot(slotItems, "category-strip").length > 0,
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
+147 -19
View File
@@ -6,26 +6,61 @@ import ProductListHero from "@/components/products/ProductListHero";
import ProductFilters from "@/components/products/ProductFilters";
import ProductGrid from "@/components/products/ProductGrid";
import ProductPagination from "@/components/products/ProductPagination";
import ProductEmptyState from "@/components/products/ProductEmptyState";
import ProductListExtras from "@/components/products/ProductListExtras";
import { LIST_PAGE_META } from "@/data/products";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicWebsiteSlots,
itemsForSlot,
} from "@/lib/website-slots";
import { getStorefrontProducts } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
resolveHomeCategoryStrip,
type ApiCategoryNode,
} from "@/lib/categories-api";
import {
paginateItems,
parseProductListQuery,
type ProductListQuery,
} from "@/lib/products-query";
type PageProps = {
searchParams: Promise<{ q?: string; cat?: string }>;
searchParams: Promise<ProductListQuery>;
};
function findCategory(
nodes: ApiCategoryNode[],
id: string,
): ApiCategoryNode | null {
for (const node of nodes) {
if (String(node.id) === id) return node;
const child = findCategory(node.children ?? [], id);
if (child) return child;
}
return null;
}
function findCategoryName(
nodes: ApiCategoryNode[],
id: string,
): string | null {
return findCategory(nodes, id)?.nameFa ?? null;
}
export default async function ProductsPage({ searchParams }: PageProps) {
const params = await searchParams;
const raw = await searchParams;
const query = parseProductListQuery(raw);
const [slotItems, products, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontProducts({
name: params.q,
categoryId: params.cat,
name: query.q || undefined,
categoryId: query.cat || undefined,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
featuredSlot: query.sale ? "promo-1" : undefined,
}),
fetchPublicCategories(),
]);
@@ -35,31 +70,117 @@ export default async function ProductsPage({ searchParams }: PageProps) {
home.megaTabs.map((t) => ({ image: t.image })),
);
const stripCategories = resolveHomeCategoryStrip(
apiCategories,
home.categories,
itemsForSlot(slotItems, "category-strip").length > 0,
);
const catNode = query.cat ? findCategory(apiCategories, query.cat) : null;
const categorySeo = catNode?.seoDescription?.trim()
? {
nameFa: catNode.nameFa,
nameEn: catNode.nameEn,
description: catNode.seoDescription.trim(),
}
: null;
const { pageItems, totalPages, page, total } = paginateItems(
products,
query.page,
);
const categoryLabel = query.cat
? findCategoryName(apiCategories, query.cat)
: null;
const hasFilters = Boolean(
query.q || query.cat || query.min || query.max || query.sale,
);
const heroTitle = query.sale
? "فروش ویژه"
: query.q
? `جستجو: ${query.q}`
: categoryLabel
? categoryLabel
: LIST_PAGE_META.titleFa;
const heroEn = query.sale
? "Special Sale"
: query.q
? "Search results"
: categoryLabel
? ""
: LIST_PAGE_META.titleEn;
const breadcrumbs = query.sale
? [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "فروش ویژه" },
]
: query.q
? [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "جستجو" },
]
: LIST_PAGE_META.breadcrumbs;
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={params.q ? `جستجو: ${params.q}` : LIST_PAGE_META.titleFa}
titleEn={params.q ? "Search results" : LIST_PAGE_META.titleEn}
titleFa={heroTitle}
titleEn={heroEn}
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
breadcrumbs={
params.q
? [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "جستجو" },
]
: LIST_PAGE_META.breadcrumbs
}
breadcrumbs={breadcrumbs}
/>
</div>
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[60px]">
<ProductFilters />
<ProductFilters
basePath="/products"
initial={{
q: query.q,
cat: query.cat,
min: query.min,
max: query.max,
sale: query.sale,
}}
categoryLabel={categoryLabel}
/>
<div className="space-y-3 sm:space-y-4">
<ProductGrid products={products} />
<ProductPagination />
{total === 0 ? (
<ProductEmptyState
title={
hasFilters
? "محصولی با این فیلترها پیدا نشد"
: "محصولی موجود نیست"
}
description={
hasFilters
? "فیلترها را تغییر دهید یا همه را پاک کنید تا محصولات بیشتری ببینید."
: "به‌زودی محصولات جدید اضافه می‌شوند."
}
/>
) : (
<>
<ProductGrid products={pageItems} />
<ProductPagination
page={page}
totalPages={totalPages}
basePath="/products"
query={{
q: query.q,
cat: query.cat,
min: query.min,
max: query.max,
sale: query.sale,
}}
/>
</>
)}
</div>
</div>
@@ -71,6 +192,13 @@ export default async function ProductsPage({ searchParams }: PageProps) {
link={home.leather.link}
/>
<PartnerCta />
<ProductListExtras
stripCategories={stripCategories}
about={home.about}
categorySeo={categorySeo}
/>
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);