feat(dashboard): Next.js 16 merchant panel with offline POS and PWA

Complete merchant dashboard upgrade:

Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors

Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect

PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
import axios, { type AxiosError } from "axios";
import type { ApiResponse } from "./types";
const baseURL =
process.env.NEXT_PUBLIC_ADMIN_API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"https://localhost:7210";
export const adminApi = axios.create({
baseURL,
headers: { "Content-Type": "application/json" },
});
adminApi.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("meezi_admin_access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
adminApi.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiResponse<unknown>>) => {
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new AdminApiClientError(apiError.code, apiError.message));
}
if (error.response?.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("meezi_admin_access_token");
localStorage.removeItem("meezi_admin_refresh_token");
localStorage.removeItem("meezi_admin_auth");
const locale = window.location.pathname.split("/")[1] ?? "fa";
window.location.href = `/${locale}/admin/login`;
}
return Promise.reject(error);
}
);
export class AdminApiClientError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
this.name = "AdminApiClientError";
}
}
export async function adminGet<T>(url: string): Promise<T> {
const { data } = await adminApi.get<ApiResponse<T>>(url);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminPost<T>(url: string, body?: unknown): Promise<T> {
const { data } = await adminApi.post<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminPut<T>(url: string, body: unknown): Promise<T> {
const { data } = await adminApi.put<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminPatch<T>(url: string, body: unknown): Promise<T> {
const { data } = await adminApi.patch<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function adminDelete(url: string): Promise<void> {
const { data } = await adminApi.delete<ApiResponse<unknown>>(url);
if (!data.success) {
throw new Error(data.error?.message ?? "Request failed");
}
}
+143
View File
@@ -0,0 +1,143 @@
export type AdminStats = {
totalCafes: number;
activeCafes: number;
suspendedCafes: number;
openTickets: number;
plansConfigured: number;
};
export type PlanLimitsData = {
maxOrdersPerDay: number;
maxTerminals: number;
maxCustomers: number;
maxSmsPerMonth: number;
maxBranches: number;
maxReportHistoryDays: number;
};
export type AdminPlan = {
tier: string;
displayNameFa: string;
displayNameEn?: string | null;
monthlyPriceToman: number;
isBillableOnline: boolean;
isActive: boolean;
sortOrder: number;
limits: PlanLimitsData;
featureKeys: string[];
};
export type PlatformSetting = {
id: string;
key: string;
value: string;
category: string;
descriptionFa?: string | null;
updatedAt: string;
};
export type PlatformFeature = {
id: string;
key: string;
displayNameFa: string;
displayNameEn?: string | null;
moduleGroup: string;
isEnabledGlobally: boolean;
updatedAt: string;
};
export type AdminCafe = {
id: string;
name: string;
slug: string;
city: string;
planTier: string;
planExpiresAt?: string | null;
isSuspended: boolean;
isVerified: boolean;
branchCount: number;
employeeCount: number;
createdAt: string;
};
export type SupportTicket = {
id: string;
cafeId: string;
cafeName: string;
subject: string;
status: string;
priority: string;
createdByEmployeeId: string;
createdByName?: string | null;
assignedAdminId?: string | null;
createdAt: string;
updatedAt: string;
messageCount: number;
};
export type SupportTicketMessage = {
id: string;
senderKind: string;
senderId: string;
senderName?: string | null;
body: string;
createdAt: string;
};
export type SupportTicketDetail = {
ticket: SupportTicket;
messages: SupportTicketMessage[];
};
export type GatewayCredentials = {
username?: string | null;
password?: string | null;
branchCode?: string | null;
terminalCode?: string | null;
clientId?: string | null;
clientSecret?: string | null;
baseUrl?: string | null;
hasStoredPassword: boolean;
hasStoredClientSecret: boolean;
};
export type PaymentGatewayConfig = {
id: string;
displayNameFa: string;
isEnabled: boolean;
isActive: boolean;
merchantId?: string | null;
apiKey?: string | null;
sandbox: boolean;
hasStoredSecret: boolean;
credentials?: GatewayCredentials | null;
};
export type KavenegarConfig = {
isEnabled: boolean;
apiKey?: string | null;
otpTemplate: string;
hasStoredApiKey: boolean;
};
export type PlatformIntegrations = {
activePaymentGateway: string;
paymentGateways: PaymentGatewayConfig[];
kavenegar: KavenegarConfig;
};
export type AdminNotificationRow = {
id: string;
cafeId: string;
cafeName: string;
type: string;
title: string;
body?: string | null;
isRead: boolean;
createdAt: string;
};
export type BroadcastResult = {
cafeCount: number;
notificationCount: number;
};
+57
View File
@@ -0,0 +1,57 @@
import { apiDelete, apiGet, apiPut } from "@/lib/api/client";
import type { MenuItem } from "@/lib/api/types";
export interface BranchMenuItem extends MenuItem {
masterPrice: number;
effectivePrice: number;
isOverridden: boolean;
hasPriceOverride: boolean;
}
export function branchMenuItemToMenuItem(row: BranchMenuItem): MenuItem {
return {
id: row.id,
categoryId: row.categoryId,
name: row.name,
nameAr: row.nameAr,
nameEn: row.nameEn,
description: row.description,
price: row.effectivePrice,
imageUrl: row.imageUrl,
videoUrl: row.videoUrl,
isAvailable: row.isAvailable,
};
}
export async function getBranchMenu(
cafeId: string,
branchId: string,
options?: { includeUnavailable?: boolean }
): Promise<BranchMenuItem[]> {
const qs = options?.includeUnavailable ? "?includeUnavailable=true" : "";
return apiGet<BranchMenuItem[]>(
`/api/cafes/${cafeId}/branches/${branchId}/menu${qs}`
);
}
export async function upsertBranchMenuOverride(
cafeId: string,
branchId: string,
menuItemId: string,
body: { isAvailable: boolean; priceOverride: number | null }
): Promise<void> {
await apiPut(
`/api/cafes/${cafeId}/branches/${branchId}/menu/${menuItemId}/override`,
body
);
}
export async function deleteBranchMenuOverride(
cafeId: string,
branchId: string,
menuItemId: string
): Promise<void> {
await apiDelete(
`/api/cafes/${cafeId}/branches/${branchId}/menu/${menuItemId}/override`
);
}
+134
View File
@@ -0,0 +1,134 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import type { TableBoardItem } from "@/lib/api/types";
export interface TableSectionDto {
id: string;
branchId: string;
name: string;
sortOrder: number;
isActive: boolean;
tableCount: number;
}
export function branchTablesPath(cafeId: string, branchId: string): string {
return `/api/cafes/${cafeId}/branches/${branchId}/tables`;
}
export async function fetchBranchTableBoard(
cafeId: string,
branchId: string,
activeOnly = false
): Promise<TableBoardItem[]> {
const params = new URLSearchParams({ activeOnly: String(activeOnly) });
return apiGet<TableBoardItem[]>(
`${branchTablesPath(cafeId, branchId)}/board?${params}`
);
}
/** POS + admin board: café-wide endpoint (optional branch filter), with fallback if branch has no rows. */
export async function fetchCafeTableBoard(
cafeId: string,
branchId?: string | null
): Promise<TableBoardItem[]> {
const params = new URLSearchParams({ activeOnly: "false" });
if (branchId) params.set("branchId", branchId);
const scoped = await apiGet<TableBoardItem[]>(
`/api/cafes/${cafeId}/tables/board?${params}`
);
if (scoped.length > 0 || !branchId) return scoped;
return apiGet<TableBoardItem[]>(
`/api/cafes/${cafeId}/tables/board?activeOnly=false`
);
}
export async function fetchBranchSections(
cafeId: string,
branchId: string
): Promise<TableSectionDto[]> {
return apiGet<TableSectionDto[]>(
`${branchTablesPath(cafeId, branchId)}/sections`
);
}
export async function createBranchTable(
cafeId: string,
branchId: string,
body: {
number: string;
capacity: number;
floor?: string | null;
sectionId?: string | null;
sortOrder?: number;
}
): Promise<void> {
await apiPost(`${branchTablesPath(cafeId, branchId)}`, body);
}
export async function patchBranchTable(
cafeId: string,
branchId: string,
tableId: string,
body: Record<string, unknown>
): Promise<void> {
await apiPatch(`${branchTablesPath(cafeId, branchId)}/${tableId}`, body);
}
export async function deleteBranchTable(
cafeId: string,
branchId: string,
tableId: string
): Promise<void> {
await apiDelete(`${branchTablesPath(cafeId, branchId)}/${tableId}`);
}
export async function setTableCleaning(
cafeId: string,
tableId: string,
isCleaning: boolean,
branchId?: string | null
): Promise<TableBoardItem> {
const body = { isCleaning };
if (branchId) {
return apiPatch<TableBoardItem>(
`${branchTablesPath(cafeId, branchId)}/${tableId}/cleaning`,
body
);
}
return apiPatch<TableBoardItem>(
`/api/cafes/${cafeId}/tables/${tableId}/cleaning`,
body
);
}
export async function createBranchSection(
cafeId: string,
branchId: string,
body: { name: string; sortOrder?: number }
): Promise<TableSectionDto> {
return apiPost<TableSectionDto>(
`${branchTablesPath(cafeId, branchId)}/sections`,
body
);
}
export async function patchBranchSection(
cafeId: string,
branchId: string,
sectionId: string,
body: { name?: string; sortOrder?: number }
): Promise<TableSectionDto> {
return apiPatch<TableSectionDto>(
`${branchTablesPath(cafeId, branchId)}/sections/${sectionId}`,
body
);
}
export async function deleteBranchSection(
cafeId: string,
branchId: string,
sectionId: string
): Promise<void> {
await apiDelete(
`${branchTablesPath(cafeId, branchId)}/sections/${sectionId}`
);
}
@@ -0,0 +1,57 @@
import { api } from "./client";
import type { ApiResponse } from "./types";
import type { WorkingHours } from "./public-discover";
export type CafeProfileEdit = {
description: string | null;
galleryUrls: string[];
instagramHandle: string | null;
websiteUrl: string | null;
workingHours: WorkingHours | null;
};
export type UpdateCafeProfilePayload = {
description?: string | null;
instagramHandle?: string | null;
websiteUrl?: string | null;
workingHours?: WorkingHours | null;
};
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
const { data } = await promise;
if (!data.success || data.data === undefined)
throw new Error(data.error?.message ?? "Request failed");
return data.data;
}
export async function fetchCafePublicProfile(cafeId: string): Promise<CafeProfileEdit> {
return unwrap(api.get<ApiResponse<CafeProfileEdit>>(`/api/cafes/${cafeId}/public-profile`));
}
export async function updateCafePublicProfile(
cafeId: string,
payload: UpdateCafeProfilePayload
): Promise<CafeProfileEdit> {
return unwrap(api.put<ApiResponse<CafeProfileEdit>>(`/api/cafes/${cafeId}/public-profile`, payload));
}
export async function uploadGalleryPhoto(cafeId: string, file: File): Promise<string[]> {
const form = new FormData();
form.append("photo", file);
const { data } = await api.post<ApiResponse<{ galleryUrls: string[] }>>(
`/api/cafes/${cafeId}/public-profile/gallery`,
form,
{ headers: { "Content-Type": "multipart/form-data" } }
);
if (!data.success || !data.data) throw new Error(data.error?.message ?? "Upload failed");
return data.data.galleryUrls;
}
export async function removeGalleryPhoto(cafeId: string, url: string): Promise<string[]> {
const { data } = await api.delete<ApiResponse<{ galleryUrls: string[] }>>(
`/api/cafes/${cafeId}/public-profile/gallery`,
{ params: { url } }
);
if (!data.success || !data.data) throw new Error(data.error?.message ?? "Remove failed");
return data.data.galleryUrls;
}
+183
View File
@@ -0,0 +1,183 @@
import axios, { type AxiosError } from "axios";
import type { ApiResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal";
const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
export const api = axios.create({
baseURL,
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("meezi_access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers["X-Meezi-Terminal-Id"] = getOrCreateTerminalId();
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiResponse<unknown>>) => {
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
}
if (error.response?.status === 401 && typeof window !== "undefined") {
const path = window.location.pathname;
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
const isAdmin = path.includes("/admin");
if (!isPublicGuest && !isAdmin) {
localStorage.removeItem("meezi_access_token");
localStorage.removeItem("meezi_refresh_token");
localStorage.removeItem("meezi_auth");
const locale = path.split("/")[1] ?? "fa";
window.location.href = `/${locale}/login`;
}
}
return Promise.reject(error);
}
);
export interface PagedMeta {
total: number;
page: number;
pageSize: number;
}
export interface PagedApiResponse<T> {
success: boolean;
data?: T[];
meta?: PagedMeta;
error?: { code: string; message: string; field?: string };
}
export async function apiGet<T>(url: string): Promise<T> {
const { data } = await api.get<ApiResponse<T>>(url);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: PagedMeta }> {
const { data } = await api.get<PagedApiResponse<T>>(url);
if (!data.success || data.data === undefined || !data.meta) {
throw new Error(data.error?.message ?? "Request failed");
}
return { items: data.data, meta: data.meta };
}
export class ApiClientError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
this.name = "ApiClientError";
}
}
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.post<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.put<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.patch<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiDelete(url: string): Promise<void> {
const { data } = await api.delete<ApiResponse<unknown>>(url);
if (!data.success) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
}
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
export async function apiGetBlob(path: string): Promise<Blob> {
const response = await api.get(path, { responseType: "blob" });
return response.data as Blob;
}
/** Public GET JSON (no auth required). */
export async function apiGetPublic<T>(path: string): Promise<T> {
const { data } = await api.get<ApiResponse<T>>(path);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
return data.data;
}
/** Public POST JSON (no auth required). */
export async function apiPostPublic<T, B = unknown>(path: string, body?: B): Promise<T> {
const { data } = await api.post<ApiResponse<T>>(path, body);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
return data.data;
}
export function openBlobInNewTab(blob: Blob): void {
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}
export async function apiDownload(path: string, filename: string): Promise<void> {
const blob = await apiGetBlob(path);
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
export async function apiUpload<T>(url: string, file: File): Promise<T> {
const form = new FormData();
form.append("file", file);
const { data } = await api.post<ApiResponse<T>>(url, form, {
headers: { "Content-Type": "multipart/form-data" },
});
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Upload failed");
}
return data.data;
}
export function resolveMediaUrl(path?: string | null): string | undefined {
if (!path) return undefined;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const base = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
}
+2
View File
@@ -0,0 +1,2 @@
/** @deprecated Import from `@/lib/api/client` instead. */
export { apiDownload } from "@/lib/api/client";
@@ -0,0 +1,41 @@
import { apiGet, apiPost } from "@/lib/api/client";
export type CafeNotification = {
id: string;
type: string;
title: string;
body?: string | null;
referenceId?: string | null;
tableNumber?: string | null;
isRead: boolean;
createdAt: string;
};
export type NotificationList = {
items: CafeNotification[];
unreadCount: number;
};
export async function fetchNotifications(
cafeId: string,
unreadOnly = false,
limit = 50
): Promise<NotificationList> {
return apiGet<NotificationList>(
`/api/cafes/${cafeId}/notifications?unreadOnly=${unreadOnly}&limit=${limit}`
);
}
export async function fetchUnreadCount(cafeId: string): Promise<number> {
const data = await apiGet<{ count: number }>(
`/api/cafes/${cafeId}/notifications/unread-count`
);
return data.count;
}
export async function markNotificationsRead(
cafeId: string,
body: { ids?: string[]; all?: boolean }
): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/notifications/read`, body);
}
+33
View File
@@ -0,0 +1,33 @@
import { apiPost, ApiClientError } from "@/lib/api/client";
export type PosPaymentRequestResult = {
sent: boolean;
skipped: boolean;
message?: string | null;
};
export async function requestPosPayment(
cafeId: string,
branchId: string,
orderId: string,
amount: number
): Promise<PosPaymentRequestResult> {
return apiPost<PosPaymentRequestResult>(
`/api/cafes/${cafeId}/branches/${branchId}/pos-device/payment-request`,
{ orderId, amount }
);
}
export function posDeviceErrorMessage(
err: unknown,
t: (key: string) => string
): string {
if (err instanceof ApiClientError) {
if (err.code === "POS_DEVICE_NOT_CONFIGURED") return t("posDeviceNotConfigured");
if (err.code === "POS_DEVICE_CONNECTION_FAILED") return t("posDeviceConnectionFailed");
if (err.code === "POS_DEVICE_TIMEOUT") return t("posDeviceTimeout");
if (err.code === "POS_DEVICE_REJECTED") return t("posDeviceRejected");
if (err.code.startsWith("POS_DEVICE")) return t("posDeviceError");
}
return t("posDeviceError");
}
+26
View File
@@ -0,0 +1,26 @@
import { apiPost, ApiClientError } from "@/lib/api/client";
export async function printReceipt(cafeId: string, orderId: string): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/receipt/${orderId}`, {});
}
export async function printKitchen(cafeId: string, orderId: string): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}`, {});
}
export async function testPrinter(
cafeId: string,
printerIp: string,
port: number
): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/test`, { printerIp, port });
}
export function printErrorMessage(err: unknown, t: (key: string) => string): string {
if (err instanceof ApiClientError) {
if (err.code === "PRINTER_NOT_CONFIGURED" || err.code === "KITCHEN_PRINTER_NOT_CONFIGURED")
return t("notConfigured");
if (err.code === "PRINTER_CONNECTION_FAILED") return t("connectionFailed");
}
return t("connectionFailed");
}
@@ -0,0 +1,153 @@
import { apiGetPublic } from "@/lib/api/client";
import type { CafeDiscoverProfile } from "@/lib/cafe-discover-profile";
export type CafeDiscoverBadge = {
key: string;
label: string;
icon: string;
};
export type DaySchedule = {
isOpen: boolean;
open: string | null;
close: string | null;
};
export type WorkingHours = {
sat: DaySchedule | null;
sun: DaySchedule | null;
mon: DaySchedule | null;
tue: DaySchedule | null;
wed: DaySchedule | null;
thu: DaySchedule | null;
fri: DaySchedule | null;
};
export type PublicCafeDiscover = {
id: string;
name: string;
slug: string;
city: string | null;
address: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
isVerified: boolean;
averageRating: number;
reviewCount: number;
discoverProfile: CafeDiscoverProfile;
badges?: CafeDiscoverBadge[];
galleryUrls: string[];
isOpenNow: boolean;
instagramHandle: string | null;
websiteUrl: string | null;
relevanceScore: number;
};
export type NlpHints = {
themes: string[];
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevel: string | null;
priceTier: string | null;
size: string | null;
};
export type DiscoverTaxonomy = {
themes: string[];
sizes: string[];
floors: string[];
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevels: string[];
priceTiers: string[];
};
export type DiscoverSearchParams = {
city?: string;
q?: string;
minRating?: number;
sort?: string;
themes?: string[];
vibes?: string[];
occasions?: string[];
spaceFeatures?: string[];
noise?: string;
priceTier?: string;
size?: string;
requireProfile?: boolean;
openNow?: boolean;
};
function toQuery(params: DiscoverSearchParams): string {
const q = new URLSearchParams();
if (params.city) q.set("city", params.city);
if (params.q) q.set("q", params.q);
if (params.minRating != null) q.set("minRating", String(params.minRating));
if (params.sort) q.set("sort", params.sort);
if (params.themes?.length) q.set("themes", params.themes.join(","));
if (params.vibes?.length) q.set("vibes", params.vibes.join(","));
if (params.occasions?.length) q.set("occasions", params.occasions.join(","));
if (params.spaceFeatures?.length) q.set("spaceFeatures", params.spaceFeatures.join(","));
if (params.noise) q.set("noise", params.noise);
if (params.priceTier) q.set("priceTier", params.priceTier);
if (params.size) q.set("size", params.size);
if (params.requireProfile !== false) q.set("requireProfile", "true");
if (params.openNow) q.set("openNow", "true");
const s = q.toString();
return s ? `?${s}` : "";
}
export async function fetchPublicDiscover(
params: DiscoverSearchParams
): Promise<PublicCafeDiscover[]> {
return apiGetPublic<PublicCafeDiscover[]>(`/api/public/discover${toQuery(params)}`);
}
export async function fetchDiscoverTaxonomy(): Promise<DiscoverTaxonomy> {
return apiGetPublic<DiscoverTaxonomy>("/api/public/discover-profile/taxonomy");
}
export type PublicCafeReview = {
id: string;
authorName: string;
rating: number;
comment: string | null;
ownerReply: string | null;
createdAt: string;
};
export async function fetchPublicCafeReviews(slug: string) {
return apiGetPublic<PublicCafeReview[]>(
`/api/public/cafes/${encodeURIComponent(slug)}/reviews?pageSize=20`
);
}
export type PublicCafeDetail = {
id: string;
name: string;
slug: string;
city: string | null;
address: string | null;
phone: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
description: string | null;
averageRating: number;
reviewCount: number;
discoverProfile: CafeDiscoverProfile;
galleryUrls: string[];
isOpenNow: boolean;
instagramHandle: string | null;
websiteUrl: string | null;
workingHours: WorkingHours | null;
};
export async function fetchPublicCafe(slug: string): Promise<PublicCafeDetail> {
return apiGetPublic<PublicCafeDetail>(`/api/public/cafes/${encodeURIComponent(slug)}`);
}
export async function fetchNlpHints(q: string): Promise<NlpHints> {
return apiGetPublic<NlpHints>(`/api/public/discover/nlp-parse?q=${encodeURIComponent(q)}`);
}
+176
View File
@@ -0,0 +1,176 @@
import { apiGetPublic, ApiClientError } from "@/lib/api/client";
import type { ApiResponse } from "@/lib/api/types";
import { api } from "@/lib/api/client";
import type { CafeTheme } from "@/lib/cafe-theme";
export type QrResolve = {
cafeId: string;
cafeSlug: string;
tableId: string;
tableNumber: string;
tableName: string;
branchId: string;
branchName: string;
cafeName: string;
primaryColor: string;
logoUrl?: string | null;
welcomeText: string;
wifiPassword?: string | null;
address?: string | null;
isCleaning: boolean;
};
export type QrPublicMenuItem = {
id: string;
categoryId: string;
name: string;
nameAr?: string | null;
nameEn?: string | null;
description?: string | null;
price: number;
discountPercent: number;
imageUrl?: string | null;
videoUrl?: string | null;
model3dUrl?: string | null;
isAvailable: boolean;
};
export type QrPublicMenuCategory = {
id: string;
name: string;
nameAr?: string | null;
nameEn?: string | null;
icon?: string | null;
iconPresetId?: string | null;
iconStyle?: string | null;
imageUrl?: string | null;
items: QrPublicMenuItem[];
};
export type QrPublicMenu = {
cafeId: string;
cafeName: string;
slug: string;
theme?: CafeTheme | null;
categories: QrPublicMenuCategory[];
};
export type QrCartLine = {
item: QrPublicMenuItem;
qty: number;
note?: string;
};
export type QrOrderPlaced = {
orderId: string;
orderNumber: string;
totalAmount: number;
itemCount: number;
status: string;
trackingToken: string;
};
export type QrOrderTrackStep = {
key: string;
labelKey: string;
isComplete: boolean;
isCurrent: boolean;
};
export type QrOrderTrack = {
id: string;
orderNumber: string;
status: string;
statusLabelKey: string;
total: number;
tableNumber?: string | null;
createdAt: string;
statusUpdatedAt: string;
trackingToken: string;
steps: QrOrderTrackStep[];
};
export async function resolveQrCode(code: string): Promise<QrResolve> {
return apiGetPublic<QrResolve>(`/api/q/${encodeURIComponent(code)}`);
}
export async function fetchBranchPublicMenu(
cafeId: string,
branchId: string
): Promise<QrPublicMenu> {
return apiGetPublic<QrPublicMenu>(
`/api/public/${cafeId}/branches/${branchId}/menu`
);
}
export type PublicSecurityConfig = {
abuseProtectionEnabled: boolean;
turnstileSiteKey: string | null;
captchaRequired: boolean;
};
export async function fetchPublicSecurityConfig(): Promise<PublicSecurityConfig> {
return apiGetPublic<PublicSecurityConfig>("/api/public/security-config");
}
function normalizeQrOrderPlaced(raw: QrOrderPlaced & Record<string, unknown>): QrOrderPlaced {
const orderId = String(raw.orderId ?? raw.OrderId ?? "").trim();
const orderNumber = String(raw.orderNumber ?? raw.OrderNumber ?? "").trim();
const trackingToken = String(raw.trackingToken ?? raw.TrackingToken ?? "").trim();
const totalAmount = Number(raw.totalAmount ?? raw.TotalAmount ?? 0);
const itemCount = Number(raw.itemCount ?? raw.ItemCount ?? 0);
const status = String(raw.status ?? raw.Status ?? "");
return { orderId, orderNumber, totalAmount, itemCount, status, trackingToken };
}
export async function placeBranchGuestOrder(
cafeId: string,
branchId: string,
body: {
tableId: string;
guestName?: string | null;
guestPhone?: string | null;
items: { menuItemId: string; quantity: number; notes?: string | null }[];
captchaToken?: string | null;
}
): Promise<QrOrderPlaced> {
const { data } = await api.post<ApiResponse<QrOrderPlaced & Record<string, unknown>>>(
`/api/public/${cafeId}/branches/${branchId}/orders`,
body
);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
const placed = normalizeQrOrderPlaced(data.data);
if (!placed.orderId || !placed.trackingToken) {
throw new ApiClientError(
"INVALID_RESPONSE",
"Order was placed but tracking data is missing."
);
}
return placed;
}
export async function fetchOrderTrack(
orderId: string,
trackingToken: string
): Promise<QrOrderTrack> {
return apiGetPublic<QrOrderTrack>(
`/api/public/orders/${encodeURIComponent(orderId)}/track?token=${encodeURIComponent(trackingToken)}`
);
}
export async function callWaiter(cafeId: string, tableId: string): Promise<void> {
const { data } = await api.post<ApiResponse<null>>(
`/api/public/${encodeURIComponent(cafeId)}/tables/${encodeURIComponent(tableId)}/call-waiter`
);
if (!data.success) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Could not send request."
);
}
}
+202
View File
@@ -0,0 +1,202 @@
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string; field?: string };
}
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;
expiresAt: string;
userId: string;
cafeId: string;
role: string;
planTier: string;
language: string;
actor?: string;
branchId?: string | null;
}
export interface MenuCategory {
id: string;
name: string;
nameAr?: string;
nameEn?: string;
sortOrder: number;
taxId?: string;
discountPercent: number;
icon?: string;
iconPresetId?: string;
iconStyle?: string;
imageUrl?: string;
isActive: boolean;
}
export interface MenuItem {
id: string;
categoryId: string;
name: string;
nameAr?: string;
nameEn?: string;
description?: string;
price: number;
imageUrl?: string;
videoUrl?: string;
model3dUrl?: string;
isAvailable: boolean;
}
export interface OrderItemLine {
id: string;
menuItemId: string;
menuItemName: string;
quantity: number;
unitPrice: number;
notes?: string;
isVoided?: boolean;
voidedAt?: string | null;
}
export interface PaymentLine {
id: string;
method: string;
amount: number;
status: string;
reference?: string;
}
export interface Order {
id: string;
cafeId: string;
branchId?: string;
tableId?: string;
tableNumber?: string;
guestName?: string;
guestPhone?: string;
customerName?: string;
customerPhone?: string;
customerId?: string;
employeeId?: string;
orderType: string;
status: string;
subtotal: number;
taxTotal: number;
discountAmount: number;
total: number;
paidAmount: number;
createdAt: string;
displayNumber: number;
items: OrderItemLine[];
payments: PaymentLine[];
}
export type TableBoardStatus = "Free" | "Busy" | "Reserved" | "Cleaning";
export interface TableBoardItem {
id: string;
branchId: string;
sectionId?: string | null;
sectionName?: string | null;
sortOrder?: number;
number: string;
capacity: number;
floor?: string;
qrCode: string;
qrCodeUrl: string;
imageUrl?: string;
videoUrl?: string;
isActive: boolean;
isCleaning: boolean;
status: TableBoardStatus;
currentOrder?: {
orderId: string;
status: string;
total: number;
guestLabel?: string;
source?: string;
};
}
export interface LiveOrder {
id: string;
displayNumber: number;
status: string;
tableNumber?: number;
orderType: string;
total: number;
createdAt: string;
items: OrderItemLine[];
}
export type CustomerGroup = "Regular" | "Vip" | "New" | "Employee";
export type CouponType = "Percentage" | "FixedAmount" | "FreeItem";
export interface Customer {
id: string;
name: string;
phone: string;
nationalId?: string;
birthDateJalali?: string;
group: CustomerGroup;
loyaltyPoints: number;
referredBy?: string;
createdAt: string;
}
export interface Coupon {
id: string;
code: string;
type: CouponType;
value: number;
minOrderAmount?: number;
maxDiscount?: number;
usageLimit?: number;
usedCount: number;
targetGroup?: CustomerGroup;
startsAt?: string;
expiresAt?: string;
isActive: boolean;
}
export interface SmsUsage {
usedThisMonth: number;
monthlyLimit: number;
month: string;
}
export interface SmsCampaignResult {
sentCount: number;
failedCount: number;
}
export interface Table {
id: string;
branchId?: string;
number: string;
capacity: number;
floor?: string;
qrCode: string;
qrUrl: string;
isActive: boolean;
}
export type QueueTicketStatus = "Waiting" | "Called" | "Done" | "Cancelled";
export interface QueueTicket {
id: string;
branchId?: string;
serviceDate: string;
number: number;
customerLabel?: string;
orderId?: string;
status: QueueTicketStatus;
issuedAt: string;
}
export interface QueueBoard {
serviceDate: string;
nowServing?: number | null;
lastIssued: number;
waitingCount: number;
tickets: QueueTicket[];
}
+38
View File
@@ -0,0 +1,38 @@
import { BRANCH_ONLY_NAV_GROUP, type NavGroupId } from "@/lib/sidebar-nav";
/** 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 canSeeNavGroup(
groupId: NavGroupId,
role: string | undefined,
branchId: string | null | undefined
): boolean {
if (isBranchAccount(branchId) && groupId !== BRANCH_ONLY_NAV_GROUP) {
return false;
}
return true;
}
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,65 @@
/** 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", "specialty_coffee", "tea_house", "art_gallery", "sport_cafe", "gaming_cafe",
],
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", "after_dinner", "group_large",
],
spaceFeatures: [
"indoor", "outdoor", "terrace", "rooftop", "garden", "plants",
"wifi", "parking", "wheelchair", "kids_friendly", "pet_friendly",
"smoking_area", "live_music", "private_room", "counter_only",
"takeaway", "hookah", "board_games", "no_smoking", "prayer_room",
],
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];
}
+548
View File
@@ -0,0 +1,548 @@
/** Per-café branding — synced with API CafeThemeDto / Core Branding.CafeTheme */
import type { CSSProperties } from "react";
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;
primaryOpacity?: number | null;
secondaryOpacity?: number | null;
accentOpacity?: number | null;
backgroundOpacity?: number | null;
surfaceOpacity?: number | null;
textOpacity?: number | null;
textMutedOpacity?: number | null;
destructiveOpacity?: number | null;
successOpacity?: number | null;
};
export type CafeThemeColorKey =
| "primary"
| "secondary"
| "accent"
| "background"
| "surface"
| "text"
| "textMuted"
| "destructive"
| "success";
export const COLOR_OPACITY_KEYS: Record<CafeThemeColorKey, keyof CafeThemeCustomColors> = {
primary: "primaryOpacity",
secondary: "secondaryOpacity",
accent: "accentOpacity",
background: "backgroundOpacity",
surface: "surfaceOpacity",
text: "textOpacity",
textMuted: "textMutedOpacity",
destructive: "destructiveOpacity",
success: "successOpacity",
};
function applyColorOpacity(hex: string, opacityPct: number | null | undefined): string {
if (opacityPct == null || opacityPct >= 100) return hex;
const a = Math.max(0, Math.min(100, opacityPct)) / 100;
const raw = hex.replace("#", "");
if (raw.length !== 6) return hex;
const r = parseInt(raw.slice(0, 2), 16);
const g = parseInt(raw.slice(2, 4), 16);
const b = parseInt(raw.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
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];
}
/** Fallback palette when QR menu has no saved theme (branch accent only). */
export function resolveQrGuestColors(
theme: CafeTheme | null | undefined,
branchPrimary?: string | null
): CafeThemePalette {
const base = theme ? resolveThemeColors(theme) : getThemePalette(DEFAULT_CAFE_THEME.paletteId);
if (!branchPrimary?.trim()) return base;
const p = branchPrimary.trim();
return { ...base, primary: p.startsWith("#") ? p : `#${p}` };
}
/** CSS variables for QR guest shell (`data-qr-guest-menu`). */
export function buildQrThemeCssVars(colors: CafeThemePalette): CSSProperties {
return {
["--qr-primary" as string]: colors.primary,
["--qr-primary-fg" as string]: colors.primaryForeground,
["--qr-secondary" as string]: colors.secondary,
["--qr-accent" as string]: colors.accent,
["--qr-bg" as string]: colors.background,
["--qr-surface" as string]: colors.surface,
["--qr-text" as string]: colors.text,
["--qr-text-muted" as string]: colors.textMuted,
["--qr-destructive" as string]: colors.destructive,
["--qr-success" as string]: colors.success,
color: colors.text,
backgroundColor: colors.background,
};
}
export function resolveThemeColors(theme: CafeTheme): CafeThemePalette {
const base = getThemePalette(theme.paletteId);
const c = theme.custom;
if (!c) return base;
const pick = (key: CafeThemeColorKey, fallback: string) => {
const customHex = c[key] as string | null | undefined;
const hex = customHex ?? fallback;
const opacity = c[COLOR_OPACITY_KEYS[key]] as number | null | undefined;
if (customHex || (opacity != null && opacity < 100))
return applyColorOpacity(hex, opacity ?? 100);
return fallback;
};
return {
...base,
primary: pick("primary", base.primary),
secondary: pick("secondary", base.secondary),
accent: pick("accent", base.accent),
background: pick("background", base.background),
surface: pick("surface", base.surface),
text: pick("text", base.text),
textMuted: pick("textMuted", base.textMuted),
destructive: pick("destructive", base.destructive),
success: pick("success", base.success),
};
}
function hexToHslChannels(hex: string, opacityPct?: number | null): 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;
}
}
const channels = `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
if (opacityPct == null || opacityPct >= 100) return channels;
const a = Math.max(0, Math.min(100, opacityPct)) / 100;
return `${channels} / ${a}`;
}
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 base = getThemePalette(theme.paletteId);
const c = theme.custom;
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, key: CafeThemeColorKey, fallback: string) => {
const hex = (c?.[key] as string | null | undefined) ?? fallback;
const opacity = c?.[COLOR_OPACITY_KEYS[key]] as number | null | undefined;
root.style.setProperty(name, hexToHslChannels(hex, opacity));
};
const setPlain = (name: string, hex: string) => {
root.style.setProperty(name, hexToHslChannels(hex));
};
set("--primary", "primary", base.primary);
setPlain("--primary-foreground", base.primaryForeground);
set("--secondary", "secondary", base.secondary);
set("--foreground", "text", base.text);
set("--accent", "secondary", base.secondary);
setPlain("--accent-foreground", base.primary);
set("--background", "background", base.background);
set("--card", "surface", base.surface);
setPlain("--card-foreground", base.text);
set("--muted", "background", base.background);
set("--muted-foreground", "textMuted", base.textMuted);
set("--destructive", "destructive", base.destructive);
set("--ring", "primary", base.primary);
set("--meezi-green", "primary", base.primary);
set("--meezi-green-tint", "secondary", base.secondary);
set("--meezi-amber", "accent", base.accent);
set("--meezi-danger", "destructive", base.destructive);
const colors = resolveThemeColors(theme);
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 };
}
}
+30
View File
@@ -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 });
}
+7
View File
@@ -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,57 @@
export type GuestOrderRef = {
orderId: string;
trackingToken: string;
orderNumber: string;
createdAt: string;
cafeId: string;
branchId: string;
tableId: string;
};
const STORAGE_KEY = "meezi_guest_orders";
function isValidRef(ref: GuestOrderRef): boolean {
return !!(
ref.orderId?.trim() &&
ref.trackingToken?.trim() &&
ref.orderNumber?.trim() &&
ref.cafeId?.trim() &&
ref.tableId?.trim()
);
}
export function saveGuestOrder(ref: GuestOrderRef): boolean {
if (typeof window === "undefined") return false;
if (!isValidRef(ref)) return false;
const list = loadGuestOrders();
const filtered = list.filter((o) => o.orderId !== ref.orderId);
const next = [ref, ...filtered].slice(0, 30);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return true;
} catch {
return false;
}
}
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[];
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidRef);
} catch {
return [];
}
}
export function ordersForTable(orders: GuestOrderRef[], cafeId: string, tableId: string) {
const cafe = cafeId.trim().toLowerCase();
const table = tableId.trim().toLowerCase();
return orders.filter(
(o) =>
o.cafeId.trim().toLowerCase() === cafe && o.tableId.trim().toLowerCase() === table
);
}
@@ -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,107 @@
"use client";
import { useCallback, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as signalR from "@microsoft/signalr";
import { useRouter } from "@/i18n/routing";
import {
fetchNotifications,
markNotificationsRead,
type CafeNotification,
} from "@/lib/api/notifications";
import { useAuthStore } from "@/lib/stores/auth.store";
import { notify } from "@/lib/notify";
type UseNotificationsFeedOptions = {
unreadOnly?: boolean;
limit?: number;
/** Show toast when a new guest order notification arrives (topbar). */
enableToasts?: boolean;
};
type OpenNotificationOptions = {
/** Navigate to KDS/tables after marking read (notifications page only). */
navigate?: boolean;
};
export function useNotificationsFeed(options: UseNotificationsFeedOptions = {}) {
const { unreadOnly = false, limit = 50, enableToasts = false } = options;
const cafeId = useAuthStore((s) => s.user?.cafeId);
const router = useRouter();
const qc = useQueryClient();
const queryKey = ["notifications", cafeId, unreadOnly] as const;
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit),
enabled: !!cafeId,
refetchInterval: 60_000,
});
const refresh = useCallback(() => {
if (!cafeId) return;
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
}, [qc, cafeId]);
useEffect(() => {
if (!cafeId) return;
const token = localStorage.getItem("meezi_access_token");
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinCafe", cafeId))
.catch(() => undefined);
connection.on("NotificationReceived", (n: CafeNotification) => {
refresh();
if (enableToasts) {
if (n.type === "table_call_waiter") {
notify.warning(n.title, { description: n.body ?? undefined });
} else if (n.type === "guest_order_new") {
notify.info(n.title, { description: n.body ?? undefined });
}
}
});
connection.on("OrderCreated", refresh);
return () => {
void connection.stop();
};
}, [cafeId, refresh, enableToasts]);
const openNotification = useCallback(
async (n: CafeNotification, opts: OpenNotificationOptions = {}) => {
if (!cafeId) return;
if (!n.isRead) await markNotificationsRead(cafeId, { ids: [n.id] });
refresh();
if (!opts.navigate) return;
if (n.referenceId) router.push("/kds");
else if (n.type.startsWith("guest_order")) router.push("/tables");
},
[cafeId, refresh, router]
);
const markAllRead = useCallback(async () => {
if (!cafeId) return;
await markNotificationsRead(cafeId, { all: true });
refresh();
}, [cafeId, refresh]);
return {
cafeId,
items: data?.items ?? [],
unreadCount: data?.unreadCount ?? 0,
isLoading,
isFetching,
refetch,
refresh,
openNotification,
markAllRead,
};
}
@@ -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;
}
+20
View File
@@ -0,0 +1,20 @@
/** Common warehouse units (Persian labels stored on the API). */
export const INVENTORY_UNITS = [
{ value: "عدد", key: "piece" },
{ value: "گرم", key: "gram" },
{ value: "کیلوگرم", key: "kilogram" },
{ value: "میلی‌لیتر", key: "milliliter" },
{ value: "لیتر", key: "liter" },
{ value: "سی‌سی", key: "cc" },
{ value: "بسته", key: "pack" },
{ value: "قوطی", key: "can" },
{ value: "کیسه", key: "bag" },
] as const;
export const INVENTORY_UNIT_VALUES = INVENTORY_UNITS.map((u) => u.value);
export type InventoryUnitValue = (typeof INVENTORY_UNIT_VALUES)[number];
export function isKnownInventoryUnit(unit: string): boolean {
return INVENTORY_UNIT_VALUES.includes(unit as InventoryUnitValue);
}
+10
View File
@@ -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();
}
+58
View File
@@ -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);
}
+52
View File
@@ -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]));
}
+56
View File
@@ -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));
}
+111
View File
@@ -0,0 +1,111 @@
/**
* IndexedDB wrapper for the POS offline order queue.
* All reads/writes happen in a single "order_queue" object store.
*/
export type OfflineQueueItem = {
/** Local UUID primary key */
id: string;
/** "create_order" or "add_items_to_order" */
type: "create_order" | "add_items";
cafeId: string;
/**
* For add_items: the real server order ID.
* For create_order: null (no server ID yet).
*/
targetOrderId: string | null;
/** Raw body to POST/PUT */
payload: unknown;
createdAt: string;
retries: number;
status: "pending" | "failed";
};
const DB_NAME = "meezi_pos_offline";
const DB_VERSION = 1;
const STORE = "order_queue";
let _db: IDBDatabase | null = null;
function openDb(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" });
}
};
req.onsuccess = () => {
_db = req.result;
resolve(_db);
};
req.onerror = () => reject(req.error);
});
}
export async function enqueueOfflineItem(
item: Omit<OfflineQueueItem, "retries" | "status">
): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put({ ...item, retries: 0, status: "pending" });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getQueueCount(): Promise<number> {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch {
return 0;
}
}
export async function getAllQueueItems(): Promise<OfflineQueueItem[]> {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result as OfflineQueueItem[]);
req.onerror = () => reject(req.error);
});
} catch {
return [];
}
}
export async function removeQueueItem(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function markQueueItemFailed(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
const getReq = store.get(id);
getReq.onsuccess = () => {
const item = getReq.result as OfflineQueueItem;
if (item) store.put({ ...item, status: "failed", retries: item.retries + 1 });
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
@@ -0,0 +1,111 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import {
getAllQueueItems,
getQueueCount,
removeQueueItem,
markQueueItemFailed,
} from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client";
/**
* Processes one queued item and returns whether it succeeded.
*/
async function processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
return true;
} catch {
return false;
}
}
/**
* Call this hook once in the app shell to:
* - Load initial queue count from IndexedDB on mount
* - Listen to online/offline events
* - Auto-sync when back online or tab becomes visible
*/
export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
const syncLock = useRef(false);
const refreshCount = useCallback(async () => {
const n = await getQueueCount();
setQueueCount(n);
return n;
}, [setQueueCount]);
const syncQueue = useCallback(async () => {
if (syncLock.current) return;
if (!navigator.onLine) return;
const count = await refreshCount();
if (count === 0) return;
syncLock.current = true;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
const ok = await processItem(item);
if (ok) {
await removeQueueItem(item.id);
} else {
await markQueueItemFailed(item.id);
}
}
} finally {
syncLock.current = false;
setSyncing(false);
await refreshCount();
}
}, [refreshCount, setSyncing]);
useEffect(() => {
// Load initial count
void refreshCount();
// Track online state
const handleOnline = () => {
setOnline(true);
void syncQueue();
};
const handleOffline = () => setOnline(false);
setOnline(typeof navigator !== "undefined" ? navigator.onLine : true);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync when tab regains focus
const handleVisibility = () => {
if (document.visibilityState === "visible" && navigator.onLine) {
void syncQueue();
}
};
document.addEventListener("visibilitychange", handleVisibility);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [syncQueue, setOnline, refreshCount]);
return { syncQueue, refreshCount };
}
+16
View File
@@ -0,0 +1,16 @@
/** Human-facing order number (digits only). */
export function formatOrderNumber(order: {
displayNumber?: number;
id: string;
}): string {
if (order.displayNumber != null && order.displayNumber > 0) {
return String(order.displayNumber);
}
const digits = order.id.replace(/\D/g, "");
if (digits.length > 0) {
const slice = digits.length > 9 ? digits.slice(-9) : digits;
const trimmed = slice.replace(/^0+/, "");
return trimmed.length > 0 ? trimmed : slice;
}
return "0";
}
+28
View File
@@ -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");
}
+17
View File
@@ -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(" · ") : "—";
}
+191
View File
@@ -0,0 +1,191 @@
import { apiPost } from "@/lib/api/client";
import type { Order, OrderItemLine } from "@/lib/api/types";
import type { CartItem } from "@/lib/stores/cart.store";
import { iranMobileForApi } from "@/lib/phone";
import { enqueueOfflineItem, getQueueCount } from "@/lib/offline/offline-db";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
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;
/** Cart items (needed to build the offline mock order) */
cartItems?: CartItem[];
};
// ─── Offline helpers ──────────────────────────────────────────────────────────
function isNetworkError(err: unknown): boolean {
if (err instanceof TypeError) {
const msg = err.message.toLowerCase();
return (
msg.includes("failed to fetch") ||
msg.includes("networkerror") ||
msg.includes("load failed") ||
msg.includes("network request failed")
);
}
return false;
}
function newLocalId(): string {
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Build a synthetic Order that keeps the POS cart functional while offline */
function buildLocalOrder(
params: SubmitOrderParams,
cartItems: CartItem[]
): Order {
const pending = params.cart.getPendingLines();
const localId = newLocalId();
const items: OrderItemLine[] = pending.map((p) => {
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
return {
id: newLocalId(),
menuItemId: p.menuItemId,
menuItemName: ci?.menuItem.name ?? p.menuItemId,
quantity: p.quantity,
unitPrice: ci?.menuItem.price ?? 0,
notes: p.notes,
isVoided: false,
};
});
const subtotal = items.reduce((s, i) => s + i.unitPrice * i.quantity, 0);
const taxTotal = Math.round(subtotal * 0.09);
const total = subtotal + taxTotal;
return {
id: localId,
cafeId: params.cafeId,
branchId: params.orderBranchId,
tableId: params.cart.tableId ?? undefined,
guestName: params.cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(params.cart.guestPhone) ?? undefined,
customerId: params.cart.customerId ?? undefined,
orderType: "DineIn",
status: "Open",
subtotal,
taxTotal,
discountAmount: 0,
total,
paidAmount: 0,
createdAt: new Date().toISOString(),
displayNumber: 0,
items,
payments: [],
};
}
async function queueAndBuildLocalOrder(
params: SubmitOrderParams,
cartItems: CartItem[]
): Promise<Order> {
const pending = params.cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const isAddToExisting =
!!params.cart.activeOrderId &&
!params.cart.activeOrderId.startsWith("local_");
await enqueueOfflineItem({
id: newLocalId(),
type: isAddToExisting ? "add_items" : "create_order",
cafeId: params.cafeId,
targetOrderId: isAddToExisting ? params.cart.activeOrderId : null,
payload: isAddToExisting
? {
cafeId: params.cafeId,
orderId: params.cart.activeOrderId!,
body: { items: pending },
}
: {
cafeId: params.cafeId,
body: {
orderType: "DineIn",
branchId: params.orderBranchId,
tableId: params.cart.tableId ?? undefined,
reservationId: params.reservationId ?? undefined,
guestName: params.cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(params.cart.guestPhone),
customerId: params.cart.customerId ?? undefined,
couponId: params.cart.appliedCoupon?.id,
items: pending,
},
},
createdAt: new Date().toISOString(),
});
// Update global queue count
const count = await getQueueCount();
useSyncQueueStore.getState().setQueueCount(count);
return buildLocalOrder(params, cartItems);
}
// ─── Main export ──────────────────────────────────────────────────────────────
export async function submitOrderToApi({
cafeId,
orderBranchId,
cart,
reservationId,
cartItems = [],
}: SubmitOrderParams): Promise<Order> {
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const tryOnline = async (): Promise<Order> => {
if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) {
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,
});
};
// Try online first
if (navigator.onLine) {
try {
return await tryOnline();
} catch (err) {
// If it's a network error despite onLine flag, fall through to offline path
if (!isNetworkError(err)) throw err;
}
}
// Offline path: queue and return a local mock order
return queueAndBuildLocalOrder({ cafeId, orderBranchId, cart, reservationId, cartItems }, cartItems);
}
export function orderAmountDue(order: Order): number {
return Math.max(0, order.total - (order.paidAmount ?? 0));
}
/** True when the order was created locally (offline) and not yet synced */
export function isLocalOrder(orderId: string | null): boolean {
return !!orderId?.startsWith("local_");
}
@@ -0,0 +1,2 @@
/** Sentinel id for the combined “all categories” tab on guest QR menu. */
export const QR_ALL_CATEGORY_ID = "all";
+34
View File
@@ -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 },
};
}
+258
View File
@@ -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);
}
+134
View File
@@ -0,0 +1,134 @@
import type { LucideIcon } from "lucide-react";
import {
LayoutGrid,
UtensilsCrossed,
Users,
Ticket,
Package,
BookOpen,
UserCog,
BarChart3,
Calendar,
Star,
MessageSquare,
Receipt,
Settings,
ChefHat,
Bell,
ListOrdered,
Building2,
CreditCard,
Wallet,
Clock,
LifeBuoy,
Compass,
} from "lucide-react";
export type NavGroupId = "operations" | "menuSales" | "customers" | "finance" | "management";
export type NavItemKey =
| "pos"
| "tables"
| "queue"
| "kds"
| "notifications"
| "reservations"
| "menu"
| "inventory"
| "coupons"
| "crm"
| "sms"
| "reviews"
| "discover"
| "reports"
| "expenses"
| "shifts"
| "taxes"
| "hr"
| "branches"
| "subscription"
| "support"
| "settings";
export type NavItemDef = {
key: NavItemKey;
href: string;
icon: LucideIcon;
};
export type NavGroupDef = {
id: NavGroupId;
defaultOpen: boolean;
items: NavItemDef[];
};
export const NAV_GROUPS: NavGroupDef[] = [
{
id: "operations",
defaultOpen: true,
items: [
{ key: "pos", href: "/pos", icon: LayoutGrid },
{ key: "tables", href: "/tables", icon: UtensilsCrossed },
{ key: "queue", href: "/queue", icon: ListOrdered },
{ key: "kds", href: "/kds", icon: ChefHat },
{ key: "notifications", href: "/notifications", icon: Bell },
{ key: "reservations", href: "/reservations", icon: Calendar },
],
},
{
id: "menuSales",
defaultOpen: true,
items: [
{ key: "menu", href: "/menu", icon: BookOpen },
{ key: "inventory", href: "/inventory", icon: Package },
{ key: "coupons", href: "/coupons", icon: Ticket },
],
},
{
id: "customers",
defaultOpen: true,
items: [
{ key: "crm", href: "/crm", icon: Users },
{ key: "sms", href: "/sms", icon: MessageSquare },
{ key: "reviews", href: "/reviews", icon: Star },
{ key: "discover", href: "/discover", icon: Compass },
],
},
{
id: "finance",
defaultOpen: true,
items: [
{ key: "reports", href: "/reports", icon: BarChart3 },
{ key: "expenses", href: "/expenses", icon: Wallet },
{ key: "shifts", href: "/shifts", icon: Clock },
{ key: "taxes", href: "/taxes", icon: Receipt },
],
},
{
id: "management",
defaultOpen: true,
items: [
{ key: "hr", href: "/hr", icon: UserCog },
{ key: "branches", href: "/branches", icon: Building2 },
{ key: "subscription", href: "/subscription", icon: CreditCard },
{ key: "settings", href: "/settings", icon: Settings },
{ key: "support", href: "/support", icon: LifeBuoy },
],
},
];
export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v3";
/** Branch-scoped staff only see daily operations. */
export const BRANCH_ONLY_NAV_GROUP: NavGroupId = "operations";
export function findNavGroupForPath(pathname: string): NavGroupId | null {
for (const group of NAV_GROUPS) {
for (const item of group.items) {
if (pathname === item.href || pathname.startsWith(`${item.href}/`)) {
return group.id;
}
}
}
return null;
}
@@ -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,44 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { AuthTokenResponse } from "@/lib/api/types";
interface AuthState {
user: AuthTokenResponse | null;
/** True once Zustand has finished rehydrating from localStorage. */
_hasHydrated: boolean;
setAuth: (user: AuthTokenResponse) => void;
clearAuth: () => void;
isAuthenticated: () => boolean;
_setHasHydrated: (v: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
_hasHydrated: false,
_setHasHydrated: (v) => set({ _hasHydrated: v }),
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",
onRehydrateStorage: () => (state) => {
state?._setHasHydrated(true);
},
}
)
);
@@ -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" }
)
);
+209
View File
@@ -0,0 +1,209 @@
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;
activeOrderDisplayNumber: number | 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,
activeOrderDisplayNumber: 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, activeOrderDisplayNumber: null }),
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,
activeOrderDisplayNumber: order.displayNumber > 0 ? order.displayNumber : null,
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,
activeOrderDisplayNumber: 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,28 @@
import { create } from "zustand";
interface SyncQueueState {
/** Number of items waiting to be synced */
queueCount: number;
/** True while a sync pass is running */
isSyncing: boolean;
/** Mirrors navigator.onLine (updated client-side) */
isOnline: boolean;
setQueueCount: (n: number) => void;
setSyncing: (v: boolean) => void;
setOnline: (v: boolean) => void;
incrementQueue: () => void;
decrementQueue: () => void;
}
export const useSyncQueueStore = create<SyncQueueState>((set) => ({
queueCount: 0,
isSyncing: false,
isOnline: true, // assume online until client hydrates
setQueueCount: (n) => set({ queueCount: Math.max(0, n) }),
setSyncing: (v) => set({ isSyncing: v }),
setOnline: (v) => set({ isOnline: v }),
incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })),
decrementQueue: () => set((s) => ({ queueCount: Math.max(0, s.queueCount - 1) })),
}));
+11
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useLocale } from "next-intl";
export function useIsRtl() {
const locale = useLocale();
return locale !== "en";
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+19
View File
@@ -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);
}