Files
arad-arisman/src/lib/meshkee.ts
T
alireza-hassani 3f479af8be Connect products and blog to Meshkee backend API
- Add src/lib/meshkee.ts API client for the live backend
  (https://api.meshkee.com, tenant aradarisman.com)
- Products list/detail pages now fetch real catalog data,
  images, categories and pricing instead of static mock data
- Blog list/detail pages fetch from /tenants/{domain}/blogs
  with a graceful empty state since no posts are published yet
- Allow the Meshkee media CDN host in next.config.ts
- Remove now-unused static src/data/products.ts and posts.ts
2026-08-10 01:08:17 +03:30

211 lines
6.0 KiB
TypeScript

import { toFa } from "@/lib/format";
const API_BASE = "https://api.meshkee.com/api/v1";
export const TENANT_DOMAIN = "aradarisman.com";
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 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 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)} دقیقه مطالعه`;
}