Files
meezi/web/dashboard/src/components/pos/pos-screen.tsx
T
soroush.asadi 131ecdbbe6 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>
2026-05-27 21:34:12 +03:30

1212 lines
42 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { Minus, Plus, Search, Trash2, Video, X } from "lucide-react";
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
import type {
MenuCategory,
MenuItem,
Order,
Table,
TableBoardItem,
QueueTicket,
} from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
import { useCartStore, type CartItem } from "@/lib/stores/cart.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number";
import { iranMobileForApi } from "@/lib/phone";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useIsRtl } from "@/lib/use-is-rtl";
import { useBranchStore } from "@/lib/stores/branch.store";
import { getOrCreateTerminalId } from "@/lib/terminal";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { CategoryVisual } from "@/components/menu/category-visual";
import { getMenuPrimaryName, menuItemMatchesSearch } from "@/lib/menu-display";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { MenuItemMedia } from "@/components/menu/menu-item-media";
import {
buildCategoryNameMap,
inferMenuItemKind,
} from "@/lib/menu-item-image";
import { PosPayPanel } from "@/components/pos/pos-pay-panel";
import { PosTableBoard } from "@/components/pos/pos-table-board";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
import { PosSlipModal, type KitchenSlipLine } from "@/components/pos/pos-slip-modal";
import { PosQueueBar } from "@/components/pos/pos-queue-bar";
import {
branchMenuItemToMenuItem,
getBranchMenu,
} from "@/lib/api/branch-menu";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { orderAmountDue, submitOrderToApi, isLocalOrder } from "@/lib/pos/submit-order";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { useConfirm } from "@/components/providers/confirm-provider";
const TAX_RATE = 0.09;
function buildKitchenLines(
pending: { menuItemId: string; quantity: number; notes?: string }[],
cartItems: CartItem[]
): KitchenSlipLine[] {
return pending.map((p) => {
const line = cartItems.find((i) => i.menuItem.id === p.menuItemId);
return {
name: line?.menuItem.name ?? p.menuItemId,
quantity: p.quantity,
notes: p.notes,
};
});
}
function cartToKitchenLines(cartItems: CartItem[]): KitchenSlipLine[] {
return cartItems
.filter((i) => !i.isVoided && i.quantity > 0)
.map((i) => ({
name: i.menuItem.name,
quantity: i.quantity,
notes: i.notes,
}));
}
/** Small square thumb on menu tiles: text | img (RTL-aware via flex order). */
function PosMenuThumbnail({
imageUrl,
kind,
hasVideo,
}: {
imageUrl?: string | null;
kind: ReturnType<typeof inferMenuItemKind>;
hasVideo?: boolean;
}) {
return (
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-border/60">
<MenuItemMedia imageUrl={imageUrl} kind={kind} size="sm" />
{hasVideo ? (
<span
className="absolute bottom-0 end-0 rounded-ss-sm bg-black/55 p-px"
aria-hidden
>
<Video className="h-2.5 w-2.5 text-white" />
</span>
) : null}
</div>
);
}
export function PosScreen() {
const t = useTranslations("pos");
const tQueue = useTranslations("queue");
const tErrors = useTranslations("errors");
const tCommon = useTranslations("common");
const tDashboard = useTranslations("dashboard");
const locale = useLocale();
const isRtl = useIsRtl();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const { data: cafeSettings } = useCafeSettings(cafeId);
const cafeName = cafeSettings?.name ?? tDashboard("cafeName");
const userRole = useAuthStore((s) => s.user?.role);
const isManager = userRole === "Manager" || userRole === "Owner";
const branchId = useBranchStore((s) => s.branchId);
const setBranchId = useBranchStore((s) => s.setBranchId);
const queryClient = useQueryClient();
const confirmDialog = useConfirm();
const isOnline = useSyncQueueStore((s) => s.isOnline);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const reservationId = searchParams.get("reservationId");
const reservationGuest = searchParams.get("guestName");
const urlOrderId = searchParams.get("orderId");
const [kitchenSlip, setKitchenSlip] = useState<{
lines: KitchenSlipLine[];
orderId?: string;
tableNumber?: string | number | null;
guestName?: string | null;
} | null>(null);
const [posMode, setPosMode] = useState<"order" | "pay">("order");
const {
items,
addItem,
removeItem,
updateQty,
couponCode,
appliedCoupon,
setCouponCode,
setAppliedCoupon,
clearCoupon,
tableId,
setTableId,
activeOrderId,
activeOrderDisplayNumber,
setActiveOrderId,
customerId,
guestName,
setGuestName,
guestPhone,
setGuestPhone,
setCustomer,
clearCustomer,
hydrateFromOrder,
getPendingLines,
clearCart,
clearSession,
subtotal,
} = useCartStore();
const syncUrl = useCallback(
(tid: string | null, oid: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (tid) params.set("tableId", tid);
else params.delete("tableId");
if (oid) params.set("orderId", oid);
else params.delete("orderId");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[pathname, router, searchParams]
);
useEffect(() => {
const tid = searchParams.get("tableId");
if (tid) setTableId(tid);
}, [searchParams, setTableId]);
useEffect(() => {
if (urlOrderId) setActiveOrderId(urlOrderId);
}, [urlOrderId, setActiveOrderId]);
useEffect(() => {
if (reservationGuest) setGuestName(reservationGuest);
}, [reservationGuest, setGuestName]);
useEffect(() => {
if (!cafeId) return;
apiPost(`/api/cafes/${cafeId}/terminals/register`, {
terminalId: getOrCreateTerminalId(),
}).catch(() => undefined);
}, [cafeId]);
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all");
const [itemSearch, setItemSearch] = useState("");
const [orderMessage, setOrderMessage] = useState<string | null>(null);
const [couponMessage, setCouponMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [showTransferPicker, setShowTransferPicker] = useState(false);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () =>
apiGet<{ id: string; name: string }[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (branches.length === 0) return;
const valid = branchId && branches.some((b) => b.id === branchId);
if (!valid) setBranchId(branches[0]!.id);
}, [branches, branchId, setBranchId]);
const orderBranchId = useMemo(() => {
if (branches.length === 0) return null;
if (branchId && branches.some((b) => b.id === branchId)) return branchId;
return branches[0]?.id ?? null;
}, [branchId, branches]);
const { data: categories, isLoading: loadingCategories } = useQuery({
queryKey: ["menu-categories", cafeId],
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
enabled: !!cafeId,
});
const { data: globalMenuItems, isLoading: loadingGlobalItems } = useQuery({
queryKey: ["menu-items", cafeId, selectedCategory],
queryFn: () => {
const qs =
selectedCategory !== "all" ? `?categoryId=${selectedCategory}` : "";
return apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items${qs}`);
},
enabled: !!cafeId && !orderBranchId,
});
const { data: branchMenuRows, isLoading: loadingBranchMenu } = useQuery({
queryKey: ["branch-menu", cafeId, orderBranchId, selectedCategory],
queryFn: () => getBranchMenu(cafeId!, orderBranchId!),
enabled: !!cafeId && !!orderBranchId,
});
const menuItems = useMemo(() => {
if (orderBranchId && branchMenuRows) {
const mapped = branchMenuRows.map(branchMenuItemToMenuItem);
if (selectedCategory === "all") return mapped;
return mapped.filter((i) => i.categoryId === selectedCategory);
}
return globalMenuItems;
}, [orderBranchId, branchMenuRows, globalMenuItems, selectedCategory]);
const loadingItems = orderBranchId ? loadingBranchMenu : loadingGlobalItems;
const { data: allMenuItems, isLoading: loadingAllCatalog } = useQuery({
queryKey: ["branch-menu-all", cafeId, orderBranchId],
queryFn: async () => {
if (orderBranchId && cafeId) {
const rows = await getBranchMenu(cafeId, orderBranchId);
return rows.map(branchMenuItemToMenuItem);
}
return apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`);
},
enabled: !!cafeId,
});
const menuById = useMemo(() => {
const map = new Map<string, MenuItem>();
for (const m of allMenuItems ?? menuItems ?? []) {
map.set(m.id, m);
}
return map;
}, [allMenuItems, menuItems]);
useEffect(() => {
if (!cafeId || !urlOrderId || menuById.size === 0) return;
if (activeOrderId === urlOrderId && items.length > 0) return;
apiGet<Order>(`/api/cafes/${cafeId}/orders/${urlOrderId}`)
.then((order) => {
hydrateFromOrder(order, menuById);
if (order.tableId) setTableId(order.tableId);
})
.catch(() => undefined);
}, [
cafeId,
urlOrderId,
menuById,
activeOrderId,
activeOrderDisplayNumber,
items.length,
hydrateFromOrder,
setTableId,
]);
const sessionPatchRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!cafeId || !activeOrderId) return;
if (sessionPatchRef.current) clearTimeout(sessionPatchRef.current);
sessionPatchRef.current = setTimeout(() => {
apiPatch(`/api/cafes/${cafeId}/orders/${activeOrderId}/session`, {
guestName: guestName.trim() || null,
guestPhone: iranMobileForApi(guestPhone) ?? null,
customerId: customerId ?? null,
}).catch(() => undefined);
}, 600);
return () => {
if (sessionPatchRef.current) clearTimeout(sessionPatchRef.current);
};
}, [guestName, guestPhone, customerId, activeOrderId, cafeId]);
const handleTableSelect = useCallback(
(table: TableBoardItem, activeOrder: Order | null) => {
if (activeOrder) {
setTableId(table.id);
hydrateFromOrder(activeOrder, menuById);
syncUrl(table.id, activeOrder.id);
setOrderMessage(t("sessionActive"));
return;
}
const hadOpenSession = !!useCartStore.getState().activeOrderId;
if (hadOpenSession) {
clearSession();
} else {
setActiveOrderId(null);
}
setTableId(table.id);
syncUrl(table.id, null);
setOrderMessage(null);
},
[
setTableId,
hydrateFromOrder,
menuById,
syncUrl,
setActiveOrderId,
activeOrderDisplayNumber,
clearSession,
t,
]
);
const tablesQuery = orderBranchId
? `?branchId=${encodeURIComponent(orderBranchId)}`
: "";
const { data: tables } = useQuery({
queryKey: ["tables", cafeId, orderBranchId],
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables${tablesQuery}`),
enabled: !!cafeId,
});
const { data: boardTables = [] } = useQuery({
queryKey: ["tables-board", cafeId, orderBranchId, "transfer"],
queryFn: () =>
apiGet<TableBoardItem[]>(
`/api/cafes/${cafeId}/tables/board${tablesQuery}`
),
enabled: !!cafeId && showTransferPicker,
});
const voidItemMutation = useMutation({
mutationFn: async (orderItemId: string) => {
if (!cafeId || !activeOrderId) throw new Error("no session");
return apiPatch<Order>(
`/api/cafes/${cafeId}/orders/${activeOrderId}/items/${orderItemId}/void`,
{}
);
},
onSuccess: async (order) => {
hydrateFromOrder(order, menuById);
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
},
onError: () => setOrderMessage(t("voidError")),
});
const transferTableMutation = useMutation({
mutationFn: async (targetTableId: string) => {
if (!cafeId || !activeOrderId) throw new Error("no session");
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${activeOrderId}/transfer`, {
targetTableId,
});
},
onSuccess: async (order) => {
setShowTransferPicker(false);
hydrateFromOrder(order, menuById);
if (order.tableId) setTableId(order.tableId);
syncUrl(order.tableId ?? null, order.id);
setOrderMessage(t("transferSuccess"));
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
},
onError: (err: Error) => {
const code = err instanceof ApiClientError ? err.code : "";
if (code === "TABLE_OCCUPIED") setOrderMessage(t("tableOccupied"));
else if (code === "TABLE_CLEANING") setOrderMessage(t("tableNotAvailable"));
else setOrderMessage(t("transferError"));
},
});
const handleVoidItem = async (orderItemId: string) => {
const ok = await confirmDialog({
description: t("confirmVoid"),
variant: "destructive",
confirmLabel: tCommon("confirm"),
});
if (!ok) return;
voidItemMutation.mutate(orderItemId);
};
const freeTransferTables = boardTables.filter(
(tbl) => tbl.status === "Free" && tbl.id !== tableId
);
const itemSearchQuery = itemSearch.trim();
const isSearchingItems = itemSearchQuery.length > 0;
const catalogForSearch = useMemo(
() => allMenuItems ?? menuItems ?? [],
[allMenuItems, menuItems]
);
const filteredItems = useMemo(() => {
const base = isSearchingItems ? catalogForSearch : (menuItems ?? []);
return base.filter((i) => {
if (!i.isAvailable) return false;
if (!isSearchingItems) return true;
return menuItemMatchesSearch(i, itemSearchQuery, locale);
});
}, [
catalogForSearch,
menuItems,
isSearchingItems,
itemSearchQuery,
locale,
]);
const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems;
const categoryNameById = useMemo(
() => buildCategoryNameMap(categories ?? []),
[categories]
);
const itemVisualKind = (item: MenuItem) =>
inferMenuItemKind(item.categoryId, categoryNameById.get(item.categoryId));
const handleSelectMenuItem = (item: MenuItem) => {
addItem(item);
};
const sub = subtotal();
const discount = appliedCoupon?.discountAmount ?? 0;
const taxable = Math.max(0, sub - discount);
const tax = Math.round(taxable * TAX_RATE);
const total = taxable + tax;
const couponErrorKey = (code: string) => {
const map: Record<string, string> = {
COUPON_NOT_FOUND: "couponInvalid",
COUPON_INACTIVE: "couponInvalid",
COUPON_EXPIRED: "couponExpired",
COUPON_NOT_STARTED: "couponNotStarted",
COUPON_LIMIT_REACHED: "couponLimitReached",
COUPON_MIN_ORDER: "couponMinOrder",
CART_EMPTY: "couponCartEmpty",
COUPON_REQUIRED: "couponRequired",
COUPON_NO_DISCOUNT: "couponInvalid",
};
return map[code] ?? "couponInvalid";
};
const validateCoupon = useMutation({
mutationFn: async () => {
if (!cafeId) throw new Error("no cafe");
return apiPost<{
couponId: string;
code: string;
discountAmount: number;
}>(`/api/cafes/${cafeId}/coupons/validate`, {
code: couponCode.trim(),
subtotal: subtotal(),
});
},
onSuccess: (data) => {
setAppliedCoupon({
id: data.couponId,
code: data.code,
discountAmount: data.discountAmount,
});
setCouponMessage({
type: "success",
text: t("couponApplied", {
code: data.code,
amount: formatCurrency(data.discountAmount, numberLocale),
}),
});
},
onError: (err: Error) => {
setAppliedCoupon(null);
const code = err instanceof ApiClientError ? err.code : "COUPON_NOT_FOUND";
setCouponMessage({ type: "error", text: t(couponErrorKey(code)) });
},
});
const openKitchenSlip = useCallback(() => {
const pending = getPendingLines();
const lines =
pending.length > 0
? buildKitchenLines(pending, items)
: cartToKitchenLines(items);
if (lines.length === 0) return;
setKitchenSlip({
lines,
orderId: activeOrderId ?? undefined,
tableNumber: tables?.find((tbl) => tbl.id === tableId)?.number ?? null,
guestName: guestName.trim() || null,
});
}, [getPendingLines, items, activeOrderId, tables, tableId, guestName]);
const submitOrder = useMutation({
mutationFn: async () => {
if (!cafeId || items.length === 0) throw new Error("empty");
const cart = useCartStore.getState();
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const kitchenLines = buildKitchenLines(pending, cart.items);
const order = await submitOrderToApi({
cafeId,
orderBranchId: orderBranchId ?? undefined,
cart,
reservationId,
cartItems: cart.items,
});
return { order, kitchenLines };
},
onMutate: () => ({ hadSession: !!useCartStore.getState().activeOrderId }),
onSuccess: ({ order, kitchenLines }, _, context) => {
hydrateFromOrder(order, menuById);
syncUrl(order.tableId ?? tableId, order.id);
setCouponMessage(null);
if (kitchenLines.length > 0) {
setKitchenSlip({
lines: kitchenLines,
orderId: order.id,
tableNumber: order.tableNumber ?? null,
guestName: order.guestName ?? (guestName.trim() || null),
});
}
const baseMsg = context?.hadSession ? t("addToOrder") : t("orderPlaced");
setOrderMessage(baseMsg);
void apiPost<QueueTicket>(`/api/cafes/${cafeId}/queue/next`, {
branchId: orderBranchId,
customerLabel:
order.guestName ?? (guestName.trim() || undefined),
orderId: order.id,
})
.then((ticket) => {
setOrderMessage(
`${baseMsg} · ${t("queueNumber", { number: ticket.number })}`
);
queryClient.invalidateQueries({ queryKey: ["queue-today"] });
})
.catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
if (reservationId) {
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
}
},
onError: (err: Error) => {
if (err instanceof ApiClientError) {
if (err.code === "ORDER_NOT_FOUND" || err.code === "ORDER_NOT_OPEN") {
setActiveOrderId(null);
syncUrl(tableId, null);
}
const key =
err.code === "TABLE_NOT_AVAILABLE"
? "tableNotAvailable"
: err.code === "TABLE_OCCUPIED"
? "tableOccupied"
: err.code === "PLAN_LIMIT_REACHED"
? "planLimit"
: err.code === "INVALID_ORDER"
? "orderInvalid"
: err.code === "ORDER_NOT_OPEN"
? "orderNotOpen"
: err.code === "ORDER_NOT_FOUND"
? "orderNotOpen"
: err.code === "VALIDATION_ERROR"
? "orderValidation"
: "orderError";
setOrderMessage(
err.code === "VALIDATION_ERROR"
? err.message
: key === "planLimit"
? tErrors("planLimit")
: t(key)
);
return;
}
if (err.message === "nothing pending") {
setOrderMessage(t("nothingPending"));
return;
}
setOrderMessage(t("orderError"));
},
});
const submitOrderAndPay = useMutation({
mutationFn: async () => {
if (!cafeId || items.length === 0) throw new Error("empty");
const cart = useCartStore.getState();
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const kitchenLines = buildKitchenLines(pending, cart.items);
const order = await submitOrderToApi({
cafeId,
orderBranchId: orderBranchId ?? undefined,
cart,
reservationId,
cartItems: cart.items,
});
const due = orderAmountDue(order);
// Can't process payment for a local (offline) order
if (isLocalOrder(order.id)) return { order, kitchenLines };
const payBranchId = order.branchId ?? orderBranchId;
if (due > 0) {
if (!payBranchId) throw new Error("no branch");
await requestPosPayment(cafeId, payBranchId, order.id, due);
await apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, {
payments: [{ method: "Card", amount: due }],
});
}
return { order, kitchenLines };
},
onMutate: () => ({ hadSession: !!useCartStore.getState().activeOrderId }),
onSuccess: ({ order, kitchenLines }, _, context) => {
hydrateFromOrder(order, menuById);
syncUrl(order.tableId ?? tableId, order.id);
setCouponMessage(null);
if (kitchenLines.length > 0) {
setKitchenSlip({
lines: kitchenLines,
orderId: order.id,
tableNumber: order.tableNumber ?? null,
guestName: order.guestName ?? (guestName.trim() || null),
});
}
const baseMsg = context?.hadSession ? t("orderPaidAdd") : t("orderPaidNew");
setOrderMessage(baseMsg);
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
if (reservationId) {
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
}
},
onError: (err: Error) => {
if (err instanceof ApiClientError) {
if (
err.code === "POS_DEVICE_CONNECTION_FAILED" ||
err.code === "POS_DEVICE_TIMEOUT" ||
err.code === "POS_DEVICE_REJECTED" ||
err.code.startsWith("POS_DEVICE")
) {
setOrderMessage(posDeviceErrorMessage(err, t));
return;
}
if (err.code === "ORDER_NOT_FOUND" || err.code === "ORDER_NOT_OPEN") {
setActiveOrderId(null);
syncUrl(tableId, null);
}
const key =
err.code === "TABLE_NOT_AVAILABLE"
? "tableNotAvailable"
: err.code === "TABLE_OCCUPIED"
? "tableOccupied"
: err.code === "PLAN_LIMIT_REACHED"
? "planLimit"
: err.code === "INVALID_ORDER"
? "orderInvalid"
: err.code === "ORDER_NOT_OPEN"
? "orderNotOpen"
: err.code === "ORDER_NOT_FOUND"
? "orderNotOpen"
: err.code === "VALIDATION_ERROR"
? "orderValidation"
: "orderError";
setOrderMessage(
err.code === "VALIDATION_ERROR"
? err.message
: key === "planLimit"
? tErrors("planLimit")
: t(key)
);
return;
}
if (err.message === "nothing pending") {
setOrderMessage(t("nothingPending"));
return;
}
if (err.message === "no branch") {
setOrderMessage(t("posDeviceNoBranch"));
return;
}
setOrderMessage(t("payError"));
},
});
const pendingCount = getPendingLines().length;
const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending;
const canSubmitOrder =
pendingCount > 0 &&
(!!tableId || !!customerId || guestName.trim().length > 0);
if (!cafeId) return null;
return (
<div
className="flex h-full min-h-0 w-full flex-col gap-3 overflow-hidden"
dir={isRtl ? "rtl" : "ltr"}
>
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant={posMode === "order" ? "default" : "outline"}
onClick={() => setPosMode("order")}
>
{t("modeOrder")}
</Button>
<Button
size="sm"
variant={posMode === "pay" ? "default" : "outline"}
onClick={() => setPosMode("pay")}
>
{t("modePay")}
</Button>
</div>
{posMode === "pay" ? (
<PosPayPanel
cafeId={cafeId}
numberLocale={numberLocale}
branchId={orderBranchId}
/>
) : (
<div
className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden"
>
{cafeId ? <PosQueueBar cafeId={cafeId} branchId={orderBranchId} /> : null}
<div
className="flex min-h-0 flex-1 gap-4 overflow-hidden"
>
{/* Menu panel */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden">
{cafeId ? (
<PosTableBoard
cafeId={cafeId}
numberLocale={numberLocale}
selectedTableId={tableId}
branchId={orderBranchId}
onSelectTable={handleTableSelect}
/>
) : null}
{activeOrderId ? (
<p className="text-xs text-[#0F6E56]">
{t("sessionActive")} · {t("order")}{" "}
{activeOrderDisplayNumber
? String(activeOrderDisplayNumber)
: formatOrderNumber({ id: activeOrderId })}
</p>
) : null}
{reservationId && reservationGuest ? (
<div className="shrink-0 rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE] px-3 py-2 text-sm text-[#0F6E56]">
{t("reservationBanner", { name: reservationGuest })}
</div>
) : null}
<div className="relative shrink-0">
<Search
className="pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-muted-foreground start-3"
aria-hidden
/>
<Input
type="search"
value={itemSearch}
onChange={(e) => setItemSearch(e.target.value)}
placeholder={t("searchItemsPlaceholder")}
aria-label={t("searchItems")}
className="h-9 ps-9 pe-9"
/>
{itemSearch ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-1/2 size-7 -translate-y-1/2 end-1"
onClick={() => setItemSearch("")}
aria-label={tCommon("cancel")}
>
<X className="size-4" />
</Button>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-end gap-2 overflow-x-auto pb-0.5">
<Button
size="sm"
variant={selectedCategory === "all" ? "default" : "outline"}
onClick={() => setSelectedCategory("all")}
>
{t("allCategories")}
</Button>
{loadingCategories
? Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-20" />
))
: categories?.map((c) => (
<Button
key={c.id}
size="sm"
variant={selectedCategory === c.id ? "default" : "outline"}
onClick={() => setSelectedCategory(c.id)}
className="gap-1.5"
>
<CategoryVisual
icon={c.icon}
iconPresetId={c.iconPresetId}
iconStyle={c.iconStyle}
imageUrl={c.imageUrl}
size="xs"
/>
{getMenuPrimaryName(c, locale)}
</Button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="grid grid-cols-2 gap-2 content-start lg:grid-cols-3 xl:grid-cols-4">
{showItemsLoading
? Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-[3.75rem] rounded-lg" />
))
: filteredItems.length === 0 && isSearchingItems
? (
<p className="col-span-full py-8 text-center text-sm text-muted-foreground">
{t("searchNoResults")}
</p>
)
: filteredItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleSelectMenuItem(item)}
className="flex items-center gap-2.5 rounded-lg border border-border bg-card p-2 text-start shadow-sm transition hover:border-primary hover:shadow-md"
>
<div className="min-w-0 flex-1">
<MenuItemLabels
item={item}
lines={1}
primaryClassName="text-sm"
secondaryClassName="text-[10px]"
/>
<p className="mt-0.5 text-sm font-medium text-primary">
{formatCurrency(item.price, numberLocale)}
</p>
</div>
<PosMenuThumbnail
imageUrl={item.imageUrl}
kind={itemVisualKind(item)}
hasVideo={!!item.videoUrl}
/>
</button>
))}
</div>
</div>
</div>
{/* Cart sidebar */}
<Card className="flex h-full min-h-0 w-[min(100%,20rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
<CardHeader className="shrink-0 space-y-1.5 p-3 pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">{t("takeOrder")}</CardTitle>
{tableId ? (
<span className="shrink-0 rounded-md border-2 border-primary bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
{t("table")}{" "}
{tables?.find((tbl) => tbl.id === tableId)?.number ?? "—"}
</span>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("selectTableBoard")}
</span>
)}
</div>
{cafeId ? (
<PosCustomerPicker
compact
cafeId={cafeId}
guestName={guestName}
guestPhone={guestPhone}
customerId={customerId}
onGuestNameChange={setGuestName}
onGuestPhoneChange={setGuestPhone}
onCustomerChange={setCustomer}
onClearCustomer={clearCustomer}
/>
) : null}
{activeOrderId && tableId ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setShowTransferPicker(true)}
>
{t("transferTable")}
</Button>
) : null}
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-3 pt-0">
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain pe-0.5">
{items.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">{t("emptyCart")}</p>
) : (
items.map((line) => (
<div
key={line.orderItemId ?? line.menuItem.id}
className={cn(
"flex items-center gap-2 rounded-md border border-border p-1.5",
line.isVoided && "opacity-60"
)}
>
<div className="min-w-0 flex-1">
<MenuItemLabels
item={line.menuItem}
lines={1}
primaryClassName={cn(
"text-xs font-medium",
line.isVoided && "line-through text-muted-foreground"
)}
secondaryClassName="text-[10px]"
/>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{line.isVoided ? (
<span>{t("voided")}</span>
) : (
formatCurrency(
line.menuItem.price * line.quantity,
numberLocale
)
)}
</p>
</div>
<div
className="flex shrink-0 items-center gap-0.5"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{isManager &&
line.orderItemId &&
!line.isVoided &&
activeOrderId ? (
<button
type="button"
className="text-[10px] text-destructive hover:underline"
onClick={() => handleVoidItem(line.orderItemId!)}
aria-label={t("voidItem")}
>
{t("void")}
</button>
) : null}
{!line.isVoided ? (
<>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() =>
updateQty(line.menuItem.id, line.quantity - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-5 text-center text-xs">
{formatNumber(line.quantity, numberLocale)}
</span>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() =>
updateQty(line.menuItem.id, line.quantity + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => removeItem(line.menuItem.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
) : null}
</div>
</div>
))
)}
</div>
<div className="shrink-0 space-y-1.5 border-t border-border bg-card pt-2">
<div className="flex flex-wrap items-end gap-1.5">
<LabeledField label={t("couponCode")} htmlFor="pos-coupon" className="min-w-0 flex-1">
<Input
id="pos-coupon"
value={couponCode}
onChange={(e) => {
setCouponCode(e.target.value);
if (couponMessage) setCouponMessage(null);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!appliedCoupon &&
couponCode.trim() &&
!validateCoupon.isPending
) {
e.preventDefault();
validateCoupon.mutate();
}
}}
disabled={!!appliedCoupon}
dir="ltr"
className="h-8 text-end text-sm"
/>
</LabeledField>
{appliedCoupon ? (
<Button
variant="outline"
onClick={() => {
clearCoupon();
setCouponMessage({ type: "success", text: t("couponRemoved") });
}}
>
{t("removeCoupon")}
</Button>
) : (
<Button
variant="outline"
disabled={!couponCode.trim() || items.length === 0 || validateCoupon.isPending}
onClick={() => validateCoupon.mutate()}
>
{t("applyCoupon")}
</Button>
)}
</div>
{couponMessage ? (
<p
className={cn(
"text-center text-sm",
couponMessage.type === "success" ? "text-[#0F6E56]" : "text-[#A32D2D]"
)}
>
{couponMessage.text}
</p>
) : null}
{appliedCoupon ? (
<div className="flex justify-between text-sm text-[#BA7517]">
<span>{t("couponActive", { code: appliedCoupon.code })}</span>
<span>-{formatCurrency(discount, numberLocale)}</span>
</div>
) : null}
<div className="flex justify-between text-sm">
<span>{t("subtotal")}</span>
<span>{formatCurrency(sub, numberLocale)}</span>
</div>
{discount > 0 ? (
<div className="flex justify-between text-sm text-[#0F6E56]">
<span>{t("discount")}</span>
<span>-{formatCurrency(discount, numberLocale)}</span>
</div>
) : null}
<div className="flex justify-between text-sm">
<span>{t("tax")}</span>
<span>{formatCurrency(tax, numberLocale)}</span>
</div>
<div className="flex justify-between text-sm font-bold">
<span>{t("total")}</span>
<span>{formatCurrency(total, numberLocale)}</span>
</div>
{!isOnline ? (
<p className="rounded-md bg-amber-50 px-2 py-1 text-center text-[11px] text-amber-700 border border-amber-200">
{t("offlineQueueNotice")}
</p>
) : null}
{orderMessage ? (
<p className="text-center text-xs text-primary">{orderMessage}</p>
) : null}
{!canSubmitOrder && items.length > 0 ? (
<p className="text-center text-[10px] text-amber-700">{t("needTableOrName")}</p>
) : null}
{items.some((line) => !line.isVoided) ? (
<Button
type="button"
size="sm"
variant="outline"
className="w-full"
onClick={openKitchenSlip}
>
{t("kitchenSlip")}
</Button>
) : null}
<div className="flex flex-col gap-2 pt-0.5">
<Button
size="sm"
className="w-full"
disabled={!canSubmitOrder || isOrderBusy}
onClick={() => submitOrderAndPay.mutate()}
>
{submitOrderAndPay.isPending ? "..." : t("submitOrderAndPay")}
</Button>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={!canSubmitOrder || isOrderBusy}
onClick={() => submitOrder.mutate()}
>
{submitOrder.isPending ? "..." : t("submitOrder")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
clearSession();
syncUrl(null, null);
setOrderMessage(null);
}}
>
{t("clearCart")}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{kitchenSlip ? (
<PosSlipModal
variant="kitchen"
cafeName={cafeName}
kitchenLines={kitchenSlip.lines}
orderId={kitchenSlip.orderId}
tableNumber={kitchenSlip.tableNumber}
guestName={kitchenSlip.guestName}
onClose={() => setKitchenSlip(null)}
/>
) : null}
{showTransferPicker ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-sm rounded-xl border border-border bg-background p-4 shadow-lg">
<p className="mb-3 text-sm font-medium">{t("selectTargetTable")}</p>
<div className="mb-4 flex max-h-48 flex-wrap gap-2 overflow-y-auto">
{freeTransferTables.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noOrderOnTable")}</p>
) : (
freeTransferTables.map((tbl) => (
<Button
key={tbl.id}
type="button"
variant="outline"
size="sm"
disabled={transferTableMutation.isPending}
onClick={() => transferTableMutation.mutate(tbl.id)}
>
{t("table")} {tbl.number}
</Button>
))
)}
</div>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => setShowTransferPicker(false)}
>
{tCommon("cancel")}
</Button>
</div>
</div>
) : null}
</div>
);
}