@classytic/pos-ui 0.1.0 → 1.2.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 (87) hide show
  1. package/LICENSE +75 -0
  2. package/dist/components/CartSidebar.d.ts +56 -0
  3. package/dist/components/CartSidebar.js +10 -9
  4. package/dist/components/PosTopBar.d.ts +19 -0
  5. package/dist/components/PosTopBar.js +4 -4
  6. package/dist/components/PrinterSettingsDialog.d.ts +8 -0
  7. package/dist/components/PrinterSettingsDialog.js +506 -20
  8. package/dist/components/ReceiptDeliveryDialog.d.ts +11 -0
  9. package/dist/components/ReceiptDeliveryDialog.js +98 -0
  10. package/dist/components/ReceiptReprintDialog.d.ts +10 -0
  11. package/dist/components/ReceiptReprintDialog.js +49 -22
  12. package/dist/components/index.d.ts +6 -0
  13. package/dist/components/index.js +7 -0
  14. package/dist/dashboard/components/CustomerLookupDialog.js +108 -90
  15. package/dist/dashboard/components/CustomerQuickAddDialog.js +3 -1
  16. package/dist/dashboard/components/DeliveryPanel.js +134 -0
  17. package/dist/dashboard/components/ProductCard.js +17 -7
  18. package/dist/dashboard/components/ProductsPanel.js +4 -4
  19. package/dist/dashboard/components/SplitPaymentPanel.js +5 -5
  20. package/dist/dashboard/components/VariantSelectorDialog.js +53 -11
  21. package/dist/dashboard/components/cart/AddChargeDialog.js +3 -2
  22. package/dist/dashboard/components/cart/CartItems.js +5 -5
  23. package/dist/dashboard/components/cart/CartSummary.js +6 -6
  24. package/dist/dashboard/components/cart/CustomerSection.js +3 -3
  25. package/dist/dashboard/components/cart/DiscountSection.js +4 -3
  26. package/dist/dashboard/components/cart/PointsRedemptionSection.js +5 -4
  27. package/dist/dashboard/pos.types.d.ts +32 -0
  28. package/dist/hardware/agent-adapter.js +127 -0
  29. package/dist/hardware/agent-pairing.d.ts +18 -0
  30. package/dist/hardware/agent-pairing.js +63 -0
  31. package/dist/hardware/context.d.ts +1 -5
  32. package/dist/hardware/index.d.ts +4 -3
  33. package/dist/hardware/index.js +3 -2
  34. package/dist/hardware/ports.d.ts +21 -4
  35. package/dist/hardware/tauri-adapter.d.ts +0 -1
  36. package/dist/hardware/tauri-adapter.js +19 -5
  37. package/dist/hardware/web-adapter.d.ts +22 -7
  38. package/dist/hardware/web-adapter.js +56 -14
  39. package/dist/hooks/branch-scoped-key.js +64 -0
  40. package/dist/hooks/index.d.ts +6 -0
  41. package/dist/hooks/index.js +7 -0
  42. package/dist/hooks/sale-attempt.js +62 -0
  43. package/dist/hooks/useManagerAuth.js +9 -1
  44. package/dist/hooks/usePosCart.d.ts +55 -0
  45. package/dist/hooks/usePosCart.js +224 -117
  46. package/dist/hooks/usePosCustomer.d.ts +31 -0
  47. package/dist/hooks/usePosCustomer.js +32 -78
  48. package/dist/hooks/usePosDelivery.d.ts +28 -0
  49. package/dist/hooks/usePosDelivery.js +133 -0
  50. package/dist/hooks/usePosMultiOrder.d.ts +22 -0
  51. package/dist/hooks/usePosMultiOrder.js +14 -4
  52. package/dist/hooks/usePosPayment.d.ts +22 -0
  53. package/dist/lib/delivery-payload.js +83 -0
  54. package/dist/lib/money.js +96 -23
  55. package/dist/node_modules/react-hook-form/dist/index.esm.js +468 -332
  56. package/dist/runtime/auth-port.d.ts +1 -5
  57. package/dist/runtime/branch-port.d.ts +1 -5
  58. package/dist/runtime/config.d.ts +43 -6
  59. package/dist/runtime/config.js +18 -1
  60. package/dist/runtime/index.d.ts +2 -2
  61. package/dist/runtime/index.js +2 -2
  62. package/dist/screens/OrderHistoryDrawer.d.ts +8 -0
  63. package/dist/screens/OrderHistoryDrawer.js +15 -15
  64. package/dist/screens/ParkedOrdersDrawer.d.ts +12 -0
  65. package/dist/screens/ParkedOrdersDrawer.js +3 -3
  66. package/dist/screens/PaymentScreen.d.ts +4 -0
  67. package/dist/screens/PaymentScreen.js +194 -33
  68. package/dist/screens/ProductScreen.d.ts +4 -0
  69. package/dist/screens/ProductScreen.js +13 -10
  70. package/dist/screens/ReceiptScreen.d.ts +4 -0
  71. package/dist/screens/ReceiptScreen.js +96 -72
  72. package/dist/screens/ShiftCloseScreen.d.ts +4 -0
  73. package/dist/screens/ShiftCloseScreen.js +18 -15
  74. package/dist/screens/ShiftOpenScreen.d.ts +4 -0
  75. package/dist/screens/ShiftOpenScreen.js +75 -19
  76. package/dist/screens/index.d.ts +8 -0
  77. package/dist/screens/index.js +9 -0
  78. package/dist/shell/index.d.ts +2 -0
  79. package/dist/shell/pos-shell.d.ts +41 -8
  80. package/dist/shell/pos-shell.js +25 -18
  81. package/dist/state/index.d.ts +3 -0
  82. package/dist/state/index.js +4 -0
  83. package/dist/state/pos-context.d.ts +13 -0
  84. package/dist/state/pos-state.d.ts +49 -0
  85. package/dist/state/pos-state.js +6 -7
  86. package/dist/utils/pos-helpers.js +137 -34
  87. package/package.json +27 -7
@@ -1,140 +1,247 @@
1
1
  "use client";
2
2
 
3
+ import { minorToMajor } from "../lib/money.js";
4
+ import { UNRESOLVED_BRANCH, branchScopedKey } from "./branch-scoped-key.js";
5
+ import { resolveSaleAttemptKey } from "./sale-attempt.js";
3
6
  import { useCallback } from "react";
4
7
  import { useLocalStorage } from "@classytic/fluid/client/hooks";
5
- import { calculateVariantPrice, formatVariantLabel, getPosPrice, getPosProductImage, getVariantStock } from "@classytic/commerce-sdk/sales";
8
+ import { calculateVariantPrice, formatVariantLabel, getPosPrice, getPosProductImage, isInStock } from "@classytic/commerce-sdk/sales";
6
9
  import { toast } from "sonner";
7
10
 
8
11
  //#region src/hooks/usePosCart.ts
9
- const CART_TTL = 1440 * 60 * 1e3;
12
+ const CART_TTL = 864e5;
10
13
  function usePosCart(branchId) {
11
- const suffix = branchId ?? "default";
12
- const [cart, setCart, clearStoredCart] = useLocalStorage(`pos:cart:${suffix}`, [], CART_TTL);
14
+ /**
15
+ * The cart namespace is per-BRANCH, and an unresolved branch is NOT a branch —
16
+ * see `branch-scoped-key.ts` for the full account of what `?? "default"` cost
17
+ * here (a cart that vanished the moment the branch context resolved, behind an
18
+ * "Added to cart" toast). `ready === false` means every mutator below refuses,
19
+ * so nothing is ever written to a namespace that is about to be abandoned.
20
+ */
21
+ const { key: cartKey, ready } = branchScopedKey("pos:cart", branchId);
22
+ const suffix = ready ? branchId : UNRESOLVED_BRANCH;
23
+ const [cart, setCart, clearStoredCart] = useLocalStorage(cartKey, [], CART_TTL);
24
+ /**
25
+ * The one guard every mutator calls FIRST.
26
+ *
27
+ * Loud on purpose: the alternative is what shipped — a success toast over a
28
+ * write that is about to be orphaned. The cashier is told to wait a moment
29
+ * instead of being told the item was added.
30
+ */
31
+ const refuseIfBranchUnresolved = useCallback(() => {
32
+ if (ready) return false;
33
+ toast.error("Branch not ready", { description: "The register is still resolving its branch. Try again in a moment." });
34
+ return true;
35
+ }, [ready]);
13
36
  const [discountInput, setDiscountInput, clearDiscountInput] = useLocalStorage(`pos:discount:${suffix}`, "", CART_TTL);
14
37
  const [membershipCardId, setMembershipCardId, clearMembershipCardId] = useLocalStorage(`pos:membership:${suffix}`, "", CART_TTL);
15
38
  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)
39
+ const [, setSaleAttempt, clearSaleAttempt] = useLocalStorage(`pos:attempt:${suffix}`, "", CART_TTL);
40
+ /**
41
+ * The ONE seam every cart mutation goes through.
42
+ *
43
+ * Not a style preference — it is what keeps the guard and the writer from
44
+ * drifting apart. `setCart` is `useCallback([key, notifyChange])` inside
45
+ * fluid's `useLocalStorage`, so its identity CHANGES whenever the branch key
46
+ * changes, and `refuseIfBranchUnresolved` changes when `ready` does. Callbacks
47
+ * that captured either one without depending on it went stale in two ways
48
+ * that both fail silently:
49
+ *
50
+ * - `updateQuantity` and `removeItem` declared `[]`, so on a mount where the
51
+ * branch had not resolved yet they froze `ready === false` FOREVER: the
52
+ * quantity steppers and the remove button refused every tap for the life
53
+ * of that mount, with a "branch not ready" toast that was true once and a
54
+ * lie thereafter.
55
+ * - After a branch switch (A B) they still held branch A's `setCart` and
56
+ * wrote A's cart while the screen displayed B's.
57
+ *
58
+ * With one guarded writer there is exactly one dependency list to get right,
59
+ * and every mutator below depends on `mutateCart` alone.
60
+ */
61
+ const mutateCart = useCallback((updater) => {
62
+ if (refuseIfBranchUnresolved()) return false;
63
+ setCart(updater);
64
+ return true;
65
+ }, [refuseIfBranchUnresolved, setCart]);
66
+ const addToCart = useCallback((product, variantSku) => {
67
+ if (refuseIfBranchUnresolved()) return;
68
+ const variant = variantSku ? product.variants?.find((v) => v.sku === variantSku) : null;
69
+ /**
70
+ * ONE stock predicate — `isInStock`, not a third copy of the rule.
71
+ *
72
+ * This re-derived it inline (`getVariantStock(...) <= 0`, else `!branchStock.inStock`)
73
+ * and so never consulted `inventory.stockTracked`. That made it the THIRD place the
74
+ * same decision lived — the product card, the variant dialog, and here — and the only
75
+ * one still refusing a membership, which holds no stock and cannot be out of it. Fixing
76
+ * the card and the dialog did nothing for the actual tap, because the tap asked this.
77
+ *
78
+ * `isInStock` already handles the untracked case and the variant case; a tracked good
79
+ * with no quantity is still correctly refused.
80
+ */
81
+ if (!isInStock(product, variantSku)) {
82
+ toast.error("Out of stock");
83
+ return;
84
+ }
85
+ const basePrice = getPosPrice(product);
86
+ /**
87
+ * An ABSOLUTE variant price wins over base-plus-modifier — and this one moves MONEY.
88
+ *
89
+ * `priceModifier` models a variant as an uplift ("Large: +৳50"), but a subscription
90
+ * PLAN carries its own price and `getPosPrice` resolves the product to `plans[0]`. With
91
+ * the modifier form a Yearly membership entered the cart at the MONTHLY price: ৳3,000
92
+ * charged for a ৳30,000 plan, with the receipt and the entitlement both agreeing on the
93
+ * wrong number. The variant dialog displayed it correctly by then, so the quote and the
94
+ * charge disagreed — the worst version of this bug.
95
+ */
96
+ const absolutePrice = variant?.price;
97
+ const unitPrice = typeof absolutePrice === "number" && absolutePrice > 0 ? minorToMajor(absolutePrice) : variant ? calculateVariantPrice(basePrice, variant.priceModifier ?? 0) : basePrice;
98
+ if (mutateCart((prev) => {
99
+ const existingIndex = prev.findIndex((item) => item.productId === product._id && item.variantSku === variantSku);
100
+ if (existingIndex >= 0) {
101
+ const next = [...prev];
102
+ const existing = next[existingIndex];
103
+ const quantity = existing.quantity + 1;
104
+ next[existingIndex] = {
105
+ ...existing,
106
+ quantity,
107
+ lineTotal: quantity * unitPrice
59
108
  };
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;
109
+ return next;
73
110
  }
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) => {
111
+ const newItem = {
112
+ productId: product._id,
113
+ productName: product.name,
114
+ variantSku,
115
+ variantLabel: variant?.attributes ? formatVariantLabel(variant.attributes) : void 0,
116
+ quantity: 1,
117
+ unitPrice,
118
+ lineTotal: unitPrice,
119
+ image: getPosProductImage(product)
120
+ };
121
+ return [...prev, newItem];
122
+ })) toast.success("Added to cart");
123
+ }, [refuseIfBranchUnresolved, mutateCart]);
124
+ const addCharge = useCallback((label, amountMajor) => {
125
+ if (refuseIfBranchUnresolved()) return;
126
+ const trimmedLabel = label.trim();
127
+ if (!trimmedLabel) {
128
+ toast.error("Label is required");
129
+ return;
130
+ }
131
+ if (!Number.isFinite(amountMajor) || amountMajor < 0) {
132
+ toast.error("Amount must be 0 or greater");
133
+ return;
134
+ }
135
+ const code = trimmedLabel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "misc";
136
+ if (mutateCart((prev) => {
137
+ const existingIndex = prev.findIndex((item) => item.kind === "service-fee" && item.serviceCode === code);
138
+ if (existingIndex >= 0) {
103
139
  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,
140
+ const existing = next[existingIndex];
141
+ const quantity = existing.quantity + 1;
142
+ next[existingIndex] = {
143
+ ...existing,
109
144
  quantity,
110
- lineTotal: quantity * item.unitPrice
145
+ lineTotal: quantity * existing.unitPrice
111
146
  };
112
147
  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
148
  }
123
- }, [setCart, clearPointsToRedeem]),
124
- resetCart: useCallback(() => {
125
- clearStoredCart();
126
- clearDiscountInput();
127
- clearMembershipCardId();
149
+ const newItem = {
150
+ kind: "service-fee",
151
+ serviceCode: code,
152
+ productId: `service:${code}`,
153
+ productName: trimmedLabel,
154
+ quantity: 1,
155
+ unitPrice: amountMajor,
156
+ lineTotal: amountMajor
157
+ };
158
+ return [...prev, newItem];
159
+ })) toast.success(`Added ${trimmedLabel}`);
160
+ }, [refuseIfBranchUnresolved, mutateCart]);
161
+ const updateQuantity = useCallback((index, delta) => {
162
+ mutateCart((prev) => {
163
+ const next = [...prev];
164
+ const item = next[index];
165
+ if (!item) return prev;
166
+ const quantity = Math.max(1, item.quantity + delta);
167
+ next[index] = {
168
+ ...item,
169
+ quantity,
170
+ lineTotal: quantity * item.unitPrice
171
+ };
172
+ return next;
173
+ });
174
+ }, [mutateCart]);
175
+ const removeItem = useCallback((index) => {
176
+ mutateCart((prev) => prev.filter((_, i) => i !== index));
177
+ }, [mutateCart]);
178
+ const clearCart = useCallback(() => {
179
+ if (refuseIfBranchUnresolved()) return;
180
+ if (window.confirm("Clear all items from cart?")) {
181
+ mutateCart(() => []);
128
182
  clearPointsToRedeem();
129
- }, [
130
- clearStoredCart,
131
- clearDiscountInput,
132
- clearMembershipCardId,
133
- clearPointsToRedeem
134
- ]),
183
+ }
184
+ }, [
185
+ refuseIfBranchUnresolved,
186
+ mutateCart,
187
+ clearPointsToRedeem
188
+ ]);
189
+ /**
190
+ * End of sale. Clears the attempt key LAST and only here — `resetCart` runs
191
+ * after a confirmed success, so up to that point every retry keeps sending the
192
+ * same key and the server keeps replaying the same order. Dropping the key on
193
+ * failure instead is precisely the double-charge path.
194
+ */
195
+ const resetCart = useCallback(() => {
196
+ clearStoredCart();
197
+ clearDiscountInput();
198
+ clearMembershipCardId();
199
+ clearPointsToRedeem();
200
+ clearSaleAttempt();
201
+ }, [
202
+ clearStoredCart,
203
+ clearDiscountInput,
204
+ clearMembershipCardId,
205
+ clearPointsToRedeem,
206
+ clearSaleAttempt
207
+ ]);
208
+ const beginSaleAttempt = useCallback(() => {
209
+ if (!ready) return null;
210
+ /**
211
+ * Read-and-mint in ONE storage pass. `setValue`'s updater is invoked
212
+ * synchronously against the value freshly read from localStorage (see
213
+ * fluid's `useLocalStorage`), which matters because `beginSaleAttempt` is
214
+ * called during a submit handler where this hook's rendered state may be a
215
+ * tick behind. Reading the rendered value instead would mint a second key
216
+ * for a retry that fired before React re-rendered — the exact case this
217
+ * function exists for.
218
+ */
219
+ let key = "";
220
+ setSaleAttempt((prev) => {
221
+ key = resolveSaleAttemptKey(prev);
222
+ return key;
223
+ });
224
+ return key;
225
+ }, [ready, setSaleAttempt]);
226
+ return {
227
+ cart,
228
+ membershipCardId,
229
+ pointsToRedeemInput,
230
+ discountInput,
231
+ setDiscountInput,
232
+ setMembershipCardId,
233
+ setPointsToRedeemInput,
234
+ addToCart,
235
+ addCharge,
236
+ updateQuantity,
237
+ removeItem,
238
+ clearCart,
239
+ resetCart,
135
240
  replaceCart: useCallback((items) => {
136
- setCart(items);
137
- }, [setCart])
241
+ mutateCart(() => items);
242
+ }, [mutateCart]),
243
+ beginSaleAttempt,
244
+ ready
138
245
  };
139
246
  }
140
247
 
@@ -0,0 +1,31 @@
1
+ import { Customer } from "@classytic/commerce-sdk/sales";
2
+ //#region src/hooks/usePosCustomer.d.ts
3
+ interface UsePosCustomerReturn {
4
+ selectedCustomer: Customer | null;
5
+ customerName: string;
6
+ customerPhone: string;
7
+ membershipCardId: string;
8
+ membershipLookupStatus: "idle" | "searching" | "found" | "not_found";
9
+ createDialogOpen: boolean;
10
+ lookupDialogOpen: boolean;
11
+ setCustomerName: (value: string) => void;
12
+ setCustomerPhone: (value: string) => void;
13
+ setMembershipCardId: (value: string) => void;
14
+ setCreateDialogOpen: (open: boolean) => void;
15
+ setLookupDialogOpen: (open: boolean) => void;
16
+ selectCustomer: (customer: Customer) => void;
17
+ clearCustomer: () => void;
18
+ handleCustomerCreated: (customer: Customer) => void;
19
+ triggerMembershipLookup: () => Promise<void>;
20
+ resetCustomer: () => void;
21
+ }
22
+ /**
23
+ * `token` is accepted and unused: the SDK injects auth from `configureAuth()`,
24
+ * and the only consumer of an explicit token here was the customer SEARCH,
25
+ * which has moved into `CustomerLookupDialog`. Kept in the signature because
26
+ * both screens pass it and dropping it is a breaking change for no gain —
27
+ * marked so the next reader does not go looking for where it is threaded.
28
+ */
29
+ declare function usePosCustomer(_token?: string): UsePosCustomerReturn;
30
+ //#endregion
31
+ export { UsePosCustomerReturn, usePosCustomer };
@@ -1,67 +1,46 @@
1
1
  "use client";
2
2
 
3
- import { usePosAuth } from "../runtime/auth-port.js";
4
- import { isPhoneSearch } from "../utils/pos-helpers.js";
5
3
  import { customerDisplayName, customerDisplayPhone } from "../utils/customer-display.js";
6
- import { useCallback, useMemo, useState } from "react";
4
+ import { useCallback, useState } from "react";
7
5
  import { useLocalStorage } from "@classytic/fluid/client/hooks";
8
- import { useCustomers, useLookupMemberByCard } from "@classytic/commerce-sdk/sales";
6
+ import { useLookupMemberByCard } from "@classytic/commerce-sdk/sales";
9
7
  import { toast } from "sonner";
10
- import { buildFilterParams, getApiParams } from "@classytic/fluid";
8
+ import { presentError } from "@classytic/fluid/error-contract";
11
9
 
12
10
  //#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 || "";
11
+ const CUSTOMER_TTL = 864e5;
12
+ /**
13
+ * `token` is accepted and unused: the SDK injects auth from `configureAuth()`,
14
+ * and the only consumer of an explicit token here was the customer SEARCH,
15
+ * which has moved into `CustomerLookupDialog`. Kept in the signature because
16
+ * both screens pass it and dropping it is a breaking change for no gain —
17
+ * marked so the next reader does not go looking for where it is threaded.
18
+ */
19
+ function usePosCustomer(_token) {
17
20
  const [selectedCustomer, setSelectedCustomer] = useLocalStorage("pos:customer:selected", null, CUSTOMER_TTL);
18
21
  const [customerName, setCustomerNameState] = useLocalStorage("pos:customer:name", "", CUSTOMER_TTL);
19
22
  const [customerPhone, setCustomerPhoneState] = useLocalStorage("pos:customer:phone", "", CUSTOMER_TTL);
20
23
  const [membershipCardId, setMembershipCardIdState] = useLocalStorage("pos:customer:membershipCard", "", CUSTOMER_TTL);
21
24
  const [membershipLookupStatus, setMembershipLookupStatus] = useState("idle");
22
- const [customerSearch, setCustomerSearch] = useState("");
23
- const [searchApplied, setSearchApplied] = useState("");
24
25
  const [createDialogOpen, setCreateDialogOpen] = useState(false);
25
26
  const [lookupDialogOpen, setLookupDialogOpen] = useState(false);
26
27
  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]);
28
+ /**
29
+ * Searching used to live here: ~50 lines that guessed the field from the text
30
+ * (`isValidPhone` exact phone, `/^\d{4,}$/` phone contains, else name) and
31
+ * hand-built the query with `buildFilterParams`.
32
+ *
33
+ * It now lives in `CustomerLookupDialog`, composed from fluid's picker parts
34
+ * over `useLocalSearch`, for two reasons. The guess had a silent failure mode
35
+ * a wrong inference returns an empty list, which reads as "not a customer"
36
+ * and gets a duplicate created. And the accounting picker searched customers
37
+ * through a different grammar, so one product held two answers to "how do I
38
+ * find a customer" and only one of them could be right.
39
+ *
40
+ * What is left here is what this hook is actually for: WHICH customer is
41
+ * attached to the sale, and keeping that across the products→payment screen
42
+ * swap.
43
+ */
65
44
  const setCustomerName = useCallback((value) => {
66
45
  setCustomerNameState(value);
67
46
  if (selectedCustomer) setSelectedCustomer(null);
@@ -80,8 +59,6 @@ function usePosCustomer(token) {
80
59
  setCustomerPhoneState(customerDisplayPhone(customer) ?? "");
81
60
  setMembershipCardIdState(customer.membership?.cardId || "");
82
61
  setMembershipLookupStatus(customer.membership?.cardId ? "found" : "idle");
83
- setCustomerSearch("");
84
- setSearchApplied("");
85
62
  setLookupDialogOpen(false);
86
63
  }, []);
87
64
  const clearCustomer = useCallback(() => {
@@ -90,8 +67,6 @@ function usePosCustomer(token) {
90
67
  setCustomerPhoneState("");
91
68
  setMembershipCardIdState("");
92
69
  setMembershipLookupStatus("idle");
93
- setCustomerSearch("");
94
- setSearchApplied("");
95
70
  }, []);
96
71
  const handleCustomerCreated = useCallback((customer) => {
97
72
  setSelectedCustomer(customer);
@@ -99,23 +74,8 @@ function usePosCustomer(token) {
99
74
  setCustomerPhoneState(customerDisplayPhone(customer) ?? "");
100
75
  setMembershipCardIdState(customer.membership?.cardId || "");
101
76
  setMembershipLookupStatus(customer.membership?.cardId ? "found" : "idle");
102
- setCustomerSearch("");
103
- setSearchApplied("");
104
77
  setLookupDialogOpen(false);
105
78
  }, []);
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
79
  /**
120
80
  * POS card-scan path. Hardware barcode scanners type the card id and emit
121
81
  * Enter, so this is invoked from the input's onKeyDown=Enter or the
@@ -144,9 +104,10 @@ function usePosCustomer(token) {
144
104
  } catch (err) {
145
105
  setMembershipLookupStatus("not_found");
146
106
  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);
107
+ const raw = err instanceof Error ? err.message : "Card not found";
108
+ if (raw.includes("404") || /not found/i.test(raw)) return;
109
+ const { title, message } = presentError(err, "The card could not be checked. Try again.", { fallbackTitle: "Card lookup failed" });
110
+ toast.error(title, { description: message });
150
111
  }
151
112
  }, [
152
113
  membershipCardId,
@@ -159,8 +120,6 @@ function usePosCustomer(token) {
159
120
  setCustomerPhoneState("");
160
121
  setMembershipCardIdState("");
161
122
  setMembershipLookupStatus("idle");
162
- setCustomerSearch("");
163
- setSearchApplied("");
164
123
  }, []);
165
124
  return {
166
125
  selectedCustomer,
@@ -168,21 +127,16 @@ function usePosCustomer(token) {
168
127
  customerPhone,
169
128
  membershipCardId,
170
129
  membershipLookupStatus: membershipLookupPending ? "searching" : membershipLookupStatus,
171
- customerSearch,
172
- customerResults,
173
- isSearching: customersLoading || customersFetching,
174
130
  createDialogOpen,
175
131
  lookupDialogOpen,
176
132
  setCustomerName,
177
133
  setCustomerPhone,
178
134
  setMembershipCardId,
179
- setCustomerSearch,
180
135
  setCreateDialogOpen,
181
136
  setLookupDialogOpen,
182
137
  selectCustomer,
183
138
  clearCustomer,
184
139
  handleCustomerCreated,
185
- triggerSearch,
186
140
  triggerMembershipLookup,
187
141
  resetCustomer
188
142
  };
@@ -0,0 +1,28 @@
1
+ //#region src/hooks/usePosDelivery.d.ts
2
+ type PosDeliveryMethod = "pickup" | "delivery";
3
+ type PosPaymentTerm = "prepaid" | "collect_on_delivery";
4
+ interface PosDeliveryAddress {
5
+ recipientName: string;
6
+ recipientPhone: string;
7
+ addressLine1: string;
8
+ city: string;
9
+ area: string;
10
+ note: string;
11
+ }
12
+ interface UsePosDeliveryReturn {
13
+ method: PosDeliveryMethod;
14
+ address: PosDeliveryAddress;
15
+ /** Delivery fee in MAJOR units, as typed. Empty string means none. */
16
+ feeInput: string;
17
+ paymentTerm: PosPaymentTerm;
18
+ setMethod: (method: PosDeliveryMethod) => void;
19
+ setAddressField: (field: keyof PosDeliveryAddress, value: string) => void;
20
+ setFeeInput: (value: string) => void;
21
+ setPaymentTerm: (term: PosPaymentTerm) => void;
22
+ reset: () => void;
23
+ /** Parsed fee in major units, `0` when blank or unparseable. */
24
+ fee: number;
25
+ }
26
+ declare function usePosDelivery(branchId?: string): UsePosDeliveryReturn;
27
+ //#endregion
28
+ export { PosDeliveryAddress, PosDeliveryMethod, PosPaymentTerm, UsePosDeliveryReturn, usePosDelivery };