From f078cdad0e8bdcb8b58061fc8f34579754497dcf Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 23 Aug 2026 00:34:23 +0330 Subject: [PATCH] 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 --- Ai-data/prompt-seo.txt | 2 + CONTEXT.md | 9 +- app/products/[id]/[slug]/page.tsx | 8 +- app/products/category/[id]/[slug]/page.tsx | 79 +++++++++++++++++ app/products/page.tsx | 90 +++++++------------- components/layout/Header.tsx | 41 ++++++--- components/layout/MegaMenu.tsx | 78 +++++++++-------- components/products/ProductsListing.tsx | 54 ++++++++++++ lib/categories.ts | 99 ++++++++++++++++++---- lib/products-list.ts | 41 +++++++++ lib/store.ts | 2 +- 11 files changed, 375 insertions(+), 128 deletions(-) create mode 100644 app/products/category/[id]/[slug]/page.tsx create mode 100644 components/products/ProductsListing.tsx create mode 100644 lib/products-list.ts diff --git a/Ai-data/prompt-seo.txt b/Ai-data/prompt-seo.txt index 58cb317..54a55b8 100644 --- a/Ai-data/prompt-seo.txt +++ b/Ai-data/prompt-seo.txt @@ -1,9 +1,11 @@ ### Sitemap + robots.txt - 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. - Include public static routes automatically; exclude auth/checkout/cart/admin paths. - Canonical detail URLs (Meshkee defaults — omit templates unless customized): - 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} - 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. diff --git a/CONTEXT.md b/CONTEXT.md index 11e9af3..60c5eef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,7 +40,8 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the | Path | Source | | --- | --- | | `/` | 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}` | | `/blog` | Blog list | | `/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 -- 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=`. - Product pages are display-only (price, gallery, description, comments). No cart / add-to-cart. - 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. - 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. + - Category: `/products/category/{id}/{nameFaSlug}` — `categories/by-id/{id}` then list products with that `categoryId`. - 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. ## Original business data (do not invent replacements) diff --git a/app/products/[id]/[slug]/page.tsx b/app/products/[id]/[slug]/page.tsx index fb7b1de..9ec4b69 100644 --- a/app/products/[id]/[slug]/page.tsx +++ b/app/products/[id]/[slug]/page.tsx @@ -149,9 +149,11 @@ export default async function ProductDetailsPage({ )}

-

- {inStock ? "موجود در فروشگاه" : "ناموجود"} -

+ {price != null ? ( +

+ {inStock ? "موجود در فروشگاه" : "ناموجود"} +

+ ) : null} {variants.length > 1 ? (
diff --git a/app/products/category/[id]/[slug]/page.tsx b/app/products/category/[id]/[slug]/page.tsx new file mode 100644 index 0000000..ec1e5a0 --- /dev/null +++ b/app/products/category/[id]/[slug]/page.tsx @@ -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 { + 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 ( + <> + +
+ + (number > 1 ? `${baseHref}?page=${number}` : baseHref)} + /> + +
+ + ); +} diff --git a/app/products/page.tsx b/app/products/page.tsx index 04c5642..420a852 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,16 +1,10 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import { redirect } from "next/navigation"; import { Container } from "@/components/Container"; import { PageHero } from "@/components/layout/PageHero"; -import { ProductCard } from "@/components/ui/ProductCard"; -import { getNavCategories } from "@/lib/categories"; -import { faDigits } from "@/lib/format"; -import { - catalogProductToCard, - fetchCatalogProducts, - fetchStoreItems, - variantToProduct, -} from "@/lib/store"; +import { ProductsListing } from "@/components/products/ProductsListing"; +import { categoryHref, fetchCategoryById } from "@/lib/categories"; +import { loadCategoryProducts } from "@/lib/products-list"; export const revalidate = 60; @@ -30,21 +24,21 @@ export default async function ProductsPage({ const q = params.q?.trim(); const name = q && q.length > 2 ? q : undefined; - const [storeResult, catalogResult, nav] = await Promise.all([ - fetchStoreItems({ page, pageSize: 20, categoryId, name }), - fetchCatalogProducts({ page, pageSize: 20, categoryId, name }), - getNavCategories(), - ]); + // Legacy ?categoryId= → canonical /products/category/{id}/{slug} + if (categoryId) { + const category = await fetchCategoryById(categoryId); + 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 = fromStore.length ? fromStore : catalogResult.items.map(catalogProductToCard); - 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}` : "محصولات"); + const { products, total, pageCount } = await loadCategoryProducts({ page, pageSize: 20, name }); + const heroTitle = q ? `جستجو: ${q}` : "محصولات"; return ( <> @@ -55,41 +49,19 @@ export default async function ProductsPage({ />
- {total ?

{faDigits(total)} کالا

: null} - - {products.length === 0 ? ( -
-

محصولی برای نمایش وجود ندارد

-

به‌زودی موجودی فروشگاه از پنل Meshkee اینجا دیده می‌شود.

-
- ) : ( -
- {products.map((product) => ( - - ))} -
- )} - - {pageCount > 1 ? ( -
- {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 ( - - {faDigits(number)} - - ); - })} -
- ) : null} + { + const query = new URLSearchParams(); + if (q) query.set("q", q); + if (number > 1) query.set("page", String(number)); + const qs = query.toString(); + return qs ? `/products?${qs}` : "/products"; + }} + />
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index c4698ff..968bf13 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -32,17 +32,17 @@ export function Header({ categories = [] }: { categories?: NavCategory[] }) { -