Add category detail URLs and polish mega menu UX.
Route categories to /products/category/{id}/{slug}, improve menu hover/layout, and hide stock/category clutter on product cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5d53cf91eb
commit
f078cdad0e
@@ -1,9 +1,11 @@
|
|||||||
### Sitemap + robots.txt
|
### Sitemap + robots.txt
|
||||||
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
|
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
|
||||||
|
- Live sitemap is an index: /sitemap.xml → /sitemap-main.xml + /sitemap-products.xml.
|
||||||
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
|
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
|
||||||
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
|
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
|
||||||
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
|
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
|
||||||
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
|
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
|
||||||
|
- Category: /products/category/{id}/{nameFaSlug} → GET /tenants/{domain}/categories/by-id/{id} (then list products with categoryId)
|
||||||
- Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id}
|
- Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id}
|
||||||
- Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id}
|
- Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id}
|
||||||
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
|
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
|
||||||
|
|||||||
+6
-3
@@ -40,7 +40,8 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the
|
|||||||
| Path | Source |
|
| Path | Source |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | Homepage + static image slots + store specials + latest blogs |
|
| `/` | Homepage + static image slots + store specials + latest blogs |
|
||||||
| `/products` | Catalog / store items list |
|
| `/products` | Catalog / store items list + search (`?q=`) |
|
||||||
|
| `/products/category/[id]/[slug]` | Category page via `GET .../categories/by-id/{id}` + products filtered by `categoryId` |
|
||||||
| `/products/[id]/[slug]` | Product detail via `GET .../products/by-id/{id}` |
|
| `/products/[id]/[slug]` | Product detail via `GET .../products/by-id/{id}` |
|
||||||
| `/blog` | Blog list |
|
| `/blog` | Blog list |
|
||||||
| `/blog/[id]/[slug]` | Blog detail via `GET .../blogs/by-id/{id}` |
|
| `/blog/[id]/[slug]` | Blog detail via `GET .../blogs/by-id/{id}` |
|
||||||
@@ -68,7 +69,7 @@ Products list/search: prefer store-items; if empty, fall back to `GET /products`
|
|||||||
|
|
||||||
## Catalog
|
## Catalog
|
||||||
|
|
||||||
- Mega menu: product categories **level 1 and 2** (`GET /tenants/{domain}/categories?entityType=product`). Links: `/products?categoryId={id}`.
|
- Mega menu: full product category tree (unwraps shallow roots like «کالاها»); nested level-4+ links (`GET /tenants/{domain}/categories?entityType=product`). Links: `/products/category/{id}/{nameFaSlug}`.
|
||||||
- Instant search: header input searches **products** when query length **> 2** (`/api/search` → `store-items?name=`). Enter or “مشاهده همه” → `/products?q=`.
|
- Instant search: header input searches **products** when query length **> 2** (`/api/search` → `store-items?name=`). Enter or “مشاهده همه” → `/products?q=`.
|
||||||
- Product pages are display-only (price, gallery, description, comments). No cart / add-to-cart.
|
- Product pages are display-only (price, gallery, description, comments). No cart / add-to-cart.
|
||||||
- Details gallery: product `images[].url` + thumbnail. Click = lightbox, hover = zoom.
|
- Details gallery: product `images[].url` + thumbnail. Click = lightbox, hover = zoom.
|
||||||
@@ -98,8 +99,10 @@ Products list/search: prefer store-items; if empty, fall back to `GET /products`
|
|||||||
- After deploy: business dashboard → Website → Settings → Sync sitemap config.
|
- After deploy: business dashboard → Website → Settings → Sync sitemap config.
|
||||||
- Detail URLs (Meshkee defaults — omit `templates` unless customized):
|
- Detail URLs (Meshkee defaults — omit `templates` unless customized):
|
||||||
- Product: `/products/{id}/{nameFaSlug}` — slugify `nameFa` (Farsi kept, spaces → `-`); resolve with `products/by-id/{id}`. Never use legacy DB slug in public URLs.
|
- Product: `/products/{id}/{nameFaSlug}` — slugify `nameFa` (Farsi kept, spaces → `-`); resolve with `products/by-id/{id}`. Never use legacy DB slug in public URLs.
|
||||||
|
- Category: `/products/category/{id}/{nameFaSlug}` — `categories/by-id/{id}` then list products with that `categoryId`.
|
||||||
- Blog: `/blog/{id}/{titleSlug}` — `blogs/by-id/{id}`.
|
- Blog: `/blog/{id}/{titleSlug}` — `blogs/by-id/{id}`.
|
||||||
- Legacy `/products/{slug}` and `/blog/{slug}` redirect to the canonical `{id}/{slug}` form.
|
- Legacy `/products?categoryId=` and slug-only detail URLs redirect to the canonical `{id}/{slug}` form.
|
||||||
|
- Live sitemap is an index: `/sitemap.xml` → `/sitemap-main.xml` + `/sitemap-products.xml` (Meshkee/nginx; do not ship static files).
|
||||||
- Every public page has unique title/description; product/blog prefer CMS fields.
|
- Every public page has unique title/description; product/blog prefer CMS fields.
|
||||||
|
|
||||||
## Original business data (do not invent replacements)
|
## Original business data (do not invent replacements)
|
||||||
|
|||||||
@@ -149,9 +149,11 @@ export default async function ProductDetailsPage({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-2 text-[13px] ${inStock ? "text-emerald-600" : "text-brand"}`}>
|
{price != null ? (
|
||||||
{inStock ? "موجود در فروشگاه" : "ناموجود"}
|
<p className={`mt-2 text-[13px] ${inStock ? "text-emerald-600" : "text-brand"}`}>
|
||||||
</p>
|
{inStock ? "موجود در فروشگاه" : "ناموجود"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{variants.length > 1 ? (
|
{variants.length > 1 ? (
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { PageHero } from "@/components/layout/PageHero";
|
||||||
|
import { ProductsListing } from "@/components/products/ProductsListing";
|
||||||
|
import {
|
||||||
|
categoryHref,
|
||||||
|
categoryNameSlug,
|
||||||
|
fetchCategoryById,
|
||||||
|
} from "@/lib/categories";
|
||||||
|
import { loadCategoryProducts } from "@/lib/products-list";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const category = await fetchCategoryById(id);
|
||||||
|
if (!category) return { title: "دستهبندی" };
|
||||||
|
const title = category.nameFa?.trim() || category.name?.trim() || "دستهبندی";
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: category.description?.trim() || `محصولات دسته ${title} در فروشگاه برادران برقعی`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryProductsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, slug } = await params;
|
||||||
|
const { page: pageParam } = await searchParams;
|
||||||
|
const page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
|
||||||
|
const category = await fetchCategoryById(id);
|
||||||
|
if (!category || category.isActive === false) notFound();
|
||||||
|
|
||||||
|
const title = category.nameFa?.trim() || category.name?.trim() || "دستهبندی";
|
||||||
|
const canonicalSlug = categoryNameSlug(title);
|
||||||
|
if (decodeURIComponent(slug) !== canonicalSlug) {
|
||||||
|
const href = categoryHref(category);
|
||||||
|
redirect(page > 1 ? `${href}?page=${page}` : href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { products, total, pageCount } = await loadCategoryProducts({
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
categoryId: String(category.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseHref = categoryHref(category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero
|
||||||
|
kicker="Category"
|
||||||
|
title={title}
|
||||||
|
image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
<section className="py-10 sm:py-14">
|
||||||
|
<Container>
|
||||||
|
<ProductsListing
|
||||||
|
products={products}
|
||||||
|
total={total}
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pageHref={(number) => (number > 1 ? `${baseHref}?page=${number}` : baseHref)}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-59
@@ -1,16 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { PageHero } from "@/components/layout/PageHero";
|
import { PageHero } from "@/components/layout/PageHero";
|
||||||
import { ProductCard } from "@/components/ui/ProductCard";
|
import { ProductsListing } from "@/components/products/ProductsListing";
|
||||||
import { getNavCategories } from "@/lib/categories";
|
import { categoryHref, fetchCategoryById } from "@/lib/categories";
|
||||||
import { faDigits } from "@/lib/format";
|
import { loadCategoryProducts } from "@/lib/products-list";
|
||||||
import {
|
|
||||||
catalogProductToCard,
|
|
||||||
fetchCatalogProducts,
|
|
||||||
fetchStoreItems,
|
|
||||||
variantToProduct,
|
|
||||||
} from "@/lib/store";
|
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
|
|
||||||
@@ -30,21 +24,21 @@ export default async function ProductsPage({
|
|||||||
const q = params.q?.trim();
|
const q = params.q?.trim();
|
||||||
const name = q && q.length > 2 ? q : undefined;
|
const name = q && q.length > 2 ? q : undefined;
|
||||||
|
|
||||||
const [storeResult, catalogResult, nav] = await Promise.all([
|
// Legacy ?categoryId= → canonical /products/category/{id}/{slug}
|
||||||
fetchStoreItems({ page, pageSize: 20, categoryId, name }),
|
if (categoryId) {
|
||||||
fetchCatalogProducts({ page, pageSize: 20, categoryId, name }),
|
const category = await fetchCategoryById(categoryId);
|
||||||
getNavCategories(),
|
if (category) {
|
||||||
]);
|
const href = categoryHref(category);
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (page > 1) query.set("page", String(page));
|
||||||
|
if (q) query.set("q", q);
|
||||||
|
const qs = query.toString();
|
||||||
|
redirect(qs ? `${href}?${qs}` : href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fromStore = storeResult.items.map(variantToProduct);
|
const { products, total, pageCount } = await loadCategoryProducts({ page, pageSize: 20, name });
|
||||||
const products = fromStore.length ? fromStore : catalogResult.items.map(catalogProductToCard);
|
const heroTitle = q ? `جستجو: ${q}` : "محصولات";
|
||||||
const total = fromStore.length ? storeResult.total : catalogResult.total;
|
|
||||||
const pageSize = fromStore.length ? storeResult.pageSize : catalogResult.pageSize;
|
|
||||||
const pageCount = Math.max(1, Math.ceil((total || products.length) / (pageSize || 20)));
|
|
||||||
const categoryTitle = nav
|
|
||||||
.flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))])
|
|
||||||
.find((item) => item.id === categoryId)?.title;
|
|
||||||
const heroTitle = categoryTitle || (q ? `جستجو: ${q}` : "محصولات");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -55,41 +49,19 @@ export default async function ProductsPage({
|
|||||||
/>
|
/>
|
||||||
<section className="py-10 sm:py-14">
|
<section className="py-10 sm:py-14">
|
||||||
<Container>
|
<Container>
|
||||||
{total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null}
|
<ProductsListing
|
||||||
|
products={products}
|
||||||
{products.length === 0 ? (
|
total={total}
|
||||||
<div className="rounded-2xl border border-line bg-white px-6 py-16 text-center">
|
page={page}
|
||||||
<p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p>
|
pageCount={pageCount}
|
||||||
<p className="mt-2 text-[13px] text-muted">بهزودی موجودی فروشگاه از پنل Meshkee اینجا دیده میشود.</p>
|
pageHref={(number) => {
|
||||||
</div>
|
const query = new URLSearchParams();
|
||||||
) : (
|
if (q) query.set("q", q);
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
if (number > 1) query.set("page", String(number));
|
||||||
{products.map((product) => (
|
const qs = query.toString();
|
||||||
<ProductCard key={product.id} product={product} />
|
return qs ? `/products?${qs}` : "/products";
|
||||||
))}
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{pageCount > 1 ? (
|
|
||||||
<div className="mt-8 flex justify-center gap-2">
|
|
||||||
{Array.from({ length: pageCount }, (_, index) => {
|
|
||||||
const number = index + 1;
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (categoryId) query.set("categoryId", categoryId);
|
|
||||||
if (q) query.set("q", q);
|
|
||||||
query.set("page", String(number));
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={number}
|
|
||||||
href={`/products?${query.toString()}`}
|
|
||||||
className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`}
|
|
||||||
>
|
|
||||||
{faDigits(number)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -32,17 +32,17 @@ export function Header({ categories = [] }: { categories?: NavCategory[] }) {
|
|||||||
<Placeholder src={site.logo} alt={site.name} className="h-14 w-auto object-contain" />
|
<Placeholder src={site.logo} alt={site.name} className="h-14 w-auto object-contain" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="mx-2 hidden items-center lg:flex">
|
<nav className="mx-2 hidden h-14 items-center lg:flex">
|
||||||
<Link href="/" className="px-3 py-2 text-[14px] hover:text-brand">
|
<Link href="/" className="inline-flex h-full items-center px-3 text-[14px] hover:text-brand">
|
||||||
خانه
|
خانه
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative flex h-full items-center"
|
||||||
onMouseEnter={() => setMegaOpen(true)}
|
onMouseEnter={() => setMegaOpen(true)}
|
||||||
onMouseLeave={() => setMegaOpen(false)}
|
onMouseLeave={() => setMegaOpen(false)}
|
||||||
>
|
>
|
||||||
<Link href="/products" className="flex items-center gap-1 px-3 py-2 text-[14px] hover:text-brand">
|
<Link href="/products" className="inline-flex h-full items-center gap-1 px-3 text-[14px] hover:text-brand">
|
||||||
محصولات
|
محصولات
|
||||||
<ChevronDown className="size-3.5" />
|
<ChevronDown className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -52,7 +52,11 @@ export function Header({ categories = [] }: { categories?: NavCategory[] }) {
|
|||||||
{navLinks
|
{navLinks
|
||||||
.filter((link) => link.href !== "/" && link.href !== "/products")
|
.filter((link) => link.href !== "/" && link.href !== "/products")
|
||||||
.map((link) => (
|
.map((link) => (
|
||||||
<Link key={link.href} href={link.href} className="px-3 py-2 text-[14px] hover:text-brand">
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="inline-flex h-full items-center px-3 text-[14px] hover:text-brand"
|
||||||
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -107,14 +111,25 @@ export function Header({ categories = [] }: { categories?: NavCategory[] }) {
|
|||||||
{category.title}
|
{category.title}
|
||||||
</Link>
|
</Link>
|
||||||
{category.children.map((child) => (
|
{category.children.map((child) => (
|
||||||
<Link
|
<div key={child.id} className="ps-2">
|
||||||
key={child.id}
|
<Link
|
||||||
href={child.href}
|
href={child.href}
|
||||||
className="block py-1 text-[12px] text-muted"
|
className="block py-1 text-[12px] font-medium text-ink"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{child.title}
|
{child.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
{child.children.map((leaf) => (
|
||||||
|
<Link
|
||||||
|
key={leaf.id}
|
||||||
|
href={leaf.href}
|
||||||
|
className="block py-1 ps-3 text-[12px] text-muted"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{leaf.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,44 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { toMegaSections, type NavCategory } from "@/lib/categories";
|
||||||
import type { NavCategory } from "@/lib/categories";
|
|
||||||
|
function CategoryLinks({ items, depth = 0 }: { items: NavCategory[]; depth?: number }) {
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={depth === 0 ? "space-y-1" : "mt-1 space-y-1 border-r border-line/70 pr-2 mr-1"}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={`block rounded-lg px-2 py-1 transition hover:bg-paper hover:text-brand ${
|
||||||
|
depth === 0 ? "text-[12.5px] text-muted" : "text-[12px] text-muted/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
{item.children.length ? <CategoryLinks items={item.children} depth={depth + 1} /> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MegaMenu({ open, categories }: { open: boolean; categories: NavCategory[] }) {
|
export function MegaMenu({ open, categories }: { open: boolean; categories: NavCategory[] }) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const sections = toMegaSections(categories);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-full right-0 z-50 mt-1 w-[min(920px,calc(100vw-2rem))] border border-line bg-white p-5 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
<div className="absolute top-full right-0 z-50 -mt-1 pt-3">
|
||||||
{categories.length === 0 ? (
|
<div className="max-h-[min(70vh,640px)] w-[min(920px,calc(100vw-2rem))] overflow-y-auto border border-line bg-white p-5 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
||||||
<p className="text-[13px] text-muted">دستهبندیها بهزودی اضافه میشوند.</p>
|
{sections.length === 0 ? (
|
||||||
) : (
|
<p className="text-[13px] text-muted">دستهبندیها بهزودی اضافه میشوند.</p>
|
||||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4">
|
) : (
|
||||||
{categories.map((category) => (
|
<div className="columns-2 gap-6 md:columns-3 lg:columns-4">
|
||||||
<div key={category.id}>
|
{sections.map((category) => (
|
||||||
<Link href={category.href} className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-ink hover:text-brand">
|
<div key={category.id} className="mb-6 break-inside-avoid">
|
||||||
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-paper">
|
<Link
|
||||||
<Placeholder src={category.image || "/placeholders/category.svg"} className="size-full object-cover" />
|
href={category.href}
|
||||||
</span>
|
className="mb-2 block text-[13.5px] font-semibold text-ink hover:text-brand"
|
||||||
{category.title}
|
>
|
||||||
</Link>
|
{category.title}
|
||||||
{category.children.length ? (
|
</Link>
|
||||||
<ul className="space-y-1.5 pr-1">
|
<CategoryLinks items={category.children} />
|
||||||
{category.children.map((child) => (
|
</div>
|
||||||
<li key={child.id}>
|
))}
|
||||||
<Link
|
</div>
|
||||||
href={child.href}
|
)}
|
||||||
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-paper hover:text-brand"
|
</div>
|
||||||
>
|
|
||||||
{child.title}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
|
import { faDigits } from "@/lib/format";
|
||||||
|
import type { Product } from "@/lib/data";
|
||||||
|
|
||||||
|
export function ProductsListing({
|
||||||
|
products,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
pageHref,
|
||||||
|
}: {
|
||||||
|
products: Product[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
pageHref: (page: number) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null}
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-line bg-white px-6 py-16 text-center">
|
||||||
|
<p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p>
|
||||||
|
<p className="mt-2 text-[13px] text-muted">بهزودی موجودی فروشگاه از پنل Meshkee اینجا دیده میشود.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{products.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className="mt-8 flex justify-center gap-2">
|
||||||
|
{Array.from({ length: pageCount }, (_, index) => {
|
||||||
|
const number = index + 1;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={number}
|
||||||
|
href={pageHref(number)}
|
||||||
|
className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`}
|
||||||
|
>
|
||||||
|
{faDigits(number)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+83
-16
@@ -1,4 +1,5 @@
|
|||||||
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||||
|
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
|
||||||
|
|
||||||
export type ApiCategory = {
|
export type ApiCategory = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6,6 +7,7 @@ export type ApiCategory = {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
nameFa?: string | null;
|
nameFa?: string | null;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
sortOrder?: number | null;
|
sortOrder?: number | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
};
|
};
|
||||||
@@ -15,15 +17,20 @@ export type NavCategory = {
|
|||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
children: { id: string; title: string; href: string }[];
|
children: NavCategory[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function titleOf(category: ApiCategory) {
|
function titleOf(category: Pick<ApiCategory, "nameFa" | "name" | "id">) {
|
||||||
return category.nameFa?.trim() || category.name?.trim() || "دستهبندی";
|
return category.nameFa?.trim() || category.name?.trim() || String(category.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hrefOf(category: ApiCategory) {
|
/** Public category URL: /products/category/{id}/{nameFaSlug} */
|
||||||
return `/products?categoryId=${encodeURIComponent(category.id)}`;
|
export function categoryHref(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
|
||||||
|
return detailPath("/products/category", category.id, titleOf(category));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryNameSlug(title: string) {
|
||||||
|
return slugifyForUrl(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProductCategories() {
|
export async function fetchProductCategories() {
|
||||||
@@ -33,6 +40,14 @@ export async function fetchProductCategories() {
|
|||||||
return (data.items ?? []).filter((category) => category.isActive !== false);
|
return (data.items ?? []).filter((category) => category.isActive !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchCategoryById(categoryId: string) {
|
||||||
|
const data = await meshkeeGet<{ category?: ApiCategory }>(
|
||||||
|
tenantPath(`/categories/by-id/${encodeURIComponent(categoryId)}`),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return data.category ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCategoryTree(flat: ApiCategory[]): NavCategory[] {
|
export function buildCategoryTree(flat: ApiCategory[]): NavCategory[] {
|
||||||
const byId = new Map(flat.map((category) => [String(category.id), category]));
|
const byId = new Map(flat.map((category) => [String(category.id), category]));
|
||||||
const children = new Map<string, ApiCategory[]>();
|
const children = new Map<string, ApiCategory[]>();
|
||||||
@@ -48,18 +63,70 @@ export function buildCategoryTree(flat: ApiCategory[]): NavCategory[] {
|
|||||||
const sort = (list: ApiCategory[]) =>
|
const sort = (list: ApiCategory[]) =>
|
||||||
[...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || titleOf(a).localeCompare(titleOf(b), "fa"));
|
[...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || titleOf(a).localeCompare(titleOf(b), "fa"));
|
||||||
|
|
||||||
return sort(children.get("root") ?? []).map((root) => ({
|
const build = (category: ApiCategory): NavCategory => ({
|
||||||
id: String(root.id),
|
id: String(category.id),
|
||||||
title: titleOf(root),
|
title: titleOf(category),
|
||||||
href: hrefOf(root),
|
href: categoryHref(category),
|
||||||
children: sort(children.get(String(root.id)) ?? []).map((child) => ({
|
children: sort(children.get(String(category.id)) ?? []).map(build),
|
||||||
id: String(child.id),
|
});
|
||||||
title: titleOf(child),
|
|
||||||
href: hrefOf(child),
|
return sort(children.get("root") ?? []).map(build);
|
||||||
})),
|
}
|
||||||
}));
|
|
||||||
|
/** Skip single-child wrapper roots (e.g. کالاها → لوازم…) so mega menu starts at useful columns. */
|
||||||
|
export function unwrapNavRoots(nodes: NavCategory[]): NavCategory[] {
|
||||||
|
let current = nodes;
|
||||||
|
while (current.length === 1 && current[0].children.length > 0) {
|
||||||
|
current = current[0].children;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenNavCategories(nodes: NavCategory[]): NavCategory[] {
|
||||||
|
const result: NavCategory[] = [];
|
||||||
|
const walk = (list: NavCategory[]) => {
|
||||||
|
for (const node of list) {
|
||||||
|
result.push(node);
|
||||||
|
if (node.children.length) walk(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(nodes);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNavCategory(nodes: NavCategory[], id?: string | null) {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return flattenNavCategories(nodes).find((item) => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten one level for the mega menu: nested groups become sibling sections
|
||||||
|
* so CSS columns can pack them evenly.
|
||||||
|
*/
|
||||||
|
export function toMegaSections(categories: NavCategory[]): NavCategory[] {
|
||||||
|
const sections: NavCategory[] = [];
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const leaves = category.children.filter((child) => child.children.length === 0);
|
||||||
|
const branches = category.children.filter((child) => child.children.length > 0);
|
||||||
|
|
||||||
|
if (!branches.length) {
|
||||||
|
sections.push(category);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaves.length) {
|
||||||
|
sections.push({ ...category, children: leaves });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const branch of branches) {
|
||||||
|
sections.push(branch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNavCategories() {
|
export async function getNavCategories() {
|
||||||
return buildCategoryTree(await fetchProductCategories());
|
return unwrapNavRoots(buildCategoryTree(await fetchProductCategories()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Product } from "@/lib/data";
|
||||||
|
import {
|
||||||
|
catalogProductToCard,
|
||||||
|
fetchCatalogProducts,
|
||||||
|
fetchStoreItems,
|
||||||
|
variantToProduct,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
export async function loadCategoryProducts(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
const page = params.page ?? 1;
|
||||||
|
const pageSize = params.pageSize ?? 20;
|
||||||
|
const [storeResult, catalogResult] = await Promise.all([
|
||||||
|
fetchStoreItems({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
categoryId: params.categoryId,
|
||||||
|
name: params.name,
|
||||||
|
}),
|
||||||
|
fetchCatalogProducts({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
categoryId: params.categoryId,
|
||||||
|
name: params.name,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fromStore = storeResult.items.map(variantToProduct);
|
||||||
|
const products: Product[] = fromStore.length
|
||||||
|
? fromStore
|
||||||
|
: catalogResult.items.map(catalogProductToCard);
|
||||||
|
const total = fromStore.length ? storeResult.total : catalogResult.total;
|
||||||
|
const resolvedPageSize = fromStore.length ? storeResult.pageSize : catalogResult.pageSize;
|
||||||
|
const pageCount = Math.max(1, Math.ceil((total || products.length) / (resolvedPageSize || pageSize)));
|
||||||
|
|
||||||
|
return { products, total, pageCount, pageSize: resolvedPageSize || pageSize };
|
||||||
|
}
|
||||||
+1
-1
@@ -255,7 +255,7 @@ export function catalogProductToCard(product: CatalogProduct): Product {
|
|||||||
id: String(product.id),
|
id: String(product.id),
|
||||||
href: productHref(product),
|
href: productHref(product),
|
||||||
title: catalogTitle(product),
|
title: catalogTitle(product),
|
||||||
brand: brandName(product) || product.categoryNameFa || "",
|
brand: brandName(product),
|
||||||
price: price ?? undefined,
|
price: price ?? undefined,
|
||||||
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
|
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
|
||||||
inStock: product.store?.inStock !== false,
|
inStock: product.store?.inStock !== false,
|
||||||
|
|||||||
Reference in New Issue
Block a user