Polish storefront listing: filters, category directory, and list extras.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4089247ad3
commit
fa248d2f6a
+14
-16
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user