Files
arad-arisman/src/lib/meshkee.ts
T
Alireza HassaniandCursor 69e6261949 Fix product gallery to include all Meshkee images and show catalog downloads.
Parse gallery entries as URL objects from the API and surface attached catalog files on the product detail page when present.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 14:45:19 +03:30

516 lines
15 KiB
TypeScript

import { toFa } from "@/lib/format";
const API_BASE = "https://api.meshkee.com/api/v1";
export const TENANT_DOMAIN = "aradarisman.com";
export type Tenant = {
id: number;
name: string;
nameFa: string;
slug: string;
domain: string;
};
export async function getTenant(): Promise<Tenant | null> {
try {
return await meshkeeFetch<Tenant>(`/tenants/${TENANT_DOMAIN}`, 3600);
} catch {
return null;
}
}
export async function getBusinessId(): Promise<string> {
const tenant = await getTenant();
return tenant?.id != null ? String(tenant.id) : "";
}
export type WebsiteFavicon = {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
};
const defaultFavicon: WebsiteFavicon = {
faviconUrl: null,
logoUrl: null,
logoDarkUrl: null,
hasDedicatedFavicon: false,
};
export async function getWebsiteFavicon(): Promise<WebsiteFavicon> {
try {
return await meshkeeFetch<WebsiteFavicon>(
`/tenants/${TENANT_DOMAIN}/website/favicon`,
3600
);
} catch {
return defaultFavicon;
}
}
/**
* Business-managed image overrides (hero category icons, 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<StaticImageSlot[]> {
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<StaticImageSlot | null> {
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;
};
/** Gallery entry from Meshkee — list/detail may return a URL string or `{ url }`. */
export type ProductImageRef = string | { url: string; mediaId?: string };
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: ProductImageRef[];
/** Attached catalog / datasheet file (PDF etc.), when the owner uploaded one. */
catalogUrl: string | null;
catalogFileName: string | null;
catalogMediaId: string | null;
commentCount: number;
variantCount: number;
createdAt: string;
updatedAt: string;
store: ProductStore;
};
export type ProductListResponse = {
items: Product[];
total: number;
page: number;
pageSize: number;
};
async function meshkeeFetch<T>(path: string, revalidateSeconds = 300): Promise<T> {
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<ProductListResponse> {
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<ProductListResponse>(
`/tenants/${TENANT_DOMAIN}/products${qs ? `?${qs}` : ""}`
);
}
export async function getProduct(slug: string): Promise<Product | null> {
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 async function getProductById(id: string): Promise<Product | null> {
const res = await fetch(
`${API_BASE}/tenants/${TENANT_DOMAIN}/products/by-id/${id}`,
{ next: { revalidate: 300 } }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Meshkee API product id "${id}" 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;
}
function imageRefUrl(ref: ProductImageRef | null | undefined): string | null {
if (!ref) return null;
if (typeof ref === "string") return ref || null;
return typeof ref.url === "string" && ref.url ? ref.url : null;
}
/**
* Unique gallery URLs for the product detail lightbox.
* Primary thumbnail/image first, then every gallery entry (API may return
* `images` as `{ url, mediaId }[]` rather than plain strings).
*/
export function productImages(product: Product): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const ref of [
product.thumbnail,
product.image,
product.thumbnailUrl,
...(product.images ?? []),
]) {
const url = imageRefUrl(ref as ProductImageRef);
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
/** Attached catalog/datasheet when the business uploaded one in Meshkee. */
export function productCatalog(
product: Product
): { url: string; fileName: string } | null {
const url = product.catalogUrl?.trim();
if (!url) return null;
const fileName =
product.catalogFileName?.trim() ||
decodeURIComponent(url.split("/").pop() || "catalog") ||
"فایل پیوست";
return { url, fileName };
}
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 Category = {
id: string;
parentId: string | null;
name: string;
nameFa: string;
slug: string;
sortOrder: number;
isActive: boolean;
};
export async function getCategories(): Promise<Category[]> {
try {
const data = await meshkeeFetch<{ items: Category[] }>(
`/tenants/${TENANT_DOMAIN}/categories`
);
return data.items.filter((c) => c.isActive);
} catch {
return [];
}
}
export async function getCategoryById(id: string): Promise<Category | null> {
const res = await fetch(
`${API_BASE}/tenants/${TENANT_DOMAIN}/categories/by-id/${id}`,
{ next: { revalidate: 300 } }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Meshkee API category id "${id}" failed: ${res.status}`);
const data = await res.json();
return data.category as Category;
}
export async function getTopLevelCategories(): Promise<Category[]> {
const items = await getCategories();
return items
.filter((c) => c.parentId === null)
.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
a.nameFa.localeCompare(b.nameFa, "fa")
);
}
/** Includes the category itself and all nested descendants. */
export function getCategoryDescendantIds(
categoryId: string,
categories: Category[]
): string[] {
const ids = new Set<string>([categoryId]);
let changed = true;
while (changed) {
changed = false;
for (const c of categories) {
if (c.parentId && ids.has(c.parentId) && !ids.has(c.id)) {
ids.add(c.id);
changed = true;
}
}
}
return Array.from(ids);
}
export type CategorySummary = {
categoryId: string;
name: string;
nameEn: string | null;
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<CategorySummary[]> {
const { items } = await getProducts({ pageSize: 100 });
const perId = new Map<
string,
{ id: string; name: string; nameEn: string | null; 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;
if (!entry.nameEn && p.categoryName) entry.nameEn = p.categoryName;
} else {
perId.set(p.categoryId, {
id: p.categoryId,
name: p.categoryNameFa,
nameEn: p.categoryName ?? null,
count: 1,
image: img,
});
}
}
const perName = new Map<string, CategorySummary>();
for (const entry of perId.values()) {
const existing = perName.get(entry.name);
if (!existing) {
perName.set(entry.name, {
categoryId: entry.id,
name: entry.name,
nameEn: entry.nameEn,
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 (entry.nameEn) existing.nameEn = entry.nameEn;
}
if (!existing.image && entry.image) existing.image = entry.image;
if (!existing.nameEn && entry.nameEn) existing.nameEn = entry.nameEn;
}
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(/&nbsp;/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<PostListResponse> {
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<PostListResponse>(
`/tenants/${TENANT_DOMAIN}/blogs${qs ? `?${qs}` : ""}`
);
}
export async function getPost(slug: string): Promise<Post | null> {
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 async function getPostById(id: string): Promise<Post | null> {
const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/blogs/by-id/${id}`, {
next: { revalidate: 300 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Meshkee API blog id "${id}" 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)} دقیقه مطالعه`;
}