Files
meezi/web/dashboard/src/components/pos/pos-screen.tsx
T
soroush.asadi 24da1e0522
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 57s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m43s
feat(orders): per-item kitchen/bar notes (POS + QR app + KDS)
Lets the POS agent and the QR/app customer attach a free-text note to each
order line (e.g. "no tomato", "extra hot") that reaches the kitchen/bar.

- Backend already supported it (OrderItem.Notes persists; CreateOrderItemRequest
  and OrderItemDto carry Notes; LiveOrderDto items include it) — this wires the UI.
- cart.store: add setNotes(menuItemId, notes); notes already travel in
  getPendingLines and round-trip via hydrateFromOrder.
- POS pos-screen: a note input under each cart line.
- QR guest menu: a note input under each cart line (QrCartLine.note).
- KDS: render the note prominently under each item so kitchen/bar sees it.
- i18n: pos.itemNotePlaceholder + qrMenu.itemNote (fa/ar/en).

Note: notes are captured on items being added; editing a note on an
already-submitted line is out of scope (no pending delta to re-send).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:37:59 +03:30

1569 lines
59 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 {
ChevronLeft,
ChevronRight,
Minus,
Package,
Plus,
Search,
ShoppingCart,
Trash2,
UtensilsCrossed,
Users,
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 type { OrderType } 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,
}));
}
// ─── Order Type Picker ───────────────────────────────────────────────────────
function OrderTypePicker({
onSelect,
t,
}: {
onSelect: (type: OrderType) => void;
t: ReturnType<typeof useTranslations<"pos">>;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 py-10">
<div className="text-center">
<h2 className="text-2xl font-bold tracking-tight">
{t("orderTypePicker")}
</h2>
</div>
<div className="grid w-full max-w-xl grid-cols-3 gap-3 sm:gap-4">
{/* Table */}
<button
type="button"
onClick={() => onSelect("table")}
className="group flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-border bg-card p-5 shadow-sm transition-all hover:border-primary hover:shadow-md active:scale-[0.97]"
>
<div className="rounded-xl bg-primary/10 p-3.5 transition-colors group-hover:bg-primary/20">
<UtensilsCrossed className="size-8 text-primary" />
</div>
<div className="text-center">
<p className="text-sm font-semibold sm:text-base">
{t("orderTypeTable")}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{t("orderTypeTableDesc")}
</p>
</div>
</button>
{/* Counter */}
<button
type="button"
onClick={() => onSelect("counter")}
className="group flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500 hover:shadow-md active:scale-[0.97]"
>
<div className="rounded-xl bg-blue-500/10 p-3.5 transition-colors group-hover:bg-blue-500/20">
<Users className="size-8 text-blue-600" />
</div>
<div className="text-center">
<p className="text-sm font-semibold sm:text-base">
{t("orderTypeCounter")}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{t("orderTypeCounterDesc")}
</p>
</div>
</button>
{/* Takeaway */}
<button
type="button"
onClick={() => onSelect("takeaway")}
className="group flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-border bg-card p-5 shadow-sm transition-all hover:border-amber-500 hover:shadow-md active:scale-[0.97]"
>
<div className="rounded-xl bg-amber-500/10 p-3.5 transition-colors group-hover:bg-amber-500/20">
<Package className="size-8 text-amber-600" />
</div>
<div className="text-center">
<p className="text-sm font-semibold sm:text-base">
{t("orderTypeTakeaway")}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{t("orderTypeTakeawayDesc")}
</p>
</div>
</button>
</div>
</div>
);
}
// ─── Order Type Badge ─────────────────────────────────────────────────────────
function OrderTypeBadge({
orderType,
tableNumber,
onClick,
t,
}: {
orderType: OrderType;
tableNumber?: number | string | null;
onClick?: () => void;
t: ReturnType<typeof useTranslations<"pos">>;
}) {
const label =
orderType === "table"
? `${t("table")} ${tableNumber ?? "—"}`
: orderType === "counter"
? t("counterBadge")
: t("takeawayBadge");
const cls =
orderType === "table"
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
: orderType === "counter"
? "border-blue-400 bg-blue-50 text-blue-700 hover:bg-blue-100"
: "border-amber-400 bg-amber-50 text-amber-700 hover:bg-amber-100";
return (
<button
type="button"
onClick={onClick}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
cls,
onClick ? "cursor-pointer" : "cursor-default"
)}
>
{label}
</button>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
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 [showTablePicker, setShowTablePicker] = useState(false);
const [showTransferPicker, setShowTransferPicker] = useState(false);
const {
items,
addItem,
removeItem,
updateQty,
setNotes,
couponCode,
appliedCoupon,
setCouponCode,
setAppliedCoupon,
clearCoupon,
tableId,
setTableId,
orderType,
setOrderType,
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]
);
// Restore tableId + infer orderType from URL params
useEffect(() => {
const tid = searchParams.get("tableId");
if (tid) {
setTableId(tid);
if (!orderType) setOrderType("table");
}
}, [searchParams, setTableId, setOrderType, orderType]);
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 { 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]);
// Hydrate from URL order
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);
setOrderType("table");
} else if (!orderType) {
setOrderType("counter");
}
})
.catch(() => undefined);
}, [
cafeId,
urlOrderId,
menuById,
activeOrderId,
items.length,
hydrateFromOrder,
setTableId,
setOrderType,
orderType,
]);
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) => {
setShowTablePicker(false);
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,
clearSession,
t,
]
);
// Handle order type selection from the picker
const handleOrderTypeSelect = useCallback(
(type: OrderType) => {
setOrderType(type);
if (type === "table") {
setShowTablePicker(true);
}
},
[setOrderType]
);
// Go back to type picker (with clear)
const handleBackToTypePicker = useCallback(() => {
clearSession();
syncUrl(null, null);
setOrderMessage(null);
setCouponMessage(null);
}, [clearSession, syncUrl]);
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 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);
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;
// Counter/takeaway orders don't require a table
const canSubmitOrder =
pendingCount > 0 &&
(orderType === "counter" ||
orderType === "takeaway" ||
!!tableId ||
!!customerId ||
guestName.trim().length > 0);
// Show order type picker when there's no active session
const showTypePicker =
posMode === "order" &&
orderType === null &&
items.length === 0 &&
!urlOrderId &&
!activeOrderId;
// The current table number for display
const currentTableNumber = tables?.find((tbl) => tbl.id === tableId)?.number;
if (!cafeId) return null;
return (
<div
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
dir={isRtl ? "rtl" : "ltr"}
>
{/* ── Top bar: mode switcher ─────────────────────────────────────────── */}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-1 pb-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 className="flex-1" />
</div>
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
{posMode === "pay" ? (
<PosPayPanel
cafeId={cafeId}
numberLocale={numberLocale}
branchId={orderBranchId}
/>
) : showTypePicker ? (
/* ── Order type picker ──────────────────────────────────────────── */
<OrderTypePicker onSelect={handleOrderTypeSelect} t={t} />
) : (
/* ── Order screen ───────────────────────────────────────────────── */
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden pt-2">
{/* Order screen header */}
<div className="flex shrink-0 items-center gap-2 px-1">
{/* Back / new order button */}
<button
type="button"
onClick={handleBackToTypePicker}
aria-label={t("newOrder")}
className="flex shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{isRtl ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
{t("newOrder")}
</button>
{/* Order type badge — tappable to change table */}
{orderType ? (
<OrderTypeBadge
orderType={orderType}
tableNumber={currentTableNumber}
onClick={
orderType === "table"
? () => setShowTablePicker(true)
: undefined
}
t={t}
/>
) : null}
{/* Active order number */}
{activeOrderId ? (
<span className="text-xs text-muted-foreground">
#
{activeOrderDisplayNumber
? String(activeOrderDisplayNumber)
: formatOrderNumber({ id: activeOrderId })}
</span>
) : null}
<div className="flex-1" />
{/* Queue bar */}
{cafeId ? (
<PosQueueBar cafeId={cafeId} branchId={orderBranchId} />
) : null}
</div>
{/* Reservation banner */}
{reservationId && reservationGuest ? (
<div className="mx-1 shrink-0 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-primary">
{t("reservationBanner", { name: reservationGuest })}
</div>
) : null}
{/* ── Main split: menu + cart ──────────────────────────────────── */}
<div className="flex min-h-0 flex-1 gap-3 overflow-hidden">
{/* ── Menu panel ────────────────────────────────────────────── */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
{/* Search bar */}
<div className="relative shrink-0">
<Search
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
value={itemSearch}
onChange={(e) => setItemSearch(e.target.value)}
placeholder={t("searchItemsPlaceholder")}
aria-label={t("searchItems")}
className="h-10 ps-9 pe-9"
/>
{itemSearch ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute end-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => setItemSearch("")}
aria-label={tCommon("cancel")}
>
<X className="size-4" />
</Button>
) : null}
</div>
{/* Horizontal scrolling category tabs — no wrap */}
<div className="flex shrink-0 gap-1.5 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none]">
<Button
size="sm"
variant={selectedCategory === "all" ? "default" : "outline"}
className="shrink-0"
onClick={() => setSelectedCategory("all")}
>
{t("allCategories")}
</Button>
{loadingCategories
? Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-20 shrink-0" />
))
: categories?.map((c) => (
<Button
key={c.id}
size="sm"
variant={
selectedCategory === c.id ? "default" : "outline"
}
className="shrink-0 gap-1.5"
onClick={() => setSelectedCategory(c.id)}
>
<CategoryVisual
icon={c.icon}
iconPresetId={c.iconPresetId}
iconStyle={c.iconStyle}
imageUrl={c.imageUrl}
size="xs"
/>
{getMenuPrimaryName(c, locale)}
</Button>
))}
</div>
{/* Product grid — bigger cards with image area */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="grid grid-cols-2 gap-2 content-start sm:grid-cols-3 lg:grid-cols-4">
{showItemsLoading
? Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-xl" />
))
: filteredItems.length === 0 && isSearchingItems
? (
<p className="col-span-full py-10 text-center text-sm text-muted-foreground">
{t("searchNoResults")}
</p>
)
: filteredItems.map((item) => {
const qty = items.find(
(ci) => ci.menuItem.id === item.id
)?.quantity;
return (
<button
key={item.id}
type="button"
onClick={() => addItem(item)}
className={cn(
"group relative flex cursor-pointer flex-col gap-2 rounded-xl border bg-card p-2.5 text-start shadow-sm transition-all hover:border-primary hover:shadow-md active:scale-[0.97]",
qty
? "border-primary"
: "border-border"
)}
>
{/* Thumbnail */}
<div className="relative w-full overflow-hidden rounded-lg border border-border/50 bg-muted aspect-[4/3]">
<MenuItemMedia
imageUrl={item.imageUrl}
kind={itemVisualKind(item)}
size="sm"
/>
{item.videoUrl ? (
<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}
{/* Cart quantity badge */}
{qty ? (
<span className="absolute start-1.5 top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
{qty}
</span>
) : null}
</div>
{/* Info */}
<div className="min-w-0">
<MenuItemLabels
item={item}
lines={2}
primaryClassName="text-sm font-medium leading-snug"
secondaryClassName="text-[10px]"
/>
<p className="mt-1 text-sm font-semibold text-primary">
{formatCurrency(item.price, numberLocale)}
</p>
</div>
</button>
);
})}
</div>
</div>
</div>
{/* ── Cart sidebar ──────────────────────────────────────────── */}
<Card className="flex h-full min-h-0 w-[min(100%,21rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
<CardHeader className="shrink-0 space-y-2 p-3 pb-2">
{/* Cart header: title + table/type badge */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ShoppingCart className="size-4 text-muted-foreground" />
<CardTitle className="text-sm">{t("takeOrder")}</CardTitle>
</div>
{orderType === "table" ? (
tableId ? (
<button
type="button"
onClick={() => setShowTablePicker(true)}
className="shrink-0 cursor-pointer rounded-md border-2 border-primary bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary transition-colors hover:bg-primary/20"
>
{t("table")}{" "}
{currentTableNumber ?? "—"}
</button>
) : (
<button
type="button"
onClick={() => setShowTablePicker(true)}
className="shrink-0 cursor-pointer rounded-md border border-dashed border-primary px-2 py-0.5 text-xs text-primary transition-colors hover:bg-primary/5"
>
{t("selectTableBoard")}
</button>
)
) : orderType ? (
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium",
orderType === "counter"
? "bg-blue-50 text-blue-700"
: "bg-amber-50 text-amber-700"
)}
>
{orderType === "counter"
? t("counterBadge")
: t("takeawayBadge")}
</span>
) : null}
</div>
{/* Customer picker */}
{cafeId ? (
<PosCustomerPicker
compact
cafeId={cafeId}
guestName={guestName}
guestPhone={guestPhone}
customerId={customerId}
onGuestNameChange={setGuestName}
onGuestPhoneChange={setGuestPhone}
onCustomerChange={setCustomer}
onClearCustomer={clearCustomer}
/>
) : null}
{/* Transfer table (for table orders with active session) */}
{activeOrderId && tableId ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setShowTransferPicker(true)}
>
{t("transferTable")}
</Button>
) : null}
{/* Assign table button (for counter orders) */}
{orderType === "counter" && activeOrderId ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setShowTablePicker(true)}
>
{t("assignTable")}
</Button>
) : null}
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-3 pt-0">
{/* Cart items */}
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain pe-0.5">
{items.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{t("emptyCart")}
</p>
) : (
items.map((line) => (
<div
key={line.orderItemId ?? line.menuItem.id}
className={cn(
"flex flex-col gap-1.5 rounded-lg border border-border p-2",
line.isVoided && "opacity-60"
)}
>
<div className="flex items-center gap-2">
<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="cursor-pointer 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-8 w-8"
onClick={() =>
updateQty(line.menuItem.id, line.quantity - 1)
}
>
<Minus className="h-3.5 w-3.5" />
</Button>
<span className="w-6 text-center text-xs font-medium">
{formatNumber(line.quantity, numberLocale)}
</span>
<Button
size="icon"
variant="outline"
className="h-8 w-8"
onClick={() =>
updateQty(line.menuItem.id, line.quantity + 1)
}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive"
onClick={() => removeItem(line.menuItem.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
) : null}
</div>
</div>
{!line.isVoided && (
<input
type="text"
value={line.notes ?? ""}
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
placeholder={t("itemNotePlaceholder")}
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
)}
</div>
))
)}
</div>
{/* Totals + actions */}
<div className="shrink-0 space-y-1.5 border-t border-border bg-card pt-2">
{/* Coupon row */}
<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 border border-amber-200 bg-amber-50 px-2 py-1 text-center text-[11px] text-amber-700">
{t("offlineQueueNotice")}
</p>
) : null}
{orderMessage ? (
<p className="text-center text-xs text-primary">
{orderMessage}
</p>
) : null}
{!canSubmitOrder &&
items.length > 0 &&
orderType !== "counter" &&
orderType !== "takeaway" ? (
<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={handleBackToTypePicker}
>
{t("clearCart")}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* ── Table picker modal (for Table orders & counter assign) ────────── */}
{showTablePicker ? (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-16">
<div className="w-full max-w-3xl rounded-2xl border border-border bg-background p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between gap-2">
<h3 className="text-base font-semibold">{t("selectTableBoard")}</h3>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setShowTablePicker(false);
// If table type was selected but user dismissed without choosing a table,
// go back to type picker
if (orderType === "table" && !tableId) {
setOrderType(null);
}
}}
>
<X className="size-5" />
</Button>
</div>
{cafeId ? (
<PosTableBoard
cafeId={cafeId}
numberLocale={numberLocale}
selectedTableId={tableId}
branchId={orderBranchId}
onSelectTable={handleTableSelect}
/>
) : null}
</div>
</div>
) : null}
{/* ── Kitchen slip modal ────────────────────────────────────────────── */}
{kitchenSlip ? (
<PosSlipModal
variant="kitchen"
cafeName={cafeName}
kitchenLines={kitchenSlip.lines}
orderId={kitchenSlip.orderId}
tableNumber={kitchenSlip.tableNumber}
guestName={kitchenSlip.guestName}
onClose={() => setKitchenSlip(null)}
/>
) : null}
{/* ── Transfer table modal ──────────────────────────────────────────── */}
{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>
);
}