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": "تعذّر حفظ العميل", "customerSaveError": "تعذّر حفظ العميل",
"customerPhoneExists": "الهاتف مسجّل مسبقاً — ابحث واختر", "customerPhoneExists": "الهاتف مسجّل مسبقاً — ابحث واختر",
"newCustomerHint": "للطلب الحالي فقط، أو احفظ في CRM عبر «إضافة عميل»", "newCustomerHint": "للطلب الحالي فقط، أو احفظ في CRM عبر «إضافة عميل»",
"offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال" "offlineQueueNotice": "غير متصل — تم حفظ الطلب في الطابور وسيتم إرساله عند الاتصال",
"orderTypePicker": "كيف تريد تسجيل هذا الطلب؟",
"orderTypeTable": "طاولة",
"orderTypeTableDesc": "إجلاس الضيف على طاولة",
"orderTypeCounter": "كاونتر",
"orderTypeCounterDesc": "دون تخصيص طاولة",
"orderTypeTakeaway": "تيك أواي",
"orderTypeTakeawayDesc": "طلب للخارج",
"counterBadge": "كاونتر",
"takeawayBadge": "تيك أواي",
"assignTable": "تعيين طاولة",
"newOrder": "طلب جديد"
}, },
"print": { "print": {
"printReceipt": "طباعة الإيصال", "printReceipt": "طباعة الإيصال",
+12 -1
View File
@@ -231,7 +231,18 @@
"customerSaveError": "Could not save customer", "customerSaveError": "Could not save customer",
"customerPhoneExists": "Phone already registered — search and select", "customerPhoneExists": "Phone already registered — search and select",
"newCustomerHint": "Use for this order only, or tap Add customer to save to CRM", "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": { "print": {
"printReceipt": "Print receipt", "printReceipt": "Print receipt",
+12 -1
View File
@@ -231,7 +231,18 @@
"customerSaveError": "خطا در ذخیره مشتری", "customerSaveError": "خطا در ذخیره مشتری",
"customerPhoneExists": "این موبایل قبلاً ثبت شده — از جستجو انتخاب کنید", "customerPhoneExists": "این موبایل قبلاً ثبت شده — از جستجو انتخاب کنید",
"newCustomerHint": "می‌توانید فقط برای این سفارش نام بزنید یا با «افزودن مشتری» در CRM ذخیره کنید", "newCustomerHint": "می‌توانید فقط برای این سفارش نام بزنید یا با «افزودن مشتری» در CRM ذخیره کنید",
"offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال می‌شود" "offlineQueueNotice": "آفلاین ‐ سفارش در صف ذخیره شد و پس از اتصال ارسال می‌شود",
"orderTypePicker": "سفارش چطور ثبت می‌شود؟",
"orderTypeTable": "میز",
"orderTypeTableDesc": "مهمان روی میز می‌نشیند",
"orderTypeCounter": "پیشخوان",
"orderTypeCounterDesc": "بدون تخصیص میز",
"orderTypeTakeaway": "بیرون‌بر",
"orderTypeTakeawayDesc": "سفارش برای بیرون",
"counterBadge": "پیشخوان",
"takeawayBadge": "بیرون‌بر",
"assignTable": "تخصیص میز",
"newOrder": "سفارش جدید"
}, },
"print": { "print": {
"printReceipt": "چاپ رسید", "printReceipt": "چاپ رسید",
+465 -124
View File
@@ -1,10 +1,23 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl"; 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 { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
import type { import type {
MenuCategory, MenuCategory,
@@ -17,6 +30,7 @@ import type {
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings"; import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
import { useCartStore, type CartItem } from "@/lib/stores/cart.store"; import { useCartStore, type CartItem } from "@/lib/stores/cart.store";
import type { OrderType } from "@/lib/stores/cart.store";
import { formatCurrency, formatNumber } from "@/lib/format"; import { formatCurrency, formatNumber } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number"; import { formatOrderNumber } from "@/lib/order-number";
import { iranMobileForApi } from "@/lib/phone"; 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). */ // ─── Order Type Picker ───────────────────────────────────────────────────────
function PosMenuThumbnail({
imageUrl, function OrderTypePicker({
kind, onSelect,
hasVideo, t,
}: { }: {
imageUrl?: string | null; onSelect: (type: OrderType) => void;
kind: ReturnType<typeof inferMenuItemKind>; t: ReturnType<typeof useTranslations<"pos">>;
hasVideo?: boolean;
}) { }) {
return ( return (
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-border/60"> <div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 py-10">
<MenuItemMedia imageUrl={imageUrl} kind={kind} size="sm" /> <div className="text-center">
{hasVideo ? ( <h2 className="text-2xl font-bold tracking-tight">
<span {t("orderTypePicker")}
className="absolute bottom-0 end-0 rounded-ss-sm bg-black/55 p-px" </h2>
aria-hidden </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" /> <div className="rounded-xl bg-primary/10 p-3.5 transition-colors group-hover:bg-primary/20">
</span> <UtensilsCrossed className="size-8 text-primary" />
) : null} </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> </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() { export function PosScreen() {
const t = useTranslations("pos"); const t = useTranslations("pos");
const tQueue = useTranslations("queue"); const tQueue = useTranslations("queue");
@@ -135,6 +247,8 @@ export function PosScreen() {
guestName?: string | null; guestName?: string | null;
} | null>(null); } | null>(null);
const [posMode, setPosMode] = useState<"order" | "pay">("order"); const [posMode, setPosMode] = useState<"order" | "pay">("order");
const [showTablePicker, setShowTablePicker] = useState(false);
const [showTransferPicker, setShowTransferPicker] = useState(false);
const { const {
items, items,
@@ -148,6 +262,8 @@ export function PosScreen() {
clearCoupon, clearCoupon,
tableId, tableId,
setTableId, setTableId,
orderType,
setOrderType,
activeOrderId, activeOrderId,
activeOrderDisplayNumber, activeOrderDisplayNumber,
setActiveOrderId, setActiveOrderId,
@@ -178,10 +294,14 @@ export function PosScreen() {
[pathname, router, searchParams] [pathname, router, searchParams]
); );
// Restore tableId + infer orderType from URL params
useEffect(() => { useEffect(() => {
const tid = searchParams.get("tableId"); const tid = searchParams.get("tableId");
if (tid) setTableId(tid); if (tid) {
}, [searchParams, setTableId]); setTableId(tid);
if (!orderType) setOrderType("table");
}
}, [searchParams, setTableId, setOrderType, orderType]);
useEffect(() => { useEffect(() => {
if (urlOrderId) setActiveOrderId(urlOrderId); if (urlOrderId) setActiveOrderId(urlOrderId);
@@ -205,7 +325,6 @@ export function PosScreen() {
type: "success" | "error"; type: "success" | "error";
text: string; text: string;
} | null>(null); } | null>(null);
const [showTransferPicker, setShowTransferPicker] = useState(false);
const { data: branches = [] } = useQuery({ const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId], queryKey: ["branches", cafeId],
@@ -279,13 +398,19 @@ export function PosScreen() {
return map; return map;
}, [allMenuItems, menuItems]); }, [allMenuItems, menuItems]);
// Hydrate from URL order
useEffect(() => { useEffect(() => {
if (!cafeId || !urlOrderId || menuById.size === 0) return; if (!cafeId || !urlOrderId || menuById.size === 0) return;
if (activeOrderId === urlOrderId && items.length > 0) return; if (activeOrderId === urlOrderId && items.length > 0) return;
apiGet<Order>(`/api/cafes/${cafeId}/orders/${urlOrderId}`) apiGet<Order>(`/api/cafes/${cafeId}/orders/${urlOrderId}`)
.then((order) => { .then((order) => {
hydrateFromOrder(order, menuById); hydrateFromOrder(order, menuById);
if (order.tableId) setTableId(order.tableId); if (order.tableId) {
setTableId(order.tableId);
setOrderType("table");
} else if (!orderType) {
setOrderType("counter");
}
}) })
.catch(() => undefined); .catch(() => undefined);
}, [ }, [
@@ -293,10 +418,11 @@ export function PosScreen() {
urlOrderId, urlOrderId,
menuById, menuById,
activeOrderId, activeOrderId,
activeOrderDisplayNumber,
items.length, items.length,
hydrateFromOrder, hydrateFromOrder,
setTableId, setTableId,
setOrderType,
orderType,
]); ]);
const sessionPatchRef = useRef<ReturnType<typeof setTimeout> | null>(null); const sessionPatchRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -317,6 +443,8 @@ export function PosScreen() {
const handleTableSelect = useCallback( const handleTableSelect = useCallback(
(table: TableBoardItem, activeOrder: Order | null) => { (table: TableBoardItem, activeOrder: Order | null) => {
setShowTablePicker(false);
if (activeOrder) { if (activeOrder) {
setTableId(table.id); setTableId(table.id);
hydrateFromOrder(activeOrder, menuById); hydrateFromOrder(activeOrder, menuById);
@@ -342,12 +470,30 @@ export function PosScreen() {
menuById, menuById,
syncUrl, syncUrl,
setActiveOrderId, setActiveOrderId,
activeOrderDisplayNumber,
clearSession, clearSession,
t, 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 const tablesQuery = orderBranchId
? `?branchId=${encodeURIComponent(orderBranchId)}` ? `?branchId=${encodeURIComponent(orderBranchId)}`
: ""; : "";
@@ -436,13 +582,7 @@ export function PosScreen() {
if (!isSearchingItems) return true; if (!isSearchingItems) return true;
return menuItemMatchesSearch(i, itemSearchQuery, locale); return menuItemMatchesSearch(i, itemSearchQuery, locale);
}); });
}, [ }, [catalogForSearch, menuItems, isSearchingItems, itemSearchQuery, locale]);
catalogForSearch,
menuItems,
isSearchingItems,
itemSearchQuery,
locale,
]);
const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems; const showItemsLoading = isSearchingItems ? loadingAllCatalog : loadingItems;
@@ -454,10 +594,6 @@ export function PosScreen() {
const itemVisualKind = (item: MenuItem) => const itemVisualKind = (item: MenuItem) =>
inferMenuItemKind(item.categoryId, categoryNameById.get(item.categoryId)); inferMenuItemKind(item.categoryId, categoryNameById.get(item.categoryId));
const handleSelectMenuItem = (item: MenuItem) => {
addItem(item);
};
const sub = subtotal(); const sub = subtotal();
const discount = appliedCoupon?.discountAmount ?? 0; const discount = appliedCoupon?.discountAmount ?? 0;
const taxable = Math.max(0, sub - discount); const taxable = Math.max(0, sub - discount);
@@ -566,7 +702,7 @@ export function PosScreen() {
}) })
.then((ticket) => { .then((ticket) => {
setOrderMessage( setOrderMessage(
`${baseMsg} · ${t("queueNumber", { number: ticket.number })}` `${baseMsg} · ${t("queueNumber", { number: ticket.number })}`
); );
queryClient.invalidateQueries({ queryKey: ["queue-today"] }); queryClient.invalidateQueries({ queryKey: ["queue-today"] });
}) })
@@ -632,7 +768,6 @@ export function PosScreen() {
cartItems: cart.items, cartItems: cart.items,
}); });
const due = orderAmountDue(order); const due = orderAmountDue(order);
// Can't process payment for a local (offline) order
if (isLocalOrder(order.id)) return { order, kitchenLines }; if (isLocalOrder(order.id)) return { order, kitchenLines };
const payBranchId = order.branchId ?? orderBranchId; const payBranchId = order.branchId ?? orderBranchId;
if (due > 0) { if (due > 0) {
@@ -720,18 +855,36 @@ export function PosScreen() {
const pendingCount = getPendingLines().length; const pendingCount = getPendingLines().length;
const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending; const isOrderBusy = submitOrder.isPending || submitOrderAndPay.isPending;
// Counter/takeaway orders don't require a table
const canSubmitOrder = const canSubmitOrder =
pendingCount > 0 && 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; if (!cafeId) return null;
return ( return (
<div <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"} 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 <Button
size="sm" size="sm"
variant={posMode === "order" ? "default" : "outline"} variant={posMode === "order" ? "default" : "outline"}
@@ -748,47 +901,83 @@ export function PosScreen() {
</Button> </Button>
</div> </div>
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
{posMode === "pay" ? ( {posMode === "pay" ? (
<PosPayPanel <PosPayPanel
cafeId={cafeId} cafeId={cafeId}
numberLocale={numberLocale} numberLocale={numberLocale}
branchId={orderBranchId} branchId={orderBranchId}
/> />
) : showTypePicker ? (
/* ── Order type picker ──────────────────────────────────────────── */
<OrderTypePicker onSelect={handleOrderTypeSelect} t={t} />
) : ( ) : (
<div /* ── Order screen ───────────────────────────────────────────────── */
className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden" <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} {isRtl ? (
<div <ChevronRight className="size-4" />
className="flex min-h-0 flex-1 gap-4 overflow-hidden" ) : (
> <ChevronLeft className="size-4" />
{/* Menu panel */} )}
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden"> {t("newOrder")}
{cafeId ? ( </button>
<PosTableBoard
cafeId={cafeId} {/* Order type badge — tappable to change table */}
numberLocale={numberLocale} {orderType ? (
selectedTableId={tableId} <OrderTypeBadge
branchId={orderBranchId} orderType={orderType}
onSelectTable={handleTableSelect} tableNumber={currentTableNumber}
onClick={
orderType === "table"
? () => setShowTablePicker(true)
: undefined
}
t={t}
/> />
) : null} ) : null}
{/* Active order number */}
{activeOrderId ? ( {activeOrderId ? (
<p className="text-xs text-[#0F6E56]"> <span className="text-xs text-muted-foreground">
{t("sessionActive")} · {t("order")}{" "} #
{activeOrderDisplayNumber {activeOrderDisplayNumber
? String(activeOrderDisplayNumber) ? String(activeOrderDisplayNumber)
: formatOrderNumber({ id: activeOrderId })} : formatOrderNumber({ id: activeOrderId })}
</p> </span>
) : null} ) : null}
<div className="flex-1" />
{/* Queue bar */}
{cafeId ? (
<PosQueueBar cafeId={cafeId} branchId={orderBranchId} />
) : null}
</div>
{/* Reservation banner */}
{reservationId && reservationGuest ? ( {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 })} {t("reservationBanner", { name: reservationGuest })}
</div> </div>
) : null} ) : 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"> <div className="relative shrink-0">
<Search <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 aria-hidden
/> />
<Input <Input
@@ -797,14 +986,14 @@ export function PosScreen() {
onChange={(e) => setItemSearch(e.target.value)} onChange={(e) => setItemSearch(e.target.value)}
placeholder={t("searchItemsPlaceholder")} placeholder={t("searchItemsPlaceholder")}
aria-label={t("searchItems")} aria-label={t("searchItems")}
className="h-9 ps-9 pe-9" className="h-10 ps-9 pe-9"
/> />
{itemSearch ? ( {itemSearch ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" 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("")} onClick={() => setItemSearch("")}
aria-label={tCommon("cancel")} aria-label={tCommon("cancel")}
> >
@@ -812,25 +1001,30 @@ export function PosScreen() {
</Button> </Button>
) : null} ) : null}
</div> </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 <Button
size="sm" size="sm"
variant={selectedCategory === "all" ? "default" : "outline"} variant={selectedCategory === "all" ? "default" : "outline"}
className="shrink-0"
onClick={() => setSelectedCategory("all")} onClick={() => setSelectedCategory("all")}
> >
{t("allCategories")} {t("allCategories")}
</Button> </Button>
{loadingCategories {loadingCategories
? Array.from({ length: 3 }).map((_, i) => ( ? Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-20" /> <Skeleton key={i} className="h-9 w-20 shrink-0" />
)) ))
: categories?.map((c) => ( : categories?.map((c) => (
<Button <Button
key={c.id} key={c.id}
size="sm" size="sm"
variant={selectedCategory === c.id ? "default" : "outline"} variant={
selectedCategory === c.id ? "default" : "outline"
}
className="shrink-0 gap-1.5"
onClick={() => setSelectedCategory(c.id)} onClick={() => setSelectedCategory(c.id)}
className="gap-1.5"
> >
<CategoryVisual <CategoryVisual
icon={c.icon} icon={c.icon}
@@ -844,63 +1038,123 @@ export function PosScreen() {
))} ))}
</div> </div>
{/* Product grid — bigger cards with image area */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain"> <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 {showItemsLoading
? Array.from({ length: 8 }).map((_, i) => ( ? 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 : 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")} {t("searchNoResults")}
</p> </p>
) )
: filteredItems.map((item) => ( : filteredItems.map((item) => {
const qty = items.find(
(ci) => ci.menuItem.id === item.id
)?.quantity;
return (
<button <button
key={item.id} key={item.id}
type="button" type="button"
onClick={() => handleSelectMenuItem(item)} onClick={() => addItem(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" 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 <MenuItemLabels
item={item} item={item}
lines={1} lines={2}
primaryClassName="text-sm" primaryClassName="text-sm font-medium leading-snug"
secondaryClassName="text-[10px]" 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)} {formatCurrency(item.price, numberLocale)}
</p> </p>
</div> </div>
<PosMenuThumbnail
imageUrl={item.imageUrl}
kind={itemVisualKind(item)}
hasVideo={!!item.videoUrl}
/>
</button> </button>
))} );
})}
</div> </div>
</div> </div>
</div> </div>
{/* Cart sidebar */} {/* ── 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"> <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-1.5 p-3 pb-2"> <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 justify-between gap-2">
<CardTitle className="text-base">{t("takeOrder")}</CardTitle> <div className="flex items-center gap-2">
{tableId ? ( <ShoppingCart className="size-4 text-muted-foreground" />
<span className="shrink-0 rounded-md border-2 border-primary bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"> <CardTitle className="text-sm">{t("takeOrder")}</CardTitle>
{t("table")}{" "}
{tables?.find((tbl) => tbl.id === tableId)?.number ?? "—"}
</span>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("selectTableBoard")}
</span>
)}
</div> </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 ? ( {cafeId ? (
<PosCustomerPicker <PosCustomerPicker
compact compact
@@ -914,6 +1168,8 @@ export function PosScreen() {
onClearCustomer={clearCustomer} onClearCustomer={clearCustomer}
/> />
) : null} ) : null}
{/* Transfer table (for table orders with active session) */}
{activeOrderId && tableId ? ( {activeOrderId && tableId ? (
<Button <Button
type="button" type="button"
@@ -925,18 +1181,34 @@ export function PosScreen() {
{t("transferTable")} {t("transferTable")}
</Button> </Button>
) : null} ) : 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> </CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-3 pt-0"> <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"> <div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain pe-0.5">
{items.length === 0 ? ( {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) => ( items.map((line) => (
<div <div
key={line.orderItemId ?? line.menuItem.id} key={line.orderItemId ?? line.menuItem.id}
className={cn( 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" line.isVoided && "opacity-60"
)} )}
> >
@@ -946,7 +1218,8 @@ export function PosScreen() {
lines={1} lines={1}
primaryClassName={cn( primaryClassName={cn(
"text-xs font-medium", "text-xs font-medium",
line.isVoided && "line-through text-muted-foreground" line.isVoided &&
"line-through text-muted-foreground"
)} )}
secondaryClassName="text-[10px]" secondaryClassName="text-[10px]"
/> />
@@ -961,6 +1234,7 @@ export function PosScreen() {
)} )}
</p> </p>
</div> </div>
<div <div
className="flex shrink-0 items-center gap-0.5" className="flex shrink-0 items-center gap-0.5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -972,7 +1246,7 @@ export function PosScreen() {
activeOrderId ? ( activeOrderId ? (
<button <button
type="button" type="button"
className="text-[10px] text-destructive hover:underline" className="cursor-pointer text-[10px] text-destructive hover:underline"
onClick={() => handleVoidItem(line.orderItemId!)} onClick={() => handleVoidItem(line.orderItemId!)}
aria-label={t("voidItem")} aria-label={t("voidItem")}
> >
@@ -984,33 +1258,33 @@ export function PosScreen() {
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
className="h-7 w-7" className="h-8 w-8"
onClick={() => onClick={() =>
updateQty(line.menuItem.id, line.quantity - 1) updateQty(line.menuItem.id, line.quantity - 1)
} }
> >
<Minus className="h-3 w-3" /> <Minus className="h-3.5 w-3.5" />
</Button> </Button>
<span className="w-5 text-center text-xs"> <span className="w-6 text-center text-xs font-medium">
{formatNumber(line.quantity, numberLocale)} {formatNumber(line.quantity, numberLocale)}
</span> </span>
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
className="h-7 w-7" className="h-8 w-8"
onClick={() => onClick={() =>
updateQty(line.menuItem.id, line.quantity + 1) updateQty(line.menuItem.id, line.quantity + 1)
} }
> >
<Plus className="h-3 w-3" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-7 w-7 text-destructive" className="h-8 w-8 text-destructive"
onClick={() => removeItem(line.menuItem.id)} onClick={() => removeItem(line.menuItem.id)}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
) : null} ) : null}
@@ -1020,9 +1294,15 @@ export function PosScreen() {
)} )}
</div> </div>
{/* Totals + actions */}
<div className="shrink-0 space-y-1.5 border-t border-border bg-card pt-2"> <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"> <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 <Input
id="pos-coupon" id="pos-coupon"
value={couponCode} value={couponCode}
@@ -1051,7 +1331,10 @@ export function PosScreen() {
variant="outline" variant="outline"
onClick={() => { onClick={() => {
clearCoupon(); clearCoupon();
setCouponMessage({ type: "success", text: t("couponRemoved") }); setCouponMessage({
type: "success",
text: t("couponRemoved"),
});
}} }}
> >
{t("removeCoupon")} {t("removeCoupon")}
@@ -1059,29 +1342,38 @@ export function PosScreen() {
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
disabled={!couponCode.trim() || items.length === 0 || validateCoupon.isPending} disabled={
!couponCode.trim() ||
items.length === 0 ||
validateCoupon.isPending
}
onClick={() => validateCoupon.mutate()} onClick={() => validateCoupon.mutate()}
> >
{t("applyCoupon")} {t("applyCoupon")}
</Button> </Button>
)} )}
</div> </div>
{couponMessage ? ( {couponMessage ? (
<p <p
className={cn( className={cn(
"text-center text-sm", "text-center text-sm",
couponMessage.type === "success" ? "text-[#0F6E56]" : "text-[#A32D2D]" couponMessage.type === "success"
? "text-[#0F6E56]"
: "text-[#A32D2D]"
)} )}
> >
{couponMessage.text} {couponMessage.text}
</p> </p>
) : null} ) : null}
{appliedCoupon ? ( {appliedCoupon ? (
<div className="flex justify-between text-sm text-[#BA7517]"> <div className="flex justify-between text-sm text-[#BA7517]">
<span>{t("couponActive", { code: appliedCoupon.code })}</span> <span>{t("couponActive", { code: appliedCoupon.code })}</span>
<span>-{formatCurrency(discount, numberLocale)}</span> <span>-{formatCurrency(discount, numberLocale)}</span>
</div> </div>
) : null} ) : null}
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>{t("subtotal")}</span> <span>{t("subtotal")}</span>
<span>{formatCurrency(sub, numberLocale)}</span> <span>{formatCurrency(sub, numberLocale)}</span>
@@ -1100,17 +1392,28 @@ export function PosScreen() {
<span>{t("total")}</span> <span>{t("total")}</span>
<span>{formatCurrency(total, numberLocale)}</span> <span>{formatCurrency(total, numberLocale)}</span>
</div> </div>
{!isOnline ? ( {!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")} {t("offlineQueueNotice")}
</p> </p>
) : null} ) : null}
{orderMessage ? ( {orderMessage ? (
<p className="text-center text-xs text-primary">{orderMessage}</p> <p className="text-center text-xs text-primary">
{orderMessage}
</p>
) : null} ) : 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} ) : null}
{items.some((line) => !line.isVoided) ? ( {items.some((line) => !line.isVoided) ? (
<Button <Button
type="button" type="button"
@@ -1122,6 +1425,7 @@ export function PosScreen() {
{t("kitchenSlip")} {t("kitchenSlip")}
</Button> </Button>
) : null} ) : null}
<div className="flex flex-col gap-2 pt-0.5"> <div className="flex flex-col gap-2 pt-0.5">
<Button <Button
size="sm" size="sm"
@@ -1129,7 +1433,9 @@ export function PosScreen() {
disabled={!canSubmitOrder || isOrderBusy} disabled={!canSubmitOrder || isOrderBusy}
onClick={() => submitOrderAndPay.mutate()} onClick={() => submitOrderAndPay.mutate()}
> >
{submitOrderAndPay.isPending ? "..." : t("submitOrderAndPay")} {submitOrderAndPay.isPending
? "..."
: t("submitOrderAndPay")}
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -1144,11 +1450,7 @@ export function PosScreen() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={handleBackToTypePicker}
clearSession();
syncUrl(null, null);
setOrderMessage(null);
}}
> >
{t("clearCart")} {t("clearCart")}
</Button> </Button>
@@ -1161,6 +1463,42 @@ export function PosScreen() {
</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 ? ( {kitchenSlip ? (
<PosSlipModal <PosSlipModal
variant="kitchen" variant="kitchen"
@@ -1173,13 +1511,16 @@ export function PosScreen() {
/> />
) : null} ) : null}
{/* ── Transfer table modal ──────────────────────────────────────────── */}
{showTransferPicker ? ( {showTransferPicker ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <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"> <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> <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"> <div className="mb-4 flex max-h-48 flex-wrap gap-2 overflow-y-auto">
{freeTransferTables.length === 0 ? ( {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) => ( freeTransferTables.map((tbl) => (
<Button <Button
@@ -16,6 +16,8 @@ export interface AppliedCoupon {
discountAmount: number; discountAmount: number;
} }
export type OrderType = "table" | "counter" | "takeaway";
interface CartState { interface CartState {
items: CartItem[]; items: CartItem[];
syncedQtyByMenuId: Record<string, number>; syncedQtyByMenuId: Record<string, number>;
@@ -27,6 +29,7 @@ interface CartState {
customerId: string | null; customerId: string | null;
guestName: string; guestName: string;
guestPhone: string; guestPhone: string;
orderType: OrderType | null;
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[]; getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
addItem: (item: MenuItem) => void; addItem: (item: MenuItem) => void;
removeItem: (menuItemId: string) => void; removeItem: (menuItemId: string) => void;
@@ -35,6 +38,7 @@ interface CartState {
setAppliedCoupon: (coupon: AppliedCoupon | null) => void; setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
clearCoupon: () => void; clearCoupon: () => void;
setTableId: (tableId: string | null) => void; setTableId: (tableId: string | null) => void;
setOrderType: (type: OrderType | null) => void;
setActiveOrderId: (orderId: string | null) => void; setActiveOrderId: (orderId: string | null) => void;
setGuestName: (name: string) => void; setGuestName: (name: string) => void;
setGuestPhone: (phone: string) => void; setGuestPhone: (phone: string) => void;
@@ -77,6 +81,7 @@ export const useCartStore = create<CartState>((set, get) => ({
customerId: null, customerId: null,
guestName: "", guestName: "",
guestPhone: "", guestPhone: "",
orderType: null,
getPendingLines: () => { getPendingLines: () => {
const { items, syncedQtyByMenuId } = get(); const { items, syncedQtyByMenuId } = get();
@@ -134,6 +139,7 @@ export const useCartStore = create<CartState>((set, get) => ({
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }), setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
clearCoupon: () => set(clearCouponState), clearCoupon: () => set(clearCouponState),
setTableId: (tableId) => set({ tableId }), setTableId: (tableId) => set({ tableId }),
setOrderType: (orderType) => set({ orderType }),
setActiveOrderId: (activeOrderId) => set({ activeOrderId, activeOrderDisplayNumber: null }), setActiveOrderId: (activeOrderId) => set({ activeOrderId, activeOrderDisplayNumber: null }),
setGuestName: (guestName) => setGuestName: (guestName) =>
set((s) => ({ set((s) => ({
@@ -197,6 +203,7 @@ export const useCartStore = create<CartState>((set, get) => ({
customerId: null, customerId: null,
guestName: "", guestName: "",
guestPhone: "", guestPhone: "",
orderType: null,
...clearCouponState, ...clearCouponState,
}), }),