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>
);
@@ -0,0 +1,130 @@
import Link from "next/link";
import type { ApiCategoryNode } from "@/lib/categories-api";
import { categoryPath } from "@/lib/category-path";
type CategoriesIndexDirectoryProps = {
categories: ApiCategoryNode[];
};
function NamePair({
nameFa,
nameEn,
size = "child",
}: {
nameFa: string;
nameEn?: string;
size?: "root" | "child";
}) {
const en = (nameEn || "").trim();
if (size === "root") {
return (
<span className="block text-right">
<span className="font-doran text-[clamp(1.35rem,2vw,1.75rem)] font-medium text-ink transition-opacity group-hover:opacity-70">
{nameFa}
</span>
{en ? (
<span
dir="ltr"
className="mt-1 block font-montserrat text-[10px] font-light uppercase tracking-[0.2em] text-muted sm:text-[11px]"
>
{en}
</span>
) : null}
</span>
);
}
return (
<span className="block text-right">
<span className="text-[15px] font-light text-ink transition-opacity group-hover:opacity-70 sm:text-base">
{nameFa}
</span>
{en ? (
<span
dir="ltr"
className="mt-0.5 block font-montserrat text-[9px] font-light uppercase tracking-[0.16em] text-muted sm:text-[10px]"
>
{en}
</span>
) : null}
</span>
);
}
export default function CategoriesIndexDirectory({
categories,
}: CategoriesIndexDirectoryProps) {
if (!categories.length) {
return (
<p className="mx-auto max-w-[1920px] px-4 py-16 text-center text-sm text-muted sm:px-6 lg:px-[120px]">
هنوز دسته‌بندی‌ای ثبت نشده است.
</p>
);
}
return (
<section
dir="rtl"
className="mx-auto w-full max-w-[1920px] px-4 py-8 sm:px-6 sm:py-10 lg:px-[120px] lg:py-12"
>
<ul className="divide-y divide-[#e8e8e8] border-y border-[#e8e8e8]">
{categories.map((root) => {
const children = root.children ?? [];
return (
<li key={root.id} className="py-6 sm:py-8">
<div className="mb-5 sm:mb-6">
<Link href={categoryPath(root)} className="group inline-block">
<NamePair
nameFa={root.nameFa}
nameEn={root.nameEn}
size="root"
/>
</Link>
</div>
{children.length > 0 ? (
<ul className="grid grid-cols-2 gap-x-6 gap-y-6 sm:grid-cols-3 sm:gap-x-8 sm:gap-y-7 lg:grid-cols-4 lg:gap-x-10 xl:grid-cols-5">
{children.map((child) => {
const grands = child.children ?? [];
return (
<li key={child.id} className="min-w-0">
<Link
href={categoryPath(child)}
className="group block"
>
<NamePair
nameFa={child.nameFa}
nameEn={child.nameEn}
size="child"
/>
</Link>
{grands.length > 0 ? (
<ul className="mt-2.5 space-y-1.5 border-r border-[#ececec] pr-2.5">
{grands.map((g) => (
<li key={g.id}>
<Link
href={categoryPath(g)}
className="block text-right text-[13px] font-light text-ink/70 transition-colors hover:text-ink"
>
{g.nameFa}
</Link>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : (
<p className="text-sm font-light text-muted">
زیردسته‌ای ندارد — برای مشاهده محصولات وارد شوید.
</p>
)}
</li>
);
})}
</ul>
</section>
);
}
+8 -11
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import { ABOUT } from "@/data/home";
import BilingualTitle from "@/components/ui/BilingualTitle";
type AboutSectionProps = {
titleFa?: string;
@@ -54,17 +55,13 @@ export default function AboutSection({
</div>
<div className="relative text-right">
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute -top-4 start-0 font-doran text-[clamp(2rem,5vw,3.5rem)] font-normal tracking-[0.08em] text-ink/10"
>
{titleEn}
</p>
<h2 className="font-doran relative text-[clamp(1.75rem,3vw,2.5rem)] font-light text-ink">
{titleFa}
</h2>
<p className="font-doran mt-4 text-base font-medium text-ink sm:text-lg">
<BilingualTitle
titleFa={titleFa}
titleEn={titleEn}
size="xl"
align="end"
/>
<p className="mt-4 text-base font-medium text-ink sm:text-lg">
{lead}
</p>
<p className="mt-4 text-sm font-light leading-8 text-ink/75 sm:text-[15px] sm:leading-8">
+8 -11
View File
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { BLACK_ITEMS } from "@/data/home";
import BilingualTitle from "@/components/ui/BilingualTitle";
export type BlackItemData = {
id: string;
@@ -60,23 +61,19 @@ export default function BlackCollection({ items }: BlackCollectionProps) {
className="relative mx-auto w-full max-w-[1710px] px-4 py-10 sm:px-6 lg:px-[120px] lg:py-14"
>
<div
className="seq-fade-item relative mb-8 flex items-center justify-center text-center lg:mb-10"
className="seq-fade-item relative mb-8 lg:mb-10"
style={{
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
}}
>
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center font-doran text-[clamp(2.5rem,8vw,6.25rem)] font-normal leading-none tracking-[0.1em] text-ink/10"
>
i Like it Black
</p>
<h2 className="font-doran relative translate-y-[0.12em] text-[clamp(1.75rem,4vw,3.125rem)] font-light leading-none text-ink">
من عاشق رنگ <span className="font-medium">مشکی</span> هستم
</h2>
<BilingualTitle
titleFa="من عاشق رنگ مشکی هستم"
titleEn="I Like It Black"
size="xl"
align="center"
/>
</div>
<ul className="grid grid-cols-2 gap-[14px] lg:grid-cols-4 lg:gap-[22px]">
+1 -1
View File
@@ -35,7 +35,7 @@ export default function CategoryStrip({ categories }: CategoryStripProps) {
}, []);
return (
<section className="mx-auto w-full max-w-[1600px] px-4 py-20 sm:px-6 sm:py-24 lg:px-10 lg:py-32">
<section className="mx-auto w-full max-w-[1600px] px-4 pt-12 pb-6 sm:px-6 sm:pt-16 sm:pb-8 lg:px-10 lg:pt-20 lg:pb-10">
<ul
ref={ref}
className="flex flex-wrap items-stretch justify-center gap-x-4 gap-y-8 sm:gap-x-6 lg:gap-x-8"
+49 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { NAV_LINKS } from "@/data/home";
import { CONTACT_PAGE } from "@/data/pages";
import HeaderSearch from "./HeaderSearch";
import HeaderUserLink from "./HeaderUserLink";
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
@@ -54,7 +55,7 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
className={`pointer-events-none z-50 transition-[padding,background-color] duration-300 ease-out ${
scrolled
? "fixed inset-x-0 top-0 bg-white px-0 pt-0 shadow-[0_1px_0_rgba(0,0,0,0.06)]"
: "absolute inset-x-0 top-0 px-0 pt-6 sm:pt-8 lg:pt-14"
: "absolute inset-x-0 top-0 px-0 pt-3 sm:pt-4 lg:pt-5"
}`}
>
<div
@@ -62,6 +63,53 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
scrolled ? "px-0" : "px-4 sm:px-6 lg:px-[120px]"
}`}
>
{!scrolled ? (
<div
dir="rtl"
className="pointer-events-auto mx-auto mb-1.5 flex max-w-[1709px] items-center justify-between gap-4 font-montserrat text-sm font-light text-ink sm:mb-2 sm:text-base"
>
<a
href={`tel:${CONTACT_PAGE.phoneStore.replace(/-/g, "")}`}
dir="ltr"
className="inline-flex items-center gap-1.5 transition-opacity hover:opacity-70"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden
className="opacity-70"
>
<path
d="M8.5 4.5h3.2l1 3.2-1.8 1.1a11 11 0 0 0 4.3 4.3l1.1-1.8 3.2 1v3.2a2 2 0 0 1-2.1 2A14.5 14.5 0 0 1 4.5 6.6 2 2 0 0 1 6.5 4.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
<span>{CONTACT_PAGE.phoneStore}</span>
</a>
<a
href={CONTACT_PAGE.instagramUrl}
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="inline-flex items-center gap-1.5 transition-opacity hover:opacity-70"
>
<Image
src="/images/home/social/instagram.svg"
alt=""
width={16}
height={16}
unoptimized
className="opacity-70"
/>
<span>{CONTACT_PAGE.instagram}</span>
</a>
</div>
) : null}
<div
className={`pointer-events-auto mx-auto flex items-center justify-between gap-3 transition-[height,border-radius,background-color,backdrop-filter,border-color,max-width,padding] duration-300 ease-out ${
scrolled
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useMemo, useState } from "react";
import type { ProductDetail, ProductVariantOption } from "@/data/product-detail";
import BilingualTitle from "@/components/ui/BilingualTitle";
type ProductBuyBoxProps = {
product: ProductDetail;
@@ -115,15 +116,12 @@ export default function ProductBuyBox({ product }: ProductBuyBoxProps) {
return (
<div className="flex flex-col gap-6 lg:gap-8">
<div className="text-right">
<h2 className="font-doran text-[clamp(1.5rem,2.5vw,2rem)] font-medium text-ink">
{product.nameFa}
</h2>
<p
dir="ltr"
className="mt-2 text-right font-montserrat text-xs font-light uppercase tracking-[0.18em] text-muted"
>
{product.nameEn}
</p>
<BilingualTitle
titleFa={product.nameFa}
titleEn={product.nameEn}
size="lg"
align="end"
/>
<p className="mt-4 text-sm leading-7 text-ink/75">{product.summary}</p>
</div>
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import ProductCard from "@/components/home/ProductCard";
import BilingualTitle from "@/components/ui/BilingualTitle";
type SimilarProductsProps = {
products: Product[];
@@ -62,17 +63,13 @@ export default function SimilarProducts({ products }: SimilarProductsProps) {
return (
<section dir="rtl" className="border-t border-line pt-10">
<div className="mb-6 flex items-end justify-between gap-4">
<div className="min-w-0 text-right">
<h2 className="font-doran text-2xl font-medium text-ink sm:text-3xl">
محصولات مشابه
</h2>
<p
dir="ltr"
className="mt-1 w-full text-right font-montserrat text-[11px] font-light uppercase tracking-[0.18em] text-muted"
>
Similar products
</p>
</div>
<BilingualTitle
titleFa="محصولات مشابه"
titleEn="Similar products"
size="md"
align="end"
className="min-w-0"
/>
<div className="flex shrink-0 gap-2">
<button
@@ -0,0 +1,37 @@
import BilingualTitle from "@/components/ui/BilingualTitle";
type CategorySeoBlockProps = {
nameFa: string;
nameEn?: string;
description: string;
};
export default function CategorySeoBlock({
nameFa,
nameEn,
description,
}: CategorySeoBlockProps) {
const text = description.trim();
if (!text) return null;
return (
<section
dir="rtl"
className="mx-auto w-full max-w-[1920px] px-4 py-10 sm:px-6 sm:py-12 lg:px-[120px] lg:py-14"
>
<div className="mx-auto max-w-[920px]">
<BilingualTitle
titleFa={nameFa}
titleEn={nameEn}
size="lg"
align="center"
/>
<div className="mt-5 space-y-3 text-center text-sm font-light leading-8 text-[#5c5c5c] sm:text-[15px] sm:leading-8">
{text.split(/\n+/).map((paragraph, i) => (
<p key={i}>{paragraph.trim()}</p>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,44 @@
type ProductEmptyStateProps = {
title?: string;
description?: string;
};
export default function ProductEmptyState({
title = "محصولی پیدا نشد",
description = "با فیلترهای انتخاب‌شده نتیجه‌ای وجود ندارد. فیلترها را تغییر دهید یا همه را پاک کنید.",
}: ProductEmptyStateProps) {
return (
<div
dir="rtl"
className="mx-auto flex w-full max-w-[1920px] flex-col items-center justify-center px-4 py-20 text-center sm:px-6 sm:py-28 lg:px-[120px] lg:py-32"
role="status"
>
<div className="flex size-24 items-center justify-center rounded-full border border-[#818181]/35 bg-[#f7f7f7] sm:size-28">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
aria-hidden
className="text-[#818181]"
>
<path
d="M4 7h16l-1.2 12.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-1.99-1.8L4 7Z"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M9 10v7M15 10v7M8 7l1.2-2.4A1 1 0 0 1 10.1 4h3.8a1 1 0 0 1 .9.6L16 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div>
<h2 className="mt-6 text-xl font-normal text-ink sm:text-2xl">{title}</h2>
<p className="mt-3 max-w-md text-sm font-light leading-7 text-[#6a6a6a] sm:text-base">
{description}
</p>
</div>
);
}
+197 -18
View File
@@ -1,18 +1,35 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import {
buildProductListHref,
formatToman,
parsePriceInput,
} from "@/lib/products-query";
type ActiveFilter = {
id: string;
id: "q" | "cat" | "min" | "max" | "sale";
label: string;
value: string;
};
const INITIAL_FILTERS: ActiveFilter[] = [
{ id: "cat", label: "دسته بندی", value: "شلوار جین" },
{ id: "min", label: "حداقل قیمت", value: "۳.۰۰۰.۰۰۰ تومان" },
];
type ProductFiltersProps = {
basePath?: string;
/** Category locked by the page path (e.g. /categories/3/...) */
lockedCategory?: { id: string | number; nameFa: string } | null;
/** Initial values from the current URL */
initial?: {
q?: string;
cat?: string;
min?: string;
max?: string;
sale?: boolean;
};
/** Label for `cat` query when not locked */
categoryLabel?: string | null;
};
function FilterField({
label,
@@ -22,6 +39,7 @@ function FilterField({
iconSrc,
className = "",
inputMode,
onSubmit,
}: {
label: string;
value: string;
@@ -30,6 +48,7 @@ function FilterField({
iconSrc: string;
className?: string;
inputMode?: "text" | "numeric";
onSubmit?: () => void;
}) {
return (
<label className={`relative block w-full ${className}`}>
@@ -42,43 +61,200 @@ function FilterField({
inputMode={inputMode}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit?.();
}
}}
placeholder={placeholder}
className="h-9 w-full rounded-[5px] border border-[#818181]/40 bg-white pe-3 ps-9 text-right text-sm font-light text-ink placeholder:text-[#818181] outline-none focus:border-ink"
className="h-9 w-full rounded-[5px] border border-[#d8d8d8] bg-white pe-3 ps-9 text-right text-sm font-light text-ink placeholder:text-[#818181] outline-none focus:border-ink"
/>
</label>
);
}
export default function ProductFilters() {
const [name, setName] = useState("");
const [minPrice, setMinPrice] = useState("");
const [maxPrice, setMaxPrice] = useState("");
const [active, setActive] = useState<ActiveFilter[]>(INITIAL_FILTERS);
function buildActiveChips({
q,
cat,
min,
max,
sale,
lockedCategory,
categoryLabel,
}: {
q: string;
cat: string;
min: string;
max: string;
sale?: boolean;
lockedCategory?: ProductFiltersProps["lockedCategory"];
categoryLabel?: string | null;
}): ActiveFilter[] {
const chips: ActiveFilter[] = [];
if (sale) {
chips.push({ id: "sale", label: "جایگاه", value: "فروش ویژه" });
}
if (q.trim()) {
chips.push({ id: "q", label: "نام محصول", value: q.trim() });
}
if (lockedCategory) {
chips.push({
id: "cat",
label: "دسته بندی",
value: lockedCategory.nameFa,
});
} else if (cat) {
chips.push({
id: "cat",
label: "دسته بندی",
value: categoryLabel || cat,
});
}
const minPrice = parsePriceInput(min);
if (minPrice !== undefined) {
chips.push({
id: "min",
label: "حداقل قیمت",
value: formatToman(minPrice),
});
}
const maxPrice = parsePriceInput(max);
if (maxPrice !== undefined) {
chips.push({
id: "max",
label: "حداکثر قیمت",
value: formatToman(maxPrice),
});
}
return chips;
}
export default function ProductFilters({
basePath = "/products",
lockedCategory = null,
initial,
categoryLabel = null,
}: ProductFiltersProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [name, setName] = useState(initial?.q ?? "");
const [minPrice, setMinPrice] = useState(initial?.min ?? "");
const [maxPrice, setMaxPrice] = useState(initial?.max ?? "");
const [cat, setCat] = useState(
lockedCategory ? String(lockedCategory.id) : (initial?.cat ?? ""),
);
const [sale, setSale] = useState(Boolean(initial?.sale));
useEffect(() => {
setName(initial?.q ?? "");
setMinPrice(initial?.min ?? "");
setMaxPrice(initial?.max ?? "");
setCat(lockedCategory ? String(lockedCategory.id) : (initial?.cat ?? ""));
setSale(Boolean(initial?.sale));
}, [
initial?.q,
initial?.min,
initial?.max,
initial?.cat,
initial?.sale,
lockedCategory?.id,
]);
const navigate = (next: {
q?: string;
cat?: string;
min?: string;
max?: string;
sale?: boolean;
}) => {
const href = buildProductListHref(
next.cat === "" && lockedCategory ? "/products" : basePath,
{
q: next.q ?? "",
cat: lockedCategory ? undefined : next.cat,
min: next.min ?? "",
max: next.max ?? "",
sale: next.sale ?? false,
page: 1,
},
lockedCategory && next.cat !== "" ? lockedCategory.id : undefined,
);
startTransition(() => {
router.push(href);
});
};
const apply = () => {
navigate({
q: name,
cat,
min: minPrice,
max: maxPrice,
sale,
});
};
const clearAll = () => {
setName("");
setMinPrice("");
setMaxPrice("");
setActive([]);
setCat("");
setSale(false);
startTransition(() => {
router.push(lockedCategory ? "/products" : basePath);
});
};
const removeChip = (id: string) => {
setActive((prev) => prev.filter((f) => f.id !== id));
const removeChip = (id: ActiveFilter["id"]) => {
const next = {
q: id === "q" ? "" : name,
cat: id === "cat" ? "" : cat,
min: id === "min" ? "" : minPrice,
max: id === "max" ? "" : maxPrice,
sale: id === "sale" ? false : sale,
};
setName(next.q);
setMinPrice(next.min);
setMaxPrice(next.max);
setCat(next.cat);
setSale(next.sale);
navigate(next);
};
const active = buildActiveChips({
q: initial?.q ?? "",
cat: lockedCategory ? String(lockedCategory.id) : (initial?.cat ?? ""),
min: initial?.min ?? "",
max: initial?.max ?? "",
sale: Boolean(initial?.sale),
lockedCategory,
categoryLabel,
});
return (
<div dir="rtl" className="mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-[120px]">
<div className="flex flex-col gap-3 rounded-[5px] border border-[#818181]/40 px-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:px-4 sm:py-4">
<div
dir="rtl"
className={`mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-[120px] ${isPending ? "opacity-80" : ""}`}
>
<div className="flex flex-col gap-3 rounded-[5px] border border-[#e5e5e5] px-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:px-4 sm:py-4">
<p className="shrink-0 text-base font-normal text-ink sm:text-[18px] lg:text-[20px]">
فیلترها
</p>
{/* justify-start packs toward inline-start = right in RTL (next to label) */}
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-start sm:gap-3">
<FilterField
label="نام محصول"
value={name}
onChange={setName}
onSubmit={apply}
placeholder="نام محصول"
iconSrc="/images/home/icons/search.svg"
className="sm:max-w-[269px]"
@@ -87,6 +263,7 @@ export default function ProductFilters() {
label="حداقل قیمت"
value={minPrice}
onChange={setMinPrice}
onSubmit={apply}
placeholder="حداقل قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
@@ -96,6 +273,7 @@ export default function ProductFilters() {
label="حداکثر قیمت"
value={maxPrice}
onChange={setMaxPrice}
onSubmit={apply}
placeholder="حداکثر قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
@@ -121,6 +299,7 @@ export default function ProductFilters() {
<button
type="button"
aria-label="اعمال فیلتر"
onClick={apply}
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
>
<Image
@@ -0,0 +1,57 @@
import CategoryStrip from "@/components/home/CategoryStrip";
import AboutSection from "@/components/home/AboutSection";
import CategorySeoBlock from "@/components/products/CategorySeoBlock";
import type { Category } from "@/data/home";
type ProductListExtrasProps = {
stripCategories: Category[];
about: {
titleFa: string;
titleEn: string;
lead: string;
body: string;
image: string;
imageSecondary: string;
};
/** When set, show category SEO instead of About */
categorySeo?: {
nameFa: string;
nameEn?: string;
description: string;
} | null;
};
/** Categories strip + category about (or home About) — used under product lists */
export default function ProductListExtras({
stripCategories,
about,
categorySeo = null,
}: ProductListExtrasProps) {
const seoText = categorySeo?.description?.trim() ?? "";
const showCategoryAbout = Boolean(seoText);
return (
<>
{showCategoryAbout && categorySeo ? (
<CategorySeoBlock
nameFa={categorySeo.nameFa}
nameEn={categorySeo.nameEn}
description={seoText}
/>
) : (
<AboutSection
titleFa={about.titleFa}
titleEn={about.titleEn}
lead={about.lead}
body={about.body}
image={about.image}
imageSecondary={about.imageSecondary}
/>
)}
{stripCategories.length > 0 ? (
<CategoryStrip categories={stripCategories} />
) : null}
</>
);
}
+34 -43
View File
@@ -11,12 +11,12 @@ type ProductListHeroProps = {
export default function ProductListHero({
titleFa = LIST_PAGE_META.titleFa,
titleEn = LIST_PAGE_META.titleEn,
heroImage = LIST_PAGE_META.heroImage,
breadcrumbs = LIST_PAGE_META.breadcrumbs,
}: ProductListHeroProps) {
return (
<section className="relative h-[min(52vw,469px)] min-h-[280px] w-full overflow-hidden bg-[#ebe6df]">
<section className="relative h-[min(28vw,220px)] min-h-[140px] w-full overflow-hidden bg-[#ebe6df] sm:min-h-[160px]">
<h1 className="sr-only">{titleFa}</h1>
<Image
src={heroImage}
alt=""
@@ -27,48 +27,39 @@ export default function ProductListHero({
/>
<div className="absolute inset-0 bg-gradient-to-l from-white/20 via-transparent to-transparent" />
{/* Same inset as header / filters / grid: px-4 sm:px-6 lg:px-[120px] */}
<div className="absolute inset-x-0 bottom-5 z-10 px-4 sm:bottom-6 sm:px-6 lg:bottom-8 lg:px-[120px]">
<div className="w-full max-w-[420px] sm:max-w-[460px]">
<nav
dir="rtl"
aria-label="مسیر صفحه"
className="flex h-8 items-center justify-start gap-1.5 bg-ink px-3 text-[11px] font-light text-white sm:h-9 sm:gap-2 sm:px-4 sm:text-[13px]"
>
{breadcrumbs.map((crumb, i) => (
<span key={`${crumb.label}-${i}`} className="inline-flex items-center gap-1.5 sm:gap-2">
{i > 0 ? (
<Image
src="/images/home/icons/caret-left.svg"
alt=""
width={12}
height={12}
unoptimized
className="brightness-0 invert"
/>
) : null}
{crumb.href ? (
<Link href={crumb.href} className="transition-opacity hover:opacity-70">
{crumb.label}
</Link>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
<div className="bg-white px-4 pb-2.5 pt-2 text-right sm:px-5 sm:pb-3 sm:pt-2.5">
<h1 className="text-lg font-normal leading-none text-ink sm:text-xl lg:text-[22px]">
{titleFa}
</h1>
<p
dir="ltr"
className="mt-1.5 text-right text-xs font-light leading-none uppercase tracking-[0.3em] text-[#898989] sm:text-sm sm:tracking-[0.35em]"
<div className="absolute inset-x-0 bottom-4 z-10 px-4 sm:bottom-5 sm:px-6 lg:bottom-6 lg:px-[120px]">
<nav
dir="rtl"
aria-label="مسیر صفحه"
className="flex flex-wrap items-center gap-1.5 text-[11px] font-light text-ink sm:text-[13px]"
>
{breadcrumbs.map((crumb, i) => (
<span
key={`${crumb.label}-${i}`}
className="inline-flex items-center gap-1.5 sm:gap-2"
>
{titleEn}
</p>
</div>
</div>
{i > 0 ? (
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={12}
height={12}
unoptimized
/>
) : null}
{crumb.href ? (
<Link
href={crumb.href}
className="transition-opacity hover:opacity-70"
>
{crumb.label}
</Link>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
</div>
</section>
);
+119 -46
View File
@@ -1,18 +1,85 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { buildProductListHref } from "@/lib/products-query";
const PAGES = [1, 2, 3, 4, 5] as const;
type ProductPaginationProps = {
page: number;
totalPages: number;
basePath: string;
query?: {
q?: string;
cat?: string;
min?: string;
max?: string;
sale?: boolean;
};
/** When set, `cat` is omitted from the query string (path already scopes category) */
lockedCategoryId?: string | number;
};
const linkClass =
"px-1 py-0.5 transition-colors duration-200 hover:text-ink";
"px-1 py-0.5 transition-colors duration-200 hover:text-ink disabled:pointer-events-none disabled:opacity-40";
const pageClass =
"flex h-7 w-7 items-center justify-center rounded-[4px] transition-colors duration-200 sm:h-8 sm:w-8";
export default function ProductPagination() {
const [page, setPage] = useState(3);
const total = 12;
function pageWindow(current: number, total: number): (number | "ellipsis")[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages = new Set<number>();
pages.add(1);
pages.add(total);
for (let i = current - 1; i <= current + 1; i += 1) {
if (i >= 1 && i <= total) pages.add(i);
}
if (current <= 3) {
pages.add(2);
pages.add(3);
pages.add(4);
}
if (current >= total - 2) {
pages.add(total - 1);
pages.add(total - 2);
pages.add(total - 3);
}
const sorted = [...pages].sort((a, b) => a - b);
const out: (number | "ellipsis")[] = [];
let prev = 0;
for (const n of sorted) {
if (prev && n - prev > 1) out.push("ellipsis");
out.push(n);
prev = n;
}
return out;
}
export default function ProductPagination({
page,
totalPages,
basePath,
query = {},
lockedCategoryId,
}: ProductPaginationProps) {
// Parent only mounts this when there is at least one product.
const hrefForPage = (p: number) =>
buildProductListHref(
basePath,
{
q: query.q,
cat: query.cat,
min: query.min,
max: query.max,
sale: query.sale,
page: p,
},
lockedCategoryId,
);
const items = pageWindow(page, Math.max(1, totalPages));
return (
<nav
@@ -20,54 +87,60 @@ export default function ProductPagination() {
aria-label="صفحه‌بندی محصولات"
className="mx-auto flex max-w-[1920px] flex-wrap items-center justify-center gap-x-3 gap-y-2 px-4 pt-2 pb-6 font-montserrat text-sm text-[#6a6a6a] sm:gap-x-4 sm:text-[15px] lg:pt-3 lg:pb-8"
>
<button type="button" onClick={() => setPage(1)} className={linkClass}>
<Link
href={hrefForPage(1)}
aria-disabled={page <= 1}
tabIndex={page <= 1 ? -1 : undefined}
className={`${linkClass} ${page <= 1 ? "pointer-events-none opacity-40" : ""}`}
>
FIRST
</button>
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
className={linkClass}
</Link>
<Link
href={hrefForPage(Math.max(1, page - 1))}
aria-disabled={page <= 1}
tabIndex={page <= 1 ? -1 : undefined}
className={`${linkClass} ${page <= 1 ? "pointer-events-none opacity-40" : ""}`}
>
PREV
</button>
</Link>
{PAGES.map((n) => (
<button
key={n}
type="button"
aria-current={page === n ? "page" : undefined}
onClick={() => setPage(n)}
className={
page === n
? `${pageClass} bg-ink text-white`
: `${pageClass} text-ink hover:bg-ink hover:text-white`
}
>
{n}
</button>
))}
{items.map((item, index) =>
item === "ellipsis" ? (
<span key={`e-${index}`} className="text-ink" aria-hidden>
...
</span>
) : (
<Link
key={item}
href={hrefForPage(item)}
aria-current={page === item ? "page" : undefined}
className={
page === item
? `${pageClass} bg-ink text-white`
: `${pageClass} text-ink hover:bg-ink hover:text-white`
}
>
{item}
</Link>
),
)}
<span className="text-ink" aria-hidden>
...
</span>
<button
type="button"
onClick={() => setPage(total)}
className={`${pageClass} text-ink hover:bg-ink hover:text-white`}
>
{total}
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(total, p + 1))}
className={linkClass}
<Link
href={hrefForPage(Math.min(totalPages, page + 1))}
aria-disabled={page >= totalPages}
tabIndex={page >= totalPages ? -1 : undefined}
className={`${linkClass} ${page >= totalPages ? "pointer-events-none opacity-40" : ""}`}
>
NEXT
</button>
<button type="button" onClick={() => setPage(total)} className={linkClass}>
</Link>
<Link
href={hrefForPage(totalPages)}
aria-disabled={page >= totalPages}
tabIndex={page >= totalPages ? -1 : undefined}
className={`${linkClass} ${page >= totalPages ? "pointer-events-none opacity-40" : ""}`}
>
LAST
</button>
</Link>
</nav>
);
}
+51
View File
@@ -0,0 +1,51 @@
type BilingualTitleProps = {
titleFa: string;
titleEn?: string;
as?: "h1" | "h2" | "h3" | "p";
align?: "center" | "start" | "end";
size?: "md" | "lg" | "xl";
className?: string;
};
const sizeClass = {
md: "text-[clamp(1.35rem,2.2vw,1.75rem)]",
lg: "text-[clamp(1.5rem,2.5vw,2rem)]",
xl: "text-[clamp(1.75rem,3.2vw,2.75rem)]",
} as const;
const alignClass = {
center: "text-center",
/** Physical left — use for LTR blocks */
start: "text-left",
/** Physical right — Persian titles in RTL layouts */
end: "text-right",
} as const;
export default function BilingualTitle({
titleFa,
titleEn,
as: Tag = "h2",
align = "end",
size = "lg",
className = "",
}: BilingualTitleProps) {
const en = titleEn?.trim();
return (
<div className={`${alignClass[align]} ${className}`}>
<Tag
className={`font-doran font-medium leading-tight text-ink ${sizeClass[size]}`}
>
{titleFa}
</Tag>
{en ? (
<p
dir="ltr"
className={`mt-2 font-montserrat text-xs font-light uppercase tracking-[0.18em] text-muted sm:text-[13px] ${alignClass[align]}`}
>
{en}
</p>
) : null}
</div>
);
}
+2 -2
View File
@@ -28,7 +28,7 @@ export type Feature = {
export const NAV_LINKS = [
{ label: "محصولات", href: "/products", hasDropdown: true },
{ label: "جدیدترین ها", href: "/products?sort=newest" },
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: "فروش ویژه", href: "/products?sale=1" },
{ label: "تماس با ما", href: "/contact" },
{ label: "درباره ما", href: "/about" },
@@ -348,7 +348,7 @@ export const FOOTER_CATEGORIES_COL2 = [
] as const;
export const SOCIAL_LINKS = [
{ id: "instagram", href: "#", icon: "/images/home/social/instagram.svg", label: "اینستاگرام" },
{ id: "instagram", href: "https://www.instagram.com/manacollection", icon: "/images/home/social/instagram.svg", label: "اینستاگرام" },
{ id: "telegram", href: "#", icon: "/images/home/social/telegram.svg", label: "تلگرام" },
{ id: "whatsapp", href: "#", icon: "/images/home/social/whatsapp.svg", label: "واتساپ" },
{ id: "linkedin", href: "#", icon: "/images/home/social/linkedin.svg", label: "لینکدین" },
+3 -3
View File
@@ -49,6 +49,8 @@ export const CONTACT_PAGE = {
phoneStore: "021-22423600",
phoneMobile: "09122504800",
email: "Info@ManaCollecitions.com",
instagram: "manacollection",
instagramUrl: "https://www.instagram.com/manacollection",
hours: "شنبه تا پنج‌شنبه | ۱۰:۰۰ الی ۲۰:۰۰",
} as const;
@@ -89,9 +91,7 @@ export function getCategoryPage(slug: string): CategoryPageMeta | null {
const extra = CATEGORY_EXTRA[slug];
return {
...base,
description:
extra?.description ||
`مجموعه‌ای از محصولات ${base.nameFa} در مانا کالکشن.`,
description: extra?.description || "",
heroImage: extra?.heroImage || LIST_PAGE_META.heroImage,
};
}
+2 -3
View File
@@ -14,12 +14,11 @@ export const LIST_PRODUCTS: Product[] = Array.from({ length: 15 }, (_, i) => {
});
export const LIST_PAGE_META = {
titleFa: "شلوار جین",
titleEn: "Jean Pants",
titleFa: "محصولات",
titleEn: "Products",
heroImage: "/images/home/banners/hero.jpg",
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "لیست محصولات", href: "/products" },
{ label: "دسته بندی شلوار جین", href: "/products?cat=jeans" },
],
} as const;
+72
View File
@@ -11,6 +11,7 @@ export type ApiCategoryNode = {
nameEn: string;
sortOrder: number;
imageUrl: string | null;
seoDescription?: string;
children: ApiCategoryNode[];
};
@@ -37,6 +38,19 @@ export function mapCategoriesToStrip(
}));
}
/** Same source as the home page category strip */
export function resolveHomeCategoryStrip(
apiCategories: ApiCategoryNode[],
homeCategories: Category[],
hasWebsiteCategoryStrip: boolean,
): Category[] {
if (hasWebsiteCategoryStrip && homeCategories.length) {
return homeCategories;
}
const fromApi = mapCategoriesToStrip(apiCategories);
return fromApi.length ? fromApi : homeCategories;
}
export function mapCategoriesToMegaTabs(
nodes: ApiCategoryNode[],
slotImages?: Array<{ image: string; nameFa?: string; href?: string }>,
@@ -89,3 +103,61 @@ export function mapCategoriesToMegaTabs(
};
});
}
export function findCategoryNode(
nodes: ApiCategoryNode[],
id: string | number,
): ApiCategoryNode | null {
const target = String(id);
for (const node of nodes) {
if (String(node.id) === target) return node;
const child = findCategoryNode(node.children ?? [], target);
if (child) return child;
}
return null;
}
/** Root → … → current (inclusive). Empty if not found. */
export function categoryAncestry(
nodes: ApiCategoryNode[],
id: string | number,
): ApiCategoryNode[] {
const target = String(id);
function walk(
list: ApiCategoryNode[],
trail: ApiCategoryNode[],
): ApiCategoryNode[] | null {
for (const node of list) {
const next = [...trail, node];
if (String(node.id) === target) return next;
const found = walk(node.children ?? [], next);
if (found) return found;
}
return null;
}
return walk(nodes, []) ?? [];
}
/** Categories shown in the bottom strip on a category page */
export function relatedCategoriesForStrip(
nodes: ApiCategoryNode[],
currentId: string | number,
): Category[] {
const current = findCategoryNode(nodes, currentId);
if (!current) return mapCategoriesToStrip(nodes);
if (current.children?.length) {
return mapCategoriesToStrip(current.children);
}
if (current.parentId != null) {
const parent = findCategoryNode(nodes, current.parentId);
if (parent?.children?.length) {
return mapCategoriesToStrip(parent.children);
}
}
return mapCategoriesToStrip(nodes);
}
+19 -2
View File
@@ -281,21 +281,38 @@ function fallbackCards(): Product[] {
export async function getStorefrontProducts(query?: {
name?: string;
categoryId?: string | number;
minPrice?: number;
maxPrice?: number;
tag?: string;
/** e.g. `promo-1` for special sale */
featuredSlot?: string;
}): Promise<Product[]> {
const { ok, products: rows } = await fetchPublicProducts({
name: query?.name,
categoryId: query?.categoryId,
minPrice: query?.minPrice,
maxPrice: query?.maxPrice,
});
if (!ok) {
// Offline: ignore filters and show static sample cards
if (query?.name || query?.categoryId) return [];
if (
query?.name ||
query?.categoryId ||
query?.minPrice !== undefined ||
query?.maxPrice !== undefined ||
query?.featuredSlot
) {
return [];
}
return fallbackCards();
}
let filtered = rows;
if (query?.featuredSlot) {
filtered = filtered.filter((p) => p.featuredSlot === query.featuredSlot);
}
if (query?.tag) {
const tag = query.tag.toLowerCase();
const tagged = rows.filter((p) =>
const tagged = filtered.filter((p) =>
(p.tags ?? []).some((t) => t.toLowerCase().includes(tag)),
);
if (tagged.length) filtered = tagged;
+116
View File
@@ -0,0 +1,116 @@
export const PRODUCTS_PAGE_SIZE = 10;
export type ProductListQuery = {
q?: string;
cat?: string;
min?: string;
max?: string;
page?: string;
/** Special sale / promo-1 placement */
sale?: string;
};
export type ParsedProductListQuery = {
q: string;
cat: string;
min: string;
max: string;
minPrice?: number;
maxPrice?: number;
page: number;
/** When true, only products in featuredSlot `promo-1` */
sale: boolean;
};
function digitsOnly(value: string | undefined): string {
return (value ?? "").replace(/\D/g, "");
}
export function parsePriceInput(value: string | undefined): number | undefined {
const digits = digitsOnly(value);
if (!digits) return undefined;
const n = Number(digits);
return Number.isFinite(n) ? n : undefined;
}
export function formatToman(amount: number): string {
return `${new Intl.NumberFormat("fa-IR").format(amount)} تومان`;
}
export function isSaleQuery(value: string | undefined): boolean {
const v = (value ?? "").trim().toLowerCase();
return v === "1" || v === "true" || v === "promo-1" || v === "promo1";
}
export function parseProductListQuery(
params: ProductListQuery = {},
): ParsedProductListQuery {
const pageRaw = Number(params.page);
const page =
Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1;
return {
q: (params.q ?? "").trim(),
cat: (params.cat ?? "").trim(),
min: digitsOnly(params.min),
max: digitsOnly(params.max),
minPrice: parsePriceInput(params.min),
maxPrice: parsePriceInput(params.max),
page,
sale: isSaleQuery(params.sale),
};
}
export function buildProductListHref(
basePath: string,
query: {
q?: string;
cat?: string;
min?: string | number;
max?: string | number;
page?: number;
sale?: boolean | string;
},
lockedCategoryId?: string | number,
): string {
const params = new URLSearchParams();
const q = (query.q ?? "").trim();
const cat =
lockedCategoryId !== undefined && lockedCategoryId !== ""
? ""
: (query.cat ?? "").trim();
const min = digitsOnly(String(query.min ?? ""));
const max = digitsOnly(String(query.max ?? ""));
const page = query.page && query.page > 1 ? query.page : undefined;
const sale =
typeof query.sale === "boolean"
? query.sale
: isSaleQuery(query.sale);
if (q) params.set("q", q);
if (cat) params.set("cat", cat);
if (min) params.set("min", min);
if (max) params.set("max", max);
if (sale) params.set("sale", "1");
if (page) params.set("page", String(page));
const qs = params.toString();
return qs ? `${basePath}?${qs}` : basePath;
}
export function paginateItems<T>(
items: T[],
page: number,
pageSize = PRODUCTS_PAGE_SIZE,
): { pageItems: T[]; totalPages: number; page: number; total: number } {
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
const start = (safePage - 1) * pageSize;
return {
pageItems: items.slice(start, start + pageSize),
totalPages,
page: safePage,
total,
};
}