Create Glassmond storefront with Meshkee integration
Build the RTL Next.js experience with live catalog, journal, customer interactions, and SEO-ready canonical routes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+297
@@ -0,0 +1,297 @@
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user