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:
soroush.asadi
2026-05-28 00:07:58 +03:30
parent 9ed305e5bd
commit 79deab543a
5 changed files with 807 additions and 426 deletions
+12 -1
View File
@@ -229,7 +229,18 @@
"customerSaveError": "تعذّر حفظ العميل",
"customerPhoneExists": "الهاتف مسجّل مسبقاً — ابحث واختر",
"newCustomerHint": "للطلب الحالي فقط، أو احفظ في CRM عبر «إضافة عميل»",
"offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال"
"offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال",
"orderTypePicker": "كيف تريد تسجيل هذا الطلب؟",
"orderTypeTable": "طاولة",
"orderTypeTableDesc": "إجلاس الضيف على طاولة",
"orderTypeCounter": "كاونتر",
"orderTypeCounterDesc": "دون تخصيص طاولة",
"orderTypeTakeaway": "تيك أواي",
"orderTypeTakeawayDesc": "طلب للخارج",
"counterBadge": "كاونتر",
"takeawayBadge": "تيك أواي",
"assignTable": "تعيين طاولة",
"newOrder": "طلب جديد"
},
"print": {
"printReceipt": "طباعة الإيصال",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -231,7 +231,18 @@
"customerSaveError": "خطا در ذخیره مشتری",
"customerPhoneExists": "این موبایل قبلاً ثبت شده — از جستجو انتخاب کنید",
"newCustomerHint": "می‌توانید فقط برای این سفارش نام بزنید یا با «افزودن مشتری» در CRM ذخیره کنید",
"offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال می‌شود"
"offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال می‌شود",
"orderTypePicker": "سفارش چطور ثبت می‌شود؟",
"orderTypeTable": "میز",
"orderTypeTableDesc": "مهمان روی میز می‌نشیند",
"orderTypeCounter": "پیشخوان",
"orderTypeCounterDesc": "بدون تخصیص میز",
"orderTypeTakeaway": "بیرون‌بر",
"orderTypeTakeawayDesc": "سفارش برای بیرون",
"counterBadge": "پیشخوان",
"takeawayBadge": "بیرون‌بر",
"assignTable": "تخصیص میز",
"newOrder": "سفارش جدید"
},
"print": {
"printReceipt": "چاپ رسید",
+465 -124
View File
@@ -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,
}),