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
+22
View File
@@ -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 -1
View File
@@ -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 ? (
+21 -14
View File
@@ -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">
اعمال فیلتر
+36
View File
@@ -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
View File
@@ -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)}
/>
+4 -4
View File
@@ -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}
/>
);
+10 -53
View File
@@ -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>
+55
View File
@@ -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
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 }] : [];
}