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 { enqueueOutboxOp, getOutboxCount, 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[]; }; // ─── 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") ); } // axios network errors surface as an Error with code ERR_NETWORK and no response. const ax = err as { isAxiosError?: boolean; response?: unknown }; if (ax?.isAxiosError && !ax.response) return true; return false; } function newLocalId(): string { return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } /** A stable idempotency key used for BOTH the online attempt and any queued * replay of the same submit, so the server de-duplicates them. */ function newIdempotencyKey(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; } /** Body for a create-order POST. */ function buildCreateBody( params: SubmitOrderParams, pending: ReturnType ) { const { cart, orderBranchId, reservationId } = params; return { 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, }; } /** Build a synthetic Order so the POS stays usable offline. Uses the supplied * id so it matches the outbox op's createsClientId (enabling later remap). */ function buildLocalOrder(params: SubmitOrderParams, cartItems: CartItem[], orderId: string): Order { const pending = params.cart.getPendingLines(); 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: orderId, 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 refreshQueueBadge(): Promise { const count = (await getOutboxCount()) + (await getQueueCount()); useSyncQueueStore.getState().setQueueCount(count); } /** * Queue the write and return a local mock order. Two cases: * - create: enqueue POST /orders with a fresh local id as createsClientId; * - add items: enqueue POST /orders/{id}/items. {id} may be a local id — the * outbox blocks then remaps it once the create syncs. */ async function queueAndBuildLocalOrder( params: SubmitOrderParams, cartItems: CartItem[], idempotencyKey: string ): Promise { const { cafeId, cart } = params; const pending = cart.getPendingLines(); if (pending.length === 0) throw new Error("nothing pending"); const activeId = cart.activeOrderId; if (activeId) { // Add items to an existing order (real server id, or a not-yet-synced local id). await enqueueOutboxOp({ id: newLocalId(), idempotencyKey, method: "POST", url: `/api/cafes/${cafeId}/orders/${activeId}/items`, body: { items: pending }, entityType: "order_items", createdAt: Date.now(), }); await refreshQueueBadge(); return buildLocalOrder(params, cartItems, activeId); } // Create a brand-new order. createsClientId lets later add-items ops remap. const localOrderId = newLocalId(); await enqueueOutboxOp({ id: newLocalId(), idempotencyKey, method: "POST", url: `/api/cafes/${cafeId}/orders`, body: buildCreateBody(params, pending), entityType: "order", createsClientId: localOrderId, idField: "id", createdAt: Date.now(), }); await refreshQueueBadge(); return buildLocalOrder(params, cartItems, localOrderId); } // ─── Main export ────────────────────────────────────────────────────────────── export async function submitOrderToApi({ cafeId, orderBranchId, cart, reservationId, cartItems = [], }: SubmitOrderParams): Promise { const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems }; const pending = cart.getPendingLines(); if (pending.length === 0) throw new Error("nothing pending"); const idempotencyKey = newIdempotencyKey(); const addingToLocalOrder = isLocalOrder(cart.activeOrderId); // Fast path: online, and either a new order or adding to a real server order. // (Adding to a still-local order must be queued so the outbox can remap its id.) if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) { try { if (cart.activeOrderId) { return await apiPost( `/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, { items: pending }, { idempotencyKey } ); } return await apiPost( `/api/cafes/${cafeId}/orders`, buildCreateBody(params, pending), { idempotencyKey } ); } catch (err) { // Only fall back to the offline queue on a genuine network failure; a real // server/validation error must surface. The same idempotencyKey is reused // so the server de-dups if the failed attempt actually reached it. if (!isNetworkError(err)) throw err; } } return queueAndBuildLocalOrder(params, cartItems, idempotencyKey); } 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_"); }