Initial commit: Hoomat storefront with Meshkee API and static-image slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 15:00:41 +03:30
co-authored by Cursor
commit cfabf3e327
95 changed files with 12651 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
import {
MESHKEE_API_BASE,
MESHKEE_DOMAIN,
} from "@/lib/meshkee/config";
import type {
AuthUser,
BlogPost,
Category,
Comment,
FaviconInfo,
Paginated,
Product,
StaticImageSlot,
StoreItemVariant,
TechnicalInfo,
Tenant,
} from "@/lib/meshkee/types";
export class MeshkeeApiError extends Error {
status: number;
payload: unknown;
constructor(message: string, status: number, payload?: unknown) {
super(message);
this.name = "MeshkeeApiError";
this.status = status;
this.payload = payload;
}
}
type FetchOptions = {
method?: string;
body?: unknown;
token?: string | null;
signal?: AbortSignal;
cache?: RequestCache;
next?: NextFetchRequestConfig;
};
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/json",
};
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const init: RequestInit = {
method: options.method ?? "GET",
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: options.signal,
};
// `next` / `cache` are server-only fetch extensions
if (typeof window === "undefined") {
if (options.cache) init.cache = options.cache;
if (options.next) {
(init as RequestInit & { next?: NextFetchRequestConfig }).next = options.next;
}
}
const response = await fetch(`${MESHKEE_API_BASE}${path}`, init);
const payload = await response.json().catch(() => null);
if (!response.ok) {
const record =
payload && typeof payload === "object"
? (payload as Record<string, unknown>)
: null;
const errorValue = record?.error;
const messageValue = record?.message;
const message =
(typeof errorValue === "string" && errorValue) ||
(typeof messageValue === "string" && messageValue) ||
"خطا در ارتباط با سرور";
throw new MeshkeeApiError(message, response.status, payload);
}
return payload as T;
}
function tenantPath(suffix = "") {
return `/tenants/${encodeURIComponent(MESHKEE_DOMAIN)}${suffix}`;
}
export async function getTenant(): Promise<Tenant> {
return request<Tenant>(tenantPath(), { next: { revalidate: 300 } });
}
export async function getFavicon(): Promise<FaviconInfo> {
return request<FaviconInfo>(tenantPath("/website/favicon"), {
next: { revalidate: 300 },
});
}
export async function getStaticImages(
pageKey?: string,
): Promise<StaticImageSlot[]> {
const query = pageKey ? `?pageKey=${encodeURIComponent(pageKey)}` : "";
const data = await request<{ items: StaticImageSlot[] }>(
`${tenantPath("/website/static-images")}${query}`,
{ next: { revalidate: 60 } },
);
return data.items ?? [];
}
export async function getStaticImageSlot(
key: string,
): Promise<StaticImageSlot | null> {
try {
return await request<StaticImageSlot>(
tenantPath(`/website/static-images/${encodeURIComponent(key)}`),
{ next: { revalidate: 60 } },
);
} catch {
return null;
}
}
export async function getCategories(
entityType: "product" | "blog" = "product",
): Promise<Category[]> {
const data = await request<{ items: Category[] }>(
`${tenantPath("/categories")}?entityType=${entityType}`,
{ next: { revalidate: 120 } },
);
return data.items ?? [];
}
export async function listStoreItems(params: {
page?: number;
pageSize?: number;
categoryId?: string;
name?: string;
productId?: string;
} = {}): Promise<Paginated<StoreItemVariant>> {
const query = new URLSearchParams();
if (params.page) query.set("page", String(params.page));
if (params.pageSize) query.set("pageSize", String(params.pageSize));
if (params.categoryId) query.set("categoryId", params.categoryId);
if (params.name) query.set("name", params.name);
if (params.productId) query.set("productId", params.productId);
const qs = query.toString();
return request<Paginated<StoreItemVariant>>(
`${tenantPath("/store-items")}${qs ? `?${qs}` : ""}`,
{ next: { revalidate: 60 } },
);
}
export async function getStoreItemVariant(
variantId: string,
): Promise<StoreItemVariant> {
const data = await request<{ variant: StoreItemVariant }>(
tenantPath(`/store-items/${encodeURIComponent(variantId)}`),
{ next: { revalidate: 60 } },
);
return data.variant;
}
export async function getProductById(productId: string): Promise<{
product: Product;
relatedProducts: Product[];
}> {
const data = await request<{
product: Product;
relatedProducts?: Product[] | null;
}>(tenantPath(`/products/by-id/${encodeURIComponent(productId)}`), {
next: { revalidate: 60 },
});
return {
product: data.product,
relatedProducts: data.relatedProducts ?? [],
};
}
export async function getProductTechnicalInfo(
productId: string,
): Promise<TechnicalInfo> {
return request<TechnicalInfo>(
tenantPath(`/products/by-id/${encodeURIComponent(productId)}/technical-info`),
{ next: { revalidate: 120 } },
);
}
export async function listBlogs(params: {
page?: number;
pageSize?: number;
} = {}): Promise<Paginated<BlogPost>> {
const query = new URLSearchParams();
if (params.page) query.set("page", String(params.page));
if (params.pageSize) query.set("pageSize", String(params.pageSize));
const qs = query.toString();
return request<Paginated<BlogPost>>(
`${tenantPath("/blogs")}${qs ? `?${qs}` : ""}`,
{ next: { revalidate: 60 } },
);
}
export async function getBlogById(blogId: string): Promise<BlogPost> {
const data = await request<{ blog?: BlogPost } & BlogPost>(
tenantPath(`/blogs/by-id/${encodeURIComponent(blogId)}`),
{ next: { revalidate: 60 } },
);
return data.blog ?? data;
}
export async function listComments(params: {
entityType: "product" | "blog";
entityId: string;
}): Promise<Comment[]> {
const query = new URLSearchParams({
entityType: params.entityType,
entityId: params.entityId,
});
const data = await request<{ items: Comment[] }>(
`${tenantPath("/comments")}?${query.toString()}`,
{ cache: "no-store" },
);
return data.items ?? [];
}
export async function submitComment(
body: {
entityType: "product" | "blog";
entityId: string;
authorName: string;
authorEmail?: string;
text: string;
},
token?: string | null,
) {
return request(tenantPath("/comments"), {
method: "POST",
body,
token,
cache: "no-store",
});
}
export async function submitContact(body: {
title: string;
name: string;
email?: string;
cellNumber?: string;
text: string;
}) {
return request(tenantPath("/contact-submissions"), {
method: "POST",
body,
cache: "no-store",
});
}
export async function getMe(token: string, signal?: AbortSignal): Promise<AuthUser> {
const data = await request<{ user?: AuthUser } & AuthUser>("/auth/me", {
token,
signal,
cache: "no-store",
});
return data.user ?? data;
}
export async function refreshTokens(refreshToken: string) {
return request<{ accessToken: string; refreshToken: string }>("/auth/refresh", {
method: "POST",
body: { refreshToken },
cache: "no-store",
});
}
export async function createAuthHandoff(token: string) {
return request<{ ticket?: string }>("/auth/handoff", {
method: "POST",
token,
cache: "no-store",
});
}
export async function getCart(businessId: string, token: string) {
return request(`/businesses/${encodeURIComponent(businessId)}/cart`, {
token,
cache: "no-store",
});
}
export async function addCartItem(
businessId: string,
token: string,
body: { storeItemVariantId: string; quantity: number },
) {
return request(`/businesses/${encodeURIComponent(businessId)}/cart/items`, {
method: "POST",
token,
body,
cache: "no-store",
});
}
export async function updateCartItem(
businessId: string,
token: string,
itemId: string,
quantity: number,
) {
return request(
`/businesses/${encodeURIComponent(businessId)}/cart/items/${encodeURIComponent(itemId)}`,
{
method: "PATCH",
token,
body: { quantity },
cache: "no-store",
},
);
}
export async function removeCartItem(
businessId: string,
token: string,
itemId: string,
) {
return request(
`/businesses/${encodeURIComponent(businessId)}/cart/items/${encodeURIComponent(itemId)}`,
{
method: "DELETE",
token,
cache: "no-store",
},
);
}