feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import axios, { type AxiosError } from "axios";
|
||||
import type { ApiResponse } from "./types";
|
||||
|
||||
const baseURL =
|
||||
process.env.NEXT_PUBLIC_ADMIN_API_URL ??
|
||||
process.env.NEXT_PUBLIC_API_URL ??
|
||||
"https://localhost:7210";
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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 PlatformIntegrations = {
|
||||
activePaymentGateway: string;
|
||||
paymentGateways: PaymentGatewayConfig[];
|
||||
kavenegar: KavenegarConfig;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from "./client";
|
||||
import type { ApiResponse } from "./types";
|
||||
import type { WorkingHours } from "./public-discover";
|
||||
|
||||
export type CafeProfileEdit = {
|
||||
description: string | null;
|
||||
galleryUrls: string[];
|
||||
instagramHandle: string | null;
|
||||
websiteUrl: string | null;
|
||||
workingHours: WorkingHours | null;
|
||||
};
|
||||
|
||||
export type UpdateCafeProfilePayload = {
|
||||
description?: string | null;
|
||||
instagramHandle?: string | null;
|
||||
websiteUrl?: string | null;
|
||||
workingHours?: WorkingHours | null;
|
||||
};
|
||||
|
||||
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
|
||||
const { data } = await promise;
|
||||
if (!data.success || data.data === undefined)
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchCafePublicProfile(cafeId: string): Promise<CafeProfileEdit> {
|
||||
return unwrap(api.get<ApiResponse<CafeProfileEdit>>(`/api/cafes/${cafeId}/public-profile`));
|
||||
}
|
||||
|
||||
export async function updateCafePublicProfile(
|
||||
cafeId: string,
|
||||
payload: UpdateCafeProfilePayload
|
||||
): Promise<CafeProfileEdit> {
|
||||
return unwrap(api.put<ApiResponse<CafeProfileEdit>>(`/api/cafes/${cafeId}/public-profile`, payload));
|
||||
}
|
||||
|
||||
export async function uploadGalleryPhoto(cafeId: string, file: File): Promise<string[]> {
|
||||
const form = new FormData();
|
||||
form.append("photo", file);
|
||||
const { data } = await api.post<ApiResponse<{ galleryUrls: string[] }>>(
|
||||
`/api/cafes/${cafeId}/public-profile/gallery`,
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
if (!data.success || !data.data) throw new Error(data.error?.message ?? "Upload failed");
|
||||
return data.data.galleryUrls;
|
||||
}
|
||||
|
||||
export async function removeGalleryPhoto(cafeId: string, url: string): Promise<string[]> {
|
||||
const { data } = await api.delete<ApiResponse<{ galleryUrls: string[] }>>(
|
||||
`/api/cafes/${cafeId}/public-profile/gallery`,
|
||||
{ params: { url } }
|
||||
);
|
||||
if (!data.success || !data.data) throw new Error(data.error?.message ?? "Remove failed");
|
||||
return data.data.galleryUrls;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** Public POST JSON (no auth required). */
|
||||
export async function apiPostPublic<T, B = unknown>(path: string, body?: B): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(path, body);
|
||||
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}`}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from `@/lib/api/client` instead. */
|
||||
export { apiDownload } from "@/lib/api/client";
|
||||
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
limit = 50
|
||||
): Promise<NotificationList> {
|
||||
return apiGet<NotificationList>(
|
||||
`/api/cafes/${cafeId}/notifications?unreadOnly=${unreadOnly}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { apiGetPublic } from "@/lib/api/client";
|
||||
import type { CafeDiscoverProfile } from "@/lib/cafe-discover-profile";
|
||||
|
||||
export type CafeDiscoverBadge = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export type DaySchedule = {
|
||||
isOpen: boolean;
|
||||
open: string | null;
|
||||
close: string | null;
|
||||
};
|
||||
|
||||
export type WorkingHours = {
|
||||
sat: DaySchedule | null;
|
||||
sun: DaySchedule | null;
|
||||
mon: DaySchedule | null;
|
||||
tue: DaySchedule | null;
|
||||
wed: DaySchedule | null;
|
||||
thu: DaySchedule | null;
|
||||
fri: DaySchedule | null;
|
||||
};
|
||||
|
||||
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;
|
||||
badges?: CafeDiscoverBadge[];
|
||||
galleryUrls: string[];
|
||||
isOpenNow: boolean;
|
||||
instagramHandle: string | null;
|
||||
websiteUrl: string | null;
|
||||
relevanceScore: number;
|
||||
};
|
||||
|
||||
export type NlpHints = {
|
||||
themes: string[];
|
||||
vibes: string[];
|
||||
occasions: string[];
|
||||
spaceFeatures: string[];
|
||||
noiseLevel: string | null;
|
||||
priceTier: string | null;
|
||||
size: string | null;
|
||||
};
|
||||
|
||||
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;
|
||||
openNow?: 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");
|
||||
if (params.openNow) q.set("openNow", "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 type PublicCafeReview = {
|
||||
id: string;
|
||||
authorName: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
ownerReply: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function fetchPublicCafeReviews(slug: string) {
|
||||
return apiGetPublic<PublicCafeReview[]>(
|
||||
`/api/public/cafes/${encodeURIComponent(slug)}/reviews?pageSize=20`
|
||||
);
|
||||
}
|
||||
|
||||
export type PublicCafeDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
description: string | null;
|
||||
averageRating: number;
|
||||
reviewCount: number;
|
||||
discoverProfile: CafeDiscoverProfile;
|
||||
galleryUrls: string[];
|
||||
isOpenNow: boolean;
|
||||
instagramHandle: string | null;
|
||||
websiteUrl: string | null;
|
||||
workingHours: WorkingHours | null;
|
||||
};
|
||||
|
||||
export async function fetchPublicCafe(slug: string): Promise<PublicCafeDetail> {
|
||||
return apiGetPublic<PublicCafeDetail>(`/api/public/cafes/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function fetchNlpHints(q: string): Promise<NlpHints> {
|
||||
return apiGetPublic<NlpHints>(`/api/public/discover/nlp-parse?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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");
|
||||
}
|
||||
|
||||
function normalizeQrOrderPlaced(raw: QrOrderPlaced & Record<string, unknown>): QrOrderPlaced {
|
||||
const orderId = String(raw.orderId ?? raw.OrderId ?? "").trim();
|
||||
const orderNumber = String(raw.orderNumber ?? raw.OrderNumber ?? "").trim();
|
||||
const trackingToken = String(raw.trackingToken ?? raw.TrackingToken ?? "").trim();
|
||||
const totalAmount = Number(raw.totalAmount ?? raw.TotalAmount ?? 0);
|
||||
const itemCount = Number(raw.itemCount ?? raw.ItemCount ?? 0);
|
||||
const status = String(raw.status ?? raw.Status ?? "");
|
||||
return { orderId, orderNumber, totalAmount, itemCount, status, trackingToken };
|
||||
}
|
||||
|
||||
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 & Record<string, unknown>>>(
|
||||
`/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"
|
||||
);
|
||||
}
|
||||
const placed = normalizeQrOrderPlaced(data.data);
|
||||
if (!placed.orderId || !placed.trackingToken) {
|
||||
throw new ApiClientError(
|
||||
"INVALID_RESPONSE",
|
||||
"Order was placed but tracking data is missing."
|
||||
);
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
export async function fetchOrderTrack(
|
||||
orderId: string,
|
||||
trackingToken: string
|
||||
): Promise<QrOrderTrack> {
|
||||
return apiGetPublic<QrOrderTrack>(
|
||||
`/api/public/orders/${encodeURIComponent(orderId)}/track?token=${encodeURIComponent(trackingToken)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function callWaiter(cafeId: string, tableId: string): Promise<void> {
|
||||
const { data } = await api.post<ApiResponse<null>>(
|
||||
`/api/public/${encodeURIComponent(cafeId)}/tables/${encodeURIComponent(tableId)}/call-waiter`
|
||||
);
|
||||
if (!data.success) {
|
||||
throw new ApiClientError(
|
||||
data.error?.code ?? "REQUEST_FAILED",
|
||||
data.error?.message ?? "Could not send request."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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;
|
||||
displayNumber: number;
|
||||
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;
|
||||
displayNumber: number;
|
||||
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[];
|
||||
}
|
||||
Reference in New Issue
Block a user