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,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" }
|
||||
)
|
||||
);
|
||||
@@ -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) })),
|
||||
}));
|
||||
Reference in New Issue
Block a user