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>
518 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|