feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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_");
|
||||
}
|
||||
Reference in New Issue
Block a user