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);
|
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 {
|
.btn-secondary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ProductTabs } from "@/components/ProductTabs";
|
|||||||
import { Products } from "@/components/Products";
|
import { Products } from "@/components/Products";
|
||||||
import { SiteShell } from "@/components/SiteShell";
|
import { SiteShell } from "@/components/SiteShell";
|
||||||
import { site } from "@/data/home";
|
import { site } from "@/data/home";
|
||||||
|
import { getCategoryVariationFieldMap } from "@/lib/api/product-fields";
|
||||||
import {
|
import {
|
||||||
formatCondition,
|
formatCondition,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
@@ -48,6 +49,10 @@ export default async function UserProductDetailPage({ params }: PageProps) {
|
|||||||
const country = product.countryNameFa || product.countryName || null;
|
const country = product.countryNameFa || product.countryName || null;
|
||||||
const description =
|
const description =
|
||||||
product.descriptionFa || product.description || product.descriptionEn;
|
product.descriptionFa || product.description || product.descriptionEn;
|
||||||
|
const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null;
|
||||||
|
const technicalFieldLabels = await getCategoryVariationFieldMap(
|
||||||
|
product.categoryId,
|
||||||
|
);
|
||||||
|
|
||||||
const similarResult = product.categoryId
|
const similarResult = product.categoryId
|
||||||
? await listUserProducts({
|
? await listUserProducts({
|
||||||
@@ -142,8 +147,9 @@ export default async function UserProductDetailPage({ params }: PageProps) {
|
|||||||
<ProductTabs
|
<ProductTabs
|
||||||
description={description}
|
description={description}
|
||||||
technicalNotes={product.technicalNotes}
|
technicalNotes={product.technicalNotes}
|
||||||
deliveryNotes={product.deliveryNotes}
|
deliveryNotes={deliveryNotes}
|
||||||
technicalValues={product.technicalValues}
|
technicalValues={product.technicalValues}
|
||||||
|
technicalFieldLabels={technicalFieldLabels}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{similarProducts.length > 0 ? (
|
{similarProducts.length > 0 ? (
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteShell } from "@/components/SiteShell";
|
import { SiteShell } from "@/components/SiteShell";
|
||||||
import { Products } from "@/components/Products";
|
import { Products } from "@/components/Products";
|
||||||
import { CategoryTreeSelect } from "@/components/CategoryTreeSelect";
|
import { FilterSelect } from "@/components/FilterSelect";
|
||||||
import { listCategories } from "@/lib/api/categories";
|
import {
|
||||||
|
categoryLabel,
|
||||||
|
listCategories,
|
||||||
|
listProductSubcategories,
|
||||||
|
} from "@/lib/api/categories";
|
||||||
import { listUserProducts } from "@/lib/api/user-products";
|
import { listUserProducts } from "@/lib/api/user-products";
|
||||||
import type { UserProductCondition } from "@/lib/api/types";
|
import type { UserProductCondition } from "@/lib/api/types";
|
||||||
|
|
||||||
@@ -49,6 +53,11 @@ export default async function UserProductsPage({ searchParams }: PageProps) {
|
|||||||
listCategories("product"),
|
listCategories("product"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const subcategories = listProductSubcategories(categories).map((category) => ({
|
||||||
|
value: category.id,
|
||||||
|
label: categoryLabel(category),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteShell
|
<SiteShell
|
||||||
banner={{
|
banner={{
|
||||||
@@ -61,7 +70,7 @@ export default async function UserProductsPage({ searchParams }: PageProps) {
|
|||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
<form method="get" className="filter-shell mb-8 p-4 md:p-5">
|
<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
|
<input
|
||||||
name="q"
|
name="q"
|
||||||
defaultValue={q ?? ""}
|
defaultValue={q ?? ""}
|
||||||
@@ -69,23 +78,21 @@ export default async function UserProductsPage({ searchParams }: PageProps) {
|
|||||||
className="field-control min-h-12 w-full"
|
className="field-control min-h-12 w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategoryTreeSelect
|
<FilterSelect
|
||||||
categories={categories}
|
name="categoryId"
|
||||||
defaultValue={categoryId ?? ""}
|
defaultValue={categoryId ?? ""}
|
||||||
placeholder="فیلتر دستهبندی"
|
placeholder="فیلتر دستهبندی"
|
||||||
|
options={[{ value: "", label: "همه دستهها" }, ...subcategories]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<select
|
<FilterSelect
|
||||||
name="condition"
|
name="condition"
|
||||||
defaultValue={condition ?? ""}
|
defaultValue={condition ?? ""}
|
||||||
className="field-control min-h-12 w-full appearance-none"
|
options={conditions.map((item) => ({
|
||||||
>
|
value: item.value,
|
||||||
{conditions.map((item) => (
|
label: item.label,
|
||||||
<option key={item.value || "all"} value={item.value}>
|
}))}
|
||||||
{item.label}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary min-h-12 px-6">
|
<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;
|
href: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NavProductMegaMenuGroup = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
items: NavProductCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
forceSolid?: boolean;
|
forceSolid?: boolean;
|
||||||
productCategories?: NavProductCategory[];
|
productMegaMenu?: NavProductMegaMenuGroup[];
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,24 +41,44 @@ function ChevronDown({ open }: { open?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProductsNavDropdown({
|
function ProductsNavDropdown({
|
||||||
categories,
|
groups,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
variant = "desktop",
|
variant = "desktop",
|
||||||
}: {
|
}: {
|
||||||
categories: NavProductCategory[];
|
groups: NavProductMegaMenuGroup[];
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
variant?: "desktop" | "mobile";
|
variant?: "desktop" | "mobile";
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [expandedGroupId, setExpandedGroupId] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
if (variant !== "desktop" || !open) return;
|
if (variant !== "desktop" || !open) return;
|
||||||
|
|
||||||
const onPointerDown = (event: MouseEvent) => {
|
const onPointerDown = (event: MouseEvent) => {
|
||||||
if (!containerRef.current?.contains(event.target as Node)) {
|
const target = event.target as Node;
|
||||||
setOpen(false);
|
if (
|
||||||
|
containerRef.current?.contains(target) ||
|
||||||
|
panelRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -66,6 +93,13 @@ function ProductsNavDropdown({
|
|||||||
};
|
};
|
||||||
}, [open, variant]);
|
}, [open, variant]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (closeTimerRef.current) window.clearTimeout(closeTimerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (variant === "mobile") {
|
if (variant === "mobile") {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-white/10 pb-1">
|
<div className="border-b border-white/10 pb-1">
|
||||||
@@ -79,7 +113,7 @@ function ProductsNavDropdown({
|
|||||||
<ChevronDown open={open} />
|
<ChevronDown open={open} />
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="flex flex-col gap-1 pb-2 pr-3">
|
<div className="flex flex-col gap-2 pb-2 pr-3">
|
||||||
<a
|
<a
|
||||||
href="/user-products"
|
href="/user-products"
|
||||||
className="rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
|
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>
|
</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
|
<a
|
||||||
key={category.id}
|
href={group.href}
|
||||||
href={category.href}
|
className="block rounded px-2 py-2 text-sm !text-brand transition hover:bg-white/10"
|
||||||
className="rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
|
|
||||||
onClick={onNavigate}
|
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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative"
|
className="relative"
|
||||||
onMouseEnter={() => setOpen(true)}
|
onMouseEnter={openMenu}
|
||||||
onMouseLeave={() => setOpen(false)}
|
onMouseLeave={scheduleClose}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,46 +185,72 @@ function ProductsNavDropdown({
|
|||||||
محصولات
|
محصولات
|
||||||
<ChevronDown open={open} />
|
<ChevronDown open={open} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
ref={panelRef}
|
||||||
className="absolute end-0 top-full z-50 mt-1 min-w-[15rem] border border-white/10 bg-brand-ink py-2 shadow-xl"
|
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
|
<a
|
||||||
href="/user-products"
|
href="/user-products"
|
||||||
role="menuitem"
|
className="btn-primary !min-h-10 shrink-0 !px-4 !text-sm"
|
||||||
className="block px-4 py-2.5 text-sm !text-white/85 transition hover:bg-white/10 hover:!text-white"
|
|
||||||
>
|
>
|
||||||
همه محصولات
|
همه محصولات
|
||||||
</a>
|
</a>
|
||||||
{categories.length > 0 ? (
|
</div>
|
||||||
<div className="my-1 border-t border-white/10" />
|
|
||||||
) : null}
|
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{categories.map((category) => (
|
{groups.map((group) => (
|
||||||
|
<div key={group.id}>
|
||||||
<a
|
<a
|
||||||
key={category.id}
|
href={group.href}
|
||||||
href={category.href}
|
className="block text-base font-extrabold !text-white transition hover:!text-brand"
|
||||||
role="menuitem"
|
|
||||||
className="block px-4 py-2.5 text-sm !text-white/85 transition hover:bg-white/10 hover:!text-white"
|
|
||||||
>
|
>
|
||||||
{category.label}
|
{group.label}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
forceSolid = false,
|
forceSolid = false,
|
||||||
productCategories = [],
|
productMegaMenu = [],
|
||||||
logoUrl = null,
|
logoUrl = null,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(forceSolid);
|
const [scrolled, setScrolled] = useState(forceSolid);
|
||||||
const hasProductDropdown = productCategories.length > 0;
|
const hasProductDropdown = productMegaMenu.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (forceSolid) {
|
if (forceSolid) {
|
||||||
@@ -184,7 +275,7 @@ export function Header({
|
|||||||
return (
|
return (
|
||||||
<ProductsNavDropdown
|
<ProductsNavDropdown
|
||||||
key={item.label}
|
key={item.label}
|
||||||
categories={productCategories}
|
groups={productMegaMenu}
|
||||||
variant="desktop"
|
variant="desktop"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -206,7 +297,7 @@ export function Header({
|
|||||||
return (
|
return (
|
||||||
<ProductsNavDropdown
|
<ProductsNavDropdown
|
||||||
key={item.label}
|
key={item.label}
|
||||||
categories={productCategories}
|
groups={productMegaMenu}
|
||||||
variant="mobile"
|
variant="mobile"
|
||||||
onNavigate={() => setOpen(false)}
|
onNavigate={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getBusinessInfo } from "@/lib/api/business";
|
import { getBusinessInfo } from "@/lib/api/business";
|
||||||
import { getLevel2ProductCategories } from "@/lib/api/categories";
|
import { getProductMegaMenuGroups } from "@/lib/api/categories";
|
||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
|
|
||||||
type HeaderContainerProps = {
|
type HeaderContainerProps = {
|
||||||
@@ -7,15 +7,15 @@ type HeaderContainerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function HeaderContainer({ forceSolid = false }: HeaderContainerProps) {
|
export async function HeaderContainer({ forceSolid = false }: HeaderContainerProps) {
|
||||||
const [productCategories, businessInfo] = await Promise.all([
|
const [productMegaMenu, businessInfo] = await Promise.all([
|
||||||
getLevel2ProductCategories(),
|
getProductMegaMenuGroups(),
|
||||||
getBusinessInfo(),
|
getBusinessInfo(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header
|
<Header
|
||||||
forceSolid={forceSolid}
|
forceSolid={forceSolid}
|
||||||
productCategories={productCategories}
|
productMegaMenu={productMegaMenu}
|
||||||
logoUrl={businessInfo?.logoUrl ?? null}
|
logoUrl={businessInfo?.logoUrl ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, type ReactNode } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { site } from "@/data/home";
|
import { site } from "@/data/home";
|
||||||
|
import { TechnicalValuesTable } from "@/components/TechnicalValuesTable";
|
||||||
|
|
||||||
type ProductTabsProps = {
|
type ProductTabsProps = {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
technicalNotes?: string | null;
|
technicalNotes?: string | null;
|
||||||
deliveryNotes?: string | null;
|
deliveryNotes?: string | null;
|
||||||
technicalValues?: unknown;
|
technicalValues?: unknown;
|
||||||
|
technicalFieldLabels?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabId = "description" | "technical" | "comments";
|
type TabId = "description" | "technical" | "comments";
|
||||||
@@ -21,62 +23,12 @@ const tabMeta: Record<
|
|||||||
comments: { label: "نظرات", labelEn: "Comments" },
|
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({
|
export function ProductTabs({
|
||||||
description,
|
description,
|
||||||
technicalNotes,
|
technicalNotes,
|
||||||
deliveryNotes,
|
deliveryNotes,
|
||||||
technicalValues,
|
technicalValues,
|
||||||
|
technicalFieldLabels = {},
|
||||||
}: ProductTabsProps) {
|
}: ProductTabsProps) {
|
||||||
const tabs = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
const items: { id: TabId; label: string; labelEn: string }[] = [];
|
const items: { id: TabId; label: string; labelEn: string }[] = [];
|
||||||
@@ -168,7 +120,12 @@ export function ProductTabs({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{renderTechnicalValues(technicalValues)}
|
{technicalValues ? (
|
||||||
|
<TechnicalValuesTable
|
||||||
|
value={technicalValues}
|
||||||
|
fieldLabels={technicalFieldLabels}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{deliveryNotes ? (
|
{deliveryNotes ? (
|
||||||
<div>
|
<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);
|
return buildCategoryTree(categories).filter((category) => category.isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listLevel2ProductCategories(categories: Category[]) {
|
/** Subcategories under intermediate nodes such as «صنعت سیم و کابل». */
|
||||||
return listRootProductCategories(categories).flatMap((root) =>
|
export function listProductSubcategories(categories: Category[]) {
|
||||||
root.children.filter((category) => category.isActive),
|
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() {
|
export async function getRootProductCategories() {
|
||||||
const categories = await listCategories("product");
|
const categories = await listCategories("product");
|
||||||
return listRootProductCategories(categories).map((category) => ({
|
return mapCategoriesToNavItems(listRootProductCategories(categories));
|
||||||
id: category.id,
|
|
||||||
label: categoryLabel(category),
|
|
||||||
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLevel2ProductCategories() {
|
export async function getLevel2ProductCategories() {
|
||||||
const categories = await listCategories("product");
|
const categories = await listCategories("product");
|
||||||
return listLevel2ProductCategories(categories).map((category) => ({
|
return mapCategoriesToNavItems(listProductSubcategories(categories));
|
||||||
id: category.id,
|
|
||||||
label: categoryLabel(category),
|
|
||||||
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
countryId?: string | null;
|
||||||
cityId?: string | null;
|
cityId?: string | null;
|
||||||
countrySlug?: string | null;
|
countrySlug?: string | null;
|
||||||
|
deliveryNote?: string | null;
|
||||||
deliveryNotes?: string | null;
|
deliveryNotes?: string | null;
|
||||||
technicalNotes?: string | null;
|
technicalNotes?: string | null;
|
||||||
technicalValues?: unknown;
|
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