feat(admin-web): add web/admin to repo

Initial commit of the Super-Admin web panel (Next.js + TypeScript).
CI admin-web-check job was failing because the directory was never
tracked in git.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-28 18:45:57 +03:30
parent f717c02467
commit 0a33497d40
98 changed files with 17848 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import axios, { type AxiosError } from "axios";
import type { ApiResponse } from "./types";
const baseURL =
process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "http://localhost:5081";
export const adminApi = axios.create({
baseURL,
headers: { "Content-Type": "application/json" },
});
adminApi.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("meezi_admin_access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
adminApi.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiResponse<unknown>>) => {
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new AdminApiClientError(apiError.code, apiError.message));
}
if (error.response?.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("meezi_admin_access_token");
localStorage.removeItem("meezi_admin_refresh_token");
localStorage.removeItem("meezi_admin_auth");
const locale = window.location.pathname.split("/")[1] ?? "fa";
window.location.href = `/${locale}/admin/login`;
}
return Promise.reject(error);
}
);
export class AdminApiClientError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
this.name = "AdminApiClientError";
}
}
export async function adminGet<T>(url: string): Promise<T> {
const { data } = await adminApi.get<ApiResponse<T>>(url);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminPost<T>(url: string, body?: unknown): Promise<T> {
const { data } = await adminApi.post<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminPut<T>(url: string, body: unknown): Promise<T> {
const { data } = await adminApi.put<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminPatch<T>(url: string, body: unknown): Promise<T> {
const { data } = await adminApi.patch<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminDelete(url: string): Promise<void> {
const { data } = await adminApi.delete<ApiResponse<unknown>>(url);
if (!data.success) {
throw new Error(data.error?.message ?? "Request failed");
}
}
+217
View File
@@ -0,0 +1,217 @@
export type AdminStats = {
totalCafes: number;
activeCafes: number;
suspendedCafes: number;
openTickets: number;
plansConfigured: number;
};
export type PlanLimitsData = {
maxOrdersPerDay: number;
maxTerminals: number;
maxCustomers: number;
maxSmsPerMonth: number;
maxBranches: number;
maxReportHistoryDays: number;
};
export type AdminPlan = {
tier: string;
displayNameFa: string;
displayNameEn?: string | null;
monthlyPriceToman: number;
isBillableOnline: boolean;
isActive: boolean;
sortOrder: number;
limits: PlanLimitsData;
featureKeys: string[];
};
export type PlatformSetting = {
id: string;
key: string;
value: string;
category: string;
descriptionFa?: string | null;
updatedAt: string;
};
export type PlatformFeature = {
id: string;
key: string;
displayNameFa: string;
displayNameEn?: string | null;
moduleGroup: string;
isEnabledGlobally: boolean;
updatedAt: string;
};
export type AdminCafe = {
id: string;
name: string;
slug: string;
city: string;
planTier: string;
planExpiresAt?: string | null;
isSuspended: boolean;
isVerified: boolean;
branchCount: number;
employeeCount: number;
createdAt: string;
};
export type SupportTicket = {
id: string;
cafeId: string;
cafeName: string;
subject: string;
status: string;
priority: string;
createdByEmployeeId: string;
createdByName?: string | null;
assignedAdminId?: string | null;
createdAt: string;
updatedAt: string;
messageCount: number;
};
export type SupportTicketMessage = {
id: string;
senderKind: string;
senderId: string;
senderName?: string | null;
body: string;
createdAt: string;
};
export type SupportTicketDetail = {
ticket: SupportTicket;
messages: SupportTicketMessage[];
};
export type GatewayCredentials = {
username?: string | null;
password?: string | null;
branchCode?: string | null;
terminalCode?: string | null;
clientId?: string | null;
clientSecret?: string | null;
baseUrl?: string | null;
hasStoredPassword: boolean;
hasStoredClientSecret: boolean;
};
export type PaymentGatewayConfig = {
id: string;
displayNameFa: string;
isEnabled: boolean;
isActive: boolean;
merchantId?: string | null;
apiKey?: string | null;
sandbox: boolean;
hasStoredSecret: boolean;
credentials?: GatewayCredentials | null;
};
export type KavenegarConfig = {
isEnabled: boolean;
apiKey?: string | null;
otpTemplate: string;
hasStoredApiKey: boolean;
};
export type OpenAiIntegrationConfig = {
isEnabled: boolean;
apiKey?: string | null;
model: string;
coffeeAdvisorEnabled: boolean;
hasStoredApiKey: boolean;
};
export type MeshyIntegrationConfig = {
isEnabled: boolean;
apiKey?: string | null;
menu3dEnabled: boolean;
hasStoredApiKey: boolean;
};
export type AiIntegrationsConfig = {
openAi: OpenAiIntegrationConfig;
meshy: MeshyIntegrationConfig;
};
export type PlatformIntegrations = {
activePaymentGateway: string;
paymentGateways: PaymentGatewayConfig[];
kavenegar: KavenegarConfig;
ai: AiIntegrationsConfig;
};
export type AdminNotificationRow = {
id: string;
cafeId: string;
cafeName: string;
type: string;
title: string;
body?: string | null;
isRead: boolean;
createdAt: string;
};
export type BroadcastResult = {
cafeCount: number;
notificationCount: number;
};
// ── Website CMS ──────────────────────────────────────────────────────────────
export type AdminBlogPost = {
id: string;
slug: string;
titleFa: string;
titleEn: string;
excerptFa: string;
excerptEn: string;
author: string;
categoryFa: string;
categoryEn: string;
isPublished: boolean;
publishedAt?: string | null;
viewCount: number;
commentCount: number;
createdAt: string;
updatedAt: string;
};
export type AdminBlogPostDetail = AdminBlogPost & {
contentFa: string;
contentEn: string;
coverImage?: string | null;
tagsJson: string;
};
export type AdminComment = {
id: string;
postSlug: string;
authorName: string;
authorEmail?: string | null;
content: string;
isApproved: boolean;
ipAddress?: string | null;
createdAt: string;
};
export type AdminDemoRequest = {
id: string;
contactName: string;
businessName: string;
phone: string;
email?: string | null;
branchCount: string;
notes?: string | null;
source: string;
status: "New" | "Contacted" | "DemoScheduled" | "Converted" | "Rejected";
adminNotes?: string | null;
contactedAt?: string | null;
createdAt: string;
};
+57
View File
@@ -0,0 +1,57 @@
import { apiDelete, apiGet, apiPut } from "@/lib/api/client";
import type { MenuItem } from "@/lib/api/types";
export interface BranchMenuItem extends MenuItem {
masterPrice: number;
effectivePrice: number;
isOverridden: boolean;
hasPriceOverride: boolean;
}
export function branchMenuItemToMenuItem(row: BranchMenuItem): MenuItem {
return {
id: row.id,
categoryId: row.categoryId,
name: row.name,
nameAr: row.nameAr,
nameEn: row.nameEn,
description: row.description,
price: row.effectivePrice,
imageUrl: row.imageUrl,
videoUrl: row.videoUrl,
isAvailable: row.isAvailable,
};
}
export async function getBranchMenu(
cafeId: string,
branchId: string,
options?: { includeUnavailable?: boolean }
): Promise<BranchMenuItem[]> {
const qs = options?.includeUnavailable ? "?includeUnavailable=true" : "";
return apiGet<BranchMenuItem[]>(
`/api/cafes/${cafeId}/branches/${branchId}/menu${qs}`
);
}
export async function upsertBranchMenuOverride(
cafeId: string,
branchId: string,
menuItemId: string,
body: { isAvailable: boolean; priceOverride: number | null }
): Promise<void> {
await apiPut(
`/api/cafes/${cafeId}/branches/${branchId}/menu/${menuItemId}/override`,
body
);
}
export async function deleteBranchMenuOverride(
cafeId: string,
branchId: string,
menuItemId: string
): Promise<void> {
await apiDelete(
`/api/cafes/${cafeId}/branches/${branchId}/menu/${menuItemId}/override`
);
}
+134
View File
@@ -0,0 +1,134 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import type { TableBoardItem } from "@/lib/api/types";
export interface TableSectionDto {
id: string;
branchId: string;
name: string;
sortOrder: number;
isActive: boolean;
tableCount: number;
}
export function branchTablesPath(cafeId: string, branchId: string): string {
return `/api/cafes/${cafeId}/branches/${branchId}/tables`;
}
export async function fetchBranchTableBoard(
cafeId: string,
branchId: string,
activeOnly = false
): Promise<TableBoardItem[]> {
const params = new URLSearchParams({ activeOnly: String(activeOnly) });
return apiGet<TableBoardItem[]>(
`${branchTablesPath(cafeId, branchId)}/board?${params}`
);
}
/** POS + admin board: café-wide endpoint (optional branch filter), with fallback if branch has no rows. */
export async function fetchCafeTableBoard(
cafeId: string,
branchId?: string | null
): Promise<TableBoardItem[]> {
const params = new URLSearchParams({ activeOnly: "false" });
if (branchId) params.set("branchId", branchId);
const scoped = await apiGet<TableBoardItem[]>(
`/api/cafes/${cafeId}/tables/board?${params}`
);
if (scoped.length > 0 || !branchId) return scoped;
return apiGet<TableBoardItem[]>(
`/api/cafes/${cafeId}/tables/board?activeOnly=false`
);
}
export async function fetchBranchSections(
cafeId: string,
branchId: string
): Promise<TableSectionDto[]> {
return apiGet<TableSectionDto[]>(
`${branchTablesPath(cafeId, branchId)}/sections`
);
}
export async function createBranchTable(
cafeId: string,
branchId: string,
body: {
number: string;
capacity: number;
floor?: string | null;
sectionId?: string | null;
sortOrder?: number;
}
): Promise<void> {
await apiPost(`${branchTablesPath(cafeId, branchId)}`, body);
}
export async function patchBranchTable(
cafeId: string,
branchId: string,
tableId: string,
body: Record<string, unknown>
): Promise<void> {
await apiPatch(`${branchTablesPath(cafeId, branchId)}/${tableId}`, body);
}
export async function deleteBranchTable(
cafeId: string,
branchId: string,
tableId: string
): Promise<void> {
await apiDelete(`${branchTablesPath(cafeId, branchId)}/${tableId}`);
}
export async function setTableCleaning(
cafeId: string,
tableId: string,
isCleaning: boolean,
branchId?: string | null
): Promise<TableBoardItem> {
const body = { isCleaning };
if (branchId) {
return apiPatch<TableBoardItem>(
`${branchTablesPath(cafeId, branchId)}/${tableId}/cleaning`,
body
);
}
return apiPatch<TableBoardItem>(
`/api/cafes/${cafeId}/tables/${tableId}/cleaning`,
body
);
}
export async function createBranchSection(
cafeId: string,
branchId: string,
body: { name: string; sortOrder?: number }
): Promise<TableSectionDto> {
return apiPost<TableSectionDto>(
`${branchTablesPath(cafeId, branchId)}/sections`,
body
);
}
export async function patchBranchSection(
cafeId: string,
branchId: string,
sectionId: string,
body: { name?: string; sortOrder?: number }
): Promise<TableSectionDto> {
return apiPatch<TableSectionDto>(
`${branchTablesPath(cafeId, branchId)}/sections/${sectionId}`,
body
);
}
export async function deleteBranchSection(
cafeId: string,
branchId: string,
sectionId: string
): Promise<void> {
await apiDelete(
`${branchTablesPath(cafeId, branchId)}/sections/${sectionId}`
);
}
+171
View File
@@ -0,0 +1,171 @@
import axios, { type AxiosError } from "axios";
import type { ApiResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal";
const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
export const api = axios.create({
baseURL,
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("meezi_access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers["X-Meezi-Terminal-Id"] = getOrCreateTerminalId();
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiResponse<unknown>>) => {
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
}
if (error.response?.status === 401 && typeof window !== "undefined") {
const path = window.location.pathname;
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
const isAdmin = path.includes("/admin");
if (!isPublicGuest && !isAdmin) {
localStorage.removeItem("meezi_access_token");
localStorage.removeItem("meezi_refresh_token");
localStorage.removeItem("meezi_auth");
const locale = path.split("/")[1] ?? "fa";
window.location.href = `/${locale}/login`;
}
}
return Promise.reject(error);
}
);
export interface PagedMeta {
total: number;
page: number;
pageSize: number;
}
export interface PagedApiResponse<T> {
success: boolean;
data?: T[];
meta?: PagedMeta;
error?: { code: string; message: string; field?: string };
}
export async function apiGet<T>(url: string): Promise<T> {
const { data } = await api.get<ApiResponse<T>>(url);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: PagedMeta }> {
const { data } = await api.get<PagedApiResponse<T>>(url);
if (!data.success || data.data === undefined || !data.meta) {
throw new Error(data.error?.message ?? "Request failed");
}
return { items: data.data, meta: data.meta };
}
export class ApiClientError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
this.name = "ApiClientError";
}
}
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.post<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.put<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.patch<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiDelete(url: string): Promise<void> {
const { data } = await api.delete<ApiResponse<unknown>>(url);
if (!data.success) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
}
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
export async function apiGetBlob(path: string): Promise<Blob> {
const response = await api.get(path, { responseType: "blob" });
return response.data as Blob;
}
/** Public GET JSON (no auth required). */
export async function apiGetPublic<T>(path: string): Promise<T> {
const { data } = await api.get<ApiResponse<T>>(path);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
return data.data;
}
export function openBlobInNewTab(blob: Blob): void {
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}
export async function apiDownload(path: string, filename: string): Promise<void> {
const blob = await apiGetBlob(path);
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
export async function apiUpload<T>(url: string, file: File): Promise<T> {
const form = new FormData();
form.append("file", file);
const { data } = await api.post<ApiResponse<T>>(url, form, {
headers: { "Content-Type": "multipart/form-data" },
});
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Upload failed");
}
return data.data;
}
export function resolveMediaUrl(path?: string | null): string | undefined {
if (!path) return undefined;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const base = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
}
+2
View File
@@ -0,0 +1,2 @@
/** @deprecated Import from `@/lib/api/client` instead. */
export { apiDownload } from "@/lib/api/client";
+40
View File
@@ -0,0 +1,40 @@
import { apiGet, apiPost } from "@/lib/api/client";
export type CafeNotification = {
id: string;
type: string;
title: string;
body?: string | null;
referenceId?: string | null;
tableNumber?: string | null;
isRead: boolean;
createdAt: string;
};
export type NotificationList = {
items: CafeNotification[];
unreadCount: number;
};
export async function fetchNotifications(
cafeId: string,
unreadOnly = false
): Promise<NotificationList> {
return apiGet<NotificationList>(
`/api/cafes/${cafeId}/notifications?unreadOnly=${unreadOnly}&limit=50`
);
}
export async function fetchUnreadCount(cafeId: string): Promise<number> {
const data = await apiGet<{ count: number }>(
`/api/cafes/${cafeId}/notifications/unread-count`
);
return data.count;
}
export async function markNotificationsRead(
cafeId: string,
body: { ids?: string[]; all?: boolean }
): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/notifications/read`, body);
}
+33
View File
@@ -0,0 +1,33 @@
import { apiPost, ApiClientError } from "@/lib/api/client";
export type PosPaymentRequestResult = {
sent: boolean;
skipped: boolean;
message?: string | null;
};
export async function requestPosPayment(
cafeId: string,
branchId: string,
orderId: string,
amount: number
): Promise<PosPaymentRequestResult> {
return apiPost<PosPaymentRequestResult>(
`/api/cafes/${cafeId}/branches/${branchId}/pos-device/payment-request`,
{ orderId, amount }
);
}
export function posDeviceErrorMessage(
err: unknown,
t: (key: string) => string
): string {
if (err instanceof ApiClientError) {
if (err.code === "POS_DEVICE_NOT_CONFIGURED") return t("posDeviceNotConfigured");
if (err.code === "POS_DEVICE_CONNECTION_FAILED") return t("posDeviceConnectionFailed");
if (err.code === "POS_DEVICE_TIMEOUT") return t("posDeviceTimeout");
if (err.code === "POS_DEVICE_REJECTED") return t("posDeviceRejected");
if (err.code.startsWith("POS_DEVICE")) return t("posDeviceError");
}
return t("posDeviceError");
}
+26
View File
@@ -0,0 +1,26 @@
import { apiPost, ApiClientError } from "@/lib/api/client";
export async function printReceipt(cafeId: string, orderId: string): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/receipt/${orderId}`, {});
}
export async function printKitchen(cafeId: string, orderId: string): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}`, {});
}
export async function testPrinter(
cafeId: string,
printerIp: string,
port: number
): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/test`, { printerIp, port });
}
export function printErrorMessage(err: unknown, t: (key: string) => string): string {
if (err instanceof ApiClientError) {
if (err.code === "PRINTER_NOT_CONFIGURED" || err.code === "KITCHEN_PRINTER_NOT_CONFIGURED")
return t("notConfigured");
if (err.code === "PRINTER_CONNECTION_FAILED") return t("connectionFailed");
}
return t("connectionFailed");
}
+86
View File
@@ -0,0 +1,86 @@
import { apiGetPublic } from "@/lib/api/client";
import type { CafeDiscoverProfile } from "@/lib/cafe-discover-profile";
export type PublicCafeDiscover = {
id: string;
name: string;
slug: string;
city: string | null;
address: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
isVerified: boolean;
averageRating: number;
reviewCount: number;
discoverProfile: CafeDiscoverProfile;
};
export type DiscoverTaxonomy = {
themes: string[];
sizes: string[];
floors: string[];
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevels: string[];
priceTiers: string[];
};
export type DiscoverSearchParams = {
city?: string;
q?: string;
minRating?: number;
sort?: string;
themes?: string[];
vibes?: string[];
occasions?: string[];
spaceFeatures?: string[];
noise?: string;
priceTier?: string;
size?: string;
requireProfile?: boolean;
};
function toQuery(params: DiscoverSearchParams): string {
const q = new URLSearchParams();
if (params.city) q.set("city", params.city);
if (params.q) q.set("q", params.q);
if (params.minRating != null) q.set("minRating", String(params.minRating));
if (params.sort) q.set("sort", params.sort);
if (params.themes?.length) q.set("themes", params.themes.join(","));
if (params.vibes?.length) q.set("vibes", params.vibes.join(","));
if (params.occasions?.length) q.set("occasions", params.occasions.join(","));
if (params.spaceFeatures?.length) q.set("spaceFeatures", params.spaceFeatures.join(","));
if (params.noise) q.set("noise", params.noise);
if (params.priceTier) q.set("priceTier", params.priceTier);
if (params.size) q.set("size", params.size);
if (params.requireProfile !== false) q.set("requireProfile", "true");
const s = q.toString();
return s ? `?${s}` : "";
}
export async function fetchPublicDiscover(
params: DiscoverSearchParams
): Promise<PublicCafeDiscover[]> {
return apiGetPublic<PublicCafeDiscover[]>(`/api/public/discover${toQuery(params)}`);
}
export async function fetchDiscoverTaxonomy(): Promise<DiscoverTaxonomy> {
return apiGetPublic<DiscoverTaxonomy>("/api/public/discover-profile/taxonomy");
}
export async function fetchPublicCafe(slug: string) {
return apiGetPublic<{
id: string;
name: string;
slug: string;
city: string | null;
address: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
description: string | null;
averageRating: number;
reviewCount: number;
discoverProfile: CafeDiscoverProfile;
}>(`/api/public/cafes/${encodeURIComponent(slug)}`);
}
+147
View File
@@ -0,0 +1,147 @@
import { apiGetPublic, ApiClientError } from "@/lib/api/client";
import type { ApiResponse } from "@/lib/api/types";
import { api } from "@/lib/api/client";
import type { CafeTheme } from "@/lib/cafe-theme";
export type QrResolve = {
cafeId: string;
cafeSlug: string;
tableId: string;
tableNumber: string;
tableName: string;
branchId: string;
branchName: string;
cafeName: string;
primaryColor: string;
logoUrl?: string | null;
welcomeText: string;
wifiPassword?: string | null;
address?: string | null;
isCleaning: boolean;
};
export type QrPublicMenuItem = {
id: string;
categoryId: string;
name: string;
nameAr?: string | null;
nameEn?: string | null;
description?: string | null;
price: number;
discountPercent: number;
imageUrl?: string | null;
videoUrl?: string | null;
model3dUrl?: string | null;
isAvailable: boolean;
};
export type QrPublicMenuCategory = {
id: string;
name: string;
nameAr?: string | null;
nameEn?: string | null;
icon?: string | null;
iconPresetId?: string | null;
iconStyle?: string | null;
imageUrl?: string | null;
items: QrPublicMenuItem[];
};
export type QrPublicMenu = {
cafeId: string;
cafeName: string;
slug: string;
theme?: CafeTheme | null;
categories: QrPublicMenuCategory[];
};
export type QrCartLine = {
item: QrPublicMenuItem;
qty: number;
note?: string;
};
export type QrOrderPlaced = {
orderId: string;
orderNumber: string;
totalAmount: number;
itemCount: number;
status: string;
trackingToken: string;
};
export type QrOrderTrackStep = {
key: string;
labelKey: string;
isComplete: boolean;
isCurrent: boolean;
};
export type QrOrderTrack = {
id: string;
orderNumber: string;
status: string;
statusLabelKey: string;
total: number;
tableNumber?: string | null;
createdAt: string;
statusUpdatedAt: string;
trackingToken: string;
steps: QrOrderTrackStep[];
};
export async function resolveQrCode(code: string): Promise<QrResolve> {
return apiGetPublic<QrResolve>(`/api/q/${encodeURIComponent(code)}`);
}
export async function fetchBranchPublicMenu(
cafeId: string,
branchId: string
): Promise<QrPublicMenu> {
return apiGetPublic<QrPublicMenu>(
`/api/public/${cafeId}/branches/${branchId}/menu`
);
}
export type PublicSecurityConfig = {
abuseProtectionEnabled: boolean;
turnstileSiteKey: string | null;
captchaRequired: boolean;
};
export async function fetchPublicSecurityConfig(): Promise<PublicSecurityConfig> {
return apiGetPublic<PublicSecurityConfig>("/api/public/security-config");
}
export async function placeBranchGuestOrder(
cafeId: string,
branchId: string,
body: {
tableId: string;
guestName?: string | null;
guestPhone?: string | null;
items: { menuItemId: string; quantity: number; notes?: string | null }[];
captchaToken?: string | null;
}
): Promise<QrOrderPlaced> {
const { data } = await api.post<ApiResponse<QrOrderPlaced>>(
`/api/public/${cafeId}/branches/${branchId}/orders`,
body
);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
return data.data;
}
export async function fetchOrderTrack(
orderId: string,
trackingToken: string
): Promise<QrOrderTrack> {
return apiGetPublic<QrOrderTrack>(
`/api/public/orders/${encodeURIComponent(orderId)}/track?token=${encodeURIComponent(trackingToken)}`
);
}
+200
View File
@@ -0,0 +1,200 @@
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string; field?: string };
}
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;
expiresAt: string;
userId: string;
cafeId: string;
role: string;
planTier: string;
language: string;
actor?: string;
branchId?: string | null;
}
export interface MenuCategory {
id: string;
name: string;
nameAr?: string;
nameEn?: string;
sortOrder: number;
taxId?: string;
discountPercent: number;
icon?: string;
iconPresetId?: string;
iconStyle?: string;
imageUrl?: string;
isActive: boolean;
}
export interface MenuItem {
id: string;
categoryId: string;
name: string;
nameAr?: string;
nameEn?: string;
description?: string;
price: number;
imageUrl?: string;
videoUrl?: string;
model3dUrl?: string;
isAvailable: boolean;
}
export interface OrderItemLine {
id: string;
menuItemId: string;
menuItemName: string;
quantity: number;
unitPrice: number;
notes?: string;
isVoided?: boolean;
voidedAt?: string | null;
}
export interface PaymentLine {
id: string;
method: string;
amount: number;
status: string;
reference?: string;
}
export interface Order {
id: string;
cafeId: string;
branchId?: string;
tableId?: string;
tableNumber?: string;
guestName?: string;
guestPhone?: string;
customerName?: string;
customerPhone?: string;
customerId?: string;
employeeId?: string;
orderType: string;
status: string;
subtotal: number;
taxTotal: number;
discountAmount: number;
total: number;
paidAmount: number;
createdAt: string;
items: OrderItemLine[];
payments: PaymentLine[];
}
export type TableBoardStatus = "Free" | "Busy" | "Reserved" | "Cleaning";
export interface TableBoardItem {
id: string;
branchId: string;
sectionId?: string | null;
sectionName?: string | null;
sortOrder?: number;
number: string;
capacity: number;
floor?: string;
qrCode: string;
qrCodeUrl: string;
imageUrl?: string;
videoUrl?: string;
isActive: boolean;
isCleaning: boolean;
status: TableBoardStatus;
currentOrder?: {
orderId: string;
status: string;
total: number;
guestLabel?: string;
source?: string;
};
}
export interface LiveOrder {
id: string;
status: string;
tableNumber?: number;
orderType: string;
total: number;
createdAt: string;
items: OrderItemLine[];
}
export type CustomerGroup = "Regular" | "Vip" | "New" | "Employee";
export type CouponType = "Percentage" | "FixedAmount" | "FreeItem";
export interface Customer {
id: string;
name: string;
phone: string;
nationalId?: string;
birthDateJalali?: string;
group: CustomerGroup;
loyaltyPoints: number;
referredBy?: string;
createdAt: string;
}
export interface Coupon {
id: string;
code: string;
type: CouponType;
value: number;
minOrderAmount?: number;
maxDiscount?: number;
usageLimit?: number;
usedCount: number;
targetGroup?: CustomerGroup;
startsAt?: string;
expiresAt?: string;
isActive: boolean;
}
export interface SmsUsage {
usedThisMonth: number;
monthlyLimit: number;
month: string;
}
export interface SmsCampaignResult {
sentCount: number;
failedCount: number;
}
export interface Table {
id: string;
branchId?: string;
number: string;
capacity: number;
floor?: string;
qrCode: string;
qrUrl: string;
isActive: boolean;
}
export type QueueTicketStatus = "Waiting" | "Called" | "Done" | "Cancelled";
export interface QueueTicket {
id: string;
branchId?: string;
serviceDate: string;
number: number;
customerLabel?: string;
orderId?: string;
status: QueueTicketStatus;
issuedAt: string;
}
export interface QueueBoard {
serviceDate: string;
nowServing?: number | null;
lastIssued: number;
waitingCount: number;
tickets: QueueTicket[];
}