@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.
- package/LICENSE +75 -0
- package/dist/components/CartSidebar.d.ts +56 -0
- package/dist/components/CartSidebar.js +10 -9
- package/dist/components/PosTopBar.d.ts +19 -0
- package/dist/components/PosTopBar.js +4 -4
- package/dist/components/PrinterSettingsDialog.d.ts +8 -0
- package/dist/components/PrinterSettingsDialog.js +506 -20
- package/dist/components/ReceiptDeliveryDialog.d.ts +11 -0
- package/dist/components/ReceiptDeliveryDialog.js +98 -0
- package/dist/components/ReceiptReprintDialog.d.ts +10 -0
- package/dist/components/ReceiptReprintDialog.js +49 -22
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +7 -0
- package/dist/dashboard/components/CustomerLookupDialog.js +108 -90
- package/dist/dashboard/components/CustomerQuickAddDialog.js +3 -1
- package/dist/dashboard/components/DeliveryPanel.js +134 -0
- package/dist/dashboard/components/ProductCard.js +17 -7
- package/dist/dashboard/components/ProductsPanel.js +4 -4
- package/dist/dashboard/components/SplitPaymentPanel.js +5 -5
- package/dist/dashboard/components/VariantSelectorDialog.js +53 -11
- package/dist/dashboard/components/cart/AddChargeDialog.js +3 -2
- package/dist/dashboard/components/cart/CartItems.js +5 -5
- package/dist/dashboard/components/cart/CartSummary.js +6 -6
- package/dist/dashboard/components/cart/CustomerSection.js +3 -3
- package/dist/dashboard/components/cart/DiscountSection.js +4 -3
- package/dist/dashboard/components/cart/PointsRedemptionSection.js +5 -4
- package/dist/dashboard/pos.types.d.ts +32 -0
- package/dist/hardware/agent-adapter.js +127 -0
- package/dist/hardware/agent-pairing.d.ts +18 -0
- package/dist/hardware/agent-pairing.js +63 -0
- package/dist/hardware/context.d.ts +1 -5
- package/dist/hardware/index.d.ts +4 -3
- package/dist/hardware/index.js +3 -2
- package/dist/hardware/ports.d.ts +21 -4
- package/dist/hardware/tauri-adapter.d.ts +0 -1
- package/dist/hardware/tauri-adapter.js +19 -5
- package/dist/hardware/web-adapter.d.ts +22 -7
- package/dist/hardware/web-adapter.js +56 -14
- package/dist/hooks/branch-scoped-key.js +64 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/sale-attempt.js +62 -0
- package/dist/hooks/useManagerAuth.js +9 -1
- package/dist/hooks/usePosCart.d.ts +55 -0
- package/dist/hooks/usePosCart.js +224 -117
- package/dist/hooks/usePosCustomer.d.ts +31 -0
- package/dist/hooks/usePosCustomer.js +32 -78
- package/dist/hooks/usePosDelivery.d.ts +28 -0
- package/dist/hooks/usePosDelivery.js +133 -0
- package/dist/hooks/usePosMultiOrder.d.ts +22 -0
- package/dist/hooks/usePosMultiOrder.js +14 -4
- package/dist/hooks/usePosPayment.d.ts +22 -0
- package/dist/lib/delivery-payload.js +83 -0
- package/dist/lib/money.js +96 -23
- package/dist/node_modules/react-hook-form/dist/index.esm.js +468 -332
- package/dist/runtime/auth-port.d.ts +1 -5
- package/dist/runtime/branch-port.d.ts +1 -5
- package/dist/runtime/config.d.ts +43 -6
- package/dist/runtime/config.js +18 -1
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +2 -2
- package/dist/screens/OrderHistoryDrawer.d.ts +8 -0
- package/dist/screens/OrderHistoryDrawer.js +15 -15
- package/dist/screens/ParkedOrdersDrawer.d.ts +12 -0
- package/dist/screens/ParkedOrdersDrawer.js +3 -3
- package/dist/screens/PaymentScreen.d.ts +4 -0
- package/dist/screens/PaymentScreen.js +194 -33
- package/dist/screens/ProductScreen.d.ts +4 -0
- package/dist/screens/ProductScreen.js +13 -10
- package/dist/screens/ReceiptScreen.d.ts +4 -0
- package/dist/screens/ReceiptScreen.js +96 -72
- package/dist/screens/ShiftCloseScreen.d.ts +4 -0
- package/dist/screens/ShiftCloseScreen.js +18 -15
- package/dist/screens/ShiftOpenScreen.d.ts +4 -0
- package/dist/screens/ShiftOpenScreen.js +75 -19
- package/dist/screens/index.d.ts +8 -0
- package/dist/screens/index.js +9 -0
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/pos-shell.d.ts +41 -8
- package/dist/shell/pos-shell.js +25 -18
- package/dist/state/index.d.ts +3 -0
- package/dist/state/index.js +4 -0
- package/dist/state/pos-context.d.ts +13 -0
- package/dist/state/pos-state.d.ts +49 -0
- package/dist/state/pos-state.js +6 -7
- package/dist/utils/pos-helpers.js +137 -34
- package/package.json +27 -7
package/dist/hooks/usePosCart.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
12
|
+
const CART_TTL = 864e5;
|
|
10
13
|
function usePosCart(branchId) {
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
...item,
|
|
140
|
+
const existing = next[existingIndex];
|
|
141
|
+
const quantity = existing.quantity + 1;
|
|
142
|
+
next[existingIndex] = {
|
|
143
|
+
...existing,
|
|
109
144
|
quantity,
|
|
110
|
-
lineTotal: quantity *
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
}, [
|
|
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,
|
|
4
|
+
import { useCallback, useState } from "react";
|
|
7
5
|
import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
8
|
-
import {
|
|
6
|
+
import { useLookupMemberByCard } from "@classytic/commerce-sdk/sales";
|
|
9
7
|
import { toast } from "sonner";
|
|
10
|
-
import {
|
|
8
|
+
import { presentError } from "@classytic/fluid/error-contract";
|
|
11
9
|
|
|
12
10
|
//#region src/hooks/usePosCustomer.ts
|
|
13
|
-
const CUSTOMER_TTL =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
148
|
-
if (
|
|
149
|
-
|
|
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 };
|