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
@@ -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) })),
}));