Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:27 +03:30
co-authored by Cursor
commit 1153e8e78f
178 changed files with 30517 additions and 0 deletions
+49
View File
@@ -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();
}
+117
View File
@@ -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);
}
+76
View File
@@ -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.`;
}
+312
View File
@@ -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,
});
}
+206
View File
@@ -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 },
});
}
+124
View File
@@ -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 };
+83
View File
@@ -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,
});
}
+285
View File
@@ -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;
}
+76
View File
@@ -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: [],
};
}
+216
View File
@@ -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);
}
+133
View File
@@ -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,
};
}