Redesign POS order flow with order type picker and counter/takeaway support
- Add OrderTypePicker screen: Table / Counter / Takeaway cards shown when no active session, replacing the old always-visible table board - Move PosTableBoard into a modal overlay (opens on Table selection or "Assign Table" for counter orders) - Add orderType field + setOrderType action to cart store - Counter and Takeaway orders no longer require a table to submit - Add "Assign Table →" button in cart for counter orders with active session - Rewrite category tabs as horizontal scrollable row (no wrapping) - Larger product cards with 4:3 thumbnail + quantity badge overlay - Bigger quantity controls (h-8 w-8) and "New order" back button in header - Add i18n keys for order types in en/fa/ar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -229,7 +229,18 @@
|
||||
"customerSaveError": "تعذّر حفظ العميل",
|
||||
"customerPhoneExists": "الهاتف مسجّل مسبقاً — ابحث واختر",
|
||||
"newCustomerHint": "للطلب الحالي فقط، أو احفظ في CRM عبر «إضافة عميل»",
|
||||
"offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال"
|
||||
"offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال",
|
||||
"orderTypePicker": "كيف تريد تسجيل هذا الطلب؟",
|
||||
"orderTypeTable": "طاولة",
|
||||
"orderTypeTableDesc": "إجلاس الضيف على طاولة",
|
||||
"orderTypeCounter": "كاونتر",
|
||||
"orderTypeCounterDesc": "دون تخصيص طاولة",
|
||||
"orderTypeTakeaway": "تيك أواي",
|
||||
"orderTypeTakeawayDesc": "طلب للخارج",
|
||||
"counterBadge": "كاونتر",
|
||||
"takeawayBadge": "تيك أواي",
|
||||
"assignTable": "تعيين طاولة",
|
||||
"newOrder": "طلب جديد"
|
||||
},
|
||||
"print": {
|
||||
"printReceipt": "طباعة الإيصال",
|
||||
|
||||
@@ -231,7 +231,18 @@
|
||||
"customerSaveError": "Could not save customer",
|
||||
"customerPhoneExists": "Phone already registered — search and select",
|
||||
"newCustomerHint": "Use for this order only, or tap Add customer to save to CRM",
|
||||
"offlineQueueNotice": "Offline — order saved in queue and will sync when connected"
|
||||
"offlineQueueNotice": "Offline — order saved in queue and will sync when connected",
|
||||
"orderTypePicker": "How would you like to take this order?",
|
||||
"orderTypeTable": "Table",
|
||||
"orderTypeTableDesc": "Seat guest at a specific table",
|
||||
"orderTypeCounter": "Counter",
|
||||
"orderTypeCounterDesc": "Walk-in, no table yet",
|
||||
"orderTypeTakeaway": "Takeaway",
|
||||
"orderTypeTakeawayDesc": "Order to go",
|
||||
"counterBadge": "Counter",
|
||||
"takeawayBadge": "Takeaway",
|
||||
"assignTable": "Assign table",
|
||||
"newOrder": "New order"
|
||||
},
|
||||
"print": {
|
||||
"printReceipt": "Print receipt",
|
||||
|
||||
@@ -231,7 +231,18 @@
|
||||
"customerSaveError": "خطا در ذخیره مشتری",
|
||||
"customerPhoneExists": "این موبایل قبلاً ثبت شده — از جستجو انتخاب کنید",
|
||||
"newCustomerHint": "میتوانید فقط برای این سفارش نام بزنید یا با «افزودن مشتری» در CRM ذخیره کنید",
|
||||
"offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال میشود"
|
||||
"offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال میشود",
|
||||
"orderTypePicker": "سفارش چطور ثبت میشود؟",
|
||||
"orderTypeTable": "میز",
|
||||
"orderTypeTableDesc": "مهمان روی میز مینشیند",
|
||||
"orderTypeCounter": "پیشخوان",
|
||||
"orderTypeCounterDesc": "بدون تخصیص میز",
|
||||
"orderTypeTakeaway": "بیرونبر",
|
||||
"orderTypeTakeawayDesc": "سفارش برای بیرون",
|
||||
"counterBadge": "پیشخوان",
|
||||
"takeawayBadge": "بیرونبر",
|
||||
"assignTable": "تخصیص میز",
|
||||
"newOrder": "سفارش جدید"
|
||||
},
|
||||
"print": {
|
||||
"printReceipt": "چاپ رسید",
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
"use client";
|
||||
"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 {
|
||||
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,
|
||||
@@ -17,6 +30,7 @@ import type {
|
||||
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";
|
||||
@@ -77,31 +91,129 @@ function cartToKitchenLines(cartItems: CartItem[]): KitchenSlipLine[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Small square thumb on menu tiles: text | img (RTL-aware via flex order). */
|
||||
function PosMenuThumbnail({
|
||||
imageUrl,
|
||||
kind,
|
||||
hasVideo,
|
||||
// ─── Order Type Picker ───────────────────────────────────────────────────────
|
||||
|
||||
function OrderTypePicker({
|
||||
onSelect,
|
||||
t,
|
||||
}: {
|
||||
imageUrl?: string | null;
|
||||
kind: ReturnType<typeof inferMenuItemKind>;
|
||||
hasVideo?: boolean;
|
||||
onSelect: (type: OrderType) => void;
|
||||
t: ReturnType<typeof useTranslations<"pos">>;
|
||||
}) {
|
||||
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
|
||||
<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]"
|
||||
>
|
||||
<Video className="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
) : null}
|
||||
<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");
|
||||
@@ -135,6 +247,8 @@ export function PosScreen() {
|
||||
guestName?: string | null;
|
||||
} | null>(null);
|
||||
const [posMode, setPosMode] = useState<"order" | "pay">("order");
|
||||
const [showTablePicker, setShowTablePicker] = useState(false);
|
||||
const [showTransferPicker, setShowTransferPicker] = useState(false);
|
||||
|
||||
const {
|
||||
items,
|
||||
@@ -148,6 +262,8 @@ export function PosScreen() {
|
||||
clearCoupon,
|
||||
tableId,
|
||||
setTableId,
|
||||
orderType,
|
||||
setOrderType,
|
||||
activeOrderId,
|
||||
activeOrderDisplayNumber,
|
||||
setActiveOrderId,
|
||||
@@ -178,10 +294,14 @@ export function PosScreen() {
|
||||
[pathname, router, searchParams]
|
||||
);
|
||||
|
||||
// Restore tableId + infer orderType from URL params
|
||||
useEffect(() => {
|
||||
const tid = searchParams.get("tableId");
|
||||
if (tid) setTableId(tid);
|
||||
}, [searchParams, setTableId]);
|
||||
if (tid) {
|
||||
setTableId(tid);
|
||||
if (!orderType) setOrderType("table");
|
||||
}
|
||||
}, [searchParams, setTableId, setOrderType, orderType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOrderId) setActiveOrderId(urlOrderId);
|
||||
@@ -205,7 +325,6 @@ export function PosScreen() {
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [showTransferPicker, setShowTransferPicker] = useState(false);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
@@ -279,13 +398,19 @@ export function PosScreen() {
|
||||
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);
|
||||
if (order.tableId) {
|
||||
setTableId(order.tableId);
|
||||
setOrderType("table");
|
||||
} else if (!orderType) {
|
||||
setOrderType("counter");
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [
|
||||
@@ -293,10 +418,11 @@ export function PosScreen() {
|
||||
urlOrderId,
|
||||
menuById,
|
||||
activeOrderId,
|
||||
activeOrderDisplayNumber,
|
||||
items.length,
|
||||
hydrateFromOrder,
|
||||
setTableId,
|
||||
setOrderType,
|
||||
orderType,
|
||||
]);
|
||||
|
||||
const sessionPatchRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -317,6 +443,8 @@ export function PosScreen() {
|
||||
|
||||
const handleTableSelect = useCallback(
|
||||
(table: TableBoardItem, activeOrder: Order | null) => {
|
||||
setShowTablePicker(false);
|
||||
|
||||
if (activeOrder) {
|
||||
setTableId(table.id);
|
||||
hydrateFromOrder(activeOrder, menuById);
|
||||
@@ -342,12 +470,30 @@ export function PosScreen() {
|
||||
menuById,
|
||||
syncUrl,
|
||||
setActiveOrderId,
|
||||
activeOrderDisplayNumber,
|
||||
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)}`
|
||||
: "";
|
||||
@@ -436,13 +582,7 @@ export function PosScreen() {
|
||||
if (!isSearchingItems) return true;
|
||||
return menuItemMatchesSearch(i, itemSearchQuery, locale);
|
||||
});
|
||||
}, [
|
||||
catalogForSearch,
|
||||
menuItems,
|
||||
isSearchingItems,
|
||||
itemSearchQuery,
|
||||
locale,
|
||||
]);
|
||||
}, [catalogForSearch, menuItems, isSearchingItems, itemSearchQuery, locale]);
|
||||
|
||||
const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems;
|
||||
|
||||
@@ -454,10 +594,6 @@ export function PosScreen() {
|
||||
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);
|
||||
@@ -566,7 +702,7 @@ export function PosScreen() {
|
||||
})
|
||||
.then((ticket) => {
|
||||
setOrderMessage(
|
||||
`${baseMsg} · ${t("queueNumber", { number: ticket.number })}`
|
||||
`${baseMsg} · ${t("queueNumber", { number: ticket.number })}`
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["queue-today"] });
|
||||
})
|
||||
@@ -632,7 +768,6 @@ export function PosScreen() {
|
||||
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) {
|
||||
@@ -720,18 +855,36 @@ export function PosScreen() {
|
||||
|
||||
const pendingCount = getPendingLines().length;
|
||||
const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending;
|
||||
|
||||
// Counter/takeaway orders don't require a table
|
||||
const canSubmitOrder =
|
||||
pendingCount > 0 &&
|
||||
(!!tableId || !!customerId || guestName.trim().length > 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 gap-3 overflow-hidden"
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{/* ── 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"}
|
||||
@@ -748,47 +901,83 @@ export function PosScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
|
||||
{posMode === "pay" ? (
|
||||
<PosPayPanel
|
||||
cafeId={cafeId}
|
||||
numberLocale={numberLocale}
|
||||
branchId={orderBranchId}
|
||||
/>
|
||||
) : showTypePicker ? (
|
||||
/* ── Order type picker ──────────────────────────────────────────── */
|
||||
<OrderTypePicker onSelect={handleOrderTypeSelect} t={t} />
|
||||
) : (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden"
|
||||
/* ── 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"
|
||||
>
|
||||
{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}
|
||||
{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 ? (
|
||||
<p className="text-xs text-[#0F6E56]">
|
||||
{t("sessionActive")} · {t("order")}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#
|
||||
{activeOrderDisplayNumber
|
||||
? String(activeOrderDisplayNumber)
|
||||
: formatOrderNumber({ id: activeOrderId })}
|
||||
</p>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Queue bar */}
|
||||
{cafeId ? (
|
||||
<PosQueueBar cafeId={cafeId} branchId={orderBranchId} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Reservation banner */}
|
||||
{reservationId && reservationGuest ? (
|
||||
<div className="shrink-0 rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE] px-3 py-2 text-sm text-[#0F6E56]">
|
||||
<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 top-1/2 size-4 -translate-y-1/2 text-muted-foreground start-3"
|
||||
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
@@ -797,14 +986,14 @@ export function PosScreen() {
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
placeholder={t("searchItemsPlaceholder")}
|
||||
aria-label={t("searchItems")}
|
||||
className="h-9 ps-9 pe-9"
|
||||
className="h-10 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"
|
||||
className="absolute end-1 top-1/2 size-7 -translate-y-1/2"
|
||||
onClick={() => setItemSearch("")}
|
||||
aria-label={tCommon("cancel")}
|
||||
>
|
||||
@@ -812,25 +1001,30 @@ export function PosScreen() {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-end gap-2 overflow-x-auto pb-0.5">
|
||||
|
||||
{/* 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: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-20" />
|
||||
? 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"}
|
||||
variant={
|
||||
selectedCategory === c.id ? "default" : "outline"
|
||||
}
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => setSelectedCategory(c.id)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<CategoryVisual
|
||||
icon={c.icon}
|
||||
@@ -844,63 +1038,123 @@ export function PosScreen() {
|
||||
))}
|
||||
</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 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<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-[3.75rem] rounded-lg" />
|
||||
<Skeleton key={i} className="h-36 rounded-xl" />
|
||||
))
|
||||
: filteredItems.length === 0 && isSearchingItems
|
||||
? (
|
||||
<p className="col-span-full py-8 text-center text-sm text-muted-foreground">
|
||||
<p className="col-span-full py-10 text-center text-sm text-muted-foreground">
|
||||
{t("searchNoResults")}
|
||||
</p>
|
||||
)
|
||||
: filteredItems.map((item) => (
|
||||
: filteredItems.map((item) => {
|
||||
const qty = items.find(
|
||||
(ci) => ci.menuItem.id === item.id
|
||||
)?.quantity;
|
||||
return (
|
||||
<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"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 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={1}
|
||||
primaryClassName="text-sm"
|
||||
lines={2}
|
||||
primaryClassName="text-sm font-medium leading-snug"
|
||||
secondaryClassName="text-[10px]"
|
||||
/>
|
||||
<p className="mt-0.5 text-sm font-medium text-primary">
|
||||
<p className="mt-1 text-sm font-semibold 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">
|
||||
{/* ── 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">
|
||||
<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 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
|
||||
@@ -914,6 +1168,8 @@ export function PosScreen() {
|
||||
onClearCustomer={clearCustomer}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Transfer table (for table orders with active session) */}
|
||||
{activeOrderId && tableId ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -925,18 +1181,34 @@ export function PosScreen() {
|
||||
{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="text-center text-sm text-muted-foreground">{t("emptyCart")}</p>
|
||||
<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 items-center gap-2 rounded-md border border-border p-1.5",
|
||||
"flex items-center gap-2 rounded-lg border border-border p-2",
|
||||
line.isVoided && "opacity-60"
|
||||
)}
|
||||
>
|
||||
@@ -946,7 +1218,8 @@ export function PosScreen() {
|
||||
lines={1}
|
||||
primaryClassName={cn(
|
||||
"text-xs font-medium",
|
||||
line.isVoided && "line-through text-muted-foreground"
|
||||
line.isVoided &&
|
||||
"line-through text-muted-foreground"
|
||||
)}
|
||||
secondaryClassName="text-[10px]"
|
||||
/>
|
||||
@@ -961,6 +1234,7 @@ export function PosScreen() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -972,7 +1246,7 @@ export function PosScreen() {
|
||||
activeOrderId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-destructive hover:underline"
|
||||
className="cursor-pointer text-[10px] text-destructive hover:underline"
|
||||
onClick={() => handleVoidItem(line.orderItemId!)}
|
||||
aria-label={t("voidItem")}
|
||||
>
|
||||
@@ -984,33 +1258,33 @@ export function PosScreen() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
updateQty(line.menuItem.id, line.quantity - 1)
|
||||
}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="w-5 text-center text-xs">
|
||||
<span className="w-6 text-center text-xs font-medium">
|
||||
{formatNumber(line.quantity, numberLocale)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
updateQty(line.menuItem.id, line.quantity + 1)
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => removeItem(line.menuItem.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1020,9 +1294,15 @@ export function PosScreen() {
|
||||
)}
|
||||
</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">
|
||||
<LabeledField
|
||||
label={t("couponCode")}
|
||||
htmlFor="pos-coupon"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<Input
|
||||
id="pos-coupon"
|
||||
value={couponCode}
|
||||
@@ -1051,7 +1331,10 @@ export function PosScreen() {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
clearCoupon();
|
||||
setCouponMessage({ type: "success", text: t("couponRemoved") });
|
||||
setCouponMessage({
|
||||
type: "success",
|
||||
text: t("couponRemoved"),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("removeCoupon")}
|
||||
@@ -1059,29 +1342,38 @@ export function PosScreen() {
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!couponCode.trim() || items.length === 0 || validateCoupon.isPending}
|
||||
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.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>
|
||||
@@ -1100,17 +1392,28 @@ export function PosScreen() {
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{!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"
|
||||
@@ -1122,6 +1425,7 @@ export function PosScreen() {
|
||||
{t("kitchenSlip")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1129,7 +1433,9 @@ export function PosScreen() {
|
||||
disabled={!canSubmitOrder || isOrderBusy}
|
||||
onClick={() => submitOrderAndPay.mutate()}
|
||||
>
|
||||
{submitOrderAndPay.isPending ? "..." : t("submitOrderAndPay")}
|
||||
{submitOrderAndPay.isPending
|
||||
? "..."
|
||||
: t("submitOrderAndPay")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -1144,11 +1450,7 @@ export function PosScreen() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
syncUrl(null, null);
|
||||
setOrderMessage(null);
|
||||
}}
|
||||
onClick={handleBackToTypePicker}
|
||||
>
|
||||
{t("clearCart")}
|
||||
</Button>
|
||||
@@ -1161,6 +1463,42 @@ export function PosScreen() {
|
||||
</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"
|
||||
@@ -1173,13 +1511,16 @@ export function PosScreen() {
|
||||
/>
|
||||
) : 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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noOrderOnTable")}
|
||||
</p>
|
||||
) : (
|
||||
freeTransferTables.map((tbl) => (
|
||||
<Button
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface AppliedCoupon {
|
||||
discountAmount: number;
|
||||
}
|
||||
|
||||
export type OrderType = "table" | "counter" | "takeaway";
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
syncedQtyByMenuId: Record<string, number>;
|
||||
@@ -27,6 +29,7 @@ interface CartState {
|
||||
customerId: string | null;
|
||||
guestName: string;
|
||||
guestPhone: string;
|
||||
orderType: OrderType | null;
|
||||
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
|
||||
addItem: (item: MenuItem) => void;
|
||||
removeItem: (menuItemId: string) => void;
|
||||
@@ -35,6 +38,7 @@ interface CartState {
|
||||
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
||||
clearCoupon: () => void;
|
||||
setTableId: (tableId: string | null) => void;
|
||||
setOrderType: (type: OrderType | null) => void;
|
||||
setActiveOrderId: (orderId: string | null) => void;
|
||||
setGuestName: (name: string) => void;
|
||||
setGuestPhone: (phone: string) => void;
|
||||
@@ -77,6 +81,7 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
customerId: null,
|
||||
guestName: "",
|
||||
guestPhone: "",
|
||||
orderType: null,
|
||||
|
||||
getPendingLines: () => {
|
||||
const { items, syncedQtyByMenuId } = get();
|
||||
@@ -134,6 +139,7 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
||||
clearCoupon: () => set(clearCouponState),
|
||||
setTableId: (tableId) => set({ tableId }),
|
||||
setOrderType: (orderType) => set({ orderType }),
|
||||
setActiveOrderId: (activeOrderId) => set({ activeOrderId, activeOrderDisplayNumber: null }),
|
||||
setGuestName: (guestName) =>
|
||||
set((s) => ({
|
||||
@@ -197,6 +203,7 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
customerId: null,
|
||||
guestName: "",
|
||||
guestPhone: "",
|
||||
orderType: null,
|
||||
...clearCouponState,
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user