283 lines
7.0 KiB
TypeScript
283 lines
7.0 KiB
TypeScript
export type StaticImage = {
|
|
id: number | string;
|
|
url: string;
|
|
titleFa?: string | null;
|
|
titleEn?: string | null;
|
|
subtext?: string | null;
|
|
linkUrl?: string | null;
|
|
width?: number | null;
|
|
height?: number | null;
|
|
sortOrder?: number | null;
|
|
};
|
|
|
|
export type StaticImageSlot = {
|
|
key: string;
|
|
pageKey: string;
|
|
kind: "single" | "list";
|
|
label: string;
|
|
aspectRatio?: string | null;
|
|
recommendedWidth?: number | null;
|
|
itemCount?: number | null;
|
|
images: StaticImage[];
|
|
};
|
|
|
|
export type ProductCategory = {
|
|
id: string;
|
|
parentId?: string | null;
|
|
name: string;
|
|
nameFa?: string | null;
|
|
slug: string;
|
|
description?: string | null;
|
|
sortOrder?: number | null;
|
|
isActive?: boolean;
|
|
};
|
|
|
|
export type ProductMedia = {
|
|
mediaId?: string;
|
|
url: string;
|
|
};
|
|
|
|
export type Product = {
|
|
id: string;
|
|
title: string;
|
|
nameFa?: string | null;
|
|
summary?: string | null;
|
|
descriptionHtml?: string | null;
|
|
categoryId?: string | null;
|
|
categoryName?: string | null;
|
|
categoryNameFa?: string | null;
|
|
brand?: { name?: string | null; nameFa?: string | null } | string | null;
|
|
thumbnailUrl?: string | null;
|
|
image?: string | null;
|
|
thumbnail?: string | null;
|
|
images?: ProductMedia[];
|
|
commentCount?: number | null;
|
|
createdAt?: string | null;
|
|
updatedAt?: string | null;
|
|
store?: {
|
|
inStock?: boolean;
|
|
minPrice?: number | string | null;
|
|
maxPrice?: number | string | null;
|
|
} | null;
|
|
};
|
|
|
|
export type ProductDetailResponse = {
|
|
product: Product;
|
|
relatedProducts?: Product[];
|
|
};
|
|
|
|
type StaticImageSlotsResponse = {
|
|
items?: StaticImageSlot[];
|
|
};
|
|
|
|
type ProductCategoriesResponse = {
|
|
items?: ProductCategory[];
|
|
};
|
|
|
|
type ProductListResponse = {
|
|
items?: Product[];
|
|
total?: number;
|
|
page?: number;
|
|
pageSize?: number;
|
|
};
|
|
|
|
const API_BASE_URL =
|
|
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ??
|
|
"https://api.meshkee.com/api/v1";
|
|
|
|
const WEBSITE_DOMAIN =
|
|
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ?? "samanandish.com";
|
|
|
|
function tenantEndpoint(path: string) {
|
|
return `${API_BASE_URL}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}${path}`;
|
|
}
|
|
|
|
async function getJson<T>(endpoint: string, signal?: AbortSignal) {
|
|
const response = await fetch(endpoint, {
|
|
headers: { Accept: "application/json" },
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Meshkee request failed with ${response.status}`);
|
|
}
|
|
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
export async function fetchHomeStaticImageSlots(signal?: AbortSignal) {
|
|
const payload = await getJson<StaticImageSlotsResponse>(
|
|
tenantEndpoint("/website/static-images?pageKey=home"),
|
|
signal,
|
|
);
|
|
return Array.isArray(payload.items) ? payload.items : [];
|
|
}
|
|
|
|
export async function fetchProductCategories(signal?: AbortSignal) {
|
|
if (typeof window !== "undefined") {
|
|
const localPayload = await getJson<ProductCategoriesResponse>(
|
|
"/api/meshkee/categories",
|
|
signal,
|
|
);
|
|
return Array.isArray(localPayload.items) ? localPayload.items : [];
|
|
}
|
|
|
|
const payload = await getJson<ProductCategoriesResponse>(
|
|
tenantEndpoint("/categories?entityType=product"),
|
|
signal,
|
|
);
|
|
return Array.isArray(payload.items) ? payload.items : [];
|
|
}
|
|
|
|
export function getLevelTwoProductCategories(categories: ProductCategory[]) {
|
|
const rootIds = new Set(
|
|
categories
|
|
.filter((category) => !category.parentId)
|
|
.map((category) => category.id),
|
|
);
|
|
|
|
return categories
|
|
.filter(
|
|
(category) =>
|
|
Boolean(category.parentId) &&
|
|
rootIds.has(category.parentId as string) &&
|
|
category.isActive !== false,
|
|
)
|
|
.sort(
|
|
(first, second) =>
|
|
(first.sortOrder ?? 0) - (second.sortOrder ?? 0) ||
|
|
(first.nameFa || first.name).localeCompare(second.nameFa || second.name),
|
|
);
|
|
}
|
|
|
|
async function fetchProductPage(
|
|
page: number,
|
|
pageSize: number,
|
|
signal?: AbortSignal,
|
|
categoryId?: string,
|
|
) {
|
|
const query = new URLSearchParams({
|
|
page: String(page),
|
|
pageSize: String(pageSize),
|
|
});
|
|
if (categoryId) query.set("categoryId", categoryId);
|
|
|
|
const payload = await getJson<ProductListResponse>(
|
|
tenantEndpoint(`/products?${query.toString()}`),
|
|
signal,
|
|
);
|
|
|
|
return {
|
|
items: Array.isArray(payload.items) ? payload.items : [],
|
|
total: Number(payload.total) || 0,
|
|
};
|
|
}
|
|
|
|
export async function fetchLatestProducts(
|
|
count = 4,
|
|
signal?: AbortSignal,
|
|
categoryId?: string,
|
|
) {
|
|
if (typeof window !== "undefined" && !categoryId) {
|
|
const localPayload = await getJson<ProductListResponse>(
|
|
`/api/meshkee/latest-products?count=${encodeURIComponent(count)}`,
|
|
signal,
|
|
);
|
|
return Array.isArray(localPayload.items) ? localPayload.items : [];
|
|
}
|
|
|
|
const firstPage = await fetchProductPage(1, count, signal, categoryId);
|
|
if (firstPage.total <= count) {
|
|
return [...firstPage.items]
|
|
.sort(
|
|
(first, second) =>
|
|
Date.parse(second.createdAt || "") - Date.parse(first.createdAt || ""),
|
|
)
|
|
.slice(0, count);
|
|
}
|
|
|
|
const firstDate = Date.parse(firstPage.items[0]?.createdAt || "");
|
|
const lastDate = Date.parse(
|
|
firstPage.items[firstPage.items.length - 1]?.createdAt || "",
|
|
);
|
|
if (Number.isFinite(firstDate) && firstDate >= lastDate) {
|
|
return firstPage.items.slice(0, count);
|
|
}
|
|
|
|
const lastPageNumber = Math.ceil(firstPage.total / count);
|
|
const lastPage = await fetchProductPage(
|
|
lastPageNumber,
|
|
count,
|
|
signal,
|
|
categoryId,
|
|
);
|
|
let latestItems = lastPage.items;
|
|
|
|
if (latestItems.length < count && lastPageNumber > 1) {
|
|
const previousPage = await fetchProductPage(
|
|
lastPageNumber - 1,
|
|
count,
|
|
signal,
|
|
categoryId,
|
|
);
|
|
latestItems = [...previousPage.items, ...latestItems];
|
|
}
|
|
|
|
return latestItems
|
|
.sort(
|
|
(first, second) =>
|
|
Date.parse(second.createdAt || "") - Date.parse(first.createdAt || ""),
|
|
)
|
|
.slice(0, count);
|
|
}
|
|
|
|
export async function fetchProductById(
|
|
productId: string,
|
|
signal?: AbortSignal,
|
|
) {
|
|
return getJson<ProductDetailResponse>(
|
|
tenantEndpoint(`/products/by-id/${encodeURIComponent(productId)}`),
|
|
signal,
|
|
);
|
|
}
|
|
|
|
export async function fetchCategoryById(
|
|
categoryId: string,
|
|
signal?: AbortSignal,
|
|
) {
|
|
const payload = await getJson<{ category?: ProductCategory }>(
|
|
tenantEndpoint(
|
|
`/categories/by-id/${encodeURIComponent(categoryId)}?entityType=product`,
|
|
),
|
|
signal,
|
|
);
|
|
return payload.category;
|
|
}
|
|
|
|
export function slugifyPersianTitle(value: string) {
|
|
return value
|
|
.normalize("NFKC")
|
|
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
.trim()
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-");
|
|
}
|
|
|
|
export function productPath(product: Pick<Product, "id" | "nameFa" | "title">) {
|
|
return `/products/${product.id}/${slugifyPersianTitle(product.nameFa || product.title)}`;
|
|
}
|
|
|
|
export function categoryPath(
|
|
category: Pick<ProductCategory, "id" | "nameFa" | "name">,
|
|
) {
|
|
return `/products/category/${category.id}/${slugifyPersianTitle(category.nameFa || category.name)}`;
|
|
}
|
|
|
|
export function cssAspectRatio(value?: string | null) {
|
|
if (!value || !/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
return value.replace(":", " / ");
|
|
}
|