Add mega menu, filter selects, and technical specs table.

Improve navigation with grouped category mega menu, polish product filters, and render technical values as a table instead of raw JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 23:20:19 +03:30
co-authored by Cursor
parent 184d2f1ea4
commit 386787b4b5
12 changed files with 545 additions and 143 deletions
+69 -13
View File
@@ -53,26 +53,82 @@ export function listRootProductCategories(categories: Category[]) {
return buildCategoryTree(categories).filter((category) => category.isActive);
}
export function listLevel2ProductCategories(categories: Category[]) {
return listRootProductCategories(categories).flatMap((root) =>
root.children.filter((category) => category.isActive),
/** Subcategories under intermediate nodes such as «صنعت سیم و کابل». */
export function listProductSubcategories(categories: Category[]) {
const items: CategoryTreeNode[] = [];
for (const root of listRootProductCategories(categories)) {
for (const branch of root.children) {
if (!branch.isActive) continue;
for (const subcategory of branch.children) {
if (subcategory.isActive) items.push(subcategory);
}
}
}
items.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
categoryLabel(a).localeCompare(categoryLabel(b), "fa"),
);
return items;
}
/** @deprecated Use listProductSubcategories — kept for naming compatibility. */
export function listLevel2ProductCategories(categories: Category[]) {
return listProductSubcategories(categories);
}
export function mapCategoriesToNavItems(categories: CategoryTreeNode[]) {
return categories.map((category) => ({
id: category.id,
label: categoryLabel(category),
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
}));
}
export type ProductMegaMenuGroup = {
id: string;
label: string;
href: string;
items: ReturnType<typeof mapCategoriesToNavItems>;
};
export function listProductMegaMenuGroups(categories: Category[]): ProductMegaMenuGroup[] {
const groups: ProductMegaMenuGroup[] = [];
for (const root of listRootProductCategories(categories)) {
for (const branch of root.children) {
if (!branch.isActive) continue;
const items = branch.children.filter((child) => child.isActive);
if (items.length === 0) continue;
groups.push({
id: branch.id,
label: categoryLabel(branch),
href: `/user-products?categoryId=${encodeURIComponent(branch.id)}`,
items: mapCategoriesToNavItems(items),
});
}
}
groups.sort((a, b) => a.label.localeCompare(b.label, "fa"));
return groups;
}
export async function getProductMegaMenuGroups() {
const categories = await listCategories("product");
return listProductMegaMenuGroups(categories);
}
export async function getRootProductCategories() {
const categories = await listCategories("product");
return listRootProductCategories(categories).map((category) => ({
id: category.id,
label: categoryLabel(category),
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
}));
return mapCategoriesToNavItems(listRootProductCategories(categories));
}
export async function getLevel2ProductCategories() {
const categories = await listCategories("product");
return listLevel2ProductCategories(categories).map((category) => ({
id: category.id,
label: categoryLabel(category),
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
}));
return mapCategoriesToNavItems(listProductSubcategories(categories));
}
+47
View File
@@ -0,0 +1,47 @@
import { apiGet } from "./client";
import { findCategoryById, listCategories } from "./categories";
export type CategoryVariationField = {
id: string;
name: string;
nameFa: string | null;
sortOrder?: number;
};
function variationFieldLabel(
field: Pick<CategoryVariationField, "nameFa" | "name">,
) {
return field.nameFa || field.name;
}
export async function listCategoryVariationFields(categoryId: string) {
try {
const data = await apiGet<{ items: CategoryVariationField[] }>(
"/product-category-variation-fields",
{ categoryId },
);
return data.items ?? [];
} catch (error) {
console.error("listCategoryVariationFields failed", error);
return [] as CategoryVariationField[];
}
}
export async function getCategoryVariationFieldMap(categoryId?: string | null) {
if (!categoryId) return {};
const categories = await listCategories("product");
const category = findCategoryById(categories, categoryId);
const categoryIds = [categoryId, category?.parentId].filter(Boolean) as string[];
for (const id of categoryIds) {
const fields = await listCategoryVariationFields(id);
if (fields.length > 0) {
return Object.fromEntries(
fields.map((field) => [field.id, variationFieldLabel(field)]),
);
}
}
return {};
}
+1
View File
@@ -76,6 +76,7 @@ export type UserProduct = UserProductListItem & {
countryId?: string | null;
cityId?: string | null;
countrySlug?: string | null;
deliveryNote?: string | null;
deliveryNotes?: string | null;
technicalNotes?: string | null;
technicalValues?: unknown;
+124
View File
@@ -0,0 +1,124 @@
export type TechnicalValueItem = {
fieldId?: string | number | null;
textValue?: string | null;
numberValue?: number | string | null;
value?: string | number | null;
fieldName?: string | null;
fieldNameFa?: string | null;
name?: string | null;
nameFa?: string | null;
label?: string | null;
field?: {
id?: string | number | null;
name?: string | null;
nameFa?: string | null;
label?: string | null;
} | null;
};
export type TechnicalValueRow = {
id: string;
label: string;
value: string;
};
function pickString(...values: Array<string | number | null | undefined>) {
for (const value of values) {
if (value === null || value === undefined) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function formatValue(value: unknown) {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "";
return String(value);
}
function parseJsonObject(value: string) {
const text = value.trim();
if (!text.startsWith("{") && !text.startsWith("[")) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
export function normalizeTechnicalValueRows(
value: unknown,
fieldLabels: Record<string, string> = {},
): TechnicalValueRow[] {
if (value === null || value === undefined) return [];
if (typeof value === "string") {
const parsed = parseJsonObject(value);
if (parsed !== null) return normalizeTechnicalValueRows(parsed, fieldLabels);
const text = value.trim();
return text ? [{ id: "text", label: "", value: text }] : [];
}
if (Array.isArray(value)) {
return value
.map((item, index) => {
if (typeof item === "string") {
const parsed = parseJsonObject(item);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return normalizeTechnicalValueRows([parsed], fieldLabels)[0] ?? null;
}
return {
id: `item-${index}`,
label: "",
value: item.trim(),
};
}
if (!item || typeof item !== "object") return null;
const field = item as TechnicalValueItem;
const fieldId = field.fieldId ?? field.field?.id;
const id = fieldId ? String(fieldId) : `item-${index}`;
const label =
pickString(
field.fieldNameFa,
field.fieldName,
field.nameFa,
field.name,
field.label,
field.field?.nameFa,
field.field?.name,
field.field?.label,
id ? fieldLabels[id] : undefined,
) || "";
const rowValue = pickString(
formatValue(field.textValue),
formatValue(field.numberValue),
formatValue(field.value),
);
if (!rowValue) return null;
return { id, label, value: rowValue };
})
.filter((row): row is TechnicalValueRow => Boolean(row));
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>)
.map(([key, val]) => ({
id: key,
label: fieldLabels[key] || key,
value: formatValue(val),
}))
.filter((row) => row.value);
}
const text = formatValue(value);
return text ? [{ id: "value", label: "", value: text }] : [];
}