@classytic/pos-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +38 -0
  2. package/dist/components/CartSidebar.js +222 -0
  3. package/dist/components/PosTopBar.js +156 -0
  4. package/dist/components/PrinterSettingsDialog.js +163 -0
  5. package/dist/components/ReceiptReprintDialog.js +182 -0
  6. package/dist/dashboard/components/CustomerLookupDialog.js +120 -0
  7. package/dist/dashboard/components/CustomerQuickAddDialog.js +101 -0
  8. package/dist/dashboard/components/ManagerAuthDialog.js +77 -0
  9. package/dist/dashboard/components/ProductCard.js +98 -0
  10. package/dist/dashboard/components/ProductsPanel.js +119 -0
  11. package/dist/dashboard/components/SplitPaymentPanel.js +131 -0
  12. package/dist/dashboard/components/VariantSelectorDialog.js +150 -0
  13. package/dist/dashboard/components/cart/AddChargeDialog.js +99 -0
  14. package/dist/dashboard/components/cart/CartItems.js +103 -0
  15. package/dist/dashboard/components/cart/CartSummary.js +73 -0
  16. package/dist/dashboard/components/cart/CustomerSection.js +127 -0
  17. package/dist/dashboard/components/cart/DiscountSection.js +50 -0
  18. package/dist/dashboard/components/cart/PointsRedemptionSection.js +58 -0
  19. package/dist/hardware/context.d.ts +15 -0
  20. package/dist/hardware/context.js +33 -0
  21. package/dist/hardware/index.d.ts +7 -0
  22. package/dist/hardware/index.js +7 -0
  23. package/dist/hardware/ports.d.ts +116 -0
  24. package/dist/hardware/tauri-adapter.d.ts +7 -0
  25. package/dist/hardware/tauri-adapter.js +77 -0
  26. package/dist/hardware/use-printer-availability.d.ts +12 -0
  27. package/dist/hardware/use-printer-availability.js +50 -0
  28. package/dist/hardware/use-printer-config.d.ts +16 -0
  29. package/dist/hardware/use-printer-config.js +62 -0
  30. package/dist/hardware/web-adapter.d.ts +15 -0
  31. package/dist/hardware/web-adapter.js +80 -0
  32. package/dist/hooks/useManagerAuth.js +83 -0
  33. package/dist/hooks/usePosCart.js +142 -0
  34. package/dist/hooks/usePosCustomer.js +192 -0
  35. package/dist/hooks/usePosMultiOrder.js +48 -0
  36. package/dist/hooks/usePosPayment.js +194 -0
  37. package/dist/lib/cn.js +12 -0
  38. package/dist/lib/loyalty.js +30 -0
  39. package/dist/lib/money.js +41 -0
  40. package/dist/node_modules/react-hook-form/dist/index.esm.js +1661 -0
  41. package/dist/runtime/auth-port.d.ts +46 -0
  42. package/dist/runtime/auth-port.js +21 -0
  43. package/dist/runtime/branch-port.d.ts +38 -0
  44. package/dist/runtime/branch-port.js +26 -0
  45. package/dist/runtime/config.d.ts +24 -0
  46. package/dist/runtime/config.js +21 -0
  47. package/dist/runtime/index.d.ts +4 -0
  48. package/dist/runtime/index.js +5 -0
  49. package/dist/screens/OrderHistoryDrawer.js +245 -0
  50. package/dist/screens/ParkedOrdersDrawer.js +128 -0
  51. package/dist/screens/PaymentScreen.js +397 -0
  52. package/dist/screens/ProductScreen.js +322 -0
  53. package/dist/screens/ReceiptScreen.js +288 -0
  54. package/dist/screens/ShiftCloseScreen.js +365 -0
  55. package/dist/screens/ShiftOpenScreen.js +176 -0
  56. package/dist/shell/index.d.ts +8 -0
  57. package/dist/shell/index.js +5 -0
  58. package/dist/shell/pos-shell.d.ts +23 -0
  59. package/dist/shell/pos-shell.js +132 -0
  60. package/dist/state/pos-context.js +33 -0
  61. package/dist/state/pos-state.js +70 -0
  62. package/dist/utils/customer-display.js +35 -0
  63. package/dist/utils/pos-helpers.js +242 -0
  64. package/package.json +92 -0
  65. package/styles.css +19 -0
@@ -0,0 +1,80 @@
1
+ import { createTauriHardware, isTauri } from "./tauri-adapter.js";
2
+ import { printDocument } from "@classytic/fluid/client/core";
3
+
4
+ //#region src/hardware/web-adapter.ts
5
+ /**
6
+ * Web (browser) hardware adapter — the default for the dashboard POS.
7
+ *
8
+ * Printer → `window.print()` with print isolation (existing behavior).
9
+ * Future: POST to `@classytic/pos-agent` on `localhost:9100`
10
+ * for real ESC/POS thermal printing.
11
+ * Drawer → not available; logs a warning. Real drawer needs the agent.
12
+ * Scanner → handled at the consumer layer via fluid's `useBarcodeScan`
13
+ * hook (capture-phase HID burst detector with prefix routing).
14
+ * The port adapter here is a NO-OP stub kept for parity with
15
+ * the Tauri / RN shells that wire native scanners.
16
+ * Terminal → not available; charges go through bank standalone terminals
17
+ * with cashier-typed reference (out-of-PCI-scope by design).
18
+ *
19
+ * The Tauri / React Native shells will replace this with native adapters
20
+ * implementing the same `PosHardware` shape. Same React tree, different
21
+ * hardware backend.
22
+ */
23
+ /** DOM id of the rendered receipt — used by `printDocument` for print isolation. */
24
+ const RECEIPT_DOM_ID = "pos-receipt";
25
+ var WebReceiptPrinter = class {
26
+ async print() {
27
+ if (typeof window === "undefined") throw new Error("WebReceiptPrinter requires a browser window");
28
+ printDocument(RECEIPT_DOM_ID);
29
+ }
30
+ async isAvailable() {
31
+ return typeof window !== "undefined";
32
+ }
33
+ };
34
+ var WebCashDrawer = class {
35
+ async open() {
36
+ console.warn("[pos/hardware] Cash drawer kick is unavailable in the browser adapter. Install @classytic/pos-agent or use the Tauri shell.");
37
+ }
38
+ async isAvailable() {
39
+ return false;
40
+ }
41
+ };
42
+ var WebBarcodeScannerStub = class {
43
+ onScan() {
44
+ return () => {};
45
+ }
46
+ async isAvailable() {
47
+ return typeof window !== "undefined";
48
+ }
49
+ };
50
+ var WebPaymentTerminal = class {
51
+ async charge() {
52
+ throw new Error("[pos/hardware] EMV payment terminal not available in browser adapter — capture cashier-typed reference instead.");
53
+ }
54
+ async refund() {
55
+ throw new Error("[pos/hardware] EMV payment terminal not available in browser adapter.");
56
+ }
57
+ async cancel() {}
58
+ async isAvailable() {
59
+ return false;
60
+ }
61
+ };
62
+ /**
63
+ * Auto-detects the host: returns native Tauri adapters when the React
64
+ * tree is running inside the `cashier-tauri` webview, otherwise returns
65
+ * the browser stubs. Consumers stay agnostic — `<HardwareProvider>` in
66
+ * `pos-shell.tsx` calls this once at mount and the same React tree
67
+ * works on both surfaces with no per-component branching.
68
+ */
69
+ function createWebHardware() {
70
+ if (isTauri()) return createTauriHardware();
71
+ return {
72
+ printer: new WebReceiptPrinter(),
73
+ drawer: new WebCashDrawer(),
74
+ scanner: new WebBarcodeScannerStub(),
75
+ terminal: new WebPaymentTerminal()
76
+ };
77
+ }
78
+
79
+ //#endregion
80
+ export { RECEIPT_DOM_ID, createWebHardware };
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { usePosAuth } from "../runtime/auth-port.js";
4
+ import { useCallback, useState } from "react";
5
+ import { useMutation } from "@tanstack/react-query";
6
+
7
+ //#region src/hooks/useManagerAuth.ts
8
+ /** Roles allowed to authorize discounts */
9
+ const DISCOUNT_ALLOWED_ROLES = [
10
+ "superadmin",
11
+ "admin",
12
+ "branch_manager"
13
+ ];
14
+ /**
15
+ * Hook for temporary manager authentication in POS.
16
+ * Uses Better Auth's signIn.email() directly — same as the sign-in page.
17
+ * Does NOT create a persistent session (the token is discarded).
18
+ */
19
+ function useManagerAuth(options = {}) {
20
+ const { allowedRoles = DISCOUNT_ALLOWED_ROLES, sessionDuration = 30 } = options;
21
+ const { signInManager } = usePosAuth();
22
+ const [authState, setAuthState] = useState({
23
+ isAuthorized: false,
24
+ authorizedBy: null,
25
+ authorizedAt: null,
26
+ authorizedAtMono: null
27
+ });
28
+ const isSessionValid = useCallback(() => {
29
+ if (!authState.isAuthorized || authState.authorizedAtMono === null) return false;
30
+ return performance.now() - authState.authorizedAtMono < sessionDuration * 60 * 1e3;
31
+ }, [
32
+ authState.isAuthorized,
33
+ authState.authorizedAtMono,
34
+ sessionDuration
35
+ ]);
36
+ const verifyMutation = useMutation({
37
+ mutationFn: async (credentials) => {
38
+ const { user: sessionUser } = await signInManager({
39
+ email: credentials.email,
40
+ password: credentials.password
41
+ });
42
+ const user = sessionUser;
43
+ if (!user) throw new Error("Authentication failed");
44
+ if (!(Array.isArray(user.role) ? user.role : []).some((role) => allowedRoles.includes(role))) throw new Error("You don't have permission to authorize discounts");
45
+ return user;
46
+ },
47
+ onSuccess: (user) => {
48
+ setAuthState({
49
+ isAuthorized: true,
50
+ authorizedBy: user,
51
+ authorizedAt: /* @__PURE__ */ new Date(),
52
+ authorizedAtMono: performance.now()
53
+ });
54
+ }
55
+ });
56
+ const clearAuth = useCallback(() => {
57
+ setAuthState({
58
+ isAuthorized: false,
59
+ authorizedBy: null,
60
+ authorizedAt: null,
61
+ authorizedAtMono: null
62
+ });
63
+ }, []);
64
+ const authorize = useCallback(async (email, password) => {
65
+ return verifyMutation.mutateAsync({
66
+ email,
67
+ password
68
+ });
69
+ }, [verifyMutation]);
70
+ return {
71
+ isAuthorized: authState.isAuthorized && isSessionValid(),
72
+ authorizedBy: authState.authorizedBy,
73
+ authorizedAt: authState.authorizedAt,
74
+ authorize,
75
+ clearAuth,
76
+ isPending: verifyMutation.isPending,
77
+ error: verifyMutation.error?.message || null,
78
+ reset: verifyMutation.reset
79
+ };
80
+ }
81
+
82
+ //#endregion
83
+ export { DISCOUNT_ALLOWED_ROLES, useManagerAuth };
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useLocalStorage } from "@classytic/fluid/client/hooks";
5
+ import { calculateVariantPrice, formatVariantLabel, getPosPrice, getPosProductImage, getVariantStock } from "@classytic/commerce-sdk/sales";
6
+ import { toast } from "sonner";
7
+
8
+ //#region src/hooks/usePosCart.ts
9
+ const CART_TTL = 1440 * 60 * 1e3;
10
+ function usePosCart(branchId) {
11
+ const suffix = branchId ?? "default";
12
+ const [cart, setCart, clearStoredCart] = useLocalStorage(`pos:cart:${suffix}`, [], CART_TTL);
13
+ const [discountInput, setDiscountInput, clearDiscountInput] = useLocalStorage(`pos:discount:${suffix}`, "", CART_TTL);
14
+ const [membershipCardId, setMembershipCardId, clearMembershipCardId] = useLocalStorage(`pos:membership:${suffix}`, "", CART_TTL);
15
+ const [pointsToRedeemInput, setPointsToRedeemInput, clearPointsToRedeem] = useLocalStorage(`pos:points:${suffix}`, "", CART_TTL);
16
+ return {
17
+ cart,
18
+ membershipCardId,
19
+ pointsToRedeemInput,
20
+ discountInput,
21
+ setDiscountInput,
22
+ setMembershipCardId,
23
+ setPointsToRedeemInput,
24
+ addToCart: useCallback((product, variantSku) => {
25
+ const variant = variantSku ? product.variants?.find((v) => v.sku === variantSku) : null;
26
+ if (variantSku) {
27
+ if (getVariantStock(product, variantSku) <= 0) {
28
+ toast.error("Out of stock");
29
+ return;
30
+ }
31
+ } else if (!product.branchStock?.inStock) {
32
+ toast.error("Out of stock");
33
+ return;
34
+ }
35
+ const basePrice = getPosPrice(product);
36
+ const unitPrice = variant ? calculateVariantPrice(basePrice, variant.priceModifier ?? 0) : basePrice;
37
+ setCart((prev) => {
38
+ const existingIndex = prev.findIndex((item) => item.productId === product._id && item.variantSku === variantSku);
39
+ if (existingIndex >= 0) {
40
+ const next = [...prev];
41
+ const existing = next[existingIndex];
42
+ const quantity = existing.quantity + 1;
43
+ next[existingIndex] = {
44
+ ...existing,
45
+ quantity,
46
+ lineTotal: quantity * unitPrice
47
+ };
48
+ return next;
49
+ }
50
+ const newItem = {
51
+ productId: product._id,
52
+ productName: product.name,
53
+ variantSku,
54
+ variantLabel: variant?.attributes ? formatVariantLabel(variant.attributes) : void 0,
55
+ quantity: 1,
56
+ unitPrice,
57
+ lineTotal: unitPrice,
58
+ image: getPosProductImage(product)
59
+ };
60
+ return [...prev, newItem];
61
+ });
62
+ toast.success("Added to cart");
63
+ }, []),
64
+ addCharge: useCallback((label, amountBdt) => {
65
+ const trimmedLabel = label.trim();
66
+ if (!trimmedLabel) {
67
+ toast.error("Label is required");
68
+ return;
69
+ }
70
+ if (!Number.isFinite(amountBdt) || amountBdt < 0) {
71
+ toast.error("Amount must be 0 or greater");
72
+ return;
73
+ }
74
+ const code = trimmedLabel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "misc";
75
+ setCart((prev) => {
76
+ const existingIndex = prev.findIndex((item) => item.kind === "service-fee" && item.serviceCode === code);
77
+ if (existingIndex >= 0) {
78
+ const next = [...prev];
79
+ const existing = next[existingIndex];
80
+ const quantity = existing.quantity + 1;
81
+ next[existingIndex] = {
82
+ ...existing,
83
+ quantity,
84
+ lineTotal: quantity * existing.unitPrice
85
+ };
86
+ return next;
87
+ }
88
+ const newItem = {
89
+ kind: "service-fee",
90
+ serviceCode: code,
91
+ productId: `service:${code}`,
92
+ productName: trimmedLabel,
93
+ quantity: 1,
94
+ unitPrice: amountBdt,
95
+ lineTotal: amountBdt
96
+ };
97
+ return [...prev, newItem];
98
+ });
99
+ toast.success(`Added ${trimmedLabel}`);
100
+ }, [setCart]),
101
+ updateQuantity: useCallback((index, delta) => {
102
+ setCart((prev) => {
103
+ const next = [...prev];
104
+ const item = next[index];
105
+ if (!item) return prev;
106
+ const quantity = Math.max(1, item.quantity + delta);
107
+ next[index] = {
108
+ ...item,
109
+ quantity,
110
+ lineTotal: quantity * item.unitPrice
111
+ };
112
+ return next;
113
+ });
114
+ }, []),
115
+ removeItem: useCallback((index) => {
116
+ setCart((prev) => prev.filter((_, i) => i !== index));
117
+ }, []),
118
+ clearCart: useCallback(() => {
119
+ if (window.confirm("Clear all items from cart?")) {
120
+ setCart([]);
121
+ clearPointsToRedeem();
122
+ }
123
+ }, [setCart, clearPointsToRedeem]),
124
+ resetCart: useCallback(() => {
125
+ clearStoredCart();
126
+ clearDiscountInput();
127
+ clearMembershipCardId();
128
+ clearPointsToRedeem();
129
+ }, [
130
+ clearStoredCart,
131
+ clearDiscountInput,
132
+ clearMembershipCardId,
133
+ clearPointsToRedeem
134
+ ]),
135
+ replaceCart: useCallback((items) => {
136
+ setCart(items);
137
+ }, [setCart])
138
+ };
139
+ }
140
+
141
+ //#endregion
142
+ export { usePosCart };
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { usePosAuth } from "../runtime/auth-port.js";
4
+ import { isPhoneSearch } from "../utils/pos-helpers.js";
5
+ import { customerDisplayName, customerDisplayPhone } from "../utils/customer-display.js";
6
+ import { useCallback, useMemo, useState } from "react";
7
+ import { useLocalStorage } from "@classytic/fluid/client/hooks";
8
+ import { useCustomers, useLookupMemberByCard } from "@classytic/commerce-sdk/sales";
9
+ import { toast } from "sonner";
10
+ import { buildFilterParams, getApiParams } from "@classytic/fluid";
11
+
12
+ //#region src/hooks/usePosCustomer.ts
13
+ const CUSTOMER_TTL = 1440 * 60 * 1e3;
14
+ function usePosCustomer(token) {
15
+ const authToken = usePosAuth().getToken();
16
+ const resolvedToken = token || authToken || "";
17
+ const [selectedCustomer, setSelectedCustomer] = useLocalStorage("pos:customer:selected", null, CUSTOMER_TTL);
18
+ const [customerName, setCustomerNameState] = useLocalStorage("pos:customer:name", "", CUSTOMER_TTL);
19
+ const [customerPhone, setCustomerPhoneState] = useLocalStorage("pos:customer:phone", "", CUSTOMER_TTL);
20
+ const [membershipCardId, setMembershipCardIdState] = useLocalStorage("pos:customer:membershipCard", "", CUSTOMER_TTL);
21
+ const [membershipLookupStatus, setMembershipLookupStatus] = useState("idle");
22
+ const [customerSearch, setCustomerSearch] = useState("");
23
+ const [searchApplied, setSearchApplied] = useState("");
24
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
25
+ const [lookupDialogOpen, setLookupDialogOpen] = useState(false);
26
+ const { lookup: lookupMemberByCard, isLooking: membershipLookupPending } = useLookupMemberByCard();
27
+ const normalizedSearch = searchApplied.trim();
28
+ const phoneCheck = isPhoneSearch(normalizedSearch);
29
+ const { items: customerResultsRaw, isLoading: customersLoading, isFetching: customersFetching, refetch: refetchCustomers } = useCustomers(resolvedToken, useMemo(() => {
30
+ const params = new URLSearchParams();
31
+ params.set("limit", "5");
32
+ if (!normalizedSearch) return getApiParams(params);
33
+ if (phoneCheck.exact) {
34
+ const filterParams = buildFilterParams({ phone: normalizedSearch }, { phone: {
35
+ paramName: "contact.phone",
36
+ type: "string",
37
+ defaultValue: ""
38
+ } });
39
+ for (const [key, value] of filterParams) params.set(key, value);
40
+ } else if (phoneCheck.likely) {
41
+ const phoneParams = buildFilterParams({ phone: normalizedSearch }, { phone: {
42
+ paramName: "contact.phone[contains]",
43
+ type: "string",
44
+ defaultValue: ""
45
+ } });
46
+ for (const [key, value] of phoneParams) params.set(key, value);
47
+ } else {
48
+ const nameParams = buildFilterParams({ name: normalizedSearch }, { name: {
49
+ paramName: "name.given[contains]",
50
+ type: "string",
51
+ defaultValue: ""
52
+ } });
53
+ for (const [key, value] of nameParams) params.set(key, value);
54
+ }
55
+ return getApiParams(params);
56
+ }, [
57
+ normalizedSearch,
58
+ phoneCheck.exact,
59
+ phoneCheck.likely
60
+ ]), {
61
+ enabled: !!resolvedToken && normalizedSearch.length >= 2,
62
+ refetchOnWindowFocus: false
63
+ });
64
+ const customerResults = useMemo(() => normalizedSearch.length >= 2 ? customerResultsRaw || [] : [], [customerResultsRaw, normalizedSearch.length]);
65
+ const setCustomerName = useCallback((value) => {
66
+ setCustomerNameState(value);
67
+ if (selectedCustomer) setSelectedCustomer(null);
68
+ }, [selectedCustomer]);
69
+ const setCustomerPhone = useCallback((value) => {
70
+ setCustomerPhoneState(value);
71
+ if (selectedCustomer) setSelectedCustomer(null);
72
+ }, [selectedCustomer]);
73
+ const setMembershipCardId = useCallback((value) => {
74
+ setMembershipCardIdState(value);
75
+ setMembershipLookupStatus("idle");
76
+ }, []);
77
+ const selectCustomer = useCallback((customer) => {
78
+ setSelectedCustomer(customer);
79
+ setCustomerNameState(customerDisplayName(customer));
80
+ setCustomerPhoneState(customerDisplayPhone(customer) ?? "");
81
+ setMembershipCardIdState(customer.membership?.cardId || "");
82
+ setMembershipLookupStatus(customer.membership?.cardId ? "found" : "idle");
83
+ setCustomerSearch("");
84
+ setSearchApplied("");
85
+ setLookupDialogOpen(false);
86
+ }, []);
87
+ const clearCustomer = useCallback(() => {
88
+ setSelectedCustomer(null);
89
+ setCustomerNameState("");
90
+ setCustomerPhoneState("");
91
+ setMembershipCardIdState("");
92
+ setMembershipLookupStatus("idle");
93
+ setCustomerSearch("");
94
+ setSearchApplied("");
95
+ }, []);
96
+ const handleCustomerCreated = useCallback((customer) => {
97
+ setSelectedCustomer(customer);
98
+ setCustomerNameState(customerDisplayName(customer));
99
+ setCustomerPhoneState(customerDisplayPhone(customer) ?? "");
100
+ setMembershipCardIdState(customer.membership?.cardId || "");
101
+ setMembershipLookupStatus(customer.membership?.cardId ? "found" : "idle");
102
+ setCustomerSearch("");
103
+ setSearchApplied("");
104
+ setLookupDialogOpen(false);
105
+ }, []);
106
+ const triggerSearch = useCallback(() => {
107
+ const trimmed = customerSearch.trim();
108
+ if (trimmed.length < 2) {
109
+ toast.error("Enter at least 2 characters to search");
110
+ return;
111
+ }
112
+ setSearchApplied(trimmed);
113
+ if (trimmed === searchApplied) refetchCustomers();
114
+ }, [
115
+ customerSearch,
116
+ searchApplied,
117
+ refetchCustomers
118
+ ]);
119
+ /**
120
+ * POS card-scan path. Hardware barcode scanners type the card id and emit
121
+ * Enter, so this is invoked from the input's onKeyDown=Enter or the
122
+ * adjacent Lookup button. Calls the dedicated
123
+ * `GET /loyalty/members/by-card/:cardId` endpoint and reuses
124
+ * `selectCustomer()` so the cart-attach behavior is identical to picking
125
+ * a customer from the search dialog.
126
+ */
127
+ const triggerMembershipLookup = useCallback(async () => {
128
+ const trimmed = membershipCardId.trim();
129
+ if (!trimmed) {
130
+ setMembershipLookupStatus("idle");
131
+ return;
132
+ }
133
+ setMembershipLookupStatus("searching");
134
+ try {
135
+ const customer = (await lookupMemberByCard(trimmed))?.customer;
136
+ if (!customer) {
137
+ setMembershipLookupStatus("not_found");
138
+ setMembershipCardIdState("");
139
+ toast.error("Card not found");
140
+ return;
141
+ }
142
+ selectCustomer(customer);
143
+ setMembershipLookupStatus("found");
144
+ } catch (err) {
145
+ setMembershipLookupStatus("not_found");
146
+ setMembershipCardIdState("");
147
+ const message = err instanceof Error ? err.message : "Card not found";
148
+ if (message.includes("404") || /not found/i.test(message)) return;
149
+ toast.error(message);
150
+ }
151
+ }, [
152
+ membershipCardId,
153
+ lookupMemberByCard,
154
+ selectCustomer
155
+ ]);
156
+ const resetCustomer = useCallback(() => {
157
+ setSelectedCustomer(null);
158
+ setCustomerNameState("");
159
+ setCustomerPhoneState("");
160
+ setMembershipCardIdState("");
161
+ setMembershipLookupStatus("idle");
162
+ setCustomerSearch("");
163
+ setSearchApplied("");
164
+ }, []);
165
+ return {
166
+ selectedCustomer,
167
+ customerName,
168
+ customerPhone,
169
+ membershipCardId,
170
+ membershipLookupStatus: membershipLookupPending ? "searching" : membershipLookupStatus,
171
+ customerSearch,
172
+ customerResults,
173
+ isSearching: customersLoading || customersFetching,
174
+ createDialogOpen,
175
+ lookupDialogOpen,
176
+ setCustomerName,
177
+ setCustomerPhone,
178
+ setMembershipCardId,
179
+ setCustomerSearch,
180
+ setCreateDialogOpen,
181
+ setLookupDialogOpen,
182
+ selectCustomer,
183
+ clearCustomer,
184
+ handleCustomerCreated,
185
+ triggerSearch,
186
+ triggerMembershipLookup,
187
+ resetCustomer
188
+ };
189
+ }
190
+
191
+ //#endregion
192
+ export { usePosCustomer };
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useLocalStorage } from "@classytic/fluid/client/hooks";
5
+
6
+ //#region src/hooks/usePosMultiOrder.ts
7
+ /**
8
+ * usePosMultiOrder — Park and resume multiple orders.
9
+ *
10
+ * Max 5 parked orders stored in localStorage per branch.
11
+ * Each parked order saves: cart items, customer info, discount state.
12
+ */
13
+ const MAX_PARKED_ORDERS = 5;
14
+ const PARKED_TTL = 480 * 60 * 1e3;
15
+ function usePosMultiOrder(branchId) {
16
+ const [orders, setOrders] = useLocalStorage(branchId ? `pos:parked:${branchId}` : "pos:parked:default", [], PARKED_TTL);
17
+ const canPark = orders.length < MAX_PARKED_ORDERS;
18
+ return {
19
+ orders,
20
+ parkOrder: useCallback((order) => {
21
+ const id = `parked_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
22
+ const parked = {
23
+ ...order,
24
+ id,
25
+ parkedAt: Date.now()
26
+ };
27
+ setOrders((prev) => [...prev, parked]);
28
+ return id;
29
+ }, [setOrders]),
30
+ resumeOrder: useCallback((id) => {
31
+ let found = null;
32
+ setOrders((prev) => {
33
+ const idx = prev.findIndex((o) => o.id === id);
34
+ if (idx === -1) return prev;
35
+ found = prev[idx];
36
+ return prev.filter((_, i) => i !== idx);
37
+ });
38
+ return found;
39
+ }, [setOrders]),
40
+ deleteOrder: useCallback((id) => {
41
+ setOrders((prev) => prev.filter((o) => o.id !== id));
42
+ }, [setOrders]),
43
+ canPark
44
+ };
45
+ }
46
+
47
+ //#endregion
48
+ export { usePosMultiOrder };