Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
apiRequest,
|
||||
clearTokens,
|
||||
setTokens,
|
||||
} from "@/lib/meshkee-api";
|
||||
import type { LoginResponse, MeResponse } from "@/types/auth";
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
const data = await apiRequest<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: { cellNumber, password },
|
||||
});
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
return data;
|
||||
}
|
||||
|
||||
export type RegisterPayload = {
|
||||
cellNumber: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
export async function register(payload: RegisterPayload) {
|
||||
const data = await apiRequest<LoginResponse & { requiresOtp?: boolean }>(
|
||||
"/auth/register",
|
||||
{
|
||||
method: "POST",
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
|
||||
if (data.accessToken && data.refreshToken) {
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(signal?: AbortSignal) {
|
||||
return apiRequest<MeResponse>("/auth/me", { auth: true, signal });
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearTokens();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { BusinessListResponse, MarketplaceBusiness } from "@/types/business";
|
||||
import { getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
|
||||
process.env.NEXT_PUBLIC_ARIYAWAY_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3001/api/v1";
|
||||
|
||||
export class MarketplaceApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "MarketplaceApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Request failed (${response.status})`;
|
||||
throw new MarketplaceApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const token = getAccessToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function listBusinesses(params?: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<BusinessListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
search.set("status", params?.status ?? "all");
|
||||
search.set("page", String(params?.page ?? 1));
|
||||
search.set("limit", String(params?.limit ?? 50));
|
||||
if (params?.q) search.set("q", params.q);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/businesses?${search}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseResponse<BusinessListResponse>(response);
|
||||
}
|
||||
|
||||
export async function getMarketplaceBusiness(
|
||||
id: string,
|
||||
): Promise<MarketplaceBusiness> {
|
||||
const response = await fetch(`${API_BASE_URL}/businesses/by-id/${id}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
return parseResponse<MarketplaceBusiness>(response);
|
||||
}
|
||||
|
||||
export async function createMarketplaceBusiness(body: {
|
||||
meshkeeBusinessId: string;
|
||||
slug: string;
|
||||
name?: string;
|
||||
meshkeeCategoryIds?: string[];
|
||||
status?: string;
|
||||
}): Promise<MarketplaceBusiness> {
|
||||
const response = await fetch(`${API_BASE_URL}/businesses`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return parseResponse<MarketplaceBusiness>(response);
|
||||
}
|
||||
|
||||
export async function updateMarketplaceBusiness(
|
||||
id: string,
|
||||
body: {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
meshkeeCategoryIds?: string[];
|
||||
status?: string;
|
||||
isFeatured?: boolean;
|
||||
sortWeight?: number;
|
||||
},
|
||||
): Promise<MarketplaceBusiness> {
|
||||
const response = await fetch(`${API_BASE_URL}/businesses/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return parseResponse<MarketplaceBusiness>(response);
|
||||
}
|
||||
|
||||
export async function deleteMarketplaceBusiness(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/businesses/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 204) return;
|
||||
|
||||
await parseResponse<unknown>(response);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiRequest } from "@/lib/meshkee-api";
|
||||
|
||||
export type BusinessProfileAiField = "about" | "vision";
|
||||
|
||||
export async function fillBusinessProfileFieldByAi(
|
||||
businessId: string,
|
||||
body: {
|
||||
field: BusinessProfileAiField;
|
||||
prompt: string;
|
||||
nameEn?: string;
|
||||
nameFa?: string;
|
||||
categories?: string;
|
||||
},
|
||||
) {
|
||||
return apiRequest<{
|
||||
field: BusinessProfileAiField;
|
||||
text?: string;
|
||||
html?: string;
|
||||
}>(`/businesses/${businessId}/profile/ai-fill`, {
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBusinessAboutAiPrompt(ctx: {
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
categories: string;
|
||||
}) {
|
||||
const nameEn = ctx.nameEn || "—";
|
||||
const nameFa = ctx.nameFa || "—";
|
||||
const categories = ctx.categories || "—";
|
||||
|
||||
return `Write a short professional business abstract (1–4 sentences).
|
||||
|
||||
Business name (EN): ${nameEn}
|
||||
Business name (FA): ${nameFa}
|
||||
Categories: ${categories}
|
||||
|
||||
Requirements:
|
||||
- Explain what the business does and who it serves.
|
||||
- Keep an informative, specialist tone — not advertising.
|
||||
- Avoid sales CTAs, discounts, and vague praise.
|
||||
- Write in Persian (Farsi).
|
||||
- Plain text only (no HTML, no markdown).`;
|
||||
}
|
||||
|
||||
export function buildBusinessVisionAiPrompt(ctx: {
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
categories: string;
|
||||
}) {
|
||||
const nameEn = ctx.nameEn || "—";
|
||||
const nameFa = ctx.nameFa || "—";
|
||||
const categories = ctx.categories || "—";
|
||||
|
||||
return `Write a business profile description as HTML for an advanced rich-text editor.
|
||||
|
||||
Business name (EN): ${nameEn}
|
||||
Business name (FA): ${nameFa}
|
||||
Categories: ${categories}
|
||||
|
||||
Suggested structure:
|
||||
1) What the company is
|
||||
2) Main products / services / strengths
|
||||
3) Typical customers or markets
|
||||
4) Optional bullet list of key points
|
||||
|
||||
Requirements:
|
||||
- Informative, professional tone — not marketing slogans.
|
||||
- Avoid CTAs, discounts, and vague claims.
|
||||
- 2–5 short paragraphs in Persian (Farsi).
|
||||
- Return raw HTML using only <p>, <ul>, <li>, <strong>, and <em>.
|
||||
- No markdown, no code fences, no escaped entities.`;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { apiRequest, getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
export interface MeshkeeBusinessSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
label: string;
|
||||
ownerName?: string | null;
|
||||
ownerCellNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface MeshkeeBusinessCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface MeshkeeBusinessOwner {
|
||||
id: string;
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
firstNameEn: string | null;
|
||||
lastNameEn: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface MeshkeeBusinessDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
categories: MeshkeeBusinessCategory[];
|
||||
categoryIds: string[];
|
||||
owner: MeshkeeBusinessOwner | null;
|
||||
logoUrl: string | null;
|
||||
logoMediaId: string | null;
|
||||
bannerUrl: string | null;
|
||||
bannerMediaId: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
function mapSearchItem(
|
||||
item: MeshkeeBusinessSearchItem,
|
||||
): MeshkeeBusinessSearchItem {
|
||||
const name = item.name ?? "";
|
||||
const nameFa = item.nameFa ?? null;
|
||||
return {
|
||||
...item,
|
||||
id: String(item.id),
|
||||
name,
|
||||
nameFa,
|
||||
slug: item.slug ?? "",
|
||||
label: item.label || name || item.slug || String(item.id),
|
||||
ownerName: item.ownerName ?? null,
|
||||
ownerCellNumber: item.ownerCellNumber ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchMeshkeeBusinesses(
|
||||
q: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
const data = await apiRequest<{ items: MeshkeeBusinessSearchItem[] }>(
|
||||
`/businesses/search?${params}`,
|
||||
{ auth: true, signal },
|
||||
);
|
||||
|
||||
return {
|
||||
items: (data.items ?? []).map(mapSearchItem),
|
||||
};
|
||||
}
|
||||
|
||||
/** Newest Meshkee businesses (super-admin list, created_at desc). */
|
||||
export async function listLatestMeshkeeBusinesses(
|
||||
limit = 5,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const pageSize = Math.min(Math.max(limit, 5), 50);
|
||||
const params = new URLSearchParams({
|
||||
page: "1",
|
||||
pageSize: String(pageSize),
|
||||
sort: "created",
|
||||
});
|
||||
|
||||
const data = await apiRequest<{ items: MeshkeeBusinessSearchItem[] }>(
|
||||
`/businesses?${params}`,
|
||||
{ auth: true, signal },
|
||||
);
|
||||
|
||||
return {
|
||||
items: (data.items ?? []).slice(0, limit).map(mapSearchItem),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMeshkeeBusiness(
|
||||
businessId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data = await apiRequest<MeshkeeBusinessDetail>(
|
||||
`/businesses/${businessId}`,
|
||||
{ auth: true, signal },
|
||||
);
|
||||
|
||||
return {
|
||||
...data,
|
||||
id: String(data.id),
|
||||
createdAt: data.createdAt,
|
||||
categories: (data.categories ?? []).map((category) => ({
|
||||
...category,
|
||||
id: String(category.id),
|
||||
parentId:
|
||||
category.parentId == null ? null : String(category.parentId),
|
||||
})),
|
||||
categoryIds: (data.categoryIds ?? []).map(String),
|
||||
owner: data.owner
|
||||
? {
|
||||
...data.owner,
|
||||
id: String(data.owner.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMeshkeeBusiness(body: {
|
||||
name: string;
|
||||
nameFa: string;
|
||||
slug?: string;
|
||||
categoryIds: number[];
|
||||
ownerFirstName: string;
|
||||
ownerLastName: string;
|
||||
ownerFirstNameEn: string;
|
||||
ownerLastNameEn: string;
|
||||
ownerCellNumber: string;
|
||||
ownerPassword: string;
|
||||
}) {
|
||||
const data = await apiRequest<MeshkeeBusinessDetail>(`/businesses`, {
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body,
|
||||
});
|
||||
return { ...data, id: String(data.id) };
|
||||
}
|
||||
|
||||
export async function uploadBusinessMedia(
|
||||
businessId: string,
|
||||
file: File,
|
||||
): Promise<{ id: string; publicUrl: string }> {
|
||||
const token = getAccessToken();
|
||||
const form = new FormData();
|
||||
form.append("files", file);
|
||||
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3010/api/v1";
|
||||
|
||||
const response = await fetch(`${base}/businesses/${businessId}/media`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: form,
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Upload failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const item = Array.isArray(payload?.items)
|
||||
? payload.items[0]
|
||||
: Array.isArray(payload)
|
||||
? payload[0]
|
||||
: payload;
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
publicUrl: String(item.publicUrl ?? item.url ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateBusinessBanner(
|
||||
businessId: string,
|
||||
bannerMediaId: string | null,
|
||||
) {
|
||||
return updateBusinessProfile(businessId, {
|
||||
bannerMediaId: bannerMediaId == null ? null : Number(bannerMediaId),
|
||||
});
|
||||
}
|
||||
|
||||
export type BusinessPhoneType = "landline" | "cell";
|
||||
|
||||
export interface BusinessPhoneNumber {
|
||||
type: BusinessPhoneType;
|
||||
number: string;
|
||||
/** Optional; supported after Meshkee deploy with label field */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface BusinessAddress {
|
||||
id?: string;
|
||||
/** Display name / label for the address */
|
||||
name: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string;
|
||||
landline?: string | null;
|
||||
}
|
||||
|
||||
export interface BusinessProfile {
|
||||
businessId: string;
|
||||
profile: {
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
title: string;
|
||||
about: string;
|
||||
vision: string;
|
||||
emails: string[];
|
||||
phoneNumbers: BusinessPhoneNumber[];
|
||||
socialMedia: Record<string, string>;
|
||||
logoMediaId: string | null;
|
||||
logoUrl: string | null;
|
||||
bannerMediaId: string | null;
|
||||
bannerUrl: string | null;
|
||||
categoryIds: string[];
|
||||
};
|
||||
addresses: BusinessAddress[];
|
||||
}
|
||||
|
||||
export type UpdateBusinessProfileInput = {
|
||||
nameEn?: string;
|
||||
nameFa?: string;
|
||||
title?: string;
|
||||
about?: string;
|
||||
vision?: string;
|
||||
phoneNumbers?: BusinessPhoneNumber[];
|
||||
logoMediaId?: number | null;
|
||||
bannerMediaId?: number | null;
|
||||
categoryIds?: number[];
|
||||
addresses?: BusinessAddress[];
|
||||
};
|
||||
|
||||
export async function getBusinessProfile(businessId: string) {
|
||||
const data = await apiRequest<BusinessProfile>(
|
||||
`/businesses/${businessId}/profile`,
|
||||
{ auth: true },
|
||||
);
|
||||
|
||||
return {
|
||||
businessId: String(data.businessId),
|
||||
profile: {
|
||||
...data.profile,
|
||||
logoMediaId:
|
||||
data.profile.logoMediaId == null
|
||||
? null
|
||||
: String(data.profile.logoMediaId),
|
||||
bannerMediaId:
|
||||
data.profile.bannerMediaId == null
|
||||
? null
|
||||
: String(data.profile.bannerMediaId),
|
||||
categoryIds: (data.profile.categoryIds ?? []).map(String),
|
||||
phoneNumbers: (data.profile.phoneNumbers ?? []).map((phone) => ({
|
||||
type: phone.type === "landline" ? "landline" : "cell",
|
||||
number: phone.number ?? "",
|
||||
...(phone.label?.trim() ? { label: phone.label.trim() } : {}),
|
||||
})),
|
||||
emails: data.profile.emails ?? [],
|
||||
about: data.profile.about ?? "",
|
||||
vision: data.profile.vision ?? "",
|
||||
nameEn: data.profile.nameEn ?? "",
|
||||
nameFa: data.profile.nameFa ?? "",
|
||||
title: data.profile.title ?? "",
|
||||
logoUrl: data.profile.logoUrl ?? null,
|
||||
bannerUrl: data.profile.bannerUrl ?? null,
|
||||
socialMedia: data.profile.socialMedia ?? {},
|
||||
},
|
||||
addresses: (data.addresses ?? []).map((address) => ({
|
||||
...address,
|
||||
id: address.id == null ? undefined : String(address.id),
|
||||
name: String(
|
||||
(address as BusinessAddress & { label?: string | null }).name ??
|
||||
(address as { label?: string | null }).label ??
|
||||
"",
|
||||
),
|
||||
landline: address.landline ?? "",
|
||||
postalCode: address.postalCode ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateBusinessProfile(
|
||||
businessId: string,
|
||||
body: UpdateBusinessProfileInput,
|
||||
) {
|
||||
return apiRequest<BusinessProfile>(`/businesses/${businessId}/profile`, {
|
||||
method: "PATCH",
|
||||
auth: true,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { apiRequest, getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
export type CatalogEntityType = "product" | "blog" | "portfolio";
|
||||
|
||||
export type MeshkeeEntityCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
parentId: string | null;
|
||||
description?: string | null;
|
||||
seoMetaTitle?: string | null;
|
||||
seoMetaDescription?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type UpsertCategoryInput = {
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
nameFa?: string;
|
||||
description?: string;
|
||||
seoMetaTitle?: string | null;
|
||||
seoMetaDescription?: string | null;
|
||||
};
|
||||
|
||||
export type CreateProductInput = {
|
||||
title: string;
|
||||
nameFa?: string;
|
||||
summary?: string;
|
||||
descriptionHtml?: string;
|
||||
categoryId?: string;
|
||||
featuredMediaId?: string;
|
||||
galleryMediaIds?: string[];
|
||||
tags?: string[];
|
||||
seoMetaTitle?: string | null;
|
||||
seoMetaDescription?: string | null;
|
||||
status?: "draft" | "published" | "archived";
|
||||
};
|
||||
|
||||
export type CreateBlogInput = {
|
||||
title: string;
|
||||
type: "news" | "article" | "blog";
|
||||
abstract?: string;
|
||||
mainTextHtml?: string;
|
||||
categoryId?: string;
|
||||
featuredMediaId?: string;
|
||||
tags?: string[];
|
||||
seoMetaTitle?: string | null;
|
||||
seoMetaDescription?: string | null;
|
||||
status?: "draft" | "published" | "archived";
|
||||
};
|
||||
|
||||
export type CreatePortfolioInput = {
|
||||
titleFa: string;
|
||||
titleEn?: string;
|
||||
abstract?: string;
|
||||
projectUrl?: string;
|
||||
mainTextHtml?: string;
|
||||
categoryId?: string;
|
||||
featuredMediaId?: string;
|
||||
galleryMediaIds?: string[];
|
||||
tags?: string[];
|
||||
seoMetaTitle?: string | null;
|
||||
seoMetaDescription?: string | null;
|
||||
status?: "draft" | "published" | "archived";
|
||||
};
|
||||
|
||||
export async function listMeshkeeEntityCategories(
|
||||
businessId: string,
|
||||
entityType: CatalogEntityType,
|
||||
) {
|
||||
const params = new URLSearchParams({ entityType });
|
||||
const data = await apiRequest<{ items: MeshkeeEntityCategory[] }>(
|
||||
`/businesses/${businessId}/categories?${params}`,
|
||||
{ auth: true },
|
||||
);
|
||||
return (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
parentId: item.parentId == null ? null : String(item.parentId),
|
||||
description: item.description ?? "",
|
||||
seoMetaTitle: item.seoMetaTitle ?? "",
|
||||
seoMetaDescription: item.seoMetaDescription ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export function categoryOptionLabel(category: MeshkeeEntityCategory) {
|
||||
if (category.nameFa && category.nameFa !== category.name) {
|
||||
return `${category.name} / ${category.nameFa}`;
|
||||
}
|
||||
return category.name || category.nameFa || "Category";
|
||||
}
|
||||
|
||||
export async function createMeshkeeEntityCategory(
|
||||
businessId: string,
|
||||
entityType: CatalogEntityType,
|
||||
body: UpsertCategoryInput,
|
||||
) {
|
||||
return apiRequest(`/businesses/${businessId}/categories`, {
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body: {
|
||||
entityType,
|
||||
parentId: body.parentId || undefined,
|
||||
name: body.name.trim(),
|
||||
nameFa: body.nameFa?.trim() || undefined,
|
||||
description: body.description?.trim() || undefined,
|
||||
seoMetaTitle: body.seoMetaTitle?.trim() || null,
|
||||
seoMetaDescription: body.seoMetaDescription?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMeshkeeEntityCategory(
|
||||
businessId: string,
|
||||
categoryId: string,
|
||||
body: UpsertCategoryInput,
|
||||
) {
|
||||
return apiRequest(`/businesses/${businessId}/categories/${categoryId}`, {
|
||||
method: "PATCH",
|
||||
auth: true,
|
||||
body: {
|
||||
parentId: body.parentId === undefined ? undefined : body.parentId || null,
|
||||
name: body.name.trim(),
|
||||
nameFa: body.nameFa?.trim() || null,
|
||||
description: body.description?.trim() || null,
|
||||
seoMetaTitle: body.seoMetaTitle?.trim() || null,
|
||||
seoMetaDescription: body.seoMetaDescription?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMeshkeeEntityCategory(
|
||||
businessId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
return apiRequest<{ deletedIds?: string[] }>(
|
||||
`/businesses/${businessId}/categories/${categoryId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadMeshkeeMediaFile(businessId: string, file: File) {
|
||||
const token = getAccessToken();
|
||||
const form = new FormData();
|
||||
form.append("files", file);
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3010/api/v1";
|
||||
|
||||
const response = await fetch(`${base}/businesses/${businessId}/media`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: form,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Upload failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const item = Array.isArray(payload?.items) ? payload.items[0] : payload;
|
||||
return {
|
||||
id: String(item.id),
|
||||
publicUrl: String(item.publicUrl ?? item.url ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMeshkeeProduct(
|
||||
businessId: string,
|
||||
body: CreateProductInput,
|
||||
) {
|
||||
return apiRequest(`/businesses/${businessId}/products`, {
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body: { status: "published", ...body },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMeshkeeBlog(
|
||||
businessId: string,
|
||||
body: CreateBlogInput,
|
||||
) {
|
||||
return apiRequest(`/businesses/${businessId}/blogs`, {
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body: { status: "published", ...body },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMeshkeePortfolio(
|
||||
businessId: string,
|
||||
body: CreatePortfolioInput,
|
||||
) {
|
||||
return apiRequest(`/businesses/${businessId}/portfolios`, {
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body: { status: "published", ...body },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { apiRequest } from "@/lib/meshkee-api";
|
||||
|
||||
export interface MeshkeeCategory {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type UpsertMeshkeeCategoryInput = {
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
nameFa?: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
sortOrder?: number;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
function categoryLabel(category: {
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
slug: string;
|
||||
}) {
|
||||
if (category.nameFa && category.nameFa !== category.name) {
|
||||
return `${category.name} / ${category.nameFa}`;
|
||||
}
|
||||
return category.name;
|
||||
}
|
||||
|
||||
function mapCategory(row: Record<string, unknown>): MeshkeeCategory {
|
||||
return {
|
||||
id: String(row.id),
|
||||
parentId:
|
||||
row.parentId == null && row.parent_id == null
|
||||
? null
|
||||
: String(row.parentId ?? row.parent_id),
|
||||
name: String(row.name ?? ""),
|
||||
nameFa:
|
||||
row.nameFa == null && row.name_fa == null
|
||||
? null
|
||||
: String(row.nameFa ?? row.name_fa),
|
||||
slug: String(row.slug ?? ""),
|
||||
description: row.description == null ? null : String(row.description),
|
||||
icon: row.icon == null ? null : String(row.icon),
|
||||
sortOrder: Number(row.sortOrder ?? row.sort_order ?? 0),
|
||||
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchMeshkeeCategories(
|
||||
q: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
|
||||
const data = await apiRequest<{ items: Record<string, unknown>[] }>(
|
||||
`/business-categories?${params}`,
|
||||
{ auth: true, signal },
|
||||
);
|
||||
|
||||
return (data.items ?? []).map((row) => mapCategory(row));
|
||||
}
|
||||
|
||||
export async function createMeshkeeCategory(body: UpsertMeshkeeCategoryInput) {
|
||||
const data = await apiRequest<{ item: Record<string, unknown> }>(
|
||||
`/business-categories`,
|
||||
{
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body: {
|
||||
name: body.name.trim(),
|
||||
nameFa: body.nameFa?.trim() || undefined,
|
||||
description: body.description?.trim() || undefined,
|
||||
parentId: body.parentId || undefined,
|
||||
slug: body.slug?.trim() || undefined,
|
||||
sortOrder: body.sortOrder,
|
||||
icon: body.icon?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
return mapCategory(data.item ?? (data as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function updateMeshkeeCategory(
|
||||
categoryId: string,
|
||||
body: UpsertMeshkeeCategoryInput,
|
||||
) {
|
||||
const data = await apiRequest<{ item: Record<string, unknown> }>(
|
||||
`/business-categories/${categoryId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
auth: true,
|
||||
body: {
|
||||
name: body.name.trim(),
|
||||
nameFa: body.nameFa?.trim() || null,
|
||||
description: body.description?.trim() || null,
|
||||
parentId: body.parentId === undefined ? undefined : body.parentId || null,
|
||||
slug: body.slug?.trim() || undefined,
|
||||
sortOrder: body.sortOrder,
|
||||
icon: body.icon?.trim() || null,
|
||||
},
|
||||
},
|
||||
);
|
||||
return mapCategory(data.item ?? (data as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function deleteMeshkeeCategory(categoryId: string) {
|
||||
return apiRequest<{ deletedIds?: string[] }>(
|
||||
`/business-categories/${categoryId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { categoryLabel };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiRequest } from "@/lib/meshkee-api";
|
||||
|
||||
export type MeshkeeCityLevel = "country" | "province" | "city";
|
||||
|
||||
export interface MeshkeeCity {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
level: MeshkeeCityLevel;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
landlineCode: string | null;
|
||||
slug: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function cityLabel(city: Pick<MeshkeeCity, "nameEn" | "nameFa">) {
|
||||
if (city.nameFa && city.nameFa !== city.nameEn) {
|
||||
return `${city.nameEn} / ${city.nameFa}`;
|
||||
}
|
||||
return city.nameEn;
|
||||
}
|
||||
|
||||
export function matchCityName(
|
||||
cities: MeshkeeCity[],
|
||||
name: string,
|
||||
): MeshkeeCity | undefined {
|
||||
const needle = name.trim().toLowerCase();
|
||||
if (!needle) return undefined;
|
||||
return cities.find(
|
||||
(city) =>
|
||||
city.nameEn.toLowerCase() === needle ||
|
||||
city.nameFa.toLowerCase() === needle ||
|
||||
city.slug.toLowerCase() === needle,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMeshkeeCities(params: {
|
||||
level?: MeshkeeCityLevel;
|
||||
parentId?: string;
|
||||
parentSlug?: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.level) query.set("level", params.level);
|
||||
if (params.parentId) query.set("parentId", params.parentId);
|
||||
if (params.parentSlug) query.set("parentSlug", params.parentSlug);
|
||||
|
||||
const data = await apiRequest<{ items: Record<string, unknown>[] }>(
|
||||
`/cities?${query}`,
|
||||
{ signal: params.signal },
|
||||
);
|
||||
|
||||
return (data.items ?? []).map((row) => ({
|
||||
id: String(row.id),
|
||||
parentId: row.parentId == null ? null : String(row.parentId),
|
||||
level: String(row.level ?? "city") as MeshkeeCityLevel,
|
||||
nameFa: String(row.nameFa ?? ""),
|
||||
nameEn: String(row.nameEn ?? ""),
|
||||
landlineCode:
|
||||
row.landlineCode == null ? null : String(row.landlineCode),
|
||||
slug: String(row.slug ?? ""),
|
||||
sortOrder: Number(row.sortOrder ?? 0),
|
||||
})) satisfies MeshkeeCity[];
|
||||
}
|
||||
|
||||
export async function listIranProvinces(signal?: AbortSignal) {
|
||||
return listMeshkeeCities({
|
||||
level: "province",
|
||||
parentSlug: "iran",
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCitiesInProvince(
|
||||
provinceId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return listMeshkeeCities({
|
||||
level: "city",
|
||||
parentId: provinceId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { apiRequest, getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
export interface MeshkeeProduct {
|
||||
id: string;
|
||||
title: string;
|
||||
nameFa?: string;
|
||||
summary?: string;
|
||||
slug: string;
|
||||
status?: string;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string;
|
||||
categoryParentName?: string;
|
||||
thumbnailUrl?: string | null;
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface MeshkeeBlog {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface MeshkeePortfolio {
|
||||
id: string;
|
||||
title?: string;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
name?: string;
|
||||
nameFa?: string | null;
|
||||
nameEn?: string | null;
|
||||
slug: string;
|
||||
abstract?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
coverUrl?: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
export interface MeshkeeMediaItem {
|
||||
id: string;
|
||||
mediaType: string;
|
||||
publicUrl: string;
|
||||
altText?: string | null;
|
||||
fileName?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface MeshkeeBusinessCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
parentId: string | null;
|
||||
slug?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type ListResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type ListOptions = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
async function listResource<T>(
|
||||
path: string,
|
||||
options: ListOptions = {},
|
||||
): Promise<ListResponse<T>> {
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 12;
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
const data = await apiRequest<ListResponse<T>>(`${path}?${params}`, {
|
||||
auth: true,
|
||||
});
|
||||
return {
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String((item as { id: string | number }).id),
|
||||
})),
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? page,
|
||||
pageSize: data.pageSize ?? pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function listMeshkeeProducts(
|
||||
businessId: string,
|
||||
options: ListOptions = {},
|
||||
) {
|
||||
return listResource<MeshkeeProduct>(
|
||||
`/businesses/${businessId}/products`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function listMeshkeeBlogs(
|
||||
businessId: string,
|
||||
options: ListOptions = {},
|
||||
) {
|
||||
return listResource<MeshkeeBlog>(`/businesses/${businessId}/blogs`, options);
|
||||
}
|
||||
|
||||
export function listMeshkeePortfolios(
|
||||
businessId: string,
|
||||
options: ListOptions = {},
|
||||
) {
|
||||
return listResource<MeshkeePortfolio>(
|
||||
`/businesses/${businessId}/portfolios`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMeshkeeGallery(
|
||||
businessId: string,
|
||||
options: ListOptions = {},
|
||||
) {
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 16;
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
const data = await apiRequest<ListResponse<MeshkeeMediaItem>>(
|
||||
`/businesses/${businessId}/gallery?${params}`,
|
||||
{ auth: true },
|
||||
);
|
||||
|
||||
return {
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
})),
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? page,
|
||||
pageSize: data.pageSize ?? pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export async function addMeshkeeGalleryItems(
|
||||
businessId: string,
|
||||
input: { files?: File[]; mediaIds?: string[] },
|
||||
) {
|
||||
const token = getAccessToken();
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3010/api/v1";
|
||||
|
||||
if (input.files?.length) {
|
||||
const form = new FormData();
|
||||
for (const file of input.files) {
|
||||
form.append("files", file);
|
||||
}
|
||||
if (input.mediaIds?.length) {
|
||||
form.append("mediaIds", JSON.stringify(input.mediaIds));
|
||||
}
|
||||
|
||||
const response = await fetch(`${base}/businesses/${businessId}/gallery`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: form,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Upload failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return {
|
||||
items: ((payload?.items ?? []) as MeshkeeMediaItem[]).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await apiRequest<{ items: MeshkeeMediaItem[] }>(
|
||||
`/businesses/${businessId}/gallery`,
|
||||
{
|
||||
method: "POST",
|
||||
auth: true,
|
||||
body: { mediaIds: input.mediaIds ?? [] },
|
||||
},
|
||||
);
|
||||
return {
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteMeshkeeGalleryItem(
|
||||
businessId: string,
|
||||
mediaId: string,
|
||||
) {
|
||||
await apiRequest(`/businesses/${businessId}/gallery/${mediaId}`, {
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMeshkeeBusinessCategories(businessId: string) {
|
||||
const data = await apiRequest<{ items: MeshkeeBusinessCategory[] }>(
|
||||
`/businesses/${businessId}/categories`,
|
||||
{ auth: true },
|
||||
);
|
||||
|
||||
return (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
parentId: item.parentId == null ? null : String(item.parentId),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteMeshkeeProduct(
|
||||
businessId: string,
|
||||
productId: string,
|
||||
) {
|
||||
await apiRequest(`/businesses/${businessId}/products/${productId}`, {
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMeshkeeBlog(businessId: string, blogId: string) {
|
||||
await apiRequest(`/businesses/${businessId}/blogs/${blogId}`, {
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMeshkeeMedia(businessId: string, mediaId: string) {
|
||||
await apiRequest(`/businesses/${businessId}/media/${mediaId}`, {
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMeshkeePortfolio(
|
||||
businessId: string,
|
||||
portfolioId: string,
|
||||
) {
|
||||
await apiRequest(`/businesses/${businessId}/portfolios/${portfolioId}`, {
|
||||
method: "DELETE",
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Collect category id plus all descendant ids. */
|
||||
export function collectCategorySubtreeIds(
|
||||
categories: MeshkeeBusinessCategory[],
|
||||
rootId: string,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>([rootId]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const category of categories) {
|
||||
if (
|
||||
category.parentId &&
|
||||
ids.has(category.parentId) &&
|
||||
!ids.has(category.id)
|
||||
) {
|
||||
ids.add(category.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3001/api/v1";
|
||||
|
||||
export type SpecialCarousel = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtext: string;
|
||||
slug: string;
|
||||
businessIds: string[];
|
||||
};
|
||||
|
||||
export type SpecialBusinessesConfig = {
|
||||
carousels: SpecialCarousel[];
|
||||
};
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function getSpecialBusinesses(): Promise<SpecialBusinessesConfig> {
|
||||
const response = await fetch(`${API_BASE_URL}/website/special-businesses`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
return parseResponse<SpecialBusinessesConfig>(response);
|
||||
}
|
||||
|
||||
export async function saveSpecialBusinesses(
|
||||
body: SpecialBusinessesConfig,
|
||||
): Promise<SpecialBusinessesConfig> {
|
||||
const response = await fetch(`${API_BASE_URL}/website/special-businesses`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(getAccessToken()
|
||||
? { Authorization: `Bearer ${getAccessToken()}` }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseResponse<SpecialBusinessesConfig>(response);
|
||||
}
|
||||
|
||||
export function slugFromTitle(title: string) {
|
||||
return (
|
||||
title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "carousel"
|
||||
);
|
||||
}
|
||||
|
||||
export function newCarousel(
|
||||
title = "New carousel",
|
||||
subtext = "",
|
||||
): SpecialCarousel {
|
||||
return {
|
||||
id: `carousel-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
title,
|
||||
subtext,
|
||||
slug: slugFromTitle(title),
|
||||
businessIds: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3001/api/v1";
|
||||
|
||||
function apiOrigin() {
|
||||
return API_BASE_URL.replace(/\/api\/v1$/, "");
|
||||
}
|
||||
|
||||
export type WebsiteBlogCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
description?: string | null;
|
||||
parentId: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type UpsertWebsiteBlogCategoryInput = {
|
||||
name: string;
|
||||
nameFa?: string;
|
||||
description?: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
export type WebsiteBlogPost = {
|
||||
id: string;
|
||||
title: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
imageUrl?: string | null;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateWebsiteBlogPostInput = {
|
||||
title: string;
|
||||
categoryId: string;
|
||||
abstract?: string;
|
||||
mainTextHtml?: string;
|
||||
imageUrl?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function authHeaders(json = false): HeadersInit {
|
||||
const headers: Record<string, string> = {};
|
||||
if (json) headers["Content-Type"] = "application/json";
|
||||
const token = getAccessToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function toStoredMediaUrl(url: string) {
|
||||
if (!url) return url;
|
||||
const origin = apiOrigin();
|
||||
if (url.startsWith(origin)) return url.slice(origin.length);
|
||||
return url;
|
||||
}
|
||||
|
||||
export function toDisplayMediaUrl(url: string) {
|
||||
if (!url) return url;
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
||||
if (url.startsWith("/api/v1/")) return `${apiOrigin()}${url}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
function rewriteSrc(
|
||||
html: string,
|
||||
map: (src: string) => string,
|
||||
) {
|
||||
return html.replace(
|
||||
/(<img\b[^>]*\bsrc=["'])([^"']*)(["'])/gi,
|
||||
(_match, start: string, src: string, end: string) =>
|
||||
`${start}${map(src)}${end}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function toStoredHtml(html: string) {
|
||||
return rewriteSrc(html, toStoredMediaUrl);
|
||||
}
|
||||
|
||||
export function toEditorHtml(html: string) {
|
||||
return rewriteSrc(html, toDisplayMediaUrl);
|
||||
}
|
||||
|
||||
export async function listWebsiteBlogCategories() {
|
||||
const response = await fetch(`${API_BASE_URL}/website/blog/categories`, {
|
||||
cache: "no-store",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const data = await parseResponse<{ items: WebsiteBlogCategory[] }>(response);
|
||||
return (data.items ?? []).map(normalizeCategory);
|
||||
}
|
||||
|
||||
function normalizeCategory(item: WebsiteBlogCategory): WebsiteBlogCategory {
|
||||
return {
|
||||
...item,
|
||||
id: String(item.id),
|
||||
parentId: item.parentId == null ? null : String(item.parentId),
|
||||
nameFa: item.nameFa ?? null,
|
||||
description: item.description ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function categoryBody(input: UpsertWebsiteBlogCategoryInput) {
|
||||
return {
|
||||
name: input.name,
|
||||
nameFa: input.nameFa?.trim() || undefined,
|
||||
description: input.description?.trim() || undefined,
|
||||
...(input.parentId ? { parentId: input.parentId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebsiteBlogCategory(
|
||||
input: UpsertWebsiteBlogCategoryInput,
|
||||
) {
|
||||
const response = await fetch(`${API_BASE_URL}/website/blog/categories`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(categoryBody(input)),
|
||||
});
|
||||
const item = await parseResponse<WebsiteBlogCategory>(response);
|
||||
return normalizeCategory(item);
|
||||
}
|
||||
|
||||
export async function updateWebsiteBlogCategory(
|
||||
id: string,
|
||||
input: UpsertWebsiteBlogCategoryInput,
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/website/blog/categories/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(categoryBody(input)),
|
||||
},
|
||||
);
|
||||
const item = await parseResponse<WebsiteBlogCategory>(response);
|
||||
return normalizeCategory(item);
|
||||
}
|
||||
|
||||
export async function deleteWebsiteBlogCategory(id: string) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/website/blog/categories/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
},
|
||||
);
|
||||
return parseResponse<{ ok: boolean }>(response);
|
||||
}
|
||||
|
||||
export async function listWebsiteBlogPosts() {
|
||||
const response = await fetch(`${API_BASE_URL}/website/blog/posts`, {
|
||||
cache: "no-store",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const data = await parseResponse<{
|
||||
items: WebsiteBlogPost[];
|
||||
total: number;
|
||||
}>(response);
|
||||
return {
|
||||
total: data.total ?? data.items?.length ?? 0,
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
categoryId: String(item.categoryId),
|
||||
imageUrl: item.imageUrl ? toDisplayMediaUrl(item.imageUrl) : item.imageUrl,
|
||||
tags: item.tags ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebsiteBlogPost(input: CreateWebsiteBlogPostInput) {
|
||||
const response = await fetch(`${API_BASE_URL}/website/blog/posts`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
imageUrl: input.imageUrl ? toStoredMediaUrl(input.imageUrl) : undefined,
|
||||
mainTextHtml: input.mainTextHtml
|
||||
? toStoredHtml(input.mainTextHtml)
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
return parseResponse<WebsiteBlogPost>(response);
|
||||
}
|
||||
|
||||
export async function deleteWebsiteBlogPost(id: string) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/website/blog/posts/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
},
|
||||
);
|
||||
return parseResponse<{ ok: boolean }>(response);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getAccessToken } from "@/lib/meshkee-api";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3001/api/v1";
|
||||
|
||||
const WEBSITE_ORIGIN =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
export type WebsiteMediaItem = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
linkUrl: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
cta?: string;
|
||||
};
|
||||
|
||||
export type WebsiteHomeMedia = {
|
||||
slider: WebsiteMediaItem[];
|
||||
standingBanners: WebsiteMediaItem[];
|
||||
categories: WebsiteMediaItem[];
|
||||
menuImages: WebsiteMediaItem[];
|
||||
aboutUs: WebsiteMediaItem;
|
||||
banners: WebsiteMediaItem[];
|
||||
};
|
||||
|
||||
function resolveMediaUrl(url: string) {
|
||||
if (!url) return url;
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
||||
// Uploaded files live on the IranBay marketplace API.
|
||||
if (url.startsWith("/api/v1/")) {
|
||||
const origin = API_BASE_URL.replace(/\/api\/v1$/, "");
|
||||
return `${origin}${url}`;
|
||||
}
|
||||
// Bundled homepage assets live on the public website.
|
||||
if (url.startsWith("/")) {
|
||||
return `${WEBSITE_ORIGIN}${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function getWebsiteHomeMedia(): Promise<WebsiteHomeMedia> {
|
||||
const response = await fetch(`${API_BASE_URL}/website/home-media`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await parseResponse<WebsiteHomeMedia>(response);
|
||||
return {
|
||||
...data,
|
||||
slider: data.slider.map((item) => ({
|
||||
...item,
|
||||
imageUrl: resolveMediaUrl(item.imageUrl),
|
||||
})),
|
||||
standingBanners: data.standingBanners.map((item) => ({
|
||||
...item,
|
||||
imageUrl: resolveMediaUrl(item.imageUrl),
|
||||
})),
|
||||
categories: data.categories.map((item) => ({
|
||||
...item,
|
||||
imageUrl: resolveMediaUrl(item.imageUrl),
|
||||
})),
|
||||
menuImages: (data.menuImages ?? []).map((item) => ({
|
||||
...item,
|
||||
imageUrl: resolveMediaUrl(item.imageUrl),
|
||||
})),
|
||||
aboutUs: {
|
||||
...data.aboutUs,
|
||||
imageUrl: resolveMediaUrl(data.aboutUs.imageUrl),
|
||||
},
|
||||
banners: data.banners.map((item) => ({
|
||||
...item,
|
||||
imageUrl: resolveMediaUrl(item.imageUrl),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveWebsiteHomeMedia(
|
||||
body: WebsiteHomeMedia,
|
||||
): Promise<WebsiteHomeMedia> {
|
||||
const response = await fetch(`${API_BASE_URL}/website/home-media`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(getAccessToken()
|
||||
? { Authorization: `Bearer ${getAccessToken()}` }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseResponse<WebsiteHomeMedia>(response);
|
||||
}
|
||||
|
||||
export async function uploadWebsiteMedia(file: File): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const headers: HeadersInit = {};
|
||||
const token = getAccessToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/website/upload`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
const data = await parseResponse<{ url: string }>(response);
|
||||
return resolveMediaUrl(data.url);
|
||||
}
|
||||
|
||||
export function newMediaItem(
|
||||
partial?: Partial<WebsiteMediaItem>,
|
||||
): WebsiteMediaItem {
|
||||
return {
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
imageUrl: "",
|
||||
linkUrl: "/",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user