From fa248d2f6a53a29fe0536c2d84bfb280e42df85b Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 27 Sep 2026 20:46:17 +0330 Subject: [PATCH] Polish storefront listing: filters, category directory, and list extras. Co-authored-by: Cursor --- src/app/about/page.tsx | 30 ++- src/app/categories/[...path]/page.tsx | 136 +++++++++-- src/app/categories/page.tsx | 59 ++--- src/app/contact/page.tsx | 16 +- src/app/page.tsx | 16 +- src/app/products/page.tsx | 166 ++++++++++++-- .../categories/CategoriesIndexDirectory.tsx | 130 +++++++++++ src/components/home/AboutSection.tsx | 19 +- src/components/home/BlackCollection.tsx | 19 +- src/components/home/CategoryStrip.tsx | 2 +- src/components/home/SiteHeader.tsx | 50 +++- .../product-detail/ProductBuyBox.tsx | 16 +- .../product-detail/SimilarProducts.tsx | 19 +- src/components/products/CategorySeoBlock.tsx | 37 +++ src/components/products/ProductEmptyState.tsx | 44 ++++ src/components/products/ProductFilters.tsx | 215 ++++++++++++++++-- src/components/products/ProductListExtras.tsx | 57 +++++ src/components/products/ProductListHero.tsx | 77 +++---- src/components/products/ProductPagination.tsx | 165 ++++++++++---- src/components/ui/BilingualTitle.tsx | 51 +++++ src/data/home.ts | 4 +- src/data/pages.ts | 6 +- src/data/products.ts | 5 +- src/lib/categories-api.ts | 72 ++++++ src/lib/products-api.ts | 21 +- src/lib/products-query.ts | 116 ++++++++++ 26 files changed, 1274 insertions(+), 274 deletions(-) create mode 100644 src/components/categories/CategoriesIndexDirectory.tsx create mode 100644 src/components/products/CategorySeoBlock.tsx create mode 100644 src/components/products/ProductEmptyState.tsx create mode 100644 src/components/products/ProductListExtras.tsx create mode 100644 src/components/ui/BilingualTitle.tsx create mode 100644 src/lib/products-query.ts diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 3ebd8a6..03bc633 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -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() {
-

- {ABOUT_PAGE.storyTitleFa} -

-

- {ABOUT_PAGE.storyTitleEn} -

+

{ABOUT_PAGE.story}

@@ -79,13 +77,13 @@ export default async function AboutPage() { key={value.titleEn} className="border border-line bg-[#f7f7f5] p-5" > -

{value.titleFa}

-

- {value.titleEn} -

+

{value.body}

diff --git a/src/app/categories/[...path]/page.tsx b/src/app/categories/[...path]/page.tsx index 95730f0..685d02f 100644 --- a/src/app/categories/[...path]/page.tsx +++ b/src/app/categories/[...path]/page.tsx @@ -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; }; 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 (
@@ -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} />
-
-

- {resolved.description} -

-
-
- +
- - + {total === 0 ? ( + + ) : ( + <> + + + + )}
@@ -158,6 +241,21 @@ export default async function CategoryPage({ params }: PageProps) { link={home.leather.link} /> + + +
); diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx index c929ef0..88ef84b 100644 --- a/src/app/categories/page.tsx +++ b/src/app/categories/page.tsx @@ -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() { />
-
-
    - {list.map((cat) => ( -
  • - -
    - {cat.nameFa} -
    -
    -
    -

    {cat.nameFa}

    -

    - {cat.nameEn} -

    -
    - - مشاهده - -
    - -
  • - ))} -
+
+
+ + + diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index a9123ab..318bf78 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -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() {
-

- {titleEn} -

-

- {titleFa} -

-

+ +

{lead}

diff --git a/src/components/home/BlackCollection.tsx b/src/components/home/BlackCollection.tsx index 813b6f5..e58187d 100644 --- a/src/components/home/BlackCollection.tsx +++ b/src/components/home/BlackCollection.tsx @@ -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" >

-

- i Like it Black -

-

- من عاشق رنگ مشکی هستم -

+
    diff --git a/src/components/home/CategoryStrip.tsx b/src/components/home/CategoryStrip.tsx index 08417bc..19a82c7 100644 --- a/src/components/home/CategoryStrip.tsx +++ b/src/components/home/CategoryStrip.tsx @@ -35,7 +35,7 @@ export default function CategoryStrip({ categories }: CategoryStripProps) { }, []); return ( -
    +
      + {!scrolled ? ( + + ) : null} +
      -

      - {product.nameFa} -

      -

      - {product.nameEn} -

      +

      {product.summary}

      diff --git a/src/components/product-detail/SimilarProducts.tsx b/src/components/product-detail/SimilarProducts.tsx index 60b4c5e..53349fb 100644 --- a/src/components/product-detail/SimilarProducts.tsx +++ b/src/components/product-detail/SimilarProducts.tsx @@ -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 (
      -
      -

      - محصولات مشابه -

      -

      - Similar products -

      -
      +
      + ); +} diff --git a/src/components/products/ProductEmptyState.tsx b/src/components/products/ProductEmptyState.tsx new file mode 100644 index 0000000..236676e --- /dev/null +++ b/src/components/products/ProductEmptyState.tsx @@ -0,0 +1,44 @@ +type ProductEmptyStateProps = { + title?: string; + description?: string; +}; + +export default function ProductEmptyState({ + title = "محصولی پیدا نشد", + description = "با فیلترهای انتخاب‌شده نتیجه‌ای وجود ندارد. فیلترها را تغییر دهید یا همه را پاک کنید.", +}: ProductEmptyStateProps) { + return ( +
      +
      + + + + +
      +

      {title}

      +

      + {description} +

      +
      + ); +} diff --git a/src/components/products/ProductFilters.tsx b/src/components/products/ProductFilters.tsx index 40b222d..e1ec3e3 100644 --- a/src/components/products/ProductFilters.tsx +++ b/src/components/products/ProductFilters.tsx @@ -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 ( ); } -export default function ProductFilters() { - const [name, setName] = useState(""); - const [minPrice, setMinPrice] = useState(""); - const [maxPrice, setMaxPrice] = useState(""); - const [active, setActive] = useState(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 ( -
      -
      +
      +

      فیلترها

      - {/* justify-start packs toward inline-start = right in RTL (next to label) */}
      + {showCategoryAbout && categorySeo ? ( + + ) : ( + + )} + + {stripCategories.length > 0 ? ( + + ) : null} + + ); +} diff --git a/src/components/products/ProductListHero.tsx b/src/components/products/ProductListHero.tsx index d285726..81b4a4e 100644 --- a/src/components/products/ProductListHero.tsx +++ b/src/components/products/ProductListHero.tsx @@ -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 ( -
      +
      +

      {titleFa}

      - {/* Same inset as header / filters / grid: px-4 sm:px-6 lg:px-[120px] */} -
      -
      - -
      -

      - {titleFa} -

      -

      +

      -
      + {i > 0 ? ( + + ) : null} + {crumb.href ? ( + + {crumb.label} + + ) : ( + {crumb.label} + )} + + ))} +
      ); diff --git a/src/components/products/ProductPagination.tsx b/src/components/products/ProductPagination.tsx index feb222e..16f5124 100644 --- a/src/components/products/ProductPagination.tsx +++ b/src/components/products/ProductPagination.tsx @@ -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(); + 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 ( ); } diff --git a/src/components/ui/BilingualTitle.tsx b/src/components/ui/BilingualTitle.tsx new file mode 100644 index 0000000..5a67961 --- /dev/null +++ b/src/components/ui/BilingualTitle.tsx @@ -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 ( +
      + + {titleFa} + + {en ? ( +

      + {en} +

      + ) : null} +
      + ); +} diff --git a/src/data/home.ts b/src/data/home.ts index 4c4cf95..ef1505f 100644 --- a/src/data/home.ts +++ b/src/data/home.ts @@ -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: "لینکدین" }, diff --git a/src/data/pages.ts b/src/data/pages.ts index 7ea8d9f..376e151 100644 --- a/src/data/pages.ts +++ b/src/data/pages.ts @@ -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, }; } diff --git a/src/data/products.ts b/src/data/products.ts index f597103..763d703 100644 --- a/src/data/products.ts +++ b/src/data/products.ts @@ -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; diff --git a/src/lib/categories-api.ts b/src/lib/categories-api.ts index 5de8070..2d18725 100644 --- a/src/lib/categories-api.ts +++ b/src/lib/categories-api.ts @@ -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); +} diff --git a/src/lib/products-api.ts b/src/lib/products-api.ts index 0c6b311..cf8f801 100644 --- a/src/lib/products-api.ts +++ b/src/lib/products-api.ts @@ -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 { 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; diff --git a/src/lib/products-query.ts b/src/lib/products-query.ts new file mode 100644 index 0000000..1ce21c5 --- /dev/null +++ b/src/lib/products-query.ts @@ -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( + 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, + }; +}