The storefront already consumed slots, but GET /meshkee/static-image-slots was missing, so Meshkee never registered slider, categories, four-icon-image, about-us, or brands for this domain. Co-authored-by: Cursor <cursoragent@cursor.com>
326 lines
9.6 KiB
TypeScript
326 lines
9.6 KiB
TypeScript
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<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;
|
|
};
|
|
|
|
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<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 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<CategorySummary[]> {
|
|
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<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,
|
|
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<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 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)} دقیقه مطالعه`;
|
|
}
|