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:
co-authored by
Cursor
parent
184d2f1ea4
commit
386787b4b5
+69
-13
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }] : [];
|
||||
}
|
||||
Reference in New Issue
Block a user