Add Meshkee product catalog navigation and details
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
00126e5777
commit
34ce4f6925
+230
-4
@@ -21,10 +21,66 @@ export type StaticImageSlot = {
|
||||
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";
|
||||
@@ -32,21 +88,191 @@ const API_BASE_URL =
|
||||
const WEBSITE_DOMAIN =
|
||||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ?? "samanandish.com";
|
||||
|
||||
export async function fetchHomeStaticImageSlots(signal?: AbortSignal) {
|
||||
const endpoint = `${API_BASE_URL}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}/website/static-images?pageKey=home`;
|
||||
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(`Static image request failed with ${response.status}`);
|
||||
throw new Error(`Meshkee request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as StaticImageSlotsResponse;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user