From 386787b4b5a4d00312eec39912d686b63402e71a Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 19 Aug 2026 23:20:19 +0330 Subject: [PATCH] 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 --- src/app/globals.css | 22 +++ src/app/user-products/[slug]/page.tsx | 8 +- src/app/user-products/page.tsx | 35 ++-- src/components/FilterSelect.tsx | 36 +++++ src/components/Header.tsx | 207 +++++++++++++++++------- src/components/HeaderContainer.tsx | 8 +- src/components/ProductTabs.tsx | 63 ++------ src/components/TechnicalValuesTable.tsx | 55 +++++++ src/lib/api/categories.ts | 82 ++++++++-- src/lib/api/product-fields.ts | 47 ++++++ src/lib/api/types.ts | 1 + src/lib/product-technical-values.ts | 124 ++++++++++++++ 12 files changed, 545 insertions(+), 143 deletions(-) create mode 100644 src/components/FilterSelect.tsx create mode 100644 src/components/TechnicalValuesTable.tsx create mode 100644 src/lib/api/product-fields.ts create mode 100644 src/lib/product-technical-values.ts diff --git a/src/app/globals.css b/src/app/globals.css index 2544a37..c985b09 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -185,6 +185,28 @@ img { border-color: var(--brand); } +.field-select-wrap { + position: relative; +} + +.field-select-wrap::after { + content: "▾"; + position: absolute; + inset-inline-end: 0.9rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--muted); + font-size: 0.75rem; + line-height: 1; +} + +.field-select { + appearance: none; + padding-inline-end: 2.25rem; + cursor: pointer; +} + .btn-secondary { display: inline-flex; align-items: center; diff --git a/src/app/user-products/[slug]/page.tsx b/src/app/user-products/[slug]/page.tsx index 5df4549..e8a7f60 100644 --- a/src/app/user-products/[slug]/page.tsx +++ b/src/app/user-products/[slug]/page.tsx @@ -7,6 +7,7 @@ import { ProductTabs } from "@/components/ProductTabs"; import { Products } from "@/components/Products"; import { SiteShell } from "@/components/SiteShell"; import { site } from "@/data/home"; +import { getCategoryVariationFieldMap } from "@/lib/api/product-fields"; import { formatCondition, formatPrice, @@ -48,6 +49,10 @@ export default async function UserProductDetailPage({ params }: PageProps) { const country = product.countryNameFa || product.countryName || null; const description = product.descriptionFa || product.description || product.descriptionEn; + const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null; + const technicalFieldLabels = await getCategoryVariationFieldMap( + product.categoryId, + ); const similarResult = product.categoryId ? await listUserProducts({ @@ -142,8 +147,9 @@ export default async function UserProductDetailPage({ params }: PageProps) { {similarProducts.length > 0 ? ( diff --git a/src/app/user-products/page.tsx b/src/app/user-products/page.tsx index 4974c9f..f1c29d7 100644 --- a/src/app/user-products/page.tsx +++ b/src/app/user-products/page.tsx @@ -2,8 +2,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { SiteShell } from "@/components/SiteShell"; import { Products } from "@/components/Products"; -import { CategoryTreeSelect } from "@/components/CategoryTreeSelect"; -import { listCategories } from "@/lib/api/categories"; +import { FilterSelect } from "@/components/FilterSelect"; +import { + categoryLabel, + listCategories, + listProductSubcategories, +} from "@/lib/api/categories"; import { listUserProducts } from "@/lib/api/user-products"; import type { UserProductCondition } from "@/lib/api/types"; @@ -49,6 +53,11 @@ export default async function UserProductsPage({ searchParams }: PageProps) { listCategories("product"), ]); + const subcategories = listProductSubcategories(categories).map((category) => ({ + value: category.id, + label: categoryLabel(category), + })); + return (
-
+
- - + options={conditions.map((item) => ({ + value: item.value, + label: item.label, + }))} + /> {open ? ( -
+
همه محصولات - {categories.map((category) => ( - - {category.label} - - ))} + {groups.map((group) => { + const isExpanded = expandedGroupId === group.id; + return ( +
+ + {isExpanded ? ( +
+ + همه {group.label} + + {group.items.map((item) => ( + + {item.label} + + ))} +
+ ) : null} +
+ ); + })}
) : null}
@@ -104,62 +168,89 @@ function ProductsNavDropdown({ } return ( -
setOpen(true)} - onMouseLeave={() => setOpen(false)} - > - + +
{open ? (
- - همه محصولات - - {categories.length > 0 ? ( -
- ) : null} - {categories.map((category) => ( - - {category.label} - - ))} +
+
+
+

+ PRODUCT CATEGORIES +

+

+ دسته‌بندی ماشین‌آلات موجود در ماشینیفای +

+
+ + همه محصولات + +
+ +
+ {groups.map((group) => ( +
+ + {group.label} + + +
+ ))} +
+
) : null} -
+ ); } export function Header({ forceSolid = false, - productCategories = [], + productMegaMenu = [], logoUrl = null, }: HeaderProps) { const [open, setOpen] = useState(false); const [scrolled, setScrolled] = useState(forceSolid); - const hasProductDropdown = productCategories.length > 0; + const hasProductDropdown = productMegaMenu.length > 0; useEffect(() => { if (forceSolid) { @@ -184,7 +275,7 @@ export function Header({ return ( ); @@ -206,7 +297,7 @@ export function Header({ return ( setOpen(false)} /> diff --git a/src/components/HeaderContainer.tsx b/src/components/HeaderContainer.tsx index 29b4b66..b6b555c 100644 --- a/src/components/HeaderContainer.tsx +++ b/src/components/HeaderContainer.tsx @@ -1,5 +1,5 @@ import { getBusinessInfo } from "@/lib/api/business"; -import { getLevel2ProductCategories } from "@/lib/api/categories"; +import { getProductMegaMenuGroups } from "@/lib/api/categories"; import { Header } from "@/components/Header"; type HeaderContainerProps = { @@ -7,15 +7,15 @@ type HeaderContainerProps = { }; export async function HeaderContainer({ forceSolid = false }: HeaderContainerProps) { - const [productCategories, businessInfo] = await Promise.all([ - getLevel2ProductCategories(), + const [productMegaMenu, businessInfo] = await Promise.all([ + getProductMegaMenuGroups(), getBusinessInfo(), ]); return (
); diff --git a/src/components/ProductTabs.tsx b/src/components/ProductTabs.tsx index 10373a0..3a5a382 100644 --- a/src/components/ProductTabs.tsx +++ b/src/components/ProductTabs.tsx @@ -1,13 +1,15 @@ "use client"; -import { useMemo, useState, type ReactNode } from "react"; +import { useMemo, useState } from "react"; import { site } from "@/data/home"; +import { TechnicalValuesTable } from "@/components/TechnicalValuesTable"; type ProductTabsProps = { description?: string | null; technicalNotes?: string | null; deliveryNotes?: string | null; technicalValues?: unknown; + technicalFieldLabels?: Record; }; type TabId = "description" | "technical" | "comments"; @@ -21,62 +23,12 @@ const tabMeta: Record< comments: { label: "نظرات", labelEn: "Comments" }, }; -function renderTechnicalValues(value: unknown): ReactNode { - if (value === null || value === undefined) return null; - - if (typeof value === "string") { - return ( -

- {value} -

- ); - } - - if (Array.isArray(value)) { - if (value.length === 0) return null; - return ( -
    - {value.map((item, index) => ( -
  • - • - {typeof item === "object" ? JSON.stringify(item) : String(item)} -
  • - ))} -
- ); - } - - if (typeof value === "object") { - const entries = Object.entries(value as Record); - if (entries.length === 0) return null; - return ( -
- - - {entries.map(([key, val]) => ( - - - - - ))} - -
- {key} - - {val === null || val === undefined ? "—" : String(val)} -
-
- ); - } - - return

{String(value)}

; -} - export function ProductTabs({ description, technicalNotes, deliveryNotes, technicalValues, + technicalFieldLabels = {}, }: ProductTabsProps) { const tabs = useMemo(() => { const items: { id: TabId; label: string; labelEn: string }[] = []; @@ -168,7 +120,12 @@ export function ProductTabs({
) : null} - {renderTechnicalValues(technicalValues)} + {technicalValues ? ( + + ) : null} {deliveryNotes ? (
diff --git a/src/components/TechnicalValuesTable.tsx b/src/components/TechnicalValuesTable.tsx new file mode 100644 index 0000000..4dcaaae --- /dev/null +++ b/src/components/TechnicalValuesTable.tsx @@ -0,0 +1,55 @@ +import { + normalizeTechnicalValueRows, + type TechnicalValueRow, +} from "@/lib/product-technical-values"; + +type TechnicalValuesTableProps = { + value: unknown; + fieldLabels?: Record; +}; + +export function TechnicalValuesTable({ + value, + fieldLabels = {}, +}: TechnicalValuesTableProps) { + const rows = normalizeTechnicalValueRows(value, fieldLabels); + if (rows.length === 0) return null; + + return ( +
+

+ SPECIFICATIONS +

+
+ + + + + + + + + {rows.map((row) => ( + + ))} + +
+ مشخصه + + مقدار +
+
+
+ ); +} + +function TechnicalValueTableRow({ row }: { row: TechnicalValueRow }) { + return ( + + + {row.label || "—"} + + {row.value} + + ); +} diff --git a/src/lib/api/categories.ts b/src/lib/api/categories.ts index 7c6e41e..d354406 100644 --- a/src/lib/api/categories.ts +++ b/src/lib/api/categories.ts @@ -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; +}; + +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)); } diff --git a/src/lib/api/product-fields.ts b/src/lib/api/product-fields.ts new file mode 100644 index 0000000..b669630 --- /dev/null +++ b/src/lib/api/product-fields.ts @@ -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, +) { + 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 {}; +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index cbdcef4..2f33934 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -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; diff --git a/src/lib/product-technical-values.ts b/src/lib/product-technical-values.ts new file mode 100644 index 0000000..7380f7e --- /dev/null +++ b/src/lib/product-technical-values.ts @@ -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) { + 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 = {}, +): 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) + .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 }] : []; +}