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:
co-authored by
Cursor
parent
1a2b62463e
commit
c0a95a826a
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user