Files
nini-naderi/src/lib/catalog.ts
T
Alireza HassaniandCursor c0a95a826a Connect the storefront to the Meshkee Website API.
Products, blog, search, mega menu, and home carousels now read live tenant data, and login goes to the customer portal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:22:31 +03:30

518 lines
18 KiB
TypeScript

export const PLACEHOLDER_IMAGE = "/images/sale-1.png";
export const PLACEHOLDER_BLOG = "/images/blog-1.png";
export type Tone = "purple" | "cyan" | "rose";
export type Category = {
id: string;
parentId?: string | null;
name: string;
nameFa?: string | null;
slug: string;
sortOrder?: number;
isActive?: boolean;
};
export type MegaColumn = {
id: string;
name: string;
href: string;
children: { id: string; name: string; href: string }[];
};
export type Product = {
id: string;
productId?: string;
slug: string;
href: string;
image: string;
images: string[];
title: string;
brand: string;
price: number | null;
oldPrice?: number | null;
colors: string[];
colorNames: string[];
tone: Tone;
badge?: string;
inStock?: boolean;
categoryId?: string;
categoryName?: string;
summary?: string;
descriptionHtml?: string;
variants?: ProductVariant[];
};
export type ProductVariant = {
id: string;
label: string;
price: number | null;
oldPrice?: number | null;
stock: number;
inStock: boolean;
color?: string;
size?: string;
};
export type BlogPost = {
id: string;
slug: string;
href: string;
title: string;
excerpt: string;
image: string;
html: string;
categoryName?: string;
publishedAt?: string;
author?: string;
};
export type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
export type StoreSpecial = {
id: string;
key: string;
title: string;
isActive: boolean;
sortOrder: number;
items: SpecialItem[];
};
export type SpecialItem = {
id: string;
productId: string;
productTitle: string;
productNameFa: string | null;
productImage: string | null;
isActive: boolean;
sortOrder: number;
variants: ProductVariant[];
};
type Json = Record<string, unknown>;
function asRecord(value: unknown): Json | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Json) : null;
}
function asString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function asNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && !Number.isNaN(Number(value))) return Number(value);
return null;
}
function pickString(record: Json | null, keys: string[]) {
if (!record) return "";
for (const key of keys) {
const value = asString(record[key]);
if (value) return value;
}
return "";
}
function imageFromUnknown(value: unknown): string[] {
if (!value) return [];
if (typeof value === "string") return value ? [value] : [];
if (Array.isArray(value)) {
return value.flatMap((item) => {
if (typeof item === "string") return item ? [item] : [];
const record = asRecord(item);
const url = pickString(record, ["url", "src", "image", "thumbnailUrl", "imageUrl"]);
return url ? [url] : [];
});
}
const record = asRecord(value);
const url = pickString(record, ["url", "src", "image", "thumbnailUrl", "imageUrl"]);
return url ? [url] : [];
}
export function uniqueImages(...groups: unknown[]) {
const seen = new Set<string>();
const images: string[] = [];
for (const group of groups) {
for (const url of imageFromUnknown(group)) {
if (seen.has(url)) continue;
seen.add(url);
images.push(url);
}
}
return images;
}
export function categoryLabel(category: Category) {
return category.nameFa || category.name;
}
export function categoryDepth(category: Category, all: Category[], seen = new Set<string>()): number {
if (!category.parentId) return 1;
if (seen.has(category.id)) return 1;
const parent = all.find((item) => item.id === category.parentId);
if (!parent) return 1;
seen.add(category.id);
return 1 + categoryDepth(parent, all, seen);
}
export function categoryIdsWithDescendants(id: string, all: Category[]): string[] {
const ids = [id];
for (const category of all) {
if (category.parentId === id) ids.push(...categoryIdsWithDescendants(category.id, all));
}
return ids;
}
export function buildMegaMenu(categories: Category[]): MegaColumn[] {
const active = categories.filter((category) => category.isActive !== false);
const byParent = new Map<string, Category[]>();
for (const category of active) {
const key = category.parentId || "";
const list = byParent.get(key) ?? [];
list.push(category);
byParent.set(key, list);
}
const sortCats = (list: Category[]) =>
[...list].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || categoryLabel(a).localeCompare(categoryLabel(b), "fa"),
);
const roots = sortCats(byParent.get("") ?? active.filter((category) => !category.parentId));
const levelTwo = sortCats(roots.flatMap((root) => byParent.get(root.id) ?? []));
const columns = levelTwo.length ? levelTwo : roots;
return columns.map((column) => ({
id: column.id,
name: categoryLabel(column),
href: `/products?categoryId=${column.id}`,
children: sortCats(byParent.get(column.id) ?? []).map((child) => ({
id: child.id,
name: categoryLabel(child),
href: `/products?categoryId=${child.id}`,
})),
}));
}
export function plainText(value: string, max = 160) {
const text = value
.replace(/<[^>]+>/g, " ")
.replace(/[*_#`]+/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length <= max) return text;
return `${text.slice(0, max).trim()}…`;
}
export function sanitizeHtml(html: string) {
return html
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "")
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "");
}
const COLOR_HEX: Record<string, string> = {
"زرد": "#FBBF24",
"سبز": "#34D399",
"صورتی": "#F9A8D4",
"بنفش": "#C4B5FD",
"یاسی": "#C4B5FD",
"آبی": "#67E8F9",
"فیروزه": "#22D3EE",
"طوسی": "#94A3B8",
"خاکستری": "#94A3B8",
"سفید": "#F8FAFC",
"مشکی": "#111827",
"سیاه": "#111827",
"قرمز": "#F43F5E",
"سرخابی": "#FB7185",
"نارنجی": "#FB923C",
"کرم": "#FDE68A",
"قهوه‌ای": "#92400E",
"قهوه ای": "#92400E",
};
export function colorHex(name: string) {
return COLOR_HEX[name.trim()] || (name.startsWith("#") ? name : "#C4B5FD");
}
function selectionValue(selections: unknown, names: string[]) {
if (!Array.isArray(selections)) return "";
for (const item of selections) {
const record = asRecord(item);
const variation = pickString(record, ["variationName", "name"]);
if (names.some((name) => variation.includes(name))) {
return pickString(record, ["value", "optionValue"]);
}
}
return "";
}
function colorNamesFrom(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const names = new Set<string>();
for (const item of value) {
const record = asRecord(item);
const variation = pickString(record, ["variationName", "name"]);
if (variation.includes("رنگ")) {
const color = pickString(record, ["value", "optionValue"]);
if (color) names.add(color);
}
}
return [...names];
}
export function mapVariant(raw: unknown): ProductVariant {
const record = asRecord(raw) ?? {};
const price = asNumber(record.discountedPrice) ?? asNumber(record.price);
const oldPrice = asNumber(record.discountedPrice) != null ? asNumber(record.price) : null;
const stock = asNumber(record.stockQuantity) ?? 0;
const unlimited = Boolean(record.unlimited);
return {
id: pickString(record, ["id", "variantId"]),
label: pickString(record, ["label", "nameFa", "name"]) || "تنوع",
price,
oldPrice: oldPrice ?? undefined,
stock: stock ?? 0,
inStock: typeof record.inStock === "boolean" ? record.inStock : unlimited || (stock ?? 0) > 0,
color: selectionValue(record.selections, ["رنگ"]) || undefined,
size: selectionValue(record.selections, ["سایز", "اندازه"]) || undefined,
};
}
function pickPricedVariant(variants: ProductVariant[]) {
const inStock = variants.filter((variant) => variant.inStock && variant.price != null);
const priced = variants.filter((variant) => variant.price != null);
const pool = inStock.length ? inStock : priced.length ? priced : variants;
return pool.reduce<ProductVariant | undefined>((best, next) => {
if (!best) return next;
const bestPrice = best.price ?? Number.POSITIVE_INFINITY;
const nextPrice = next.price ?? Number.POSITIVE_INFINITY;
return nextPrice < bestPrice ? next : best;
}, undefined);
}
function discountBadge(price: number | null, oldPrice?: number | null) {
if (price == null || oldPrice == null || oldPrice <= price) return undefined;
return `${Math.round(((oldPrice - price) / oldPrice) * 100)}٪`;
}
function toneFromId(id: string): Tone {
const tones: Tone[] = ["purple", "cyan", "rose"];
let hash = 0;
for (const char of id) hash = (hash + char.charCodeAt(0)) % tones.length;
return tones[hash] ?? "purple";
}
export function mapStoreItem(raw: unknown, product?: Product): Product {
const record = asRecord(raw) ?? {};
const nestedProduct = asRecord(record.product);
const variantList = Array.isArray(record.variants) ? record.variants.map(mapVariant) : [];
const selfVariant = record.price != null || record.discountedPrice != null || record.label ? mapVariant(record) : null;
const variants = variantList.length ? variantList : selfVariant ? [selfVariant] : [];
const chosen = pickPricedVariant(variants);
const firstRawVariant = Array.isArray(record.variants) ? asRecord(record.variants[0]) : null;
const productId =
pickString(record, ["productId"]) ||
pickString(nestedProduct, ["id"]) ||
pickString(firstRawVariant, ["productId"]) ||
product?.productId ||
product?.id ||
"";
const id = pickString(record, ["id", "variantId"]) || productId;
const slug = product?.slug || pickString(nestedProduct, ["slug"]) || productId || id || "item";
const title =
pickString(record, ["productNameFa", "productTitle", "nameFa", "titleFa", "title", "name"]) ||
pickString(firstRawVariant, ["productNameFa", "productTitle", "nameFa", "title"]) ||
pickString(nestedProduct, ["nameFa", "titleFa", "title", "name"]) ||
product?.title ||
"محصول";
const images = uniqueImages(
record.productImage,
record.imageUrl,
record.thumbnailUrl,
record.image,
record.images,
firstRawVariant?.productImage,
firstRawVariant?.image,
nestedProduct?.thumbnailUrl,
nestedProduct?.image,
nestedProduct?.images,
product?.images,
product?.image,
);
const colorNames = [
...new Set([
...colorNamesFrom(record.selections),
...variants.map((variant) => variant.color).filter((value): value is string => Boolean(value)),
...(product?.colorNames ?? []),
]),
];
const price = chosen?.price ?? asNumber(record.discountedPrice) ?? asNumber(record.price) ?? product?.price ?? null;
const oldPrice = chosen?.oldPrice ?? (asNumber(record.discountedPrice) != null ? asNumber(record.price) : null) ?? product?.oldPrice;
const qty = asNumber(record.productTotalStock) ?? asNumber(record.stockQuantity);
const inStock = variants.some((variant) => variant.inStock) || (qty != null && qty > 0);
return {
id: id || slug,
productId: productId || undefined,
slug,
href: `/products/${encodeURIComponent(slug)}`,
image: images[0] || product?.image || PLACEHOLDER_IMAGE,
images: images.length ? images : product?.images?.length ? product.images : [PLACEHOLDER_IMAGE],
title,
brand: product?.brand || pickString(asRecord(record.brand), ["nameFa", "name"]) || pickString(nestedProduct, ["brandName"]) || "نینی نادری",
price,
oldPrice: oldPrice ?? undefined,
colors: colorNames.map(colorHex),
colorNames,
tone: toneFromId(id || slug),
badge: discountBadge(price, oldPrice),
inStock,
categoryId: product?.categoryId || pickString(record, ["categoryId"]) || pickString(nestedProduct, ["categoryId"]) || undefined,
categoryName:
product?.categoryName ||
pickString(record, ["categoryNameFa", "categoryName"]) ||
pickString(nestedProduct, ["categoryNameFa", "categoryName"]) ||
undefined,
summary: product?.summary || pickString(nestedProduct, ["summary"]) || pickString(record, ["summary"]),
descriptionHtml: product?.descriptionHtml || sanitizeHtml(pickString(nestedProduct, ["descriptionHtml"]) || pickString(record, ["descriptionHtml"])),
variants,
};
}
export function mapProduct(raw: unknown): Product {
const record = asRecord(raw) ?? {};
const store = asRecord(record.store);
const brand = asRecord(record.brand);
const slug = pickString(record, ["slug", "id"]);
const images = uniqueImages(record.thumbnailUrl, record.image, record.thumbnail, record.images);
const price = asNumber(store?.minPrice) ?? asNumber(record.price);
return {
id: pickString(record, ["id"]) || slug,
productId: pickString(record, ["id"]) || undefined,
slug,
href: `/products/${encodeURIComponent(slug)}`,
image: images[0] || PLACEHOLDER_IMAGE,
images: images.length ? images : [PLACEHOLDER_IMAGE],
title: pickString(record, ["nameFa", "titleFa", "title", "name"]) || "محصول",
brand: pickString(brand, ["nameFa", "name"]) || "نینی نادری",
price,
colors: [],
colorNames: [],
tone: toneFromId(slug),
inStock: typeof store?.inStock === "boolean" ? store.inStock : undefined,
categoryId: pickString(record, ["categoryId"]) || undefined,
categoryName: pickString(record, ["categoryNameFa", "categoryName"]) || undefined,
summary: pickString(record, ["summary"]),
descriptionHtml: sanitizeHtml(pickString(record, ["descriptionHtml"])),
};
}
export function mapSpecialItem(item: SpecialItem, product?: Product, badge?: string): Product {
const variants = item.variants ?? [];
const chosen = pickPricedVariant(variants);
const colorNames = [...new Set(variants.map((variant) => variant.color).filter((value): value is string => Boolean(value)))];
const slug = product?.slug || item.productId;
const price = chosen?.price ?? product?.price ?? null;
const oldPrice = chosen?.oldPrice ?? product?.oldPrice;
return {
id: item.productId,
productId: item.productId,
slug,
href: `/products/${encodeURIComponent(slug)}`,
image: item.productImage || product?.image || PLACEHOLDER_IMAGE,
images: uniqueImages(item.productImage, product?.images, product?.image),
title: item.productNameFa || item.productTitle || product?.title || "محصول",
brand: product?.brand || "نینی نادری",
price,
oldPrice: oldPrice ?? undefined,
colors: colorNames.map(colorHex),
colorNames,
tone: toneFromId(item.productId),
badge: badge || discountBadge(price, oldPrice),
inStock: variants.some((variant) => variant.inStock),
categoryId: product?.categoryId,
categoryName: product?.categoryName,
summary: product?.summary,
descriptionHtml: product?.descriptionHtml,
variants,
};
}
export function mapSpecial(raw: unknown): StoreSpecial {
const record = asRecord(raw) ?? {};
const items = Array.isArray(record.items) ? record.items : [];
return {
id: pickString(record, ["id"]),
key: pickString(record, ["key"]),
title: pickString(record, ["title", "titleFa", "name"]),
isActive: record.isActive !== false,
sortOrder: asNumber(record.sortOrder) ?? 0,
items: items.map((item) => {
const row = asRecord(item) ?? {};
return {
id: pickString(row, ["id"]),
productId: pickString(row, ["productId"]),
productTitle: pickString(row, ["productTitle", "title"]),
productNameFa: pickString(row, ["productNameFa"]) || null,
productImage: pickString(row, ["productImage", "image"]) || null,
isActive: row.isActive !== false,
sortOrder: asNumber(row.sortOrder) ?? 0,
variants: Array.isArray(row.variants) ? row.variants.map(mapVariant) : [],
};
}),
};
}
export function mapBlog(raw: unknown): BlogPost {
const record = asRecord(raw) ?? {};
const author = asRecord(record.author);
const slug = pickString(record, ["slug", "id"]);
const authorName = [asString(author?.firstName), asString(author?.lastName)].filter(Boolean).join(" ");
const html = sanitizeHtml(pickString(record, ["mainTextHtml", "contentHtml", "bodyHtml"]));
const excerptSource = pickString(record, ["abstract", "summary", "excerpt"]) || html;
return {
id: pickString(record, ["id"]) || slug,
slug,
href: `/blog/${encodeURIComponent(slug)}`,
title: pickString(record, ["title", "titleFa", "name"]) || "مقاله",
excerpt: plainText(excerptSource, 180),
image: pickString(record, ["titleImageUrl", "image", "thumbnailUrl"]) || PLACEHOLDER_BLOG,
html,
categoryName: pickString(record, ["categoryNameFa", "categoryName"]) || undefined,
publishedAt: pickString(record, ["publishedAt", "createdAt"]) || undefined,
author: authorName || undefined,
};
}
export function mergeProduct(store: Product, product?: Product | null): Product {
if (!product) return store;
return {
...store,
slug: product.slug || store.slug,
href: product.slug ? `/products/${encodeURIComponent(product.slug)}` : store.href,
title: store.title && store.title !== "محصول" ? store.title : product.title || store.title,
brand: product.brand || store.brand,
image: store.image !== PLACEHOLDER_IMAGE ? store.image : product.image,
images: uniqueImages(store.images, product.images),
categoryId: store.categoryId || product.categoryId,
categoryName: store.categoryName || product.categoryName,
summary: store.summary || product.summary,
descriptionHtml: store.descriptionHtml || product.descriptionHtml,
price: store.price ?? product.price,
oldPrice: store.oldPrice ?? product.oldPrice,
};
}