0a33497d40
Initial commit of the Super-Admin web panel (Next.js + TypeScript). CI admin-web-check job was failing because the directory was never tracked in git. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
206 lines
5.4 KiB
TypeScript
206 lines
5.4 KiB
TypeScript
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;
|
|
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,
|
|
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 }),
|
|
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,
|
|
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,
|
|
customerId: null,
|
|
guestName: "",
|
|
guestPhone: "",
|
|
...clearCouponState,
|
|
}),
|
|
|
|
subtotal: () =>
|
|
get().items.reduce(
|
|
(sum, i) =>
|
|
i.isVoided ? sum : sum + i.menuItem.price * i.quantity,
|
|
0
|
|
),
|
|
}));
|