Build the RTL Next.js experience with live catalog, journal, customer interactions, and SEO-ready canonical routes. Co-authored-by: Cursor <cursoragent@cursor.com>
298 lines
7.0 KiB
TypeScript
298 lines
7.0 KiB
TypeScript
import sanitizeHtml from "sanitize-html";
|
|
|
|
export const MESHKEE_API_BASE = "https://api.meshkee.com/api/v1";
|
|
export const WEBSITE_DOMAIN = "glassmond.com";
|
|
export const CUSTOMER_URL = `https://customer.${WEBSITE_DOMAIN}`;
|
|
export const BUSINESS_URL = `https://business.${WEBSITE_DOMAIN}`;
|
|
export const BUSINESS_ID = "22";
|
|
|
|
export type Tenant = {
|
|
id: number | string;
|
|
name: string;
|
|
nameFa: string;
|
|
domain: string;
|
|
logoUrl: string | null;
|
|
faviconUrl: string | null;
|
|
specialProductsSource: "product" | "store_item";
|
|
enabledModules: string[];
|
|
};
|
|
|
|
export type MediaImage = {
|
|
mediaId?: string;
|
|
url: string;
|
|
};
|
|
|
|
export type Product = {
|
|
id: string;
|
|
title: string;
|
|
nameFa: string;
|
|
summary: string;
|
|
descriptionHtml: string;
|
|
slug: string;
|
|
categoryId: string | null;
|
|
categoryName?: string | null;
|
|
categoryNameFa?: string | null;
|
|
thumbnailUrl: string | null;
|
|
image?: string;
|
|
thumbnail?: string;
|
|
images: MediaImage[];
|
|
commentCount: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
store?: {
|
|
variantCount: number;
|
|
minPrice: number | null;
|
|
maxPrice: number | null;
|
|
inStock: boolean;
|
|
};
|
|
};
|
|
|
|
export type Blog = {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
type: "news" | "article" | "blog";
|
|
abstract: string;
|
|
mainTextHtml: string;
|
|
categoryId: string | null;
|
|
categoryName?: string | null;
|
|
author?: {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
} | null;
|
|
titleImageUrl: string | null;
|
|
commentCount: number;
|
|
publishedAt: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type Category = {
|
|
id: string;
|
|
name: string;
|
|
nameFa: string;
|
|
slug: string;
|
|
parentId: string | null;
|
|
description: string | null;
|
|
};
|
|
|
|
export type PublicComment = {
|
|
id: string;
|
|
authorName: string;
|
|
text: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type ListResponse<T> = {
|
|
items: T[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
};
|
|
|
|
async function apiFetch<T>(
|
|
path: string,
|
|
options: RequestInit & { revalidate?: number } = {},
|
|
): Promise<T> {
|
|
const { revalidate = 300, ...requestOptions } = options;
|
|
const response = await fetch(`${MESHKEE_API_BASE}${path}`, {
|
|
...requestOptions,
|
|
headers: {
|
|
Accept: "application/json",
|
|
...requestOptions.headers,
|
|
},
|
|
next: { revalidate },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Meshkee API ${response.status}: ${path}`);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export function tenantPath(path = "") {
|
|
return `/tenants/${WEBSITE_DOMAIN}${path}`;
|
|
}
|
|
|
|
export async function getTenant() {
|
|
return apiFetch<Tenant>(tenantPath(), { revalidate: 3600 });
|
|
}
|
|
|
|
export async function getProducts({
|
|
page = 1,
|
|
pageSize = 12,
|
|
name,
|
|
categoryId,
|
|
}: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
name?: string;
|
|
categoryId?: string;
|
|
} = {}) {
|
|
const query = new URLSearchParams({
|
|
page: String(page),
|
|
pageSize: String(pageSize),
|
|
});
|
|
if (name) query.set("name", name);
|
|
if (categoryId) query.set("categoryId", categoryId);
|
|
return apiFetch<ListResponse<Product>>(`${tenantPath("/products")}?${query}`);
|
|
}
|
|
|
|
export async function getProductById(productId: string) {
|
|
return apiFetch<{ product: Product; relatedProducts: Product[] }>(
|
|
tenantPath(`/products/by-id/${encodeURIComponent(productId)}`),
|
|
);
|
|
}
|
|
|
|
export async function getProductTechnicalInfo(productId: string) {
|
|
return apiFetch<{ form?: unknown; values?: unknown }>(
|
|
tenantPath(`/products/by-id/${encodeURIComponent(productId)}/technical-info`),
|
|
).catch(() => null);
|
|
}
|
|
|
|
export async function getBlogs({
|
|
page = 1,
|
|
pageSize = 12,
|
|
title,
|
|
categoryId,
|
|
}: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
title?: string;
|
|
categoryId?: string;
|
|
} = {}) {
|
|
const query = new URLSearchParams({
|
|
page: String(page),
|
|
pageSize: String(pageSize),
|
|
});
|
|
if (title) query.set("title", title);
|
|
if (categoryId) query.set("categoryId", categoryId);
|
|
return apiFetch<ListResponse<Blog>>(`${tenantPath("/blogs")}?${query}`);
|
|
}
|
|
|
|
export async function getBlogById(blogId: string) {
|
|
return apiFetch<{ blog: Blog }>(
|
|
tenantPath(`/blogs/by-id/${encodeURIComponent(blogId)}`),
|
|
);
|
|
}
|
|
|
|
export async function getCategories(entityType: "product" | "blog") {
|
|
return apiFetch<{ items: Category[] }>(
|
|
`${tenantPath("/categories")}?entityType=${entityType}`,
|
|
{ revalidate: 1800 },
|
|
);
|
|
}
|
|
|
|
export async function getCategoryById(
|
|
categoryId: string,
|
|
entityType: "product" | "blog" = "product",
|
|
) {
|
|
return apiFetch<{ category: Category }>(
|
|
`${tenantPath(`/categories/by-id/${encodeURIComponent(categoryId)}`)}?entityType=${entityType}`,
|
|
{ revalidate: 1800 },
|
|
);
|
|
}
|
|
|
|
export async function getComments(
|
|
entityType: "product" | "blog",
|
|
entityId: string,
|
|
) {
|
|
const query = new URLSearchParams({ entityType, entityId });
|
|
return apiFetch<{ items: PublicComment[] }>(
|
|
`${tenantPath("/comments")}?${query}`,
|
|
).catch(() => ({ items: [] }));
|
|
}
|
|
|
|
export function slugifyFa(value: string) {
|
|
return value
|
|
.normalize("NFKC")
|
|
.trim()
|
|
.replace(/[\u200c\u200f\u202a-\u202e]/g, " ")
|
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.toLowerCase();
|
|
}
|
|
|
|
export function productUrl(product: Pick<Product, "id" | "nameFa" | "title">) {
|
|
return `/products/${product.id}/${slugifyFa(product.nameFa || product.title)}`;
|
|
}
|
|
|
|
export function productCategoryUrl(
|
|
category: Pick<Category, "id" | "nameFa" | "name">,
|
|
) {
|
|
return `/products/category/${category.id}/${slugifyFa(category.nameFa || category.name)}`;
|
|
}
|
|
|
|
export function blogUrl(blog: Pick<Blog, "id" | "title">) {
|
|
return `/blog/${blog.id}/${slugifyFa(blog.title)}`;
|
|
}
|
|
|
|
export function productImage(product: Product) {
|
|
return product.thumbnailUrl || product.image || product.thumbnail || null;
|
|
}
|
|
|
|
export function sanitizeCmsContent(
|
|
content: string,
|
|
imageAlt = "تصویر مرتبط با محتوای گلسموند",
|
|
) {
|
|
const cleaned = sanitizeHtml(content || "", {
|
|
allowedTags: [
|
|
"p",
|
|
"br",
|
|
"strong",
|
|
"b",
|
|
"em",
|
|
"i",
|
|
"ul",
|
|
"ol",
|
|
"li",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"blockquote",
|
|
"a",
|
|
"img",
|
|
],
|
|
allowedAttributes: {
|
|
a: ["href", "target", "rel"],
|
|
img: ["src", "alt", "width", "height", "loading"],
|
|
},
|
|
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
transformTags: {
|
|
a: sanitizeHtml.simpleTransform("a", {
|
|
rel: "noopener noreferrer",
|
|
}),
|
|
img: (tagName, attribs) => ({
|
|
tagName,
|
|
attribs: {
|
|
src: attribs.src || "",
|
|
alt: attribs.alt?.trim() || imageAlt,
|
|
loading: "lazy",
|
|
...(attribs.width ? { width: attribs.width } : {}),
|
|
...(attribs.height ? { height: attribs.height } : {}),
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
return cleaned.includes("<") ? cleaned : cleaned.replace(/\n/g, "<br />");
|
|
}
|
|
|
|
export function plainText(content: string) {
|
|
return sanitizeHtml(content || "", { allowedTags: [], allowedAttributes: {} })
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
export function formatPersianDate(value: string) {
|
|
try {
|
|
return new Intl.DateTimeFormat("fa-IR", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
}).format(new Date(value));
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|