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 = { 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; 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(); 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()): 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(); 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(/[\s\S]*?<\/script>/gi, "") .replace(/[\s\S]*?<\/style>/gi, "") .replace(/\son\w+="[^"]*"/gi, "") .replace(/\son\w+='[^']*'/gi, ""); } const COLOR_HEX: Record = { "زرد": "#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(); 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((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, }; }