import { toFa } from "@/lib/format"; const API_BASE = "https://api.meshkee.com/api/v1"; export const TENANT_DOMAIN = "aradarisman.com"; /** * Business-managed image overrides (hero slider, category tiles, feature * icons, about photo, brand logos). Each slot has a stable `key`; when a * slot has no images (or the fetch fails) callers must keep their current * placeholder — never render broken UI. See CLAUDE.md for the slot-key map. */ export type StaticImageItem = { id: string; url: string; title: string | null; titleFa: string | null; titleEn: string | null; subtext: string | null; linkUrl: string | null; width: number | null; height: number | null; sortOrder: number; }; export type StaticImageSlot = { id: string; key: string; kind: "single" | "list"; label: string; aspectRatio: string | null; recommendedWidth: number | null; itemCount: number | null; pageKey: string; images: StaticImageItem[]; }; export async function getStaticImageSlots(pageKey?: string): Promise { try { const qs = pageKey ? `?pageKey=${encodeURIComponent(pageKey)}` : ""; const data = await meshkeeFetch<{ items: StaticImageSlot[] }>( `/tenants/${TENANT_DOMAIN}/website/static-images${qs}`, 60 ); return data.items; } catch { return []; } } export async function getStaticImageSlot(key: string): Promise { const slots = await getStaticImageSlots(); return slots.find((s) => s.key === key) ?? null; } /** "21:9" -> "21 / 9" for CSS aspect-ratio; stays in sync with whatever the API declares. */ export function aspectRatioToCss(ratio: string | null | undefined, fallback = "16 / 9"): string { if (!ratio) return fallback; const [w, h] = ratio.split(":"); if (!w || !h) return fallback; return `${w} / ${h}`; } export type ProductStore = { variantCount: number; minPrice: number | null; maxPrice: number | null; inStock: boolean; }; export type Product = { id: string; businessId: string; title: string; nameFa: string; summary: string; descriptionHtml: string; slug: string; status: string; categoryId: string | null; categoryName: string | null; categoryNameFa: string | null; brandId: string | null; brand: string | null; tags: string[]; thumbnailUrl: string | null; thumbnail: string | null; image: string | null; images: string[]; commentCount: number; variantCount: number; createdAt: string; updatedAt: string; store: ProductStore; }; export type ProductListResponse = { items: Product[]; total: number; page: number; pageSize: number; }; async function meshkeeFetch(path: string, revalidateSeconds = 300): Promise { const res = await fetch(`${API_BASE}${path}`, { next: { revalidate: revalidateSeconds }, }); if (!res.ok) { throw new Error(`Meshkee API ${path} failed: ${res.status}`); } return res.json(); } export function getProducts(params: { page?: number; pageSize?: number; categoryId?: string; name?: string; } = {}): Promise { const query = new URLSearchParams(); if (params.page) query.set("page", String(params.page)); if (params.pageSize) query.set("pageSize", String(params.pageSize)); if (params.categoryId) query.set("categoryId", params.categoryId); if (params.name) query.set("name", params.name); const qs = query.toString(); return meshkeeFetch( `/tenants/${TENANT_DOMAIN}/products${qs ? `?${qs}` : ""}` ); } export async function getProduct(slug: string): Promise { const res = await fetch( `${API_BASE}/tenants/${TENANT_DOMAIN}/products/${slug}`, { next: { revalidate: 300 } } ); if (res.status === 404) return null; if (!res.ok) throw new Error(`Meshkee API product "${slug}" failed: ${res.status}`); const data = await res.json(); return data.product as Product; } export function productImage(product: Product): string | null { return product.thumbnail || product.image || product.thumbnailUrl || null; } export function productPriceLabel(product: Product): string { if (product.store?.minPrice) { const formatted = new Intl.NumberFormat("fa-IR").format(product.store.minPrice); return `${formatted} تومان`; } return "تماس بگیرید"; } export type CategorySummary = { categoryId: string; name: string; count: number; image: string | null; }; /** * The backend's raw category taxonomy has duplicate entries sharing the same * display name under different ids (a data-entry artifact, not a frontend * bug). This derives category tiles from the products actually published, * merging duplicate-name ids into one tile (summed count) and linking to * whichever id holds the most products under that name. `image` is the * first photographed product found under that name (any of the merged * ids) — null if none of them have a real photo yet. */ export async function getCategorySummary(limit = 8): Promise { const { items } = await getProducts({ pageSize: 100 }); const perId = new Map< string, { id: string; name: string; count: number; image: string | null } >(); for (const p of items) { if (!p.categoryId || !p.categoryNameFa) continue; const img = productImage(p); const entry = perId.get(p.categoryId); if (entry) { entry.count += 1; if (!entry.image && img) entry.image = img; } else { perId.set(p.categoryId, { id: p.categoryId, name: p.categoryNameFa, count: 1, image: img }); } } const perName = new Map(); for (const entry of perId.values()) { const existing = perName.get(entry.name); if (!existing) { perName.set(entry.name, { categoryId: entry.id, name: entry.name, count: entry.count, image: entry.image, }); continue; } existing.count += entry.count; const currentBest = perId.get(existing.categoryId)?.count ?? 0; if (entry.count > currentBest) existing.categoryId = entry.id; if (!existing.image && entry.image) existing.image = entry.image; } return Array.from(perName.values()) .sort((a, b) => b.count - a.count) .slice(0, limit); } export function stripHtml(html: string, maxLength = 140): string { const text = html .replace(/<[^>]*>/g, " ") .replace(/ /g, " ") .replace(/\s+/g, " ") .trim(); if (text.length <= maxLength) return text; return text.slice(0, maxLength).trim() + "…"; } /** * Blog field names are inferred from the Product/Category convention on this * same backend (bilingual title/nameFa, descriptionHtml, thumbnail/image, * categoryNameFa) since no tenant currently has published posts to verify * against. Accessors below fall back across the plausible field names so the * UI keeps working once real posts appear, even if the exact name differs. */ export type Post = { id: string; businessId?: string; title?: string; titleFa?: string; nameFa?: string; summary?: string; excerpt?: string; descriptionHtml?: string; contentHtml?: string; content?: string; slug: string; status?: string; categoryId?: string | null; categoryName?: string | null; categoryNameFa?: string | null; tags?: string[]; thumbnail?: string | null; thumbnailUrl?: string | null; image?: string | null; images?: string[]; commentCount?: number; publishedAt?: string | null; createdAt: string; updatedAt?: string; }; export type PostListResponse = { items: Post[]; total: number; page: number; pageSize: number; }; export function getPosts(params: { page?: number; pageSize?: number; categoryId?: string; title?: string; type?: "news" | "article" | "blog"; } = {}): Promise { const query = new URLSearchParams(); if (params.page) query.set("page", String(params.page)); if (params.pageSize) query.set("pageSize", String(params.pageSize)); if (params.categoryId) query.set("categoryId", params.categoryId); if (params.title) query.set("title", params.title); if (params.type) query.set("type", params.type); const qs = query.toString(); return meshkeeFetch( `/tenants/${TENANT_DOMAIN}/blogs${qs ? `?${qs}` : ""}` ); } export async function getPost(slug: string): Promise { const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/blogs/${slug}`, { next: { revalidate: 300 }, }); if (res.status === 404) return null; if (!res.ok) throw new Error(`Meshkee API blog "${slug}" failed: ${res.status}`); const data = await res.json(); return (data.blog ?? data.post ?? data) as Post; } export function postTitle(post: Post): string { return post.nameFa || post.titleFa || post.title || "بدون عنوان"; } export function postContent(post: Post): string { return post.descriptionHtml || post.contentHtml || post.content || ""; } export function postImage(post: Post): string | null { return post.thumbnail || post.image || post.thumbnailUrl || null; } export function postExcerpt(post: Post, maxLength = 140): string { if (post.summary) return post.summary; if (post.excerpt) return post.excerpt; return stripHtml(postContent(post), maxLength); } export function postDate(post: Post): string { const iso = post.publishedAt || post.createdAt; return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric", month: "long", day: "numeric", }).format(new Date(iso)); } export function postReadingTime(post: Post): string { const text = stripHtml(postContent(post), 100000); const words = text.split(/\s+/).filter(Boolean).length; const minutes = Math.max(1, Math.round(words / 150)); return `${toFa(minutes)} دقیقه مطالعه`; }