Add Meshkee sitemap config, technical-info labels, and footer credit.

Wire specs to /technical-info for field labels, match Safeteb footer credit, and publish build-time /meshkee/sitemap-config.json for SEO sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-27 07:11:52 +03:30
co-authored by Cursor
parent 386787b4b5
commit d249d958eb
15 changed files with 590 additions and 108 deletions
-47
View File
@@ -1,47 +0,0 @@
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 {};
}
+62
View File
@@ -18,6 +18,42 @@ export type ListUserProductsParams = {
promoted?: boolean;
};
export type TechnicalFormFieldOption = {
id: string;
label?: string | null;
value?: string | null;
sortOrder?: number;
};
export type TechnicalFormField = {
id: string;
label: string;
key?: string | null;
type?: string | null;
isRequired?: boolean;
sortOrder?: number;
options?: TechnicalFormFieldOption[];
};
export type TechnicalForm = {
id?: string;
categoryId?: string;
fields?: TechnicalFormField[];
} | null;
export type TechnicalInfoValue = {
fieldId: string;
textValue?: string | null;
numberValue?: number | string | null;
optionId?: string | null;
optionIds?: string[] | null;
};
export type UserProductTechnicalInfo = {
form: TechnicalForm;
values: TechnicalInfoValue[];
};
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
items: [],
total: 0,
@@ -61,6 +97,32 @@ export async function getUserProductBySlugOrNull(slug: string) {
}
}
/** Labels + values for specs table. Join form.fields[].id ↔ values[].fieldId. */
export async function getUserProductTechnicalInfo(slug: string) {
try {
return await apiGet<UserProductTechnicalInfo>(
`/user-products/${encodeURIComponent(slug)}/technical-info`,
);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return { form: null, values: [] } satisfies UserProductTechnicalInfo;
}
console.error("getUserProductTechnicalInfo failed", error);
return { form: null, values: [] } satisfies UserProductTechnicalInfo;
}
}
export function technicalFieldLabelMap(
info: UserProductTechnicalInfo | null | undefined,
): Record<string, string> {
const fields = info?.form?.fields ?? [];
return Object.fromEntries(
fields
.filter((field) => field.id && field.label)
.map((field) => [String(field.id), field.label]),
);
}
export const conditionLabels: Record<string, string> = {
new: "نو",
stock: "کارکرده",
+81 -9
View File
@@ -1,7 +1,15 @@
import type {
TechnicalFormField,
TechnicalInfoValue,
UserProductTechnicalInfo,
} from "@/lib/api/user-products";
export type TechnicalValueItem = {
fieldId?: string | number | null;
textValue?: string | null;
numberValue?: number | string | null;
optionId?: string | null;
optionIds?: string[] | null;
value?: string | number | null;
fieldName?: string | null;
fieldNameFa?: string | null;
@@ -20,6 +28,7 @@ export type TechnicalValueRow = {
id: string;
label: string;
value: string;
sortOrder: number;
};
function pickString(...values: Array<string | number | null | undefined>) {
@@ -48,6 +57,71 @@ function parseJsonObject(value: string) {
}
}
function resolveOptionLabel(
field: TechnicalFormField | undefined,
optionId: string | null | undefined,
) {
if (!field || !optionId) return "";
const option = field.options?.find((item) => String(item.id) === String(optionId));
return pickString(option?.label, option?.value, optionId);
}
function resolveValueText(
value: TechnicalInfoValue | TechnicalValueItem,
field?: TechnicalFormField,
) {
const optionIds =
value.optionIds?.filter(Boolean).map(String) ??
(value.optionId ? [String(value.optionId)] : []);
if (optionIds.length > 0) {
const labels = optionIds
.map((id) => resolveOptionLabel(field, id))
.filter(Boolean);
if (labels.length > 0) return labels.join("، ");
}
return pickString(
formatValue(value.textValue),
formatValue(value.numberValue),
formatValue("value" in value ? value.value : undefined),
);
}
/** Preferred path: join technical-info form fields with submitted values. */
export function rowsFromTechnicalInfo(
info: UserProductTechnicalInfo | null | undefined,
): TechnicalValueRow[] {
const fields = info?.form?.fields ?? [];
const values = info?.values ?? [];
if (fields.length === 0 || values.length === 0) return [];
const fieldById = new Map(fields.map((field) => [String(field.id), field]));
const sortById = new Map(
fields.map((field, index) => [
String(field.id),
field.sortOrder ?? index,
]),
);
return values
.map((value, index) => {
const id = String(value.fieldId ?? `item-${index}`);
const field = fieldById.get(id);
const rowValue = resolveValueText(value, field);
if (!rowValue) return null;
return {
id,
label: pickString(field?.label) || "",
value: rowValue,
sortOrder: sortById.get(id) ?? index,
};
})
.filter((row): row is TechnicalValueRow => Boolean(row))
.sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label, "fa"));
}
export function normalizeTechnicalValueRows(
value: unknown,
fieldLabels: Record<string, string> = {},
@@ -59,7 +133,7 @@ export function normalizeTechnicalValueRows(
if (parsed !== null) return normalizeTechnicalValueRows(parsed, fieldLabels);
const text = value.trim();
return text ? [{ id: "text", label: "", value: text }] : [];
return text ? [{ id: "text", label: "", value: text, sortOrder: 0 }] : [];
}
if (Array.isArray(value)) {
@@ -75,6 +149,7 @@ export function normalizeTechnicalValueRows(
id: `item-${index}`,
label: "",
value: item.trim(),
sortOrder: index,
};
}
@@ -96,29 +171,26 @@ export function normalizeTechnicalValueRows(
id ? fieldLabels[id] : undefined,
) || "";
const rowValue = pickString(
formatValue(field.textValue),
formatValue(field.numberValue),
formatValue(field.value),
);
const rowValue = resolveValueText(field);
if (!rowValue) return null;
return { id, label, value: rowValue };
return { id, label, value: rowValue, sortOrder: index };
})
.filter((row): row is TechnicalValueRow => Boolean(row));
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>)
.map(([key, val]) => ({
.map(([key, val], index) => ({
id: key,
label: fieldLabels[key] || key,
value: formatValue(val),
sortOrder: index,
}))
.filter((row) => row.value);
}
const text = formatValue(value);
return text ? [{ id: "value", label: "", value: text }] : [];
return text ? [{ id: "value", label: "", value: text, sortOrder: 0 }] : [];
}
+39
View File
@@ -0,0 +1,39 @@
{
"baseUrl": "https://www.mashinify.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"changefreq": "monthly",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/categories",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contact",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/user-products",
"changefreq": "daily",
"priority": 0.9
}
],
"templates": {
"userProduct": "/user-products/{slug}",
"blog": "/blog/{slug}"
}
}