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>
This commit is contained in:
Alireza Hassani
2026-08-18 11:22:31 +03:30
co-authored by Cursor
parent 1a2b62463e
commit c0a95a826a
34 changed files with 2458 additions and 401 deletions
+517
View File
@@ -0,0 +1,517 @@
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,
};
}