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
+83
-16
@@ -1,4 +1,5 @@
|
||||
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
|
||||
|
||||
export type ApiCategory = {
|
||||
id: string;
|
||||
@@ -6,6 +7,7 @@ export type ApiCategory = {
|
||||
name?: string | null;
|
||||
nameFa?: string | null;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
sortOrder?: number | null;
|
||||
isActive?: boolean;
|
||||
};
|
||||
@@ -15,15 +17,20 @@ export type NavCategory = {
|
||||
title: string;
|
||||
href: string;
|
||||
image?: string;
|
||||
children: { id: string; title: string; href: string }[];
|
||||
children: NavCategory[];
|
||||
};
|
||||
|
||||
function titleOf(category: ApiCategory) {
|
||||
return category.nameFa?.trim() || category.name?.trim() || "دستهبندی";
|
||||
function titleOf(category: Pick<ApiCategory, "nameFa" | "name" | "id">) {
|
||||
return category.nameFa?.trim() || category.name?.trim() || String(category.id);
|
||||
}
|
||||
|
||||
function hrefOf(category: ApiCategory) {
|
||||
return `/products?categoryId=${encodeURIComponent(category.id)}`;
|
||||
/** Public category URL: /products/category/{id}/{nameFaSlug} */
|
||||
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() {
|
||||
@@ -33,6 +40,14 @@ export async function fetchProductCategories() {
|
||||
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[] {
|
||||
const byId = new Map(flat.map((category) => [String(category.id), category]));
|
||||
const children = new Map<string, ApiCategory[]>();
|
||||
@@ -48,18 +63,70 @@ export function buildCategoryTree(flat: ApiCategory[]): NavCategory[] {
|
||||
const sort = (list: ApiCategory[]) =>
|
||||
[...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || titleOf(a).localeCompare(titleOf(b), "fa"));
|
||||
|
||||
return sort(children.get("root") ?? []).map((root) => ({
|
||||
id: String(root.id),
|
||||
title: titleOf(root),
|
||||
href: hrefOf(root),
|
||||
children: sort(children.get(String(root.id)) ?? []).map((child) => ({
|
||||
id: String(child.id),
|
||||
title: titleOf(child),
|
||||
href: hrefOf(child),
|
||||
})),
|
||||
}));
|
||||
const build = (category: ApiCategory): NavCategory => ({
|
||||
id: String(category.id),
|
||||
title: titleOf(category),
|
||||
href: categoryHref(category),
|
||||
children: sort(children.get(String(category.id)) ?? []).map(build),
|
||||
});
|
||||
|
||||
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() {
|
||||
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),
|
||||
href: productHref(product),
|
||||
title: catalogTitle(product),
|
||||
brand: brandName(product) || product.categoryNameFa || "",
|
||||
brand: brandName(product),
|
||||
price: price ?? undefined,
|
||||
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
|
||||
inStock: product.store?.inStock !== false,
|
||||
|
||||
Reference in New Issue
Block a user