@@ -79,7 +113,7 @@ function ProductsNavDropdown({
{open ? (
-
+
همه محصولات
- {categories.map((category) => (
-
- {category.label}
-
- ))}
+ {groups.map((group) => {
+ const isExpanded = expandedGroupId === group.id;
+ return (
+
+
+ {isExpanded ? (
+
+ ) : null}
+
+ );
+ })}
) : null}
@@ -104,62 +168,89 @@ function ProductsNavDropdown({
}
return (
-
setOpen(true)}
- onMouseLeave={() => setOpen(false)}
- >
-
+ >
);
}
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 ? (
+
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 }] : [];
+}