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 AboutSection from "@/components/home/AboutSection";
|
||||||
import PartnerCta from "@/components/home/PartnerCta";
|
import PartnerCta from "@/components/home/PartnerCta";
|
||||||
import ProductListHero from "@/components/products/ProductListHero";
|
import ProductListHero from "@/components/products/ProductListHero";
|
||||||
|
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||||||
import { ABOUT_PAGE } from "@/data/pages";
|
import { ABOUT_PAGE } from "@/data/pages";
|
||||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||||
import { fetchPublicWebsiteSlots } from "@/lib/website-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">
|
<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 className="grid gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-doran text-2xl font-light text-ink">
|
<BilingualTitle
|
||||||
{ABOUT_PAGE.storyTitleFa}
|
titleFa={ABOUT_PAGE.storyTitleFa}
|
||||||
</h2>
|
titleEn={ABOUT_PAGE.storyTitleEn}
|
||||||
<p
|
size="md"
|
||||||
dir="ltr"
|
align="end"
|
||||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
/>
|
||||||
>
|
|
||||||
{ABOUT_PAGE.storyTitleEn}
|
|
||||||
</p>
|
|
||||||
<p className="mt-6 text-sm leading-8 text-ink/80">
|
<p className="mt-6 text-sm leading-8 text-ink/80">
|
||||||
{ABOUT_PAGE.story}
|
{ABOUT_PAGE.story}
|
||||||
</p>
|
</p>
|
||||||
@@ -79,13 +77,13 @@ export default async function AboutPage() {
|
|||||||
key={value.titleEn}
|
key={value.titleEn}
|
||||||
className="border border-line bg-[#f7f7f5] p-5"
|
className="border border-line bg-[#f7f7f5] p-5"
|
||||||
>
|
>
|
||||||
<p className="text-base text-ink">{value.titleFa}</p>
|
<BilingualTitle
|
||||||
<p
|
titleFa={value.titleFa}
|
||||||
dir="ltr"
|
titleEn={value.titleEn}
|
||||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
as="p"
|
||||||
>
|
size="md"
|
||||||
{value.titleEn}
|
align="end"
|
||||||
</p>
|
/>
|
||||||
<p className="mt-3 text-sm leading-7 text-ink/75">
|
<p className="mt-3 text-sm leading-7 text-ink/75">
|
||||||
{value.body}
|
{value.body}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,19 +8,32 @@ import ProductListHero from "@/components/products/ProductListHero";
|
|||||||
import ProductFilters from "@/components/products/ProductFilters";
|
import ProductFilters from "@/components/products/ProductFilters";
|
||||||
import ProductGrid from "@/components/products/ProductGrid";
|
import ProductGrid from "@/components/products/ProductGrid";
|
||||||
import ProductPagination from "@/components/products/ProductPagination";
|
import ProductPagination from "@/components/products/ProductPagination";
|
||||||
|
import ProductEmptyState from "@/components/products/ProductEmptyState";
|
||||||
|
import ProductListExtras from "@/components/products/ProductListExtras";
|
||||||
import { getCategoryPage } from "@/data/pages";
|
import { getCategoryPage } from "@/data/pages";
|
||||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
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 { getStorefrontProducts } from "@/lib/products-api";
|
||||||
import {
|
import {
|
||||||
|
categoryAncestry,
|
||||||
fetchPublicCategories,
|
fetchPublicCategories,
|
||||||
mapCategoriesToMegaTabs,
|
mapCategoriesToMegaTabs,
|
||||||
|
resolveHomeCategoryStrip,
|
||||||
type ApiCategoryNode,
|
type ApiCategoryNode,
|
||||||
} from "@/lib/categories-api";
|
} from "@/lib/categories-api";
|
||||||
import { categoryPath, categorySlug } from "@/lib/category-path";
|
import { categoryPath, categorySlug } from "@/lib/category-path";
|
||||||
|
import {
|
||||||
|
paginateItems,
|
||||||
|
parseProductListQuery,
|
||||||
|
type ProductListQuery,
|
||||||
|
} from "@/lib/products-query";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ path: string[] }>;
|
params: Promise<{ path: string[] }>;
|
||||||
|
searchParams: Promise<ProductListQuery>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function findCategory(
|
function findCategory(
|
||||||
@@ -47,7 +60,8 @@ async function resolveCategory(id: string) {
|
|||||||
nameFa: apiCat.nameFa,
|
nameFa: apiCat.nameFa,
|
||||||
nameEn: apiCat.nameEn || apiCat.nameFa,
|
nameEn: apiCat.nameEn || apiCat.nameFa,
|
||||||
imageUrl: apiCat.imageUrl,
|
imageUrl: apiCat.imageUrl,
|
||||||
description: `مجموعهای از محصولات ${apiCat.nameFa} در مانا کالکشن.`,
|
seoDescription: (apiCat.seoDescription ?? "").trim(),
|
||||||
|
description: (apiCat.seoDescription ?? "").trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const staticCat = getCategoryPage(id);
|
const staticCat = getCategoryPage(id);
|
||||||
@@ -60,6 +74,7 @@ async function resolveCategory(id: string) {
|
|||||||
nameFa: staticCat.nameFa,
|
nameFa: staticCat.nameFa,
|
||||||
nameEn: staticCat.nameEn,
|
nameEn: staticCat.nameEn,
|
||||||
imageUrl: null as string | null,
|
imageUrl: null as string | null,
|
||||||
|
seoDescription: "",
|
||||||
description: staticCat.description,
|
description: staticCat.description,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -76,11 +91,13 @@ export async function generateMetadata({
|
|||||||
if (!resolved) return { title: "دسته | مانا کالکشن" };
|
if (!resolved) return { title: "دسته | مانا کالکشن" };
|
||||||
return {
|
return {
|
||||||
title: `${resolved.nameFa} | مانا کالکشن`,
|
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;
|
const { path } = await params;
|
||||||
if (!path?.length || path.length > 2) notFound();
|
if (!path?.length || path.length > 2) notFound();
|
||||||
|
|
||||||
@@ -104,10 +121,22 @@ export default async function CategoryPage({ params }: PageProps) {
|
|||||||
redirect(canonical);
|
redirect(canonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const query = parseProductListQuery(await searchParams);
|
||||||
const [slotItems, products] = await Promise.all([
|
const [slotItems, products] = await Promise.all([
|
||||||
fetchPublicWebsiteSlots(),
|
fetchPublicWebsiteSlots(),
|
||||||
getStorefrontProducts(
|
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);
|
const home = mapHomeSlots(slotItems);
|
||||||
@@ -116,6 +145,35 @@ export default async function CategoryPage({ params }: PageProps) {
|
|||||||
home.megaTabs.map((t) => ({ image: t.image })),
|
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 (
|
return (
|
||||||
<main className="home-page min-h-dvh bg-white text-ink">
|
<main className="home-page min-h-dvh bg-white text-ink">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -128,25 +186,50 @@ export default async function CategoryPage({ params }: PageProps) {
|
|||||||
resolved.imageUrl ||
|
resolved.imageUrl ||
|
||||||
resolved.staticCat?.heroImage
|
resolved.staticCat?.heroImage
|
||||||
}
|
}
|
||||||
breadcrumbs={[
|
breadcrumbs={breadcrumbs}
|
||||||
{ label: "خانه", href: "/" },
|
|
||||||
{ label: "دستهبندیها", href: "/categories" },
|
|
||||||
{ label: resolved.nameFa },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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]">
|
<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">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<ProductGrid products={products} />
|
{total === 0 ? (
|
||||||
<ProductPagination />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,6 +241,21 @@ export default async function CategoryPage({ params }: PageProps) {
|
|||||||
link={home.leather.link}
|
link={home.leather.link}
|
||||||
/>
|
/>
|
||||||
<PartnerCta />
|
<PartnerCta />
|
||||||
|
|
||||||
|
<ProductListExtras
|
||||||
|
stripCategories={stripCategories}
|
||||||
|
about={home.about}
|
||||||
|
categorySeo={
|
||||||
|
resolved.seoDescription
|
||||||
|
? {
|
||||||
|
nameFa: resolved.nameFa,
|
||||||
|
nameEn: resolved.nameEn,
|
||||||
|
description: resolved.seoDescription,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-39
@@ -1,19 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import SiteHeader from "@/components/home/SiteHeader";
|
import SiteHeader from "@/components/home/SiteHeader";
|
||||||
import SiteFooter from "@/components/home/SiteFooter";
|
import SiteFooter from "@/components/home/SiteFooter";
|
||||||
|
import LeatherBanner from "@/components/home/LeatherBanner";
|
||||||
import PartnerCta from "@/components/home/PartnerCta";
|
import PartnerCta from "@/components/home/PartnerCta";
|
||||||
import ProductListHero from "@/components/products/ProductListHero";
|
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 { LIST_PAGE_META } from "@/data/products";
|
||||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||||
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
|
||||||
import {
|
import {
|
||||||
fetchPublicCategories,
|
fetchPublicCategories,
|
||||||
mapCategoriesToMegaTabs,
|
mapCategoriesToMegaTabs,
|
||||||
mapCategoriesToStrip,
|
|
||||||
} from "@/lib/categories-api";
|
} from "@/lib/categories-api";
|
||||||
import { categoryPath } from "@/lib/category-path";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "دستهبندیها | مانا کالکشن",
|
title: "دستهبندیها | مانا کالکشن",
|
||||||
@@ -26,7 +25,6 @@ export default async function CategoriesIndexPage() {
|
|||||||
fetchPublicCategories(),
|
fetchPublicCategories(),
|
||||||
]);
|
]);
|
||||||
const home = mapHomeSlots(slotItems);
|
const home = mapHomeSlots(slotItems);
|
||||||
const list = mapCategoriesToStrip(apiCategories);
|
|
||||||
const megaTabs = mapCategoriesToMegaTabs(
|
const megaTabs = mapCategoriesToMegaTabs(
|
||||||
apiCategories,
|
apiCategories,
|
||||||
home.megaTabs.map((t) => ({ image: t.image })),
|
home.megaTabs.map((t) => ({ image: t.image })),
|
||||||
@@ -47,43 +45,24 @@ export default async function CategoriesIndexPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
|
<div className="mx-auto w-full max-w-[1920px] px-4 pt-8 sm:px-6 sm:pt-10 lg:px-[120px]">
|
||||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3 lg:gap-6">
|
<BilingualTitle
|
||||||
{list.map((cat) => (
|
titleFa="دستهبندیها"
|
||||||
<li key={cat.id}>
|
titleEn="Categories"
|
||||||
<Link
|
size="lg"
|
||||||
href={cat.href || categoryPath(cat)}
|
align="end"
|
||||||
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>
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<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 />
|
<PartnerCta />
|
||||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SiteFooter from "@/components/home/SiteFooter";
|
|||||||
import PartnerCta from "@/components/home/PartnerCta";
|
import PartnerCta from "@/components/home/PartnerCta";
|
||||||
import ProductListHero from "@/components/products/ProductListHero";
|
import ProductListHero from "@/components/products/ProductListHero";
|
||||||
import ContactForm from "@/components/contact/ContactForm";
|
import ContactForm from "@/components/contact/ContactForm";
|
||||||
|
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||||||
import { CONTACT_PAGE } from "@/data/pages";
|
import { CONTACT_PAGE } from "@/data/pages";
|
||||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
import { mapHomeSlots } from "@/lib/map-home-slots";
|
||||||
import { fetchPublicWebsiteSlots } from "@/lib/website-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">
|
<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">
|
<aside className="space-y-6 border border-line bg-[#f7f7f5] p-6 sm:p-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-doran text-2xl font-light text-ink">
|
<BilingualTitle
|
||||||
اطلاعات تماس
|
titleFa="اطلاعات تماس"
|
||||||
</h2>
|
titleEn="Get in touch"
|
||||||
<p
|
size="md"
|
||||||
dir="ltr"
|
align="end"
|
||||||
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
/>
|
||||||
>
|
|
||||||
Get in touch
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="space-y-4 text-sm leading-7">
|
<dl className="space-y-4 text-sm leading-7">
|
||||||
|
|||||||
+6
-10
@@ -17,7 +17,7 @@ import { getStorefrontCatalog } from "@/lib/products-api";
|
|||||||
import {
|
import {
|
||||||
fetchPublicCategories,
|
fetchPublicCategories,
|
||||||
mapCategoriesToMegaTabs,
|
mapCategoriesToMegaTabs,
|
||||||
mapCategoriesToStrip,
|
resolveHomeCategoryStrip,
|
||||||
} from "@/lib/categories-api";
|
} from "@/lib/categories-api";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
@@ -32,7 +32,6 @@ export default async function HomePage() {
|
|||||||
const discountProducts = catalog.promo2;
|
const discountProducts = catalog.promo2;
|
||||||
const autumnProducts = catalog.promo3;
|
const autumnProducts = catalog.promo3;
|
||||||
|
|
||||||
const stripCategories = mapCategoriesToStrip(apiCategories);
|
|
||||||
const megaTabs = mapCategoriesToMegaTabs(
|
const megaTabs = mapCategoriesToMegaTabs(
|
||||||
apiCategories,
|
apiCategories,
|
||||||
home.megaTabs.map((t) => ({
|
home.megaTabs.map((t) => ({
|
||||||
@@ -42,14 +41,11 @@ export default async function HomePage() {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefer Website Images → category-strip slots over catalog categories
|
const categories = resolveHomeCategoryStrip(
|
||||||
const hasWebsiteCategoryStrip =
|
apiCategories,
|
||||||
itemsForSlot(slotItems, "category-strip").length > 0;
|
home.categories,
|
||||||
const categories = hasWebsiteCategoryStrip
|
itemsForSlot(slotItems, "category-strip").length > 0,
|
||||||
? home.categories
|
);
|
||||||
: stripCategories.length
|
|
||||||
? stripCategories
|
|
||||||
: home.categories;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="home-page min-h-dvh bg-white text-ink">
|
<main className="home-page min-h-dvh bg-white text-ink">
|
||||||
|
|||||||
+148
-20
@@ -6,26 +6,61 @@ import ProductListHero from "@/components/products/ProductListHero";
|
|||||||
import ProductFilters from "@/components/products/ProductFilters";
|
import ProductFilters from "@/components/products/ProductFilters";
|
||||||
import ProductGrid from "@/components/products/ProductGrid";
|
import ProductGrid from "@/components/products/ProductGrid";
|
||||||
import ProductPagination from "@/components/products/ProductPagination";
|
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 { LIST_PAGE_META } from "@/data/products";
|
||||||
import { mapHomeSlots } from "@/lib/map-home-slots";
|
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 { getStorefrontProducts } from "@/lib/products-api";
|
||||||
import {
|
import {
|
||||||
fetchPublicCategories,
|
fetchPublicCategories,
|
||||||
mapCategoriesToMegaTabs,
|
mapCategoriesToMegaTabs,
|
||||||
|
resolveHomeCategoryStrip,
|
||||||
|
type ApiCategoryNode,
|
||||||
} from "@/lib/categories-api";
|
} from "@/lib/categories-api";
|
||||||
|
import {
|
||||||
|
paginateItems,
|
||||||
|
parseProductListQuery,
|
||||||
|
type ProductListQuery,
|
||||||
|
} from "@/lib/products-query";
|
||||||
|
|
||||||
type PageProps = {
|
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) {
|
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([
|
const [slotItems, products, apiCategories] = await Promise.all([
|
||||||
fetchPublicWebsiteSlots(),
|
fetchPublicWebsiteSlots(),
|
||||||
getStorefrontProducts({
|
getStorefrontProducts({
|
||||||
name: params.q,
|
name: query.q || undefined,
|
||||||
categoryId: params.cat,
|
categoryId: query.cat || undefined,
|
||||||
|
minPrice: query.minPrice,
|
||||||
|
maxPrice: query.maxPrice,
|
||||||
|
featuredSlot: query.sale ? "promo-1" : undefined,
|
||||||
}),
|
}),
|
||||||
fetchPublicCategories(),
|
fetchPublicCategories(),
|
||||||
]);
|
]);
|
||||||
@@ -35,31 +70,117 @@ export default async function ProductsPage({ searchParams }: PageProps) {
|
|||||||
home.megaTabs.map((t) => ({ image: t.image })),
|
home.megaTabs.map((t) => ({ image: t.image })),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const stripCategories = resolveHomeCategoryStrip(
|
||||||
<main className="home-page min-h-dvh bg-white text-ink">
|
apiCategories,
|
||||||
<div className="relative">
|
home.categories,
|
||||||
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
|
itemsForSlot(slotItems, "category-strip").length > 0,
|
||||||
<ProductListHero
|
);
|
||||||
titleFa={params.q ? `جستجو: ${params.q}` : LIST_PAGE_META.titleFa}
|
|
||||||
titleEn={params.q ? "Search results" : LIST_PAGE_META.titleEn}
|
const catNode = query.cat ? findCategory(apiCategories, query.cat) : null;
|
||||||
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
|
const categorySeo = catNode?.seoDescription?.trim()
|
||||||
breadcrumbs={
|
? {
|
||||||
params.q
|
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: "/" },
|
||||||
{ label: "محصولات", href: "/products" },
|
{ label: "محصولات", href: "/products" },
|
||||||
{ label: "جستجو" },
|
{ label: "جستجو" },
|
||||||
]
|
]
|
||||||
: LIST_PAGE_META.breadcrumbs
|
: 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={heroTitle}
|
||||||
|
titleEn={heroEn}
|
||||||
|
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
|
||||||
|
breadcrumbs={breadcrumbs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[60px]">
|
<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">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<ProductGrid products={products} />
|
{total === 0 ? (
|
||||||
<ProductPagination />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,6 +192,13 @@ export default async function ProductsPage({ searchParams }: PageProps) {
|
|||||||
link={home.leather.link}
|
link={home.leather.link}
|
||||||
/>
|
/>
|
||||||
<PartnerCta />
|
<PartnerCta />
|
||||||
|
|
||||||
|
<ProductListExtras
|
||||||
|
stripCategories={stripCategories}
|
||||||
|
about={home.about}
|
||||||
|
categorySeo={categorySeo}
|
||||||
|
/>
|
||||||
|
|
||||||
<SiteFooter paymentBadges={home.paymentBadges} />
|
<SiteFooter paymentBadges={home.paymentBadges} />
|
||||||
</main>
|
</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 Image from "next/image";
|
||||||
import { ABOUT } from "@/data/home";
|
import { ABOUT } from "@/data/home";
|
||||||
|
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||||||
|
|
||||||
type AboutSectionProps = {
|
type AboutSectionProps = {
|
||||||
titleFa?: string;
|
titleFa?: string;
|
||||||
@@ -54,17 +55,13 @@ export default function AboutSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative text-right">
|
<div className="relative text-right">
|
||||||
<p
|
<BilingualTitle
|
||||||
dir="ltr"
|
titleFa={titleFa}
|
||||||
aria-hidden
|
titleEn={titleEn}
|
||||||
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"
|
size="xl"
|
||||||
>
|
align="end"
|
||||||
{titleEn}
|
/>
|
||||||
</p>
|
<p className="mt-4 text-base font-medium text-ink sm:text-lg">
|
||||||
<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">
|
|
||||||
{lead}
|
{lead}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 text-sm font-light leading-8 text-ink/75 sm:text-[15px] sm:leading-8">
|
<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 Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { BLACK_ITEMS } from "@/data/home";
|
import { BLACK_ITEMS } from "@/data/home";
|
||||||
|
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||||||
|
|
||||||
export type BlackItemData = {
|
export type BlackItemData = {
|
||||||
id: string;
|
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"
|
className="relative mx-auto w-full max-w-[1710px] px-4 py-10 sm:px-6 lg:px-[120px] lg:py-14"
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
transitionDelay: visible ? "0ms" : "0ms",
|
transitionDelay: visible ? "0ms" : "0ms",
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
|
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p
|
<BilingualTitle
|
||||||
dir="ltr"
|
titleFa="من عاشق رنگ مشکی هستم"
|
||||||
aria-hidden
|
titleEn="I Like It Black"
|
||||||
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"
|
size="xl"
|
||||||
>
|
align="center"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="grid grid-cols-2 gap-[14px] lg:grid-cols-4 lg:gap-[22px]">
|
<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 (
|
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
|
<ul
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="flex flex-wrap items-stretch justify-center gap-x-4 gap-y-8 sm:gap-x-6 lg:gap-x-8"
|
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 Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { NAV_LINKS } from "@/data/home";
|
import { NAV_LINKS } from "@/data/home";
|
||||||
|
import { CONTACT_PAGE } from "@/data/pages";
|
||||||
import HeaderSearch from "./HeaderSearch";
|
import HeaderSearch from "./HeaderSearch";
|
||||||
import HeaderUserLink from "./HeaderUserLink";
|
import HeaderUserLink from "./HeaderUserLink";
|
||||||
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
|
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 ${
|
className={`pointer-events-none z-50 transition-[padding,background-color] duration-300 ease-out ${
|
||||||
scrolled
|
scrolled
|
||||||
? "fixed inset-x-0 top-0 bg-white px-0 pt-0 shadow-[0_1px_0_rgba(0,0,0,0.06)]"
|
? "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
|
<div
|
||||||
@@ -62,6 +63,53 @@ export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
|
|||||||
scrolled ? "px-0" : "px-4 sm:px-6 lg:px-[120px]"
|
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
|
<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 ${
|
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
|
scrolled
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { ProductDetail, ProductVariantOption } from "@/data/product-detail";
|
import type { ProductDetail, ProductVariantOption } from "@/data/product-detail";
|
||||||
|
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||||||
|
|
||||||
type ProductBuyBoxProps = {
|
type ProductBuyBoxProps = {
|
||||||
product: ProductDetail;
|
product: ProductDetail;
|
||||||
@@ -115,15 +116,12 @@ export default function ProductBuyBox({ product }: ProductBuyBoxProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 lg:gap-8">
|
<div className="flex flex-col gap-6 lg:gap-8">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<h2 className="font-doran text-[clamp(1.5rem,2.5vw,2rem)] font-medium text-ink">
|
<BilingualTitle
|
||||||
{product.nameFa}
|
titleFa={product.nameFa}
|
||||||
</h2>
|
titleEn={product.nameEn}
|
||||||
<p
|
size="lg"
|
||||||
dir="ltr"
|
align="end"
|
||||||
className="mt-2 text-right font-montserrat text-xs font-light uppercase tracking-[0.18em] text-muted"
|
/>
|
||||||
>
|
|
||||||
{product.nameEn}
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-sm leading-7 text-ink/75">{product.summary}</p>
|
<p className="mt-4 text-sm leading-7 text-ink/75">{product.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Product } from "@/data/home";
|
import type { Product } from "@/data/home";
|
||||||
import ProductCard from "@/components/home/ProductCard";
|
import ProductCard from "@/components/home/ProductCard";
|
||||||
|
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||||||
|
|
||||||
type SimilarProductsProps = {
|
type SimilarProductsProps = {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
@@ -62,17 +63,13 @@ export default function SimilarProducts({ products }: SimilarProductsProps) {
|
|||||||
return (
|
return (
|
||||||
<section dir="rtl" className="border-t border-line pt-10">
|
<section dir="rtl" className="border-t border-line pt-10">
|
||||||
<div className="mb-6 flex items-end justify-between gap-4">
|
<div className="mb-6 flex items-end justify-between gap-4">
|
||||||
<div className="min-w-0 text-right">
|
<BilingualTitle
|
||||||
<h2 className="font-doran text-2xl font-medium text-ink sm:text-3xl">
|
titleFa="محصولات مشابه"
|
||||||
محصولات مشابه
|
titleEn="Similar products"
|
||||||
</h2>
|
size="md"
|
||||||
<p
|
align="end"
|
||||||
dir="ltr"
|
className="min-w-0"
|
||||||
className="mt-1 w-full text-right font-montserrat text-[11px] font-light uppercase tracking-[0.18em] text-muted"
|
/>
|
||||||
>
|
|
||||||
Similar products
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<button
|
<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";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
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 = {
|
type ActiveFilter = {
|
||||||
id: string;
|
id: "q" | "cat" | "min" | "max" | "sale";
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_FILTERS: ActiveFilter[] = [
|
type ProductFiltersProps = {
|
||||||
{ id: "cat", label: "دسته بندی", value: "شلوار جین" },
|
basePath?: string;
|
||||||
{ id: "min", label: "حداقل قیمت", value: "۳.۰۰۰.۰۰۰ تومان" },
|
/** 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({
|
function FilterField({
|
||||||
label,
|
label,
|
||||||
@@ -22,6 +39,7 @@ function FilterField({
|
|||||||
iconSrc,
|
iconSrc,
|
||||||
className = "",
|
className = "",
|
||||||
inputMode,
|
inputMode,
|
||||||
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -30,6 +48,7 @@ function FilterField({
|
|||||||
iconSrc: string;
|
iconSrc: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
inputMode?: "text" | "numeric";
|
inputMode?: "text" | "numeric";
|
||||||
|
onSubmit?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className={`relative block w-full ${className}`}>
|
<label className={`relative block w-full ${className}`}>
|
||||||
@@ -42,43 +61,200 @@ function FilterField({
|
|||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
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>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductFilters() {
|
function buildActiveChips({
|
||||||
const [name, setName] = useState("");
|
q,
|
||||||
const [minPrice, setMinPrice] = useState("");
|
cat,
|
||||||
const [maxPrice, setMaxPrice] = useState("");
|
min,
|
||||||
const [active, setActive] = useState<ActiveFilter[]>(INITIAL_FILTERS);
|
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 = () => {
|
const clearAll = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setMinPrice("");
|
setMinPrice("");
|
||||||
setMaxPrice("");
|
setMaxPrice("");
|
||||||
setActive([]);
|
setCat("");
|
||||||
|
setSale(false);
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(lockedCategory ? "/products" : basePath);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeChip = (id: string) => {
|
const removeChip = (id: ActiveFilter["id"]) => {
|
||||||
setActive((prev) => prev.filter((f) => f.id !== 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 (
|
return (
|
||||||
<div dir="rtl" className="mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-[120px]">
|
<div
|
||||||
<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">
|
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 className="shrink-0 text-base font-normal text-ink sm:text-[18px] lg:text-[20px]">
|
||||||
فیلترها
|
فیلترها
|
||||||
</p>
|
</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">
|
<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
|
<FilterField
|
||||||
label="نام محصول"
|
label="نام محصول"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
|
onSubmit={apply}
|
||||||
placeholder="نام محصول"
|
placeholder="نام محصول"
|
||||||
iconSrc="/images/home/icons/search.svg"
|
iconSrc="/images/home/icons/search.svg"
|
||||||
className="sm:max-w-[269px]"
|
className="sm:max-w-[269px]"
|
||||||
@@ -87,6 +263,7 @@ export default function ProductFilters() {
|
|||||||
label="حداقل قیمت"
|
label="حداقل قیمت"
|
||||||
value={minPrice}
|
value={minPrice}
|
||||||
onChange={setMinPrice}
|
onChange={setMinPrice}
|
||||||
|
onSubmit={apply}
|
||||||
placeholder="حداقل قیمت"
|
placeholder="حداقل قیمت"
|
||||||
iconSrc="/images/home/icons/price.svg"
|
iconSrc="/images/home/icons/price.svg"
|
||||||
className="sm:max-w-[174px]"
|
className="sm:max-w-[174px]"
|
||||||
@@ -96,6 +273,7 @@ export default function ProductFilters() {
|
|||||||
label="حداکثر قیمت"
|
label="حداکثر قیمت"
|
||||||
value={maxPrice}
|
value={maxPrice}
|
||||||
onChange={setMaxPrice}
|
onChange={setMaxPrice}
|
||||||
|
onSubmit={apply}
|
||||||
placeholder="حداکثر قیمت"
|
placeholder="حداکثر قیمت"
|
||||||
iconSrc="/images/home/icons/price.svg"
|
iconSrc="/images/home/icons/price.svg"
|
||||||
className="sm:max-w-[174px]"
|
className="sm:max-w-[174px]"
|
||||||
@@ -121,6 +299,7 @@ export default function ProductFilters() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="اعمال فیلتر"
|
aria-label="اعمال فیلتر"
|
||||||
|
onClick={apply}
|
||||||
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
|
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
|
||||||
>
|
>
|
||||||
<Image
|
<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({
|
export default function ProductListHero({
|
||||||
titleFa = LIST_PAGE_META.titleFa,
|
titleFa = LIST_PAGE_META.titleFa,
|
||||||
titleEn = LIST_PAGE_META.titleEn,
|
|
||||||
heroImage = LIST_PAGE_META.heroImage,
|
heroImage = LIST_PAGE_META.heroImage,
|
||||||
breadcrumbs = LIST_PAGE_META.breadcrumbs,
|
breadcrumbs = LIST_PAGE_META.breadcrumbs,
|
||||||
}: ProductListHeroProps) {
|
}: ProductListHeroProps) {
|
||||||
return (
|
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
|
<Image
|
||||||
src={heroImage}
|
src={heroImage}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -27,28 +27,31 @@ export default function ProductListHero({
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-l from-white/20 via-transparent to-transparent" />
|
<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-4 z-10 px-4 sm:bottom-5 sm:px-6 lg:bottom-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
|
<nav
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
aria-label="مسیر صفحه"
|
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]"
|
className="flex flex-wrap items-center gap-1.5 text-[11px] font-light text-ink sm:text-[13px]"
|
||||||
>
|
>
|
||||||
{breadcrumbs.map((crumb, i) => (
|
{breadcrumbs.map((crumb, i) => (
|
||||||
<span key={`${crumb.label}-${i}`} className="inline-flex items-center gap-1.5 sm:gap-2">
|
<span
|
||||||
|
key={`${crumb.label}-${i}`}
|
||||||
|
className="inline-flex items-center gap-1.5 sm:gap-2"
|
||||||
|
>
|
||||||
{i > 0 ? (
|
{i > 0 ? (
|
||||||
<Image
|
<Image
|
||||||
src="/images/home/icons/caret-left.svg"
|
src="/images/home/icons/caret-left-dark.svg"
|
||||||
alt=""
|
alt=""
|
||||||
width={12}
|
width={12}
|
||||||
height={12}
|
height={12}
|
||||||
unoptimized
|
unoptimized
|
||||||
className="brightness-0 invert"
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{crumb.href ? (
|
{crumb.href ? (
|
||||||
<Link href={crumb.href} className="transition-opacity hover:opacity-70">
|
<Link
|
||||||
|
href={crumb.href}
|
||||||
|
className="transition-opacity hover:opacity-70"
|
||||||
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -57,18 +60,6 @@ export default function ProductListHero({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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]"
|
|
||||||
>
|
|
||||||
{titleEn}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,85 @@
|
|||||||
"use client";
|
"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 =
|
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 =
|
const pageClass =
|
||||||
"flex h-7 w-7 items-center justify-center rounded-[4px] transition-colors duration-200 sm:h-8 sm:w-8";
|
"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() {
|
function pageWindow(current: number, total: number): (number | "ellipsis")[] {
|
||||||
const [page, setPage] = useState(3);
|
if (total <= 7) {
|
||||||
const total = 12;
|
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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -20,54 +87,60 @@ export default function ProductPagination() {
|
|||||||
aria-label="صفحهبندی محصولات"
|
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"
|
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
|
FIRST
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={hrefForPage(Math.max(1, page - 1))}
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
aria-disabled={page <= 1}
|
||||||
className={linkClass}
|
tabIndex={page <= 1 ? -1 : undefined}
|
||||||
|
className={`${linkClass} ${page <= 1 ? "pointer-events-none opacity-40" : ""}`}
|
||||||
>
|
>
|
||||||
PREV
|
PREV
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
{PAGES.map((n) => (
|
{items.map((item, index) =>
|
||||||
<button
|
item === "ellipsis" ? (
|
||||||
key={n}
|
<span key={`e-${index}`} className="text-ink" aria-hidden>
|
||||||
type="button"
|
...
|
||||||
aria-current={page === n ? "page" : undefined}
|
</span>
|
||||||
onClick={() => setPage(n)}
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item}
|
||||||
|
href={hrefForPage(item)}
|
||||||
|
aria-current={page === item ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
page === n
|
page === item
|
||||||
? `${pageClass} bg-ink text-white`
|
? `${pageClass} bg-ink text-white`
|
||||||
: `${pageClass} text-ink hover:bg-ink hover:text-white`
|
: `${pageClass} text-ink hover:bg-ink hover:text-white`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{n}
|
{item}
|
||||||
</button>
|
</Link>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="text-ink" aria-hidden>
|
<Link
|
||||||
...
|
href={hrefForPage(Math.min(totalPages, page + 1))}
|
||||||
</span>
|
aria-disabled={page >= totalPages}
|
||||||
<button
|
tabIndex={page >= totalPages ? -1 : undefined}
|
||||||
type="button"
|
className={`${linkClass} ${page >= totalPages ? "pointer-events-none opacity-40" : ""}`}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
NEXT
|
NEXT
|
||||||
</button>
|
</Link>
|
||||||
<button type="button" onClick={() => setPage(total)} className={linkClass}>
|
<Link
|
||||||
|
href={hrefForPage(totalPages)}
|
||||||
|
aria-disabled={page >= totalPages}
|
||||||
|
tabIndex={page >= totalPages ? -1 : undefined}
|
||||||
|
className={`${linkClass} ${page >= totalPages ? "pointer-events-none opacity-40" : ""}`}
|
||||||
|
>
|
||||||
LAST
|
LAST
|
||||||
</button>
|
</Link>
|
||||||
</nav>
|
</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 = [
|
export const NAV_LINKS = [
|
||||||
{ label: "محصولات", href: "/products", hasDropdown: true },
|
{ label: "محصولات", href: "/products", hasDropdown: true },
|
||||||
{ label: "جدیدترین ها", href: "/products?sort=newest" },
|
{ label: "دستهبندیها", href: "/categories" },
|
||||||
{ label: "فروش ویژه", href: "/products?sale=1" },
|
{ label: "فروش ویژه", href: "/products?sale=1" },
|
||||||
{ label: "تماس با ما", href: "/contact" },
|
{ label: "تماس با ما", href: "/contact" },
|
||||||
{ label: "درباره ما", href: "/about" },
|
{ label: "درباره ما", href: "/about" },
|
||||||
@@ -348,7 +348,7 @@ export const FOOTER_CATEGORIES_COL2 = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SOCIAL_LINKS = [
|
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: "telegram", href: "#", icon: "/images/home/social/telegram.svg", label: "تلگرام" },
|
||||||
{ id: "whatsapp", href: "#", icon: "/images/home/social/whatsapp.svg", label: "واتساپ" },
|
{ id: "whatsapp", href: "#", icon: "/images/home/social/whatsapp.svg", label: "واتساپ" },
|
||||||
{ id: "linkedin", href: "#", icon: "/images/home/social/linkedin.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",
|
phoneStore: "021-22423600",
|
||||||
phoneMobile: "09122504800",
|
phoneMobile: "09122504800",
|
||||||
email: "Info@ManaCollecitions.com",
|
email: "Info@ManaCollecitions.com",
|
||||||
|
instagram: "manacollection",
|
||||||
|
instagramUrl: "https://www.instagram.com/manacollection",
|
||||||
hours: "شنبه تا پنجشنبه | ۱۰:۰۰ الی ۲۰:۰۰",
|
hours: "شنبه تا پنجشنبه | ۱۰:۰۰ الی ۲۰:۰۰",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -89,9 +91,7 @@ export function getCategoryPage(slug: string): CategoryPageMeta | null {
|
|||||||
const extra = CATEGORY_EXTRA[slug];
|
const extra = CATEGORY_EXTRA[slug];
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
description:
|
description: extra?.description || "",
|
||||||
extra?.description ||
|
|
||||||
`مجموعهای از محصولات ${base.nameFa} در مانا کالکشن.`,
|
|
||||||
heroImage: extra?.heroImage || LIST_PAGE_META.heroImage,
|
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 = {
|
export const LIST_PAGE_META = {
|
||||||
titleFa: "شلوار جین",
|
titleFa: "محصولات",
|
||||||
titleEn: "Jean Pants",
|
titleEn: "Products",
|
||||||
heroImage: "/images/home/banners/hero.jpg",
|
heroImage: "/images/home/banners/hero.jpg",
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
{ label: "خانه", href: "/" },
|
{ label: "خانه", href: "/" },
|
||||||
{ label: "لیست محصولات", href: "/products" },
|
{ label: "لیست محصولات", href: "/products" },
|
||||||
{ label: "دسته بندی شلوار جین", href: "/products?cat=jeans" },
|
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type ApiCategoryNode = {
|
|||||||
nameEn: string;
|
nameEn: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
seoDescription?: string;
|
||||||
children: ApiCategoryNode[];
|
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(
|
export function mapCategoriesToMegaTabs(
|
||||||
nodes: ApiCategoryNode[],
|
nodes: ApiCategoryNode[],
|
||||||
slotImages?: Array<{ image: string; nameFa?: string; href?: string }>,
|
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?: {
|
export async function getStorefrontProducts(query?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
categoryId?: string | number;
|
categoryId?: string | number;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
/** e.g. `promo-1` for special sale */
|
||||||
|
featuredSlot?: string;
|
||||||
}): Promise<Product[]> {
|
}): Promise<Product[]> {
|
||||||
const { ok, products: rows } = await fetchPublicProducts({
|
const { ok, products: rows } = await fetchPublicProducts({
|
||||||
name: query?.name,
|
name: query?.name,
|
||||||
categoryId: query?.categoryId,
|
categoryId: query?.categoryId,
|
||||||
|
minPrice: query?.minPrice,
|
||||||
|
maxPrice: query?.maxPrice,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
// Offline: ignore filters and show static sample cards
|
// 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();
|
return fallbackCards();
|
||||||
}
|
}
|
||||||
let filtered = rows;
|
let filtered = rows;
|
||||||
|
if (query?.featuredSlot) {
|
||||||
|
filtered = filtered.filter((p) => p.featuredSlot === query.featuredSlot);
|
||||||
|
}
|
||||||
if (query?.tag) {
|
if (query?.tag) {
|
||||||
const tag = query.tag.toLowerCase();
|
const tag = query.tag.toLowerCase();
|
||||||
const tagged = rows.filter((p) =>
|
const tagged = filtered.filter((p) =>
|
||||||
(p.tags ?? []).some((t) => t.toLowerCase().includes(tag)),
|
(p.tags ?? []).some((t) => t.toLowerCase().includes(tag)),
|
||||||
);
|
);
|
||||||
if (tagged.length) filtered = tagged;
|
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