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:
Alireza Hassani
2026-08-10 10:54:27 +03:30
co-authored by Cursor
parent e74e5ca45f
commit 8194b7292f
31 changed files with 2160 additions and 204 deletions
+57
View File
@@ -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;
}
+11
View File
@@ -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;
}
}
+50
View File
@@ -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;
}
+92
View File
@@ -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>;
}
+6
View File
@@ -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";
+9
View File
@@ -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,
);
}
+134
View File
@@ -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;
};
+99
View File
@@ -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}`;
}