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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
<ProductTabs
|
||||
description={description}
|
||||
technicalNotes={product.technicalNotes}
|
||||
deliveryNotes={product.deliveryNotes}
|
||||
deliveryNotes={deliveryNotes}
|
||||
technicalValues={product.technicalValues}
|
||||
technicalFieldLabels={technicalFieldLabels}
|
||||
/>
|
||||
|
||||
{similarProducts.length > 0 ? (
|
||||
|
||||
@@ -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 (
|
||||
<SiteShell
|
||||
banner={{
|
||||
@@ -61,7 +70,7 @@ export default async function UserProductsPage({ searchParams }: PageProps) {
|
||||
<section className="section">
|
||||
<div className="container-page">
|
||||
<form method="get" className="filter-shell mb-8 p-4 md:p-5">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.3fr_1fr_0.9fr_auto]">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_9.5rem_auto]">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q ?? ""}
|
||||
@@ -69,23 +78,21 @@ export default async function UserProductsPage({ searchParams }: PageProps) {
|
||||
className="field-control min-h-12 w-full"
|
||||
/>
|
||||
|
||||
<CategoryTreeSelect
|
||||
categories={categories}
|
||||
<FilterSelect
|
||||
name="categoryId"
|
||||
defaultValue={categoryId ?? ""}
|
||||
placeholder="فیلتر دستهبندی"
|
||||
options={[{ value: "", label: "همه دستهها" }, ...subcategories]}
|
||||
/>
|
||||
|
||||
<select
|
||||
<FilterSelect
|
||||
name="condition"
|
||||
defaultValue={condition ?? ""}
|
||||
className="field-control min-h-12 w-full appearance-none"
|
||||
>
|
||||
{conditions.map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={conditions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<button type="submit" className="btn-primary min-h-12 px-6">
|
||||
اعمال فیلتر
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
type FilterSelectProps = {
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
options: { value: string; label: string }[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FilterSelect({
|
||||
name,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
options,
|
||||
className,
|
||||
}: FilterSelectProps) {
|
||||
return (
|
||||
<div className={`field-select-wrap ${className ?? ""}`.trim()}>
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
className="field-control field-select min-h-12 w-full"
|
||||
>
|
||||
{placeholder ? (
|
||||
<option value="" disabled={Boolean(defaultValue)}>
|
||||
{placeholder}
|
||||
</option>
|
||||
) : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value || "all"} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+122
-31
@@ -10,9 +10,16 @@ export type NavProductCategory = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type NavProductMegaMenuGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
items: NavProductCategory[];
|
||||
};
|
||||
|
||||
type HeaderProps = {
|
||||
forceSolid?: boolean;
|
||||
productCategories?: NavProductCategory[];
|
||||
productMegaMenu?: NavProductMegaMenuGroup[];
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
@@ -34,24 +41,44 @@ function ChevronDown({ open }: { open?: boolean }) {
|
||||
}
|
||||
|
||||
function ProductsNavDropdown({
|
||||
categories,
|
||||
groups,
|
||||
onNavigate,
|
||||
variant = "desktop",
|
||||
}: {
|
||||
categories: NavProductCategory[];
|
||||
groups: NavProductMegaMenuGroup[];
|
||||
onNavigate?: () => void;
|
||||
variant?: "desktop" | "mobile";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expandedGroupId, setExpandedGroupId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
|
||||
const openMenu = () => {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
closeTimerRef.current = window.setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "desktop" || !open) return;
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
containerRef.current?.contains(target) ||
|
||||
panelRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -66,6 +93,13 @@ function ProductsNavDropdown({
|
||||
};
|
||||
}, [open, variant]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (closeTimerRef.current) window.clearTimeout(closeTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (variant === "mobile") {
|
||||
return (
|
||||
<div className="border-b border-white/10 pb-1">
|
||||
@@ -79,7 +113,7 @@ function ProductsNavDropdown({
|
||||
<ChevronDown open={open} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="flex flex-col gap-1 pb-2 pr-3">
|
||||
<div className="flex flex-col gap-2 pb-2 pr-3">
|
||||
<a
|
||||
href="/user-products"
|
||||
className="rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
|
||||
@@ -87,28 +121,59 @@ function ProductsNavDropdown({
|
||||
>
|
||||
همه محصولات
|
||||
</a>
|
||||
{categories.map((category) => (
|
||||
{groups.map((group) => {
|
||||
const isExpanded = expandedGroupId === group.id;
|
||||
return (
|
||||
<div key={group.id} className="rounded border border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2.5 text-sm font-bold !text-white"
|
||||
onClick={() =>
|
||||
setExpandedGroupId((current) =>
|
||||
current === group.id ? null : group.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
<ChevronDown open={isExpanded} />
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="border-t border-white/10 px-2 py-2">
|
||||
<a
|
||||
key={category.id}
|
||||
href={category.href}
|
||||
className="rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
|
||||
href={group.href}
|
||||
className="block rounded px-2 py-2 text-sm !text-brand transition hover:bg-white/10"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{category.label}
|
||||
همه {group.label}
|
||||
</a>
|
||||
{group.items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className="block rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={openMenu}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -120,46 +185,72 @@ function ProductsNavDropdown({
|
||||
محصولات
|
||||
<ChevronDown open={open} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute end-0 top-full z-50 mt-1 min-w-[15rem] border border-white/10 bg-brand-ink py-2 shadow-xl"
|
||||
ref={panelRef}
|
||||
onMouseEnter={openMenu}
|
||||
onMouseLeave={scheduleClose}
|
||||
className="fixed inset-x-0 top-[4.5rem] z-40 border-t border-white/10 bg-brand-ink/98 shadow-[0_24px_60px_rgba(6,35,63,0.35)] backdrop-blur-md"
|
||||
>
|
||||
<div className="container-page py-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-4 border-b border-white/10 pb-4">
|
||||
<div>
|
||||
<p className="font-industrial text-xs tracking-[0.18em] text-brand">
|
||||
PRODUCT CATEGORIES
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
دستهبندی ماشینآلات موجود در ماشینیفای
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/user-products"
|
||||
role="menuitem"
|
||||
className="block px-4 py-2.5 text-sm !text-white/85 transition hover:bg-white/10 hover:!text-white"
|
||||
className="btn-primary !min-h-10 shrink-0 !px-4 !text-sm"
|
||||
>
|
||||
همه محصولات
|
||||
</a>
|
||||
{categories.length > 0 ? (
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
) : null}
|
||||
{categories.map((category) => (
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.id}>
|
||||
<a
|
||||
key={category.id}
|
||||
href={category.href}
|
||||
role="menuitem"
|
||||
className="block px-4 py-2.5 text-sm !text-white/85 transition hover:bg-white/10 hover:!text-white"
|
||||
href={group.href}
|
||||
className="block text-base font-extrabold !text-white transition hover:!text-brand"
|
||||
>
|
||||
{category.label}
|
||||
{group.label}
|
||||
</a>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={item.href}
|
||||
className="block py-1 text-sm !text-white/75 transition hover:!text-white"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : 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 (
|
||||
<ProductsNavDropdown
|
||||
key={item.label}
|
||||
categories={productCategories}
|
||||
groups={productMegaMenu}
|
||||
variant="desktop"
|
||||
/>
|
||||
);
|
||||
@@ -206,7 +297,7 @@ export function Header({
|
||||
return (
|
||||
<ProductsNavDropdown
|
||||
key={item.label}
|
||||
categories={productCategories}
|
||||
groups={productMegaMenu}
|
||||
variant="mobile"
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<Header
|
||||
forceSolid={forceSolid}
|
||||
productCategories={productCategories}
|
||||
productMegaMenu={productMegaMenu}
|
||||
logoUrl={businessInfo?.logoUrl ?? null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<p className="whitespace-pre-line text-base leading-8 text-muted">
|
||||
{value}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-2 text-base leading-8 text-muted">
|
||||
{value.map((item, index) => (
|
||||
<li key={index} className="flex gap-2">
|
||||
<span className="text-brand">•</span>
|
||||
<span>{typeof item === "object" ? JSON.stringify(item) : String(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[20rem] border-collapse text-sm">
|
||||
<tbody>
|
||||
{entries.map(([key, val]) => (
|
||||
<tr key={key} className="border-b border-line">
|
||||
<th className="px-4 py-3 text-right font-bold text-brand-ink">
|
||||
{key}
|
||||
</th>
|
||||
<td className="px-4 py-3 text-muted">
|
||||
{val === null || val === undefined ? "—" : String(val)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="text-base leading-8 text-muted">{String(value)}</p>;
|
||||
}
|
||||
|
||||
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({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderTechnicalValues(technicalValues)}
|
||||
{technicalValues ? (
|
||||
<TechnicalValuesTable
|
||||
value={technicalValues}
|
||||
fieldLabels={technicalFieldLabels}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{deliveryNotes ? (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
normalizeTechnicalValueRows,
|
||||
type TechnicalValueRow,
|
||||
} from "@/lib/product-technical-values";
|
||||
|
||||
type TechnicalValuesTableProps = {
|
||||
value: unknown;
|
||||
fieldLabels?: Record<string, string>;
|
||||
};
|
||||
|
||||
export function TechnicalValuesTable({
|
||||
value,
|
||||
fieldLabels = {},
|
||||
}: TechnicalValuesTableProps) {
|
||||
const rows = normalizeTechnicalValueRows(value, fieldLabels);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-industrial text-xs tracking-[0.16em] text-brand">
|
||||
SPECIFICATIONS
|
||||
</h3>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full min-w-[20rem] border-collapse border border-line text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#f7fafc]">
|
||||
<th className="border-b border-line px-4 py-3 text-right font-bold text-brand-ink">
|
||||
مشخصه
|
||||
</th>
|
||||
<th className="border-b border-line px-4 py-3 text-right font-bold text-brand-ink">
|
||||
مقدار
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<TechnicalValueTableRow key={row.id} row={row} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalValueTableRow({ row }: { row: TechnicalValueRow }) {
|
||||
return (
|
||||
<tr className="border-b border-line last:border-b-0">
|
||||
<th className="px-4 py-3 text-right font-bold text-brand-ink">
|
||||
{row.label || "—"}
|
||||
</th>
|
||||
<td className="px-4 py-3 text-muted">{row.value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
+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