Connect Meshkee APIs and add catalog, blog, and content pages.
Wire user-products and blogs to the Website API, add about/contact/categories routes, and polish flat UI filters and page banners. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e74e5ca45f
commit
8194b7292f
@@ -0,0 +1,57 @@
|
||||
import { apiGet, ApiError } from "./client";
|
||||
import type { BlogPost, Paginated } from "./types";
|
||||
|
||||
export type ListBlogsParams = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
type?: "news" | "article" | "blog";
|
||||
categoryId?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
export async function listBlogs(params: ListBlogsParams = {}) {
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? 12;
|
||||
try {
|
||||
return await apiGet<Paginated<BlogPost>>("/blogs", {
|
||||
page,
|
||||
pageSize,
|
||||
type: params.type,
|
||||
categoryId: params.categoryId,
|
||||
title: params.title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("listBlogs failed", error);
|
||||
return emptyPage<BlogPost>(page, pageSize);
|
||||
}
|
||||
}
|
||||
export async function getBlogBySlug(slug: string) {
|
||||
const data = await apiGet<{ blog: BlogPost }>(
|
||||
`/blogs/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return data.blog;
|
||||
}
|
||||
|
||||
export async function getBlogBySlugOrNull(slug: string) {
|
||||
try {
|
||||
return await getBlogBySlug(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefer title image; otherwise first <img> inside HTML body. */
|
||||
export function resolveBlogImage(blog: Pick<BlogPost, "titleImageUrl" | "mainTextHtml" | "abstract">) {
|
||||
if (blog.titleImageUrl) return blog.titleImageUrl;
|
||||
const html = blog.mainTextHtml ?? "";
|
||||
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { apiGet } from "./client";
|
||||
import type { BusinessInfo } from "./types";
|
||||
|
||||
export async function getBusinessInfo() {
|
||||
try {
|
||||
return await apiGet<BusinessInfo>("/website/business-info");
|
||||
} catch (error) {
|
||||
console.error("getBusinessInfo failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { apiGet } from "./client";
|
||||
import type { Category, CategoryTreeNode } from "./types";
|
||||
|
||||
export async function listCategories(entityType: "product" | "blog" = "product") {
|
||||
try {
|
||||
const data = await apiGet<{ items: Category[] }>("/categories", {
|
||||
entityType,
|
||||
});
|
||||
return data.items ?? [];
|
||||
} catch (error) {
|
||||
console.error("listCategories failed", error);
|
||||
return [] as Category[];
|
||||
}
|
||||
}
|
||||
|
||||
export function categoryLabel(category: Pick<Category, "nameFa" | "name">) {
|
||||
return category.nameFa || category.name;
|
||||
}
|
||||
|
||||
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const map = new Map<string, CategoryTreeNode>();
|
||||
for (const category of categories) {
|
||||
map.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const node of map.values()) {
|
||||
if (node.parentId && map.has(node.parentId)) {
|
||||
map.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: CategoryTreeNode[]) => {
|
||||
nodes.sort(
|
||||
(a, b) =>
|
||||
a.sortOrder - b.sortOrder ||
|
||||
categoryLabel(a).localeCompare(categoryLabel(b), "fa"),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function findCategoryById(categories: Category[], id?: string | null) {
|
||||
if (!id) return null;
|
||||
return categories.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { API_BASE_URL, SITE_DOMAIN } from "./config";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
export function buildTenantUrl(
|
||||
path: string,
|
||||
query?: Record<string, QueryValue>,
|
||||
domain: string = SITE_DOMAIN,
|
||||
) {
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = new URL(`${API_BASE_URL}/tenants/${domain}${normalized}`);
|
||||
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(
|
||||
path: string,
|
||||
query?: Record<string, QueryValue>,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = buildTenantUrl(path, query);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
next: init?.next ?? { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `API ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (body?.message) message = body.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = buildTenantUrl(path);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `API ${response.status}`;
|
||||
try {
|
||||
const data = (await response.json()) as { message?: string };
|
||||
if (data?.message) message = data.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://api.meshkee.com/api/v1";
|
||||
|
||||
/** Website apex host (no www/api/customer/business). */
|
||||
export const SITE_DOMAIN =
|
||||
process.env.NEXT_PUBLIC_SITE_DOMAIN ?? "mashinify.com";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiPost } from "./client";
|
||||
import type { ContactSubmissionInput } from "./types";
|
||||
|
||||
export async function submitContact(input: ContactSubmissionInput) {
|
||||
return apiPost<{ submission?: unknown; message?: string }>(
|
||||
"/contact-submissions",
|
||||
input,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type BlogAuthor = {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type BlogPost = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type: "news" | "article" | "blog" | string;
|
||||
abstract: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
status: string;
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
tags: string[];
|
||||
authorId: string | null;
|
||||
author: BlogAuthor | null;
|
||||
titleImageUrl: string | null;
|
||||
featuredMediaId: string | null;
|
||||
commentCount: number;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UserProductCondition =
|
||||
| "new"
|
||||
| "stock"
|
||||
| "needs_repair"
|
||||
| "scrap"
|
||||
| string;
|
||||
|
||||
export type UserProductListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
titleFa: string | null;
|
||||
titleEn: string | null;
|
||||
price: number | string | null;
|
||||
priceCurrency: string | null;
|
||||
condition: UserProductCondition | null;
|
||||
cityName?: string | null;
|
||||
cityNameFa?: string | null;
|
||||
countryName?: string | null;
|
||||
countryNameFa?: string | null;
|
||||
imageUrl: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
categoryNameFa?: string | null;
|
||||
promoted?: boolean;
|
||||
publishedAt?: string | null;
|
||||
};
|
||||
|
||||
export type UserProductImage = {
|
||||
mediaId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UserProduct = UserProductListItem & {
|
||||
description?: string | null;
|
||||
descriptionFa?: string | null;
|
||||
descriptionEn?: string | null;
|
||||
images?: UserProductImage[];
|
||||
galleryMediaIds?: string[];
|
||||
featuredMediaId?: string | null;
|
||||
countryId?: string | null;
|
||||
cityId?: string | null;
|
||||
countrySlug?: string | null;
|
||||
deliveryNotes?: string | null;
|
||||
technicalNotes?: string | null;
|
||||
technicalValues?: unknown;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
entityType: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
variationCount?: number;
|
||||
};
|
||||
|
||||
export type CategoryTreeNode = Category & {
|
||||
children: CategoryTreeNode[];
|
||||
};
|
||||
|
||||
export type BusinessInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
about: string | null;
|
||||
vision: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
emails: string[];
|
||||
phoneNumbers: string[];
|
||||
socialMedia?: {
|
||||
whatsapp?: string;
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
aparat?: string;
|
||||
};
|
||||
addresses: Array<{
|
||||
title?: string | null;
|
||||
address?: string | null;
|
||||
cityName?: string | null;
|
||||
cityNameFa?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ContactSubmissionInput = {
|
||||
title: string;
|
||||
name: string;
|
||||
text: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiGet, ApiError } from "./client";
|
||||
import type {
|
||||
Paginated,
|
||||
UserProduct,
|
||||
UserProductCondition,
|
||||
UserProductListItem,
|
||||
} from "./types";
|
||||
|
||||
export type ListUserProductsParams = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
cityId?: string;
|
||||
countryId?: string;
|
||||
condition?: UserProductCondition;
|
||||
promoted?: boolean;
|
||||
};
|
||||
|
||||
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
export async function listUserProducts(params: ListUserProductsParams = {}) {
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? 12;
|
||||
try {
|
||||
return await apiGet<Paginated<UserProductListItem>>("/user-products", {
|
||||
page,
|
||||
pageSize,
|
||||
q: params.q,
|
||||
name: params.name,
|
||||
categoryId: params.categoryId,
|
||||
cityId: params.cityId,
|
||||
countryId: params.countryId,
|
||||
condition: params.condition,
|
||||
promoted: params.promoted,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("listUserProducts failed", error);
|
||||
return emptyPage<UserProductListItem>(page, pageSize);
|
||||
}
|
||||
}
|
||||
export async function getUserProductBySlug(slug: string) {
|
||||
const data = await apiGet<{ product: UserProduct }>(
|
||||
`/user-products/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return data.product;
|
||||
}
|
||||
|
||||
export async function getUserProductBySlugOrNull(slug: string) {
|
||||
try {
|
||||
return await getUserProductBySlug(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const conditionLabels: Record<string, string> = {
|
||||
new: "نو",
|
||||
stock: "کارکرده",
|
||||
needs_repair: "نیازمند تعمیر",
|
||||
scrap: "اسقاط",
|
||||
};
|
||||
|
||||
export function formatCondition(condition: string | null | undefined) {
|
||||
if (!condition) return null;
|
||||
return conditionLabels[condition] ?? condition;
|
||||
}
|
||||
|
||||
export function productTitle(product: Pick<UserProductListItem, "titleFa" | "titleEn">) {
|
||||
return product.titleFa || product.titleEn || "بدون عنوان";
|
||||
}
|
||||
|
||||
export function productCategory(
|
||||
product: Pick<UserProductListItem, "categoryNameFa" | "categoryName">,
|
||||
) {
|
||||
return product.categoryNameFa || product.categoryName || null;
|
||||
}
|
||||
|
||||
export function formatPrice(
|
||||
price: number | string | null | undefined,
|
||||
currency: string | null | undefined = "IRR",
|
||||
) {
|
||||
if (price === null || price === undefined || price === "") return null;
|
||||
const value = typeof price === "string" ? Number(price) : price;
|
||||
if (!Number.isFinite(value)) return String(price);
|
||||
|
||||
const formatted = new Intl.NumberFormat("en-US").format(value);
|
||||
if (!currency || currency === "IRR" || currency === "IRT") {
|
||||
return `${formatted} تومان`;
|
||||
}
|
||||
return `${currency} ${formatted}`;
|
||||
}
|
||||
Reference in New Issue
Block a user