@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
|
@@ -13,10 +13,17 @@ function getInvoke() {
|
|
|
13
13
|
}
|
|
14
14
|
var TauriReceiptPrinter = class {
|
|
15
15
|
async print(payload) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
const invoke = getInvoke();
|
|
17
|
+
const config = readPrinterConfig() ?? void 0;
|
|
18
|
+
try {
|
|
19
|
+
await invoke("printer_print", {
|
|
20
|
+
payload,
|
|
21
|
+
config
|
|
22
|
+
});
|
|
23
|
+
return "confirmed";
|
|
24
|
+
} catch {
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
20
27
|
}
|
|
21
28
|
async isAvailable() {
|
|
22
29
|
try {
|
|
@@ -28,7 +35,14 @@ var TauriReceiptPrinter = class {
|
|
|
28
35
|
};
|
|
29
36
|
var TauriCashDrawer = class {
|
|
30
37
|
async open() {
|
|
31
|
-
|
|
38
|
+
const invoke = getInvoke();
|
|
39
|
+
const config = readPrinterConfig() ?? void 0;
|
|
40
|
+
try {
|
|
41
|
+
await invoke("drawer_open", { config });
|
|
42
|
+
return "confirmed";
|
|
43
|
+
} catch {
|
|
44
|
+
return "unknown";
|
|
45
|
+
}
|
|
32
46
|
}
|
|
33
47
|
async isAvailable() {
|
|
34
48
|
try {
|
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import { PosHardware } from "./ports.js";
|
|
2
|
-
|
|
1
|
+
import { PosHardware, PrintOutcome } from "./ports.js";
|
|
3
2
|
//#region src/hardware/web-adapter.d.ts
|
|
4
3
|
/** DOM id of the rendered receipt — used by `printDocument` for print isolation. */
|
|
5
4
|
declare const RECEIPT_DOM_ID = "pos-receipt";
|
|
5
|
+
/**
|
|
6
|
+
* The browser `window.print()` path, factored out so it can ALSO be called
|
|
7
|
+
* directly as an explicit cashier-visible fallback after a failed/unknown
|
|
8
|
+
* agent print (see Stream 1.4: "not an automatic retry that might create a
|
|
9
|
+
* duplicate receipt" — the fallback is a deliberate second action, not
|
|
10
|
+
* something this file chains on its own).
|
|
11
|
+
*/
|
|
12
|
+
declare function printViaBrowserWindow(): Promise<PrintOutcome>;
|
|
6
13
|
/**
|
|
7
14
|
* Auto-detects the host: returns native Tauri adapters when the React
|
|
8
|
-
* tree is running inside the `cashier-tauri` webview
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
15
|
+
* tree is running inside the `cashier-tauri` webview; otherwise checks for a
|
|
16
|
+
* paired `@classytic/pos-agent` device (see `agent-pairing.ts`) and prefers
|
|
17
|
+
* it over the plain browser stubs. Consumers stay agnostic — `<HardwareProvider>`
|
|
18
|
+
* in `pos-shell.tsx` calls this once at mount and the same React tree works
|
|
19
|
+
* across all three surfaces with no per-component branching.
|
|
20
|
+
*
|
|
21
|
+
* This read is SYNCHRONOUS (`readAgentPairing()`, localStorage) because
|
|
22
|
+
* `createWebHardware()` runs inside `useMemo` and must return a stable object
|
|
23
|
+
* immediately — see the plan's Stream 1.4 on why adapter selection cannot be
|
|
24
|
+
* probed asynchronously at boot. `PosAgentReceiptPrinter`/`PosAgentCashDrawer`
|
|
25
|
+
* are cheap to construct (no eager network call); the actual agent request
|
|
26
|
+
* happens lazily inside `print()`/`open()`/`isAvailable()`.
|
|
12
27
|
*/
|
|
13
28
|
declare function createWebHardware(): PosHardware;
|
|
14
29
|
//#endregion
|
|
15
|
-
export { RECEIPT_DOM_ID, createWebHardware };
|
|
30
|
+
export { RECEIPT_DOM_ID, createWebHardware, printViaBrowserWindow };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createTauriHardware, isTauri } from "./tauri-adapter.js";
|
|
2
|
+
import { readAgentPairing } from "./agent-pairing.js";
|
|
3
|
+
import { PosAgentCashDrawer, PosAgentReceiptPrinter } from "./agent-adapter.js";
|
|
2
4
|
import { printDocument } from "@classytic/fluid/client/core";
|
|
3
5
|
|
|
4
6
|
//#region src/hardware/web-adapter.ts
|
|
@@ -22,21 +24,53 @@ import { printDocument } from "@classytic/fluid/client/core";
|
|
|
22
24
|
*/
|
|
23
25
|
/** DOM id of the rendered receipt — used by `printDocument` for print isolation. */
|
|
24
26
|
const RECEIPT_DOM_ID = "pos-receipt";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The browser `window.print()` path, factored out so it can ALSO be called
|
|
29
|
+
* directly as an explicit cashier-visible fallback after a failed/unknown
|
|
30
|
+
* agent print (see Stream 1.4: "not an automatic retry that might create a
|
|
31
|
+
* duplicate receipt" — the fallback is a deliberate second action, not
|
|
32
|
+
* something this file chains on its own).
|
|
33
|
+
*/
|
|
34
|
+
async function printViaBrowserWindow() {
|
|
35
|
+
if (typeof window === "undefined") return "failed";
|
|
36
|
+
try {
|
|
28
37
|
printDocument(RECEIPT_DOM_ID);
|
|
38
|
+
return "confirmed";
|
|
39
|
+
} catch {
|
|
40
|
+
return "failed";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
var WebCashDrawer = class {
|
|
44
|
+
async open() {
|
|
45
|
+
console.warn("[pos/hardware] Cash drawer kick is unavailable in the browser adapter. Pair a printer via POS settings or use the Tauri shell.");
|
|
46
|
+
return "failed";
|
|
29
47
|
}
|
|
30
48
|
async isAvailable() {
|
|
31
|
-
return
|
|
49
|
+
return false;
|
|
32
50
|
}
|
|
33
51
|
};
|
|
34
|
-
var
|
|
52
|
+
var CompositeReceiptPrinter = class {
|
|
53
|
+
async print(payload, copyType) {
|
|
54
|
+
const pairing = readAgentPairing();
|
|
55
|
+
if (!pairing) return printViaBrowserWindow();
|
|
56
|
+
return new PosAgentReceiptPrinter(readAgentPairing, pairing.deviceId).print(payload, copyType);
|
|
57
|
+
}
|
|
58
|
+
async isAvailable() {
|
|
59
|
+
const pairing = readAgentPairing();
|
|
60
|
+
if (!pairing) return typeof window !== "undefined";
|
|
61
|
+
return new PosAgentReceiptPrinter(readAgentPairing, pairing.deviceId).isAvailable();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var CompositeCashDrawer = class {
|
|
35
65
|
async open() {
|
|
36
|
-
|
|
66
|
+
const pairing = readAgentPairing();
|
|
67
|
+
if (!pairing) return new WebCashDrawer().open();
|
|
68
|
+
return new PosAgentCashDrawer(readAgentPairing, pairing.deviceId).open();
|
|
37
69
|
}
|
|
38
70
|
async isAvailable() {
|
|
39
|
-
|
|
71
|
+
const pairing = readAgentPairing();
|
|
72
|
+
if (!pairing) return false;
|
|
73
|
+
return new PosAgentCashDrawer(readAgentPairing, pairing.deviceId).isAvailable();
|
|
40
74
|
}
|
|
41
75
|
};
|
|
42
76
|
var WebBarcodeScannerStub = class {
|
|
@@ -61,20 +95,28 @@ var WebPaymentTerminal = class {
|
|
|
61
95
|
};
|
|
62
96
|
/**
|
|
63
97
|
* Auto-detects the host: returns native Tauri adapters when the React
|
|
64
|
-
* tree is running inside the `cashier-tauri` webview
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
98
|
+
* tree is running inside the `cashier-tauri` webview; otherwise checks for a
|
|
99
|
+
* paired `@classytic/pos-agent` device (see `agent-pairing.ts`) and prefers
|
|
100
|
+
* it over the plain browser stubs. Consumers stay agnostic — `<HardwareProvider>`
|
|
101
|
+
* in `pos-shell.tsx` calls this once at mount and the same React tree works
|
|
102
|
+
* across all three surfaces with no per-component branching.
|
|
103
|
+
*
|
|
104
|
+
* This read is SYNCHRONOUS (`readAgentPairing()`, localStorage) because
|
|
105
|
+
* `createWebHardware()` runs inside `useMemo` and must return a stable object
|
|
106
|
+
* immediately — see the plan's Stream 1.4 on why adapter selection cannot be
|
|
107
|
+
* probed asynchronously at boot. `PosAgentReceiptPrinter`/`PosAgentCashDrawer`
|
|
108
|
+
* are cheap to construct (no eager network call); the actual agent request
|
|
109
|
+
* happens lazily inside `print()`/`open()`/`isAvailable()`.
|
|
68
110
|
*/
|
|
69
111
|
function createWebHardware() {
|
|
70
112
|
if (isTauri()) return createTauriHardware();
|
|
71
113
|
return {
|
|
72
|
-
printer: new
|
|
73
|
-
drawer: new
|
|
114
|
+
printer: new CompositeReceiptPrinter(),
|
|
115
|
+
drawer: new CompositeCashDrawer(),
|
|
74
116
|
scanner: new WebBarcodeScannerStub(),
|
|
75
117
|
terminal: new WebPaymentTerminal()
|
|
76
118
|
};
|
|
77
119
|
}
|
|
78
120
|
|
|
79
121
|
//#endregion
|
|
80
|
-
export { RECEIPT_DOM_ID, createWebHardware };
|
|
122
|
+
export { RECEIPT_DOM_ID, createWebHardware, printViaBrowserWindow };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
//#region src/hooks/branch-scoped-key.ts
|
|
4
|
+
/**
|
|
5
|
+
* Per-branch localStorage keys for the register — and the ONE rule about them:
|
|
6
|
+
* **an unresolved branch is not a branch.**
|
|
7
|
+
*
|
|
8
|
+
* ## The bug this exists to make impossible
|
|
9
|
+
*
|
|
10
|
+
* `usePosCart` keyed its cart as `pos:cart:${branchId ?? "default"}` and
|
|
11
|
+
* `usePosMultiOrder` did the same for parked sales. `branchId` is
|
|
12
|
+
* `selectedBranch?.id`, so it is `undefined` for the first paint or two while the
|
|
13
|
+
* branch context resolves — and every other consumer in these screens already
|
|
14
|
+
* knows that (`{ enabled: !!branchId }`, `if (!branchId) return`). Only the
|
|
15
|
+
* storage key invented a value.
|
|
16
|
+
*
|
|
17
|
+
* The result, observed on a real register: a cashier taps a membership, gets an
|
|
18
|
+
* "Added to cart" toast, the line is written to `pos:cart:default`, and then the
|
|
19
|
+
* branch resolves, the hook's key becomes `pos:cart:<branchId>`, and the cart
|
|
20
|
+
* renders EMPTY. Nothing errored. The cashier re-rings the sale, or worse assumes
|
|
21
|
+
* the till is broken mid-queue. `PaymentScreen` reads the same key, so the line
|
|
22
|
+
* could not be paid for either.
|
|
23
|
+
*
|
|
24
|
+
* It is also a cross-branch leak in slow motion: `default` is shared by every
|
|
25
|
+
* branch, so whatever survives there surfaces at whichever till loads next.
|
|
26
|
+
*
|
|
27
|
+
* This is the codebase's documented failure mode (AGENTS.md "FAIL LOUD", rule 1
|
|
28
|
+
* and rule 3) in frontend clothing: a general fallback silently substituted for a
|
|
29
|
+
* specific value that had not arrived yet, producing a plausible screen instead
|
|
30
|
+
* of an error.
|
|
31
|
+
*
|
|
32
|
+
* ## Why a sentinel key rather than a conditional hook
|
|
33
|
+
*
|
|
34
|
+
* `useLocalStorage` needs a stable string key — a hook cannot be skipped. So an
|
|
35
|
+
* unresolved branch gets `UNRESOLVED_BRANCH_KEY`, and **every mutator refuses to
|
|
36
|
+
* write while that is the active key.** Nothing is ever stored under the
|
|
37
|
+
* sentinel, which is what makes the loss impossible: there is no orphan to lose.
|
|
38
|
+
* The window is under a second, and callers surface it (`ready === false`) rather
|
|
39
|
+
* than accepting taps into the void.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The key used while the branch is unknown. Deliberately unusable: no mutator
|
|
43
|
+
* writes to it, so it never accumulates state to be orphaned.
|
|
44
|
+
*
|
|
45
|
+
* `__` prefix so it cannot collide with a real branch id.
|
|
46
|
+
*/
|
|
47
|
+
const UNRESOLVED_BRANCH = "__unresolved__";
|
|
48
|
+
/**
|
|
49
|
+
* Build a branch-scoped storage key.
|
|
50
|
+
*
|
|
51
|
+
* A whitespace-only or empty `branchId` counts as unresolved: it would produce
|
|
52
|
+
* `pos:cart:` and silently share one namespace across branches, which is the
|
|
53
|
+
* same defect wearing a different value.
|
|
54
|
+
*/
|
|
55
|
+
function branchScopedKey(namespace, branchId) {
|
|
56
|
+
const resolved = typeof branchId === "string" && branchId.trim().length > 0;
|
|
57
|
+
return {
|
|
58
|
+
key: `${namespace}:${resolved ? branchId.trim() : UNRESOLVED_BRANCH}`,
|
|
59
|
+
ready: resolved
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//#endregion
|
|
64
|
+
export { UNRESOLVED_BRANCH, branchScopedKey };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { UsePosCartReturn, usePosCart } from "./usePosCart.js";
|
|
2
|
+
import { UsePosCustomerReturn, usePosCustomer } from "./usePosCustomer.js";
|
|
3
|
+
import { PaymentOption, SplitPaymentEntry, UsePosPaymentReturn, paymentNeedsReference, usePosPayment } from "./usePosPayment.js";
|
|
4
|
+
import { ParkedOrder, UsePosMultiOrderReturn, usePosMultiOrder } from "./usePosMultiOrder.js";
|
|
5
|
+
import { PosDeliveryAddress, PosDeliveryMethod, PosPaymentTerm, UsePosDeliveryReturn, usePosDelivery } from "./usePosDelivery.js";
|
|
6
|
+
export { ParkedOrder, type PaymentOption, PosDeliveryAddress, PosDeliveryMethod, PosPaymentTerm, type SplitPaymentEntry, UsePosCartReturn, UsePosCustomerReturn, UsePosDeliveryReturn, UsePosMultiOrderReturn, UsePosPaymentReturn, paymentNeedsReference, usePosCart, usePosCustomer, usePosDelivery, usePosMultiOrder, usePosPayment };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { usePosCart } from "./usePosCart.js";
|
|
2
|
+
import { usePosCustomer } from "./usePosCustomer.js";
|
|
3
|
+
import { usePosMultiOrder } from "./usePosMultiOrder.js";
|
|
4
|
+
import { paymentNeedsReference, usePosPayment } from "./usePosPayment.js";
|
|
5
|
+
import { usePosDelivery } from "./usePosDelivery.js";
|
|
6
|
+
|
|
7
|
+
export { paymentNeedsReference, usePosCart, usePosCustomer, usePosDelivery, usePosMultiOrder, usePosPayment };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region src/hooks/sale-attempt.ts
|
|
2
|
+
/**
|
|
3
|
+
* The sale-attempt key — what makes a retry a RETRY instead of a second sale.
|
|
4
|
+
*
|
|
5
|
+
* ## The bug this exists to make impossible
|
|
6
|
+
*
|
|
7
|
+
* `PaymentScreen` sent `idempotencyKey: generateIdempotencyKey()` inline in the
|
|
8
|
+
* checkout payload. Every submit therefore carried a fresh UUID, which means the
|
|
9
|
+
* server saw every submit as a distinct sale. The path that costs money:
|
|
10
|
+
*
|
|
11
|
+
* cashier taps "Complete Sale" → request times out (the order COMMITTED, the
|
|
12
|
+
* response never arrived) → error toast → cashier taps again → new key → a
|
|
13
|
+
* second order, a second stock decrement, a second charge.
|
|
14
|
+
*
|
|
15
|
+
* Nothing errors. Both orders are valid. The drawer reconciles against two sales
|
|
16
|
+
* that the customer paid for once. This is AGENTS.md FAIL LOUD rule 3 — absence
|
|
17
|
+
* of an answer is not a negative answer — and the SDK docblock even warned that
|
|
18
|
+
* "a random idempotency-key fallback defeats deduplication while looking
|
|
19
|
+
* correctly wired."
|
|
20
|
+
*
|
|
21
|
+
* The server was never the problem. `packages/order`'s order model carries a
|
|
22
|
+
* partial-unique index on `idempotencyKey` and `@spinekit/pos` passes it as the
|
|
23
|
+
* placement's `winnerFilter`, so a repeated key replays the winning order rather
|
|
24
|
+
* than creating another. That guarantee was fully built and simply never
|
|
25
|
+
* reachable, because the client never sent the same key twice.
|
|
26
|
+
*
|
|
27
|
+
* ## The rule
|
|
28
|
+
*
|
|
29
|
+
* The key identifies the ATTEMPT — one cashier trying to close one cart — not
|
|
30
|
+
* the HTTP call. So it must:
|
|
31
|
+
*
|
|
32
|
+
* - be minted ONCE, when checkout starts;
|
|
33
|
+
* - SURVIVE a failure and a page reload (that is when retries happen);
|
|
34
|
+
* - be reused verbatim by every retry of that same sale;
|
|
35
|
+
* - be discarded only once the sale has actually succeeded.
|
|
36
|
+
*
|
|
37
|
+
* `usePosCart` binds that lifetime to branch-scoped storage and clears it in
|
|
38
|
+
* `resetCart()` — which already is the "this sale is finished" boundary. The two
|
|
39
|
+
* functions here are the pure part, split out so the rule is unit-testable
|
|
40
|
+
* without a DOM.
|
|
41
|
+
*/
|
|
42
|
+
/** Mint a fresh attempt id. Prefixed so it is identifiable in server logs. */
|
|
43
|
+
function newSaleAttemptId() {
|
|
44
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return `pos_${globalThis.crypto.randomUUID()}`;
|
|
45
|
+
return `pos_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Reuse the stored attempt key if there is one, otherwise mint.
|
|
49
|
+
*
|
|
50
|
+
* The whole point is the FIRST branch: a stored key means an attempt is already
|
|
51
|
+
* in flight or has already failed once, and reusing it is what lets the server
|
|
52
|
+
* recognise the retry. Whitespace and `""` count as absent — a blank key
|
|
53
|
+
* type-checks as a string and dedupes nothing, so treating it as present would
|
|
54
|
+
* reinstate the bug with a value that merely looks set.
|
|
55
|
+
*/
|
|
56
|
+
function resolveSaleAttemptKey(stored, mint = newSaleAttemptId) {
|
|
57
|
+
if (typeof stored === "string" && stored.trim().length > 0) return stored.trim();
|
|
58
|
+
return mint();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { newSaleAttemptId, resolveSaleAttemptKey };
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { usePosAuth } from "../runtime/auth-port.js";
|
|
4
4
|
import { useCallback, useState } from "react";
|
|
5
|
+
import { presentError } from "@classytic/fluid/error-contract";
|
|
5
6
|
import { useMutation } from "@tanstack/react-query";
|
|
6
7
|
|
|
7
8
|
//#region src/hooks/useManagerAuth.ts
|
|
@@ -74,7 +75,14 @@ function useManagerAuth(options = {}) {
|
|
|
74
75
|
authorize,
|
|
75
76
|
clearAuth,
|
|
76
77
|
isPending: verifyMutation.isPending,
|
|
77
|
-
|
|
78
|
+
/**
|
|
79
|
+
* A single line for `ManagerAuthDialog`'s banner, resolved through the
|
|
80
|
+
* shared error contract so a refusal the server CODED (a role denial) does
|
|
81
|
+
* not read the same as an unreachable network. The dialog has one text
|
|
82
|
+
* slot, so the presentation's `message` is the whole thing — it is written
|
|
83
|
+
* to stand alone without its title.
|
|
84
|
+
*/
|
|
85
|
+
error: verifyMutation.error ? presentError(verifyMutation.error, "Those credentials were not accepted.").message : null,
|
|
78
86
|
reset: verifyMutation.reset
|
|
79
87
|
};
|
|
80
88
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PosCartItem as PosCartItem$1 } from "../dashboard/pos.types.js";
|
|
2
|
+
import { PosProduct } from "@classytic/commerce-sdk/sales";
|
|
3
|
+
//#region src/hooks/usePosCart.d.ts
|
|
4
|
+
interface UsePosCartReturn {
|
|
5
|
+
cart: PosCartItem$1[];
|
|
6
|
+
membershipCardId: string;
|
|
7
|
+
pointsToRedeemInput: string;
|
|
8
|
+
discountInput: string;
|
|
9
|
+
setDiscountInput: (value: string) => void;
|
|
10
|
+
setMembershipCardId: (value: string) => void;
|
|
11
|
+
setPointsToRedeemInput: (value: string) => void;
|
|
12
|
+
addToCart: (product: PosProduct, variantSku?: string) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Append a cashier-authored ad-hoc charge line to the cart (gift wrap,
|
|
15
|
+
* damaged-product reimbursement, custom handling).
|
|
16
|
+
*
|
|
17
|
+
* The label is what the cashier types; the system derives a
|
|
18
|
+
* slug-shaped `serviceCode` from it for reporting consistency
|
|
19
|
+
* (`Gift wrap` and `gift wrap` collapse to `gift-wrap`). `amountMajor`
|
|
20
|
+
* is in major units (matches `unitPrice` semantics on existing cart
|
|
21
|
+
* items); PaymentScreen converts to minor units at the order-create boundary.
|
|
22
|
+
*/
|
|
23
|
+
addCharge: (label: string, amountMajor: number) => void;
|
|
24
|
+
updateQuantity: (index: number, delta: number) => void;
|
|
25
|
+
removeItem: (index: number) => void;
|
|
26
|
+
clearCart: () => void;
|
|
27
|
+
resetCart: () => void;
|
|
28
|
+
/** Replace the entire cart (used by resume-parked-order). */
|
|
29
|
+
replaceCart: (items: PosCartItem$1[]) => void;
|
|
30
|
+
/**
|
|
31
|
+
* The idempotency key for checking THIS cart out — minted on first call and
|
|
32
|
+
* then stable until `resetCart()`, which runs only after a sale succeeds.
|
|
33
|
+
*
|
|
34
|
+
* Call it at submit and send the result as `PosOrderPayload.idempotencyKey`.
|
|
35
|
+
* Because it is persisted per branch, a retry after a timeout — or after a
|
|
36
|
+
* reload — sends the SAME key, which is the only thing that lets the server's
|
|
37
|
+
* replay-the-winner index return the existing order instead of ringing a
|
|
38
|
+
* second sale. See `sale-attempt.ts` for the full account.
|
|
39
|
+
*
|
|
40
|
+
* Returns `null` while the branch is unresolved: an attempt cannot be scoped
|
|
41
|
+
* to a branch that does not exist yet, and minting into the sentinel namespace
|
|
42
|
+
* would produce a key that is discarded a moment later — which is the same
|
|
43
|
+
* defect wearing a different value.
|
|
44
|
+
*/
|
|
45
|
+
beginSaleAttempt: () => string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Is the cart bound to a resolved branch? While `false` every mutator refuses
|
|
48
|
+
* (see `branch-scoped-key.ts`) — gate the checkout affordance on it rather than
|
|
49
|
+
* letting a cashier discover it from an error toast.
|
|
50
|
+
*/
|
|
51
|
+
ready: boolean;
|
|
52
|
+
}
|
|
53
|
+
declare function usePosCart(branchId?: string): UsePosCartReturn;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { UsePosCartReturn, usePosCart };
|