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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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}`}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from `@/lib/api/client` instead. */
|
||||
export { apiDownload } from "@/lib/api/client";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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)}`);
|
||||
}
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Cafe owner (HQ) — billing, taxes, branches. */
|
||||
export function isCafeOwner(role: string | undefined): boolean {
|
||||
return role === "Owner";
|
||||
}
|
||||
|
||||
/** Logged in as a branch-scoped employee (JWT branchId). */
|
||||
export function isBranchAccount(branchId: string | null | undefined): boolean {
|
||||
return !!branchId;
|
||||
}
|
||||
|
||||
export const OWNER_ONLY_NAV_KEYS = ["subscription", "taxes", "branches"] as const;
|
||||
|
||||
export function canSeeNavItem(
|
||||
key: string,
|
||||
role: string | undefined,
|
||||
branchId: string | null | undefined
|
||||
): boolean {
|
||||
if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) {
|
||||
return false;
|
||||
}
|
||||
if (key === "branches" && isBranchAccount(branchId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Matches backend CafeDiscoverProfileKeys — labels via i18n discoverProfile.* */
|
||||
|
||||
export type CafeDiscoverProfile = {
|
||||
themes: string[];
|
||||
size: string | null;
|
||||
floors: string | null;
|
||||
vibes: string[];
|
||||
occasions: string[];
|
||||
spaceFeatures: string[];
|
||||
noiseLevel: string | null;
|
||||
priceTier: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_DISCOVER_PROFILE: CafeDiscoverProfile = {
|
||||
themes: [],
|
||||
size: null,
|
||||
floors: null,
|
||||
vibes: [],
|
||||
occasions: [],
|
||||
spaceFeatures: [],
|
||||
noiseLevel: null,
|
||||
priceTier: null,
|
||||
};
|
||||
|
||||
export const DISCOVER_TAXONOMY = {
|
||||
themes: [
|
||||
"modern",
|
||||
"minimal",
|
||||
"vintage",
|
||||
"industrial",
|
||||
"scandi",
|
||||
"persian_traditional",
|
||||
"book_cafe",
|
||||
"roastery",
|
||||
"dessert_focus",
|
||||
"brunch",
|
||||
"late_night",
|
||||
"plants_heavy",
|
||||
"instagrammable",
|
||||
"heritage",
|
||||
"luxury",
|
||||
],
|
||||
sizes: ["tiny", "cozy", "medium", "large", "spacious"],
|
||||
floors: ["one", "two", "three", "multi"],
|
||||
vibes: [
|
||||
"quiet",
|
||||
"lively",
|
||||
"romantic",
|
||||
"cozy",
|
||||
"trendy",
|
||||
"traditional",
|
||||
"artistic",
|
||||
"luxury",
|
||||
"casual",
|
||||
"study_friendly",
|
||||
],
|
||||
occasions: [
|
||||
"date",
|
||||
"family",
|
||||
"friends",
|
||||
"finding_someone",
|
||||
"solo",
|
||||
"business_meeting",
|
||||
"study_work",
|
||||
"celebration",
|
||||
"quick_coffee",
|
||||
"breakfast",
|
||||
"brunch",
|
||||
],
|
||||
spaceFeatures: [
|
||||
"indoor",
|
||||
"outdoor",
|
||||
"terrace",
|
||||
"rooftop",
|
||||
"garden",
|
||||
"plants",
|
||||
"wifi",
|
||||
"parking",
|
||||
"wheelchair",
|
||||
"kids_friendly",
|
||||
"pet_friendly",
|
||||
"smoking_area",
|
||||
"live_music",
|
||||
"private_room",
|
||||
"counter_only",
|
||||
],
|
||||
noiseLevels: ["quiet", "moderate", "lively"],
|
||||
priceTiers: ["budget", "mid", "premium"],
|
||||
} as const;
|
||||
|
||||
export type DiscoverListField = keyof Pick<
|
||||
CafeDiscoverProfile,
|
||||
"themes" | "vibes" | "occasions" | "spaceFeatures"
|
||||
>;
|
||||
|
||||
export type DiscoverSingleField = keyof Pick<
|
||||
CafeDiscoverProfile,
|
||||
"size" | "floors" | "noiseLevel" | "priceTier"
|
||||
>;
|
||||
|
||||
export function toggleListValue(list: string[], id: string): string[] {
|
||||
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
/** Per-café branding — synced with API CafeThemeDto / Core Branding.CafeTheme */
|
||||
|
||||
import { normalizeMenuTexture } from "@/lib/qr-menu-texture";
|
||||
|
||||
export type CafeThemeCustomColors = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
accent?: string | null;
|
||||
background?: string | null;
|
||||
surface?: string | null;
|
||||
text?: string | null;
|
||||
textMuted?: string | null;
|
||||
destructive?: string | null;
|
||||
success?: string | null;
|
||||
};
|
||||
|
||||
export type CafeTheme = {
|
||||
paletteId: string;
|
||||
panelStyle: string;
|
||||
menuStyle: string;
|
||||
menuTexture: string;
|
||||
density: string;
|
||||
radius: string;
|
||||
custom?: CafeThemeCustomColors | null;
|
||||
};
|
||||
|
||||
export type CafeThemePalette = {
|
||||
id: string;
|
||||
primary: string;
|
||||
primaryForeground: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
destructive: string;
|
||||
success: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_CAFE_THEME: CafeTheme = {
|
||||
paletteId: "meezi-green",
|
||||
panelStyle: "modern",
|
||||
menuStyle: "cards",
|
||||
menuTexture: "none",
|
||||
density: "comfortable",
|
||||
radius: "md",
|
||||
custom: null,
|
||||
};
|
||||
|
||||
export const CAFE_THEME_PALETTES: CafeThemePalette[] = [
|
||||
{
|
||||
id: "meezi-green",
|
||||
primary: "#0F6E56",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#E1F5EE",
|
||||
accent: "#BA7517",
|
||||
background: "#F5F5F4",
|
||||
surface: "#FFFFFF",
|
||||
text: "#1C1917",
|
||||
textMuted: "#78716C",
|
||||
destructive: "#A32D2D",
|
||||
success: "#0F6E56",
|
||||
},
|
||||
{
|
||||
id: "ocean-blue",
|
||||
primary: "#0C447C",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#E0F0FA",
|
||||
accent: "#0891B2",
|
||||
background: "#F0F9FF",
|
||||
surface: "#FFFFFF",
|
||||
text: "#0F172A",
|
||||
textMuted: "#64748B",
|
||||
destructive: "#B91C1C",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "royal-purple",
|
||||
primary: "#5B21B6",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#EDE9FE",
|
||||
accent: "#A855F7",
|
||||
background: "#FAF5FF",
|
||||
surface: "#FFFFFF",
|
||||
text: "#1E1B4B",
|
||||
textMuted: "#6B7280",
|
||||
destructive: "#DC2626",
|
||||
success: "#7C3AED",
|
||||
},
|
||||
{
|
||||
id: "sunset-orange",
|
||||
primary: "#C2410C",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FFEDD5",
|
||||
accent: "#EA580C",
|
||||
background: "#FFF7ED",
|
||||
surface: "#FFFFFF",
|
||||
text: "#431407",
|
||||
textMuted: "#9A3412",
|
||||
destructive: "#991B1B",
|
||||
success: "#15803D",
|
||||
},
|
||||
{
|
||||
id: "rose-blush",
|
||||
primary: "#BE123C",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FFE4E6",
|
||||
accent: "#DB2777",
|
||||
background: "#FFF1F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#4C0519",
|
||||
textMuted: "#9F1239",
|
||||
destructive: "#9F1239",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "charcoal-gold",
|
||||
primary: "#292524",
|
||||
primaryForeground: "#FEF3C7",
|
||||
secondary: "#E7E5E4",
|
||||
accent: "#CA8A04",
|
||||
background: "#F5F5F4",
|
||||
surface: "#FFFFFF",
|
||||
text: "#1C1917",
|
||||
textMuted: "#57534E",
|
||||
destructive: "#B91C1C",
|
||||
success: "#15803D",
|
||||
},
|
||||
{
|
||||
id: "espresso",
|
||||
primary: "#44403C",
|
||||
primaryForeground: "#FAFAF9",
|
||||
secondary: "#E7E5E4",
|
||||
accent: "#92400E",
|
||||
background: "#FAF8F5",
|
||||
surface: "#FFFFFF",
|
||||
text: "#292524",
|
||||
textMuted: "#78716C",
|
||||
destructive: "#991B1B",
|
||||
success: "#166534",
|
||||
},
|
||||
{
|
||||
id: "forest",
|
||||
primary: "#166534",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#DCFCE7",
|
||||
accent: "#65A30D",
|
||||
background: "#F0FDF4",
|
||||
surface: "#FFFFFF",
|
||||
text: "#14532D",
|
||||
textMuted: "#4B5563",
|
||||
destructive: "#DC2626",
|
||||
success: "#15803D",
|
||||
},
|
||||
{
|
||||
id: "midnight",
|
||||
primary: "#1E3A5F",
|
||||
primaryForeground: "#F8FAFC",
|
||||
secondary: "#E2E8F0",
|
||||
accent: "#38BDF8",
|
||||
background: "#F1F5F9",
|
||||
surface: "#FFFFFF",
|
||||
text: "#0F172A",
|
||||
textMuted: "#64748B",
|
||||
destructive: "#EF4444",
|
||||
success: "#22C55E",
|
||||
},
|
||||
{
|
||||
id: "coral",
|
||||
primary: "#E11D48",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FFE4E6",
|
||||
accent: "#FB7185",
|
||||
background: "#FFF1F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#881337",
|
||||
textMuted: "#9F1239",
|
||||
destructive: "#B91C1C",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "gold-luxury",
|
||||
primary: "#854D0E",
|
||||
primaryForeground: "#FFFBEB",
|
||||
secondary: "#FEF3C7",
|
||||
accent: "#D97706",
|
||||
background: "#FFFBEB",
|
||||
surface: "#FFFFFF",
|
||||
text: "#422006",
|
||||
textMuted: "#78716C",
|
||||
destructive: "#991B1B",
|
||||
success: "#166534",
|
||||
},
|
||||
{
|
||||
id: "mint-fresh",
|
||||
primary: "#0D9488",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#CCFBF1",
|
||||
accent: "#2DD4BF",
|
||||
background: "#F0FDFA",
|
||||
surface: "#FFFFFF",
|
||||
text: "#134E4A",
|
||||
textMuted: "#5EEAD4",
|
||||
destructive: "#DC2626",
|
||||
success: "#0D9488",
|
||||
},
|
||||
{
|
||||
id: "wine-bar",
|
||||
primary: "#7F1D1D",
|
||||
primaryForeground: "#FEF2F2",
|
||||
secondary: "#FEE2E2",
|
||||
accent: "#B45309",
|
||||
background: "#FEF2F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#450A0A",
|
||||
textMuted: "#991B1B",
|
||||
destructive: "#991B1B",
|
||||
success: "#166534",
|
||||
},
|
||||
{
|
||||
id: "slate-modern",
|
||||
primary: "#334155",
|
||||
primaryForeground: "#F8FAFC",
|
||||
secondary: "#F1F5F9",
|
||||
accent: "#0EA5E9",
|
||||
background: "#F8FAFC",
|
||||
surface: "#FFFFFF",
|
||||
text: "#0F172A",
|
||||
textMuted: "#64748B",
|
||||
destructive: "#EF4444",
|
||||
success: "#10B981",
|
||||
},
|
||||
{
|
||||
id: "cherry",
|
||||
primary: "#9F1239",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#FECDD3",
|
||||
accent: "#F43F5E",
|
||||
background: "#FFF1F2",
|
||||
surface: "#FFFFFF",
|
||||
text: "#4C0519",
|
||||
textMuted: "#BE123C",
|
||||
destructive: "#881337",
|
||||
success: "#059669",
|
||||
},
|
||||
{
|
||||
id: "teal-wave",
|
||||
primary: "#0F766E",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#CCFBF1",
|
||||
accent: "#14B8A6",
|
||||
background: "#F0FDFA",
|
||||
surface: "#FFFFFF",
|
||||
text: "#134E4A",
|
||||
textMuted: "#5F6B6B",
|
||||
destructive: "#DC2626",
|
||||
success: "#0F766E",
|
||||
},
|
||||
{
|
||||
id: "sand-cafe",
|
||||
primary: "#A16207",
|
||||
primaryForeground: "#FFFBEB",
|
||||
secondary: "#FEF3C7",
|
||||
accent: "#D97706",
|
||||
background: "#FAFAF9",
|
||||
surface: "#FFFFFF",
|
||||
text: "#44403C",
|
||||
textMuted: "#A8A29E",
|
||||
destructive: "#B91C1C",
|
||||
success: "#15803D",
|
||||
},
|
||||
];
|
||||
|
||||
export const CAFE_PANEL_STYLES = [
|
||||
"flat",
|
||||
"modern",
|
||||
"glass",
|
||||
"minimal",
|
||||
"bold",
|
||||
"soft",
|
||||
"elevated",
|
||||
"outline",
|
||||
] as const;
|
||||
|
||||
export const CAFE_MENU_STYLES = [
|
||||
"cards",
|
||||
"compact",
|
||||
"grid",
|
||||
"list",
|
||||
"magazine",
|
||||
"classic",
|
||||
] as const;
|
||||
|
||||
export {
|
||||
CAFE_MENU_TEXTURES,
|
||||
normalizeMenuTexture,
|
||||
qrMenuTextureShellProps,
|
||||
type CafeMenuTexture,
|
||||
} from "./qr-menu-texture";
|
||||
|
||||
export const CAFE_THEME_DENSITIES = ["compact", "comfortable", "spacious"] as const;
|
||||
export const CAFE_THEME_RADIUS = ["none", "sm", "md", "lg", "full"] as const;
|
||||
|
||||
const paletteById = new Map(CAFE_THEME_PALETTES.map((p) => [p.id, p]));
|
||||
|
||||
export function getThemePalette(id: string): CafeThemePalette {
|
||||
return paletteById.get(id) ?? CAFE_THEME_PALETTES[0];
|
||||
}
|
||||
|
||||
export function resolveThemeColors(theme: CafeTheme): CafeThemePalette {
|
||||
const base = getThemePalette(theme.paletteId);
|
||||
const c = theme.custom;
|
||||
if (!c) return base;
|
||||
return {
|
||||
...base,
|
||||
primary: c.primary ?? base.primary,
|
||||
secondary: c.secondary ?? base.secondary,
|
||||
accent: c.accent ?? base.accent,
|
||||
background: c.background ?? base.background,
|
||||
surface: c.surface ?? base.surface,
|
||||
text: c.text ?? base.text,
|
||||
textMuted: c.textMuted ?? base.textMuted,
|
||||
destructive: c.destructive ?? base.destructive,
|
||||
success: c.success ?? base.success,
|
||||
};
|
||||
}
|
||||
|
||||
function hexToHslChannels(hex: string): string {
|
||||
const raw = hex.replace("#", "");
|
||||
const r = parseInt(raw.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(raw.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(raw.slice(4, 6), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
}
|
||||
|
||||
const RADIUS_MAP: Record<string, string> = {
|
||||
none: "0px",
|
||||
sm: "0.375rem",
|
||||
md: "0.75rem",
|
||||
lg: "1rem",
|
||||
full: "1.25rem",
|
||||
};
|
||||
|
||||
const OVERRIDE_STYLE_ID = "meezi-cafe-theme-overrides";
|
||||
|
||||
function injectBrandOverrides(primaryHex: string, secondaryHex: string): void {
|
||||
let el = document.getElementById(OVERRIDE_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = OVERRIDE_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `
|
||||
html[data-cafe-theme] .bg-\\[\\#0F6E56\\],
|
||||
html[data-cafe-theme] .hover\\:bg-\\[\\#0c5a46\\]:hover,
|
||||
html[data-cafe-theme] .bg-\\[\\#0c5a46\\] {
|
||||
background-color: ${primaryHex} !important;
|
||||
}
|
||||
html[data-cafe-theme] .text-\\[\\#0F6E56\\],
|
||||
html[data-cafe-theme] .hover\\:text-\\[\\#0F6E56\\]:hover {
|
||||
color: ${primaryHex} !important;
|
||||
}
|
||||
html[data-cafe-theme] .border-\\[\\#0F6E56\\],
|
||||
html[data-cafe-theme] .hover\\:border-\\[\\#0F6E56\\]\\/40:hover,
|
||||
html[data-cafe-theme] .ring-\\[\\#0F6E56\\]\\/30 {
|
||||
border-color: color-mix(in srgb, ${primaryHex} 40%, transparent) !important;
|
||||
}
|
||||
html[data-cafe-theme] .bg-\\[\\#E1F5EE\\] {
|
||||
background-color: ${secondaryHex} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyCafeTheme(theme: CafeTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const colors = resolveThemeColors(theme);
|
||||
const root = document.documentElement;
|
||||
|
||||
root.dataset.cafeTheme = "true";
|
||||
root.dataset.panelStyle = theme.panelStyle;
|
||||
root.dataset.menuStyle = theme.menuStyle;
|
||||
root.dataset.density = theme.density;
|
||||
|
||||
const set = (name: string, hex: string) => root.style.setProperty(name, hexToHslChannels(hex));
|
||||
|
||||
set("--primary", colors.primary);
|
||||
set("--primary-foreground", colors.primaryForeground);
|
||||
set("--secondary", colors.secondary);
|
||||
set("--secondary-foreground", colors.text);
|
||||
set("--accent", colors.secondary);
|
||||
set("--accent-foreground", colors.primary);
|
||||
set("--background", colors.background);
|
||||
set("--foreground", colors.text);
|
||||
set("--card", colors.surface);
|
||||
set("--card-foreground", colors.text);
|
||||
set("--muted", colors.background);
|
||||
set("--muted-foreground", colors.textMuted);
|
||||
set("--destructive", colors.destructive);
|
||||
set("--ring", colors.primary);
|
||||
set("--meezi-green", colors.primary);
|
||||
set("--meezi-green-tint", colors.secondary);
|
||||
set("--meezi-amber", colors.accent);
|
||||
set("--meezi-danger", colors.destructive);
|
||||
|
||||
root.style.setProperty("--radius", RADIUS_MAP[theme.radius] ?? RADIUS_MAP.md);
|
||||
root.style.setProperty("--brand-primary-hex", colors.primary);
|
||||
root.style.setProperty("--brand-secondary-hex", colors.secondary);
|
||||
root.style.setProperty("--brand-accent-hex", colors.accent);
|
||||
|
||||
injectBrandOverrides(colors.primary, colors.secondary);
|
||||
}
|
||||
|
||||
export function normalizeCafeTheme(input?: Partial<CafeTheme> | null): CafeTheme {
|
||||
if (!input) return { ...DEFAULT_CAFE_THEME };
|
||||
return {
|
||||
paletteId: paletteById.has(input.paletteId ?? "") ? input.paletteId! : DEFAULT_CAFE_THEME.paletteId,
|
||||
panelStyle: CAFE_PANEL_STYLES.includes(input.panelStyle as (typeof CAFE_PANEL_STYLES)[number])
|
||||
? input.panelStyle!
|
||||
: DEFAULT_CAFE_THEME.panelStyle,
|
||||
menuStyle: CAFE_MENU_STYLES.includes(input.menuStyle as (typeof CAFE_MENU_STYLES)[number])
|
||||
? input.menuStyle!
|
||||
: DEFAULT_CAFE_THEME.menuStyle,
|
||||
menuTexture: normalizeMenuTexture(input.menuTexture ?? DEFAULT_CAFE_THEME.menuTexture),
|
||||
density: CAFE_THEME_DENSITIES.includes(input.density as (typeof CAFE_THEME_DENSITIES)[number])
|
||||
? input.density!
|
||||
: DEFAULT_CAFE_THEME.density,
|
||||
radius: CAFE_THEME_RADIUS.includes(input.radius as (typeof CAFE_THEME_RADIUS)[number])
|
||||
? input.radius!
|
||||
: DEFAULT_CAFE_THEME.radius,
|
||||
custom: input.custom ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Curated emoji sets per menu category theme (Persian café / restaurant). */
|
||||
export type CategoryEmojiGroup = {
|
||||
id: string;
|
||||
emojis: readonly string[];
|
||||
};
|
||||
|
||||
export const CATEGORY_EMOJI_GROUPS: CategoryEmojiGroup[] = [
|
||||
{
|
||||
id: "hotDrinks",
|
||||
emojis: ["☕", "🍵", "🫖", "🧉", "☕️", "🫘", "🍶", "🥛"],
|
||||
},
|
||||
{
|
||||
id: "coldDrinks",
|
||||
emojis: ["🧊", "🥤", "🧃", "🍹", "🍸", "🥂", "🍺", "🍷", "🧋", "🥛", "🍼"],
|
||||
},
|
||||
{
|
||||
id: "breakfast",
|
||||
emojis: ["🍳", "🥐", "🥞", "🧇", "🥯", "🍞", "🥚", "🧈", "🥓", "🫕"],
|
||||
},
|
||||
{
|
||||
id: "mains",
|
||||
emojis: ["🍽️", "🍛", "🍲", "🥘", "🍚", "🍖", "🍗", "🥩", "🌯", "🥙", "🍱"],
|
||||
},
|
||||
{
|
||||
id: "pastaPizza",
|
||||
emojis: ["🍕", "🍝", "🧀", "🥖", "🫓", "🥪", "🌮", "🌯"],
|
||||
},
|
||||
{
|
||||
id: "desserts",
|
||||
emojis: ["🍰", "🎂", "🧁", "🍮", "🍩", "🍪", "🍫", "🍬", "🍭", "🍦", "🍨", "🧇"],
|
||||
},
|
||||
{
|
||||
id: "salads",
|
||||
emojis: ["🥗", "🥒", "🥕", "🥬", "🍅", "🫑", "🥑", "🌽", "🧅"],
|
||||
},
|
||||
{
|
||||
id: "seafoodGrill",
|
||||
emojis: ["🐟", "🦐", "🦞", "🦀", "🍤", "🥩", "🔥", "🍖", "🥓", "🍢"],
|
||||
},
|
||||
{
|
||||
id: "snacks",
|
||||
emojis: ["🍟", "🍿", "🥨", "🥜", "🌰", "🥪", "🌭", "🍔", "🥙", "🧆"],
|
||||
},
|
||||
{
|
||||
id: "vegan",
|
||||
emojis: ["🥬", "🌱", "🥦", "🥒", "🍄", "🫛", "🫘", "🥑", "🌽", "🍆"],
|
||||
},
|
||||
{
|
||||
id: "specials",
|
||||
emojis: ["⭐", "✨", "🔥", "💎", "🎉", "🏷️", "❤️", "👨🍳", "🆕", "💫"],
|
||||
},
|
||||
{
|
||||
id: "general",
|
||||
emojis: ["🍴", "🥄", "🍽️", "🏪", "📋", "🪑", "🛎️", "☕", "🍽️", "🧾"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
Beef,
|
||||
Beer,
|
||||
CakeSlice,
|
||||
ChefHat,
|
||||
Cherry,
|
||||
Citrus,
|
||||
Coffee,
|
||||
Cookie,
|
||||
CupSoda,
|
||||
Donut,
|
||||
EggFried,
|
||||
Fish,
|
||||
Flame,
|
||||
GlassWater,
|
||||
IceCreamCone,
|
||||
Leaf,
|
||||
Milk,
|
||||
Pizza,
|
||||
Salad,
|
||||
Sandwich,
|
||||
Soup,
|
||||
Sprout,
|
||||
Star,
|
||||
UtensilsCrossed,
|
||||
Wheat,
|
||||
Wine,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Visual variant for preset category icons */
|
||||
export const CATEGORY_ICON_STYLES = [
|
||||
"flat",
|
||||
"modern",
|
||||
"real",
|
||||
"minimal",
|
||||
"outline",
|
||||
"soft",
|
||||
"bold",
|
||||
"gradient",
|
||||
"pastel",
|
||||
"duotone",
|
||||
] as const;
|
||||
|
||||
export type CategoryIconStyleId = (typeof CATEGORY_ICON_STYLES)[number];
|
||||
|
||||
export function isCategoryIconStyle(value: string | null | undefined): value is CategoryIconStyleId {
|
||||
return CATEGORY_ICON_STYLES.includes(value as CategoryIconStyleId);
|
||||
}
|
||||
|
||||
export type CategoryIconPresetKind = "drink" | "food";
|
||||
|
||||
export type CategoryIconPresetDef = {
|
||||
id: string;
|
||||
kind: CategoryIconPresetKind;
|
||||
icon: LucideIcon;
|
||||
/** Photo used when style is "real" */
|
||||
realImageUrl: string;
|
||||
};
|
||||
|
||||
export const CATEGORY_ICON_PRESETS: CategoryIconPresetDef[] = [
|
||||
{
|
||||
id: "drinks-hot",
|
||||
kind: "drink",
|
||||
icon: Coffee,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-cold",
|
||||
kind: "drink",
|
||||
icon: CupSoda,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1517487881594-2787aeee8f58?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-tea",
|
||||
kind: "drink",
|
||||
icon: GlassWater,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-juice",
|
||||
kind: "drink",
|
||||
icon: Citrus,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1523672990561-64c16245f769?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-milkshake",
|
||||
kind: "drink",
|
||||
icon: Milk,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1505252585463-0433371f7f6b?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-alcohol",
|
||||
kind: "drink",
|
||||
icon: Wine,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "drinks-beer",
|
||||
kind: "drink",
|
||||
icon: Beer,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1608270586620-916524e5f405?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "breakfast",
|
||||
kind: "food",
|
||||
icon: EggFried,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1525351484343-752d43d363f1?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "food-mains",
|
||||
kind: "food",
|
||||
icon: UtensilsCrossed,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "food-fastfood",
|
||||
kind: "food",
|
||||
icon: Sandwich,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "food-rice",
|
||||
kind: "food",
|
||||
icon: Wheat,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1534084650011-4c4d81e8ca4b?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "pasta-pizza",
|
||||
kind: "food",
|
||||
icon: Pizza,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "dessert",
|
||||
kind: "food",
|
||||
icon: CakeSlice,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "ice-cream",
|
||||
kind: "food",
|
||||
icon: IceCreamCone,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1563805042-7684c019e1cb?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "bakery",
|
||||
kind: "food",
|
||||
icon: Cookie,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1555507036342-9231d37c10f3?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "salad",
|
||||
kind: "food",
|
||||
icon: Salad,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1546793665-c74683f339c1?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "grill",
|
||||
kind: "food",
|
||||
icon: Flame,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1600891963295-d66a269b9202?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "seafood",
|
||||
kind: "food",
|
||||
icon: Fish,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "snacks",
|
||||
kind: "food",
|
||||
icon: Sandwich,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "snacks-sweet",
|
||||
kind: "food",
|
||||
icon: Donut,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1551024506-0bccd28d3071?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "appetizers",
|
||||
kind: "food",
|
||||
icon: Soup,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1547592160-23ac45744acd?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "vegan",
|
||||
kind: "food",
|
||||
icon: Sprout,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "fruits",
|
||||
kind: "food",
|
||||
icon: Cherry,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1464965911861-746a04a4c36e?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "specials",
|
||||
kind: "food",
|
||||
icon: Star,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "chef-special",
|
||||
kind: "food",
|
||||
icon: ChefHat,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=200&h=200&fit=crop",
|
||||
},
|
||||
{
|
||||
id: "generic",
|
||||
kind: "food",
|
||||
icon: Beef,
|
||||
realImageUrl: "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=200&h=200&fit=crop",
|
||||
},
|
||||
];
|
||||
|
||||
const presetById = new Map(CATEGORY_ICON_PRESETS.map((p) => [p.id, p]));
|
||||
|
||||
export function getCategoryIconPreset(presetId: string | null | undefined): CategoryIconPresetDef | null {
|
||||
if (!presetId) return null;
|
||||
return presetById.get(presetId) ?? null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORY_ICON_STYLE: CategoryIconStyleId = "flat";
|
||||
|
||||
export type CategoryIconStroke = { strokeWidth: number; className?: string };
|
||||
|
||||
export function getCategoryIconStroke(style: CategoryIconStyleId): CategoryIconStroke {
|
||||
switch (style) {
|
||||
case "minimal":
|
||||
return { strokeWidth: 1.35, className: "stroke-[1.35]" };
|
||||
case "outline":
|
||||
return { strokeWidth: 2.35 };
|
||||
case "bold":
|
||||
return { strokeWidth: 2.75 };
|
||||
case "soft":
|
||||
case "pastel":
|
||||
return { strokeWidth: 1.85 };
|
||||
case "duotone":
|
||||
return { strokeWidth: 2, className: "opacity-90" };
|
||||
default:
|
||||
return { strokeWidth: 2 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { format } from "date-fns-jalali";
|
||||
import { enUS } from "date-fns-jalali/locale/en-US";
|
||||
import { faIR } from "date-fns-jalali/locale/fa-IR";
|
||||
|
||||
const PLAN_TIERS = ["Free", "Pro", "Business", "Enterprise"] as const;
|
||||
export type PlanTierKey = (typeof PLAN_TIERS)[number];
|
||||
|
||||
export function isPlanTierKey(tier: string): tier is PlanTierKey {
|
||||
return (PLAN_TIERS as readonly string[]).includes(tier);
|
||||
}
|
||||
|
||||
export function numberLocaleForUi(locale: string): string {
|
||||
if (locale === "en") return "en-US";
|
||||
if (locale === "ar") return "ar-SA";
|
||||
return "fa-IR";
|
||||
}
|
||||
|
||||
export function formatHeaderTime(date: Date, locale: string): string {
|
||||
return date.toLocaleTimeString(numberLocaleForUi(locale), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatHeaderJalaliDate(date: Date, locale: string): string {
|
||||
const jalaliLocale = locale === "en" ? enUS : faIR;
|
||||
return format(date, "EEEE d MMMM yyyy", { locale: jalaliLocale });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatNumber(value: number, locale = "fa-IR"): string {
|
||||
return value.toLocaleString(locale);
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number, locale = "fa-IR"): string {
|
||||
return `${value.toLocaleString(locale)} ت`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type GuestOrderRef = {
|
||||
orderId: string;
|
||||
trackingToken: string;
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
tableId: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "meezi_guest_orders";
|
||||
|
||||
export function saveGuestOrder(ref: GuestOrderRef): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const list = loadGuestOrders();
|
||||
const filtered = list.filter((o) => o.orderId !== ref.orderId);
|
||||
const next = [ref, ...filtered].slice(0, 30);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function loadGuestOrders(): GuestOrderRef[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as GuestOrderRef[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ordersForTable(orders: GuestOrderRef[], cafeId: string, tableId: string) {
|
||||
return orders.filter((o) => o.cafeId === cafeId && o.tableId === tableId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import type { CafeTheme } from "@/lib/cafe-theme";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
export type CafeSettings = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
coverImageUrl?: string;
|
||||
snappfoodVendorId?: string;
|
||||
planTier: string;
|
||||
theme: CafeTheme;
|
||||
defaultTaxRate?: number;
|
||||
allowBranchTaxOverride?: boolean;
|
||||
};
|
||||
|
||||
export function cafeSettingsQueryKey(cafeId: string) {
|
||||
return ["cafe-settings", cafeId] as const;
|
||||
}
|
||||
|
||||
export function useCafeSettings(cafeId?: string | null) {
|
||||
const authCafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const id = cafeId ?? authCafeId;
|
||||
|
||||
return useQuery<CafeSettings>({
|
||||
queryKey: cafeSettingsQueryKey(id ?? ""),
|
||||
queryFn: () => {
|
||||
if (!id) throw new Error("Missing cafe id");
|
||||
return apiGet<CafeSettings>(`/api/cafes/${id}/settings`);
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLiveClock(intervalMs = 1000): Date {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setOnline(navigator.onLine);
|
||||
|
||||
const onOnline = () => setOnline(true);
|
||||
const onOffline = () => setOnline(false);
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("offline", onOffline);
|
||||
return () => {
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("offline", onOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return online;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Owner assets + guest 3D menu helpers */
|
||||
|
||||
export const MENU_3D_GLB_MAX_MB = 8;
|
||||
|
||||
/** Recommended photo count for future 360° spin (not yet in app). */
|
||||
export const MENU_360_PHOTO_COUNT = { min: 12, ideal: 24 } as const;
|
||||
|
||||
export function hasMenu3dView(item: { model3dUrl?: string | null }): boolean {
|
||||
return !!item.model3dUrl?.trim();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Localized menu label: primary name for locale + English line for international guests. */
|
||||
|
||||
export type MenuNameFields = {
|
||||
name: string;
|
||||
nameEn?: string | null;
|
||||
nameAr?: string | null;
|
||||
};
|
||||
|
||||
export function getMenuPrimaryName(
|
||||
item: MenuNameFields,
|
||||
locale: string
|
||||
): string {
|
||||
const en = item.nameEn?.trim();
|
||||
const ar = item.nameAr?.trim();
|
||||
const fa = item.name.trim();
|
||||
|
||||
if (locale === "en") return en || fa;
|
||||
if (locale === "ar") return ar || fa;
|
||||
return fa;
|
||||
}
|
||||
|
||||
/** English subtitle when primary is fa/ar (helps staff and international customers). */
|
||||
export function getMenuEnglishSubtitle(
|
||||
item: MenuNameFields,
|
||||
locale: string
|
||||
): string | undefined {
|
||||
const en = item.nameEn?.trim();
|
||||
if (!en) return undefined;
|
||||
|
||||
const primary = getMenuPrimaryName(item, locale);
|
||||
if (primary === en) return undefined;
|
||||
|
||||
if (locale === "en") return undefined;
|
||||
|
||||
return en;
|
||||
}
|
||||
|
||||
/** Case-insensitive match for POS / menu search (fa, en, ar, description). */
|
||||
export function menuItemMatchesSearch(
|
||||
item: MenuNameFields & { description?: string | null },
|
||||
query: string,
|
||||
locale: string
|
||||
): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.nameEn,
|
||||
item.nameAr,
|
||||
item.description,
|
||||
getMenuPrimaryName(item, locale),
|
||||
getMenuEnglishSubtitle(item, locale),
|
||||
]
|
||||
.filter((s): s is string => typeof s === "string" && s.length > 0)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Coffee, CupSoda, UtensilsCrossed, type LucideIcon } from "lucide-react";
|
||||
import { resolveMediaUrl } from "@/lib/api/client";
|
||||
|
||||
export type MenuItemVisualKind = "food" | "drink";
|
||||
|
||||
const DRINK_CATEGORY_IDS = new Set(["cat_demo_drinks", "cat_demo_cold"]);
|
||||
|
||||
/** Latin keywords; Persian/Arabic category names come from API `categoryName`. */
|
||||
const DRINK_HINTS = [
|
||||
"drink",
|
||||
"cold",
|
||||
"hot",
|
||||
"coffee",
|
||||
"tea",
|
||||
"juice",
|
||||
"smoothie",
|
||||
"beverage",
|
||||
"bar",
|
||||
"espresso",
|
||||
"latte",
|
||||
];
|
||||
|
||||
export function inferMenuItemKind(
|
||||
categoryId: string,
|
||||
categoryName?: string
|
||||
): MenuItemVisualKind {
|
||||
if (DRINK_CATEGORY_IDS.has(categoryId)) return "drink";
|
||||
|
||||
const haystack = `${categoryId} ${categoryName ?? ""}`.toLowerCase();
|
||||
if (DRINK_HINTS.some((h) => haystack.includes(h))) return "drink";
|
||||
|
||||
return "food";
|
||||
}
|
||||
|
||||
export function getMenuItemImageSrc(imageUrl?: string | null): string | undefined {
|
||||
return resolveMediaUrl(imageUrl);
|
||||
}
|
||||
|
||||
export function menuItemPlaceholderIcon(kind: MenuItemVisualKind): LucideIcon {
|
||||
return kind === "drink" ? CupSoda : UtensilsCrossed;
|
||||
}
|
||||
|
||||
/** Larger hero-style icon for sidebar preview */
|
||||
export function menuItemPlaceholderHeroIcon(kind: MenuItemVisualKind): LucideIcon {
|
||||
return kind === "drink" ? Coffee : UtensilsCrossed;
|
||||
}
|
||||
|
||||
export function buildCategoryNameMap(
|
||||
categories: { id: string; name: string }[]
|
||||
): Map<string, string> {
|
||||
return new Map(categories.map((c) => [c.id, c.name]));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { toast } from "sonner";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
|
||||
export type NotifyOptions = {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function baseOptions(opts?: NotifyOptions) {
|
||||
return {
|
||||
description: opts?.description,
|
||||
duration: opts?.duration ?? 4000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toast notifications — use for transient success/error/info across the app */
|
||||
export const notify = {
|
||||
success(message: string, opts?: NotifyOptions) {
|
||||
toast.success(message, baseOptions(opts));
|
||||
},
|
||||
error(message: string, opts?: NotifyOptions) {
|
||||
toast.error(message, { ...baseOptions(opts), duration: opts?.duration ?? 5500 });
|
||||
},
|
||||
warning(message: string, opts?: NotifyOptions) {
|
||||
toast.warning(message, baseOptions(opts));
|
||||
},
|
||||
info(message: string, opts?: NotifyOptions) {
|
||||
toast.info(message, baseOptions(opts));
|
||||
},
|
||||
loading(message: string) {
|
||||
return toast.loading(message);
|
||||
},
|
||||
dismiss(id?: string | number) {
|
||||
toast.dismiss(id);
|
||||
},
|
||||
promise<T>(
|
||||
promise: Promise<T>,
|
||||
messages: { loading: string; success: string; error?: string }
|
||||
) {
|
||||
return toast.promise(promise, {
|
||||
loading: messages.loading,
|
||||
success: messages.success,
|
||||
error: messages.error ?? messages.loading,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err instanceof ApiClientError) return err.message;
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function notifyError(err: unknown, fallback: string) {
|
||||
notify.error(getErrorMessage(err, fallback));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Persian/Arabic-Indic digits → ASCII */
|
||||
function toAsciiDigits(value: string): string {
|
||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0);
|
||||
if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660);
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize to 09XXXXXXXXX (matches API PhoneNormalizer). */
|
||||
export function normalizeIranMobile(phone: string): string {
|
||||
let digits = toAsciiDigits(phone).replace(/\D/g, "");
|
||||
if (digits.startsWith("98") && digits.length === 12) digits = `0${digits.slice(2)}`;
|
||||
if (digits.length === 10 && digits.startsWith("9")) digits = `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** Iranian mobile: 09XXXXXXXXX */
|
||||
export function isValidIranMobile(phone: string): boolean {
|
||||
const n = normalizeIranMobile(phone);
|
||||
return n.length === 11 && /^09\d{9}$/.test(n);
|
||||
}
|
||||
|
||||
export function iranMobileForApi(phone: string): string | undefined {
|
||||
const normalized = normalizeIranMobile(phone.trim());
|
||||
return isValidIranMobile(normalized) ? normalized : undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
type PaymentMethod = "Cash" | "Card" | "Credit";
|
||||
|
||||
type PaymentRowLike = {
|
||||
method: PaymentMethod;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
/** Button label reflecting active payment row methods (split / single). */
|
||||
export function confirmPayLabel(
|
||||
rows: PaymentRowLike[],
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
const methods = rows
|
||||
.filter((r) => (parseFloat(r.amount.replace(/,/g, "")) || 0) > 0)
|
||||
.map((r) => r.method);
|
||||
const unique = Array.from(new Set(methods));
|
||||
|
||||
if (unique.length === 0) return t("confirmPay");
|
||||
if (unique.length > 1) return t("confirmPaySplit");
|
||||
if (unique[0] === "Cash") return t("confirmPayCash");
|
||||
if (unique[0] === "Card") return t("confirmPayCard");
|
||||
return t("confirmPayCredit");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Order } from "@/lib/api/types";
|
||||
|
||||
/** Label for open orders at pay time: table + guest name. */
|
||||
export function formatPosOrderLabel(
|
||||
order: Pick<Order, "tableNumber" | "guestName" | "customerName">,
|
||||
tableWord: string
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (order.tableNumber) {
|
||||
parts.push(`${tableWord} ${order.tableNumber}`);
|
||||
}
|
||||
const name = order.guestName?.trim() || order.customerName?.trim();
|
||||
if (name) {
|
||||
parts.push(name);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" · ") : "—";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import type { Order } from "@/lib/api/types";
|
||||
import { iranMobileForApi } from "@/lib/phone";
|
||||
|
||||
export type SubmitOrderCart = {
|
||||
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
|
||||
activeOrderId: string | null;
|
||||
tableId: string | null;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
customerId: string | null;
|
||||
appliedCoupon: { id: string } | null;
|
||||
};
|
||||
|
||||
export type SubmitOrderParams = {
|
||||
cafeId: string;
|
||||
orderBranchId: string | undefined;
|
||||
cart: SubmitOrderCart;
|
||||
reservationId: string | null;
|
||||
};
|
||||
|
||||
export async function submitOrderToApi({
|
||||
cafeId,
|
||||
orderBranchId,
|
||||
cart,
|
||||
reservationId,
|
||||
}: SubmitOrderParams): Promise<Order> {
|
||||
const pending = cart.getPendingLines();
|
||||
if (pending.length === 0) throw new Error("nothing pending");
|
||||
|
||||
if (cart.activeOrderId) {
|
||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, {
|
||||
items: pending,
|
||||
});
|
||||
}
|
||||
|
||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders`, {
|
||||
orderType: "DineIn",
|
||||
branchId: orderBranchId,
|
||||
tableId: cart.tableId ?? undefined,
|
||||
reservationId: reservationId ?? undefined,
|
||||
guestName: cart.guestName.trim() || undefined,
|
||||
guestPhone: iranMobileForApi(cart.guestPhone),
|
||||
customerId: cart.customerId ?? undefined,
|
||||
couponId: cart.appliedCoupon?.id,
|
||||
items: pending,
|
||||
});
|
||||
}
|
||||
|
||||
export function orderAmountDue(order: Order): number {
|
||||
return Math.max(0, order.total - (order.paidAmount ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Sentinel id for the combined “all categories” tab on guest QR menu. */
|
||||
export const QR_ALL_CATEGORY_ID = "all";
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/** QR guest menu background textures (owner picks in Settings → Appearance). */
|
||||
|
||||
export const CAFE_MENU_TEXTURES = [
|
||||
"none",
|
||||
"paper",
|
||||
"linen",
|
||||
"dots",
|
||||
"grid",
|
||||
"marble",
|
||||
"wood",
|
||||
"warm",
|
||||
] as const;
|
||||
|
||||
export type CafeMenuTexture = (typeof CAFE_MENU_TEXTURES)[number];
|
||||
|
||||
export function normalizeMenuTexture(value?: string | null): CafeMenuTexture {
|
||||
if (value && (CAFE_MENU_TEXTURES as readonly string[]).includes(value)) {
|
||||
return value as CafeMenuTexture;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Props for the textured QR menu shell (uses CSS in globals.css). */
|
||||
export function qrMenuTextureShellProps(
|
||||
texture: CafeMenuTexture,
|
||||
backgroundColor: string
|
||||
): { "data-qr-texture": CafeMenuTexture; style: CSSProperties } {
|
||||
return {
|
||||
"data-qr-texture": texture,
|
||||
style: { ["--qr-bg" as string]: backgroundColor },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
export type TopProductSnapshot = {
|
||||
productId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
revenue: number;
|
||||
};
|
||||
|
||||
export type DailyReportSnapshot = {
|
||||
id: string;
|
||||
cafeId: string;
|
||||
branchId: string;
|
||||
date: string;
|
||||
totalRevenue: number;
|
||||
cashRevenue: number;
|
||||
cardRevenue: number;
|
||||
creditRevenue: number;
|
||||
totalOrders: number;
|
||||
avgOrderValue: number;
|
||||
totalVoids: number;
|
||||
voidAmount: number;
|
||||
totalExpenses: number;
|
||||
netIncome: number;
|
||||
topProducts: TopProductSnapshot[];
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type DateRangePreset = "7d" | "30d" | "90d" | "custom";
|
||||
|
||||
export type ReportRange = {
|
||||
from: string;
|
||||
to: string;
|
||||
preset: DateRangePreset;
|
||||
};
|
||||
|
||||
export function isoTodayTehran(): string {
|
||||
return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Tehran" });
|
||||
}
|
||||
|
||||
export function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toLocaleDateString("en-CA", { timeZone: "Asia/Tehran" });
|
||||
}
|
||||
|
||||
export function daysBetweenInclusive(from: string, to: string): number {
|
||||
const start = new Date(`${from}T12:00:00`).getTime();
|
||||
const end = new Date(`${to}T12:00:00`).getTime();
|
||||
return Math.max(1, Math.round((end - start) / 86_400_000) + 1);
|
||||
}
|
||||
|
||||
export function buildRangeFromPreset(preset: DateRangePreset): ReportRange {
|
||||
const to = isoTodayTehran();
|
||||
if (preset === "7d") return { from: addDaysIso(to, -6), to, preset };
|
||||
if (preset === "30d") return { from: addDaysIso(to, -29), to, preset };
|
||||
if (preset === "90d") return { from: addDaysIso(to, -89), to, preset };
|
||||
return { from: addDaysIso(to, -6), to, preset: "7d" };
|
||||
}
|
||||
|
||||
export function previousPeriod(from: string, to: string): { from: string; to: string } {
|
||||
const len = daysBetweenInclusive(from, to);
|
||||
return {
|
||||
from: addDaysIso(from, -len),
|
||||
to: addDaysIso(from, -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatJalaliLabel(isoDate: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : locale === "ar" ? "ar-SA" : "en-GB", {
|
||||
calendar: "persian",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "Asia/Tehran",
|
||||
}).format(new Date(`${isoDate}T12:00:00`));
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
|
||||
export function percentChange(current: number, previous: number): number | null {
|
||||
if (previous === 0) return current === 0 ? 0 : 100;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
export type RangeTotals = {
|
||||
totalRevenue: number;
|
||||
totalOrders: number;
|
||||
avgOrderValue: number;
|
||||
netIncome: number;
|
||||
totalExpenses: number;
|
||||
cashRevenue: number;
|
||||
cardRevenue: number;
|
||||
creditRevenue: number;
|
||||
};
|
||||
|
||||
export function sumSnapshots(rows: DailyReportSnapshot[]): RangeTotals {
|
||||
const totalOrders = rows.reduce((s, r) => s + r.totalOrders, 0);
|
||||
const totalRevenue = rows.reduce((s, r) => s + r.totalRevenue, 0);
|
||||
return {
|
||||
totalRevenue,
|
||||
totalOrders,
|
||||
avgOrderValue: totalOrders > 0 ? totalRevenue / totalOrders : 0,
|
||||
netIncome: rows.reduce((s, r) => s + r.netIncome, 0),
|
||||
totalExpenses: rows.reduce((s, r) => s + r.totalExpenses, 0),
|
||||
cashRevenue: rows.reduce((s, r) => s + r.cashRevenue, 0),
|
||||
cardRevenue: rows.reduce((s, r) => s + r.cardRevenue, 0),
|
||||
creditRevenue: rows.reduce((s, r) => s + r.creditRevenue, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function aggregateByDate(rows: DailyReportSnapshot[]): DailyReportSnapshot[] {
|
||||
const map = new Map<string, DailyReportSnapshot>();
|
||||
for (const r of rows) {
|
||||
const existing = map.get(r.date);
|
||||
if (!existing) {
|
||||
map.set(r.date, { ...r, branchId: "", topProducts: [...r.topProducts] });
|
||||
continue;
|
||||
}
|
||||
existing.totalRevenue += r.totalRevenue;
|
||||
existing.cashRevenue += r.cashRevenue;
|
||||
existing.cardRevenue += r.cardRevenue;
|
||||
existing.creditRevenue += r.creditRevenue;
|
||||
existing.totalOrders += r.totalOrders;
|
||||
existing.totalVoids += r.totalVoids;
|
||||
existing.voidAmount += r.voidAmount;
|
||||
existing.totalExpenses += r.totalExpenses;
|
||||
existing.netIncome += r.netIncome;
|
||||
existing.totalExpenses += r.totalExpenses;
|
||||
existing.topProducts = mergeTopProducts(existing.topProducts, r.topProducts);
|
||||
}
|
||||
const merged = Array.from(map.values());
|
||||
for (const m of merged) {
|
||||
m.avgOrderValue = m.totalOrders > 0 ? m.totalRevenue / m.totalOrders : 0;
|
||||
}
|
||||
return merged.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export function mergeTopProducts(
|
||||
a: TopProductSnapshot[],
|
||||
b: TopProductSnapshot[]
|
||||
): TopProductSnapshot[] {
|
||||
const map = new Map<string, TopProductSnapshot>();
|
||||
for (const p of [...a, ...b]) {
|
||||
const cur = map.get(p.productId);
|
||||
if (!cur) {
|
||||
map.set(p.productId, { ...p });
|
||||
continue;
|
||||
}
|
||||
cur.quantity += p.quantity;
|
||||
cur.revenue += p.revenue;
|
||||
}
|
||||
return Array.from(map.values()).sort((x, y) => y.revenue - x.revenue);
|
||||
}
|
||||
|
||||
export function topProductsFromRange(rows: DailyReportSnapshot[], take = 10): TopProductSnapshot[] {
|
||||
return mergeTopProducts([], rows.flatMap((r) => r.topProducts)).slice(0, take);
|
||||
}
|
||||
|
||||
export function revenueChartPoints(
|
||||
rows: DailyReportSnapshot[],
|
||||
locale: string,
|
||||
rtl: boolean
|
||||
) {
|
||||
const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const points = sorted.map((r) => ({
|
||||
date: r.date,
|
||||
label: formatJalaliLabel(r.date, locale),
|
||||
revenue: r.totalRevenue,
|
||||
}));
|
||||
return rtl ? [...points].reverse() : points;
|
||||
}
|
||||
|
||||
export function branchComparisonPoints(
|
||||
rows: DailyReportSnapshot[],
|
||||
branches: { id: string; name: string }[],
|
||||
locale: string,
|
||||
rtl: boolean
|
||||
) {
|
||||
const dates = Array.from(new Set(rows.map((r) => r.date))).sort();
|
||||
const points = dates.map((date) => {
|
||||
const entry: Record<string, string | number> = {
|
||||
date,
|
||||
label: formatJalaliLabel(date, locale),
|
||||
};
|
||||
for (const b of branches) {
|
||||
const row = rows.find((r) => r.date === date && r.branchId === b.id);
|
||||
entry[b.id] = row?.totalRevenue ?? 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return rtl ? [...points].reverse() : points;
|
||||
}
|
||||
|
||||
const CHART_COLORS = ["#0F6E56", "#0C447C", "#BA7517", "#6366f1", "#ec4899", "#14b8a6"];
|
||||
|
||||
export function chartColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length]!;
|
||||
}
|
||||
|
||||
export function downloadReportsCsv(
|
||||
rows: DailyReportSnapshot[],
|
||||
branchNames: Map<string, string>,
|
||||
headers: {
|
||||
date: string;
|
||||
branch: string;
|
||||
totalRevenue: string;
|
||||
totalOrders: string;
|
||||
avgOrderValue: string;
|
||||
cashRevenue: string;
|
||||
cardRevenue: string;
|
||||
creditRevenue: string;
|
||||
netIncome: string;
|
||||
totalVoids: string;
|
||||
voidAmount: string;
|
||||
totalExpenses: string;
|
||||
},
|
||||
filename: string
|
||||
) {
|
||||
const cols = [
|
||||
headers.date,
|
||||
headers.branch,
|
||||
headers.totalRevenue,
|
||||
headers.totalOrders,
|
||||
headers.avgOrderValue,
|
||||
headers.cashRevenue,
|
||||
headers.cardRevenue,
|
||||
headers.creditRevenue,
|
||||
headers.netIncome,
|
||||
headers.totalVoids,
|
||||
headers.voidAmount,
|
||||
headers.totalExpenses,
|
||||
];
|
||||
const lines = rows.map((r) =>
|
||||
[
|
||||
r.date,
|
||||
branchNames.get(r.branchId) ?? r.branchId,
|
||||
r.totalRevenue,
|
||||
r.totalOrders,
|
||||
r.avgOrderValue,
|
||||
r.cashRevenue,
|
||||
r.cardRevenue,
|
||||
r.creditRevenue,
|
||||
r.netIncome,
|
||||
r.totalVoids,
|
||||
r.voidAmount,
|
||||
r.totalExpenses,
|
||||
].join(",")
|
||||
);
|
||||
const bom = "\uFEFF";
|
||||
const csv = bom + [cols.join(","), ...lines].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
|
||||
interface AdminAuthState {
|
||||
user: AuthTokenResponse | null;
|
||||
setAuth: (user: AuthTokenResponse) => void;
|
||||
clearAuth: () => void;
|
||||
isAuthenticated: () => boolean;
|
||||
}
|
||||
|
||||
export const useAdminAuthStore = create<AdminAuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
setAuth: (user) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("meezi_admin_access_token", user.accessToken);
|
||||
localStorage.setItem("meezi_admin_refresh_token", user.refreshToken);
|
||||
}
|
||||
set({ user });
|
||||
},
|
||||
clearAuth: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("meezi_admin_access_token");
|
||||
localStorage.removeItem("meezi_admin_refresh_token");
|
||||
}
|
||||
set({ user: null });
|
||||
},
|
||||
isAuthenticated: () => !!get().user?.accessToken,
|
||||
}),
|
||||
{ name: "meezi_admin_auth" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
|
||||
interface AuthState {
|
||||
user: AuthTokenResponse | null;
|
||||
setAuth: (user: AuthTokenResponse) => void;
|
||||
clearAuth: () => void;
|
||||
isAuthenticated: () => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
setAuth: (user) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("meezi_access_token", user.accessToken);
|
||||
localStorage.setItem("meezi_refresh_token", user.refreshToken);
|
||||
}
|
||||
set({ user });
|
||||
},
|
||||
clearAuth: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("meezi_access_token");
|
||||
localStorage.removeItem("meezi_refresh_token");
|
||||
}
|
||||
set({ user: null });
|
||||
},
|
||||
isAuthenticated: () => !!get().user?.accessToken,
|
||||
}),
|
||||
{ name: "meezi_auth" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface BranchState {
|
||||
branchId: string | null;
|
||||
setBranchId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useBranchStore = create<BranchState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
branchId: null,
|
||||
setBranchId: (branchId) => set({ branchId }),
|
||||
}),
|
||||
{ name: "meezi_branch" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,205 @@
|
||||
import { create } from "zustand";
|
||||
import type { Customer, MenuItem, Order } from "@/lib/api/types";
|
||||
import { iranMobileForApi } from "@/lib/phone";
|
||||
|
||||
export interface CartItem {
|
||||
menuItem: MenuItem;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
orderItemId?: string;
|
||||
isVoided?: boolean;
|
||||
}
|
||||
|
||||
export interface AppliedCoupon {
|
||||
id: string;
|
||||
code: string;
|
||||
discountAmount: number;
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
syncedQtyByMenuId: Record<string, number>;
|
||||
couponCode: string;
|
||||
appliedCoupon: AppliedCoupon | null;
|
||||
tableId: string | null;
|
||||
activeOrderId: string | null;
|
||||
customerId: string | null;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
|
||||
addItem: (item: MenuItem) => void;
|
||||
removeItem: (menuItemId: string) => void;
|
||||
updateQty: (menuItemId: string, quantity: number) => void;
|
||||
setCouponCode: (code: string) => void;
|
||||
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
||||
clearCoupon: () => void;
|
||||
setTableId: (tableId: string | null) => void;
|
||||
setActiveOrderId: (orderId: string | null) => void;
|
||||
setGuestName: (name: string) => void;
|
||||
setGuestPhone: (phone: string) => void;
|
||||
setCustomer: (customer: Customer | null) => void;
|
||||
clearCustomer: () => void;
|
||||
hydrateFromOrder: (order: Order, menuById: Map<string, MenuItem>) => void;
|
||||
clearCart: () => void;
|
||||
clearSession: () => void;
|
||||
subtotal: () => number;
|
||||
}
|
||||
|
||||
const clearCouponState = {
|
||||
couponCode: "",
|
||||
appliedCoupon: null as AppliedCoupon | null,
|
||||
};
|
||||
|
||||
function orderLineToMenuItem(
|
||||
line: Order["items"][number],
|
||||
menuById: Map<string, MenuItem>
|
||||
): MenuItem {
|
||||
const existing = menuById.get(line.menuItemId);
|
||||
if (existing) return existing;
|
||||
return {
|
||||
id: line.menuItemId,
|
||||
categoryId: "",
|
||||
name: line.menuItemName,
|
||||
price: line.unitPrice,
|
||||
isAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>((set, get) => ({
|
||||
items: [],
|
||||
syncedQtyByMenuId: {},
|
||||
couponCode: "",
|
||||
appliedCoupon: null,
|
||||
tableId: null,
|
||||
activeOrderId: null,
|
||||
customerId: null,
|
||||
guestName: "",
|
||||
guestPhone: "",
|
||||
|
||||
getPendingLines: () => {
|
||||
const { items, syncedQtyByMenuId } = get();
|
||||
const pending: { menuItemId: string; quantity: number; notes?: string }[] = [];
|
||||
for (const line of items) {
|
||||
const synced = syncedQtyByMenuId[line.menuItem.id] ?? 0;
|
||||
const delta = line.quantity - synced;
|
||||
if (delta > 0) {
|
||||
pending.push({
|
||||
menuItemId: line.menuItem.id,
|
||||
quantity: delta,
|
||||
notes: line.notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
|
||||
addItem: (menuItem) => {
|
||||
const existing = get().items.find((i) => i.menuItem.id === menuItem.id);
|
||||
if (existing) {
|
||||
set({
|
||||
items: get().items.map((i) =>
|
||||
i.menuItem.id === menuItem.id
|
||||
? { ...i, quantity: i.quantity + 1 }
|
||||
: i
|
||||
),
|
||||
...clearCouponState,
|
||||
});
|
||||
} else {
|
||||
set({ items: [...get().items, { menuItem, quantity: 1 }], ...clearCouponState });
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: (menuItemId) =>
|
||||
set({
|
||||
items: get().items.filter((i) => i.menuItem.id !== menuItemId),
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
updateQty: (menuItemId, quantity) => {
|
||||
if (quantity <= 0) {
|
||||
get().removeItem(menuItemId);
|
||||
return;
|
||||
}
|
||||
set({
|
||||
items: get().items.map((i) =>
|
||||
i.menuItem.id === menuItemId ? { ...i, quantity } : i
|
||||
),
|
||||
...clearCouponState,
|
||||
});
|
||||
},
|
||||
|
||||
setCouponCode: (code) => set({ couponCode: code }),
|
||||
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
||||
clearCoupon: () => set(clearCouponState),
|
||||
setTableId: (tableId) => set({ tableId }),
|
||||
setActiveOrderId: (activeOrderId) => set({ activeOrderId }),
|
||||
setGuestName: (guestName) =>
|
||||
set((s) => ({
|
||||
guestName,
|
||||
customerId: s.customerId && guestName !== s.guestName ? null : s.customerId,
|
||||
})),
|
||||
setGuestPhone: (guestPhone) =>
|
||||
set((s) => ({
|
||||
guestPhone,
|
||||
customerId: s.customerId && guestPhone !== s.guestPhone ? null : s.customerId,
|
||||
})),
|
||||
|
||||
setCustomer: (customer) =>
|
||||
set({
|
||||
customerId: customer?.id ?? null,
|
||||
guestName: customer?.name ?? "",
|
||||
guestPhone: customer?.phone
|
||||
? (iranMobileForApi(customer.phone) ?? customer.phone)
|
||||
: "",
|
||||
}),
|
||||
|
||||
clearCustomer: () => set({ customerId: null }),
|
||||
|
||||
hydrateFromOrder: (order, menuById) => {
|
||||
const syncedQtyByMenuId: Record<string, number> = {};
|
||||
for (const line of order.items) {
|
||||
syncedQtyByMenuId[line.menuItemId] = line.quantity;
|
||||
}
|
||||
set({
|
||||
activeOrderId: order.id,
|
||||
tableId: order.tableId ?? null,
|
||||
customerId: order.customerId ?? null,
|
||||
guestName: order.guestName ?? order.customerName ?? "",
|
||||
guestPhone: order.guestPhone ?? order.customerPhone ?? "",
|
||||
syncedQtyByMenuId,
|
||||
items: order.items.map((line) => ({
|
||||
menuItem: orderLineToMenuItem(line, menuById),
|
||||
quantity: line.quantity,
|
||||
notes: line.notes,
|
||||
orderItemId: line.id,
|
||||
isVoided: line.isVoided ?? false,
|
||||
})),
|
||||
...clearCouponState,
|
||||
});
|
||||
},
|
||||
|
||||
clearCart: () =>
|
||||
set({
|
||||
items: [],
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
clearSession: () =>
|
||||
set({
|
||||
items: [],
|
||||
syncedQtyByMenuId: {},
|
||||
tableId: null,
|
||||
activeOrderId: null,
|
||||
customerId: null,
|
||||
guestName: "",
|
||||
guestPhone: "",
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
subtotal: () =>
|
||||
get().items.reduce(
|
||||
(sum, i) =>
|
||||
i.isVoided ? sum : sum + i.menuItem.price * i.quantity,
|
||||
0
|
||||
),
|
||||
}));
|
||||
@@ -0,0 +1,11 @@
|
||||
const TERMINAL_KEY = "meezi_terminal_id";
|
||||
|
||||
export function getOrCreateTerminalId(): string {
|
||||
if (typeof window === "undefined") return "server";
|
||||
let id = localStorage.getItem(TERMINAL_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(TERMINAL_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
export function useIsRtl() {
|
||||
const locale = useLocale();
|
||||
return locale !== "en";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Keep only ASCII digits (maps Persian/Arabic numerals). */
|
||||
export function normalizeOtpInput(value: string): string {
|
||||
const persian = "۰۱۲۳۴۵۶۷۸۹";
|
||||
const arabic = "٠١٢٣٤٥٦٧٨٩";
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
if (ch >= "0" && ch <= "9") out += ch;
|
||||
else {
|
||||
const pi = persian.indexOf(ch);
|
||||
if (pi >= 0) {
|
||||
out += String(pi);
|
||||
continue;
|
||||
}
|
||||
const ai = arabic.indexOf(ch);
|
||||
if (ai >= 0) out += String(ai);
|
||||
}
|
||||
}
|
||||
return out.slice(0, 6);
|
||||
}
|
||||
Reference in New Issue
Block a user