@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
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import { PosHardware } from "../hardware/ports.js";
|
|
2
|
+
import "../hardware/index.js";
|
|
2
3
|
import { PosAuthPort } from "../runtime/auth-port.js";
|
|
3
|
-
import { PosRuntime } from "../runtime/config.js";
|
|
4
|
+
import { PosRuntime, PosSalesCapabilities } from "../runtime/config.js";
|
|
4
5
|
import { PosBranchPort } from "../runtime/branch-port.js";
|
|
6
|
+
import "../runtime/index.js";
|
|
7
|
+
import { PosScreen } from "../state/pos-state.js";
|
|
8
|
+
import "../state/index.js";
|
|
9
|
+
import { ReactNode } from "react";
|
|
5
10
|
//#region src/shell/pos-shell.d.ts
|
|
11
|
+
/**
|
|
12
|
+
* Where a vertical injects its own UI WITHOUT forking the shell.
|
|
13
|
+
*
|
|
14
|
+
* The shell owns the register's spine — the screen state machine, hotkeys,
|
|
15
|
+
* wake-lock, shift rehydration — which is identical everywhere. What differs
|
|
16
|
+
* per vertical is decoration and, occasionally, one whole screen: a gym wants a
|
|
17
|
+
* "Find member" action and a guest banner; a hotel wants "Charge to room".
|
|
18
|
+
* Those are slots, not forks.
|
|
19
|
+
*/
|
|
20
|
+
interface PosShellSlots {
|
|
21
|
+
/** Extra top-bar actions — e.g. member lookup, day pass. */
|
|
22
|
+
topBarActions?: ReactNode;
|
|
23
|
+
/** Above the active screen — e.g. the checked-in member / guest banner. */
|
|
24
|
+
aboveScreen?: ReactNode;
|
|
25
|
+
/** Below the active screen — e.g. a scanner/turnstile status strip. */
|
|
26
|
+
belowScreen?: ReactNode;
|
|
27
|
+
/** Always mounted — the host's own drawers and dialogs. */
|
|
28
|
+
overlays?: ReactNode;
|
|
29
|
+
/**
|
|
30
|
+
* Wrap or REPLACE a screen. Receives the screen key and the default node;
|
|
31
|
+
* return the default to keep it, or anything else to take it over. This is
|
|
32
|
+
* the escape hatch that makes forking unnecessary — a gym can replace only
|
|
33
|
+
* `products` and inherit every other screen's fixes.
|
|
34
|
+
*/
|
|
35
|
+
renderScreen?: (screen: PosScreen, defaultNode: ReactNode) => ReactNode;
|
|
36
|
+
}
|
|
6
37
|
interface PosShellProps {
|
|
7
38
|
/** Auth seam — host wires it to its auth client. */
|
|
8
39
|
auth: PosAuthPort;
|
|
@@ -12,12 +43,14 @@ interface PosShellProps {
|
|
|
12
43
|
runtime: PosRuntime;
|
|
13
44
|
/** Hardware port; defaults to the web adapter. */
|
|
14
45
|
hardware?: PosHardware;
|
|
46
|
+
/** Vertical-specific UI injected into the shell. See `PosShellSlots`. */
|
|
47
|
+
slots?: PosShellSlots;
|
|
48
|
+
/**
|
|
49
|
+
* Sales capabilities this deployment has enabled. Every flag defaults to OFF,
|
|
50
|
+
* so an existing host that omits this keeps exactly its current surface.
|
|
51
|
+
*/
|
|
52
|
+
sales?: PosSalesCapabilities;
|
|
15
53
|
}
|
|
16
|
-
declare function PosShell({
|
|
17
|
-
auth,
|
|
18
|
-
branch,
|
|
19
|
-
runtime,
|
|
20
|
-
hardware
|
|
21
|
-
}: PosShellProps): import("react").JSX.Element;
|
|
54
|
+
declare function PosShell({ auth, branch, runtime, hardware, slots, sales }: PosShellProps): import("react").JSX.Element;
|
|
22
55
|
//#endregion
|
|
23
|
-
export { PosShell, PosShellProps };
|
|
56
|
+
export { PosShell, PosShellProps, PosShellSlots };
|
package/dist/shell/pos-shell.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { PosAuthProvider, usePosAuth } from "../runtime/auth-port.js";
|
|
4
|
-
import { PosRuntimeProvider } from "../runtime/config.js";
|
|
4
|
+
import { PosRuntimeProvider, PosSalesCapabilitiesProvider } from "../runtime/config.js";
|
|
5
5
|
import { PosBranchProvider } from "../runtime/branch-port.js";
|
|
6
6
|
import { createWebHardware } from "../hardware/web-adapter.js";
|
|
7
7
|
import { HardwareProvider } from "../hardware/context.js";
|
|
@@ -31,7 +31,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
31
31
|
*
|
|
32
32
|
* Screen flow: idle (shift open) → products → payment → receipt → products
|
|
33
33
|
*/
|
|
34
|
-
function PosScreenRouter() {
|
|
34
|
+
function PosScreenRouter({ slots }) {
|
|
35
35
|
const { state, dispatch } = usePosContext();
|
|
36
36
|
const sessionUser = usePosAuth().useSessionUser();
|
|
37
37
|
const { shift: activeShift } = usePosCurrentShift();
|
|
@@ -41,7 +41,10 @@ function PosScreenRouter() {
|
|
|
41
41
|
const [printerSettingsOpen, setPrinterSettingsOpen] = useState(false);
|
|
42
42
|
useWakeLock({ enabled: state.shiftOpen });
|
|
43
43
|
useEffect(() => {
|
|
44
|
-
if (state.screen === "idle" && activeShift?.state === "open" && !state.shiftOpen) dispatch({
|
|
44
|
+
if (state.screen === "idle" && activeShift?.state === "open" && !state.shiftOpen) dispatch({
|
|
45
|
+
type: "OPEN_SHIFT",
|
|
46
|
+
registerId: activeShift.registerId ?? null
|
|
47
|
+
});
|
|
45
48
|
}, [
|
|
46
49
|
state.screen,
|
|
47
50
|
state.shiftOpen,
|
|
@@ -74,26 +77,26 @@ function PosScreenRouter() {
|
|
|
74
77
|
const handleNewSale = useCallback(() => {
|
|
75
78
|
if (state.screen === "receipt") dispatch({ type: "NEW_SALE" });
|
|
76
79
|
}, [state.screen, dispatch]);
|
|
80
|
+
const defaultScreen = state.screen === "idle" ? /* @__PURE__ */ jsx(ShiftOpenScreen, {}) : state.screen === "products" ? /* @__PURE__ */ jsx(ProductScreen, {}) : state.screen === "payment" ? /* @__PURE__ */ jsx(PaymentScreen, {}) : state.screen === "receipt" ? /* @__PURE__ */ jsx(ReceiptScreen, {}) : state.screen === "closing" ? /* @__PURE__ */ jsx(ShiftCloseScreen, {}) : null;
|
|
81
|
+
const screenNode = slots.renderScreen ? slots.renderScreen(state.screen, defaultScreen) : defaultScreen;
|
|
77
82
|
return /* @__PURE__ */ jsxs("div", {
|
|
78
83
|
className: "flex flex-col h-screen",
|
|
79
84
|
children: [
|
|
80
85
|
state.screen !== "idle" && /* @__PURE__ */ jsx(PosTopBar, {
|
|
86
|
+
actions: slots.topBarActions,
|
|
81
87
|
userName,
|
|
82
88
|
onNewSale: state.screen === "products" ? handleNewSale : void 0,
|
|
83
89
|
onOpenHistory: () => setHistoryOpen(true),
|
|
84
90
|
onOpenHelp: () => setHelpOpen(true),
|
|
85
91
|
onOpenPrinterSettings: () => setPrinterSettingsOpen(true)
|
|
86
92
|
}),
|
|
87
|
-
|
|
93
|
+
slots.aboveScreen,
|
|
94
|
+
/* @__PURE__ */ jsx("div", {
|
|
88
95
|
className: "flex-1 min-h-0",
|
|
89
|
-
children:
|
|
90
|
-
state.screen === "idle" && /* @__PURE__ */ jsx(ShiftOpenScreen, {}),
|
|
91
|
-
state.screen === "products" && /* @__PURE__ */ jsx(ProductScreen, {}),
|
|
92
|
-
state.screen === "payment" && /* @__PURE__ */ jsx(PaymentScreen, {}),
|
|
93
|
-
state.screen === "receipt" && /* @__PURE__ */ jsx(ReceiptScreen, {}),
|
|
94
|
-
state.screen === "closing" && /* @__PURE__ */ jsx(ShiftCloseScreen, {})
|
|
95
|
-
]
|
|
96
|
+
children: screenNode
|
|
96
97
|
}),
|
|
98
|
+
slots.belowScreen,
|
|
99
|
+
slots.overlays,
|
|
97
100
|
/* @__PURE__ */ jsx(OrderHistoryDrawer, {
|
|
98
101
|
open: historyOpen,
|
|
99
102
|
onOpenChange: setHistoryOpen
|
|
@@ -109,19 +112,23 @@ function PosScreenRouter() {
|
|
|
109
112
|
]
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
|
-
function PosShell({ auth, branch, runtime, hardware }) {
|
|
115
|
+
function PosShell({ auth, branch, runtime, hardware, slots, sales }) {
|
|
116
|
+
const hw = useMemo(() => hardware ?? createWebHardware(), [hardware]);
|
|
113
117
|
return /* @__PURE__ */ jsx(PosAuthProvider, {
|
|
114
118
|
auth,
|
|
115
119
|
children: /* @__PURE__ */ jsx(PosBranchProvider, {
|
|
116
120
|
branch,
|
|
117
121
|
children: /* @__PURE__ */ jsx(PosRuntimeProvider, {
|
|
118
122
|
runtime,
|
|
119
|
-
children: /* @__PURE__ */ jsx(
|
|
120
|
-
|
|
121
|
-
children: /* @__PURE__ */ jsx(
|
|
122
|
-
|
|
123
|
-
children: /* @__PURE__ */ jsx(
|
|
124
|
-
|
|
123
|
+
children: /* @__PURE__ */ jsx(PosSalesCapabilitiesProvider, {
|
|
124
|
+
sales: sales ?? {},
|
|
125
|
+
children: /* @__PURE__ */ jsx(DensityProvider, {
|
|
126
|
+
density: "auto",
|
|
127
|
+
children: /* @__PURE__ */ jsx(HotkeyRegistryProvider, { children: /* @__PURE__ */ jsx(HardwareProvider, {
|
|
128
|
+
hardware: hw,
|
|
129
|
+
children: /* @__PURE__ */ jsx(PosProvider, { children: /* @__PURE__ */ jsx(PosScreenRouter, { slots: slots ?? {} }) })
|
|
130
|
+
}) })
|
|
131
|
+
})
|
|
125
132
|
})
|
|
126
133
|
})
|
|
127
134
|
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { INITIAL_POS_STATE, PosAction, PosScreen, PosState, posReducer } from "./pos-state.js";
|
|
2
|
+
import { PosProvider, usePosContext } from "./pos-context.js";
|
|
3
|
+
export { INITIAL_POS_STATE, type PosAction, PosProvider, type PosScreen, type PosState, posReducer, usePosContext };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PosAction, PosState } from "./pos-state.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/state/pos-context.d.ts
|
|
4
|
+
interface PosContextValue {
|
|
5
|
+
state: PosState;
|
|
6
|
+
dispatch: (action: PosAction) => void;
|
|
7
|
+
}
|
|
8
|
+
declare function PosProvider({ children }: {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
declare function usePosContext(): PosContextValue;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { PosProvider, usePosContext };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PosReceiptData } from "@classytic/commerce-sdk/sales";
|
|
2
|
+
//#region src/state/pos-state.d.ts
|
|
3
|
+
type PosScreen = "idle" | "products" | "payment" | "receipt" | "closing";
|
|
4
|
+
type PosAction = {
|
|
5
|
+
type: "OPEN_SHIFT";
|
|
6
|
+
registerId?: string | null;
|
|
7
|
+
} | {
|
|
8
|
+
type: "GO_TO_PAYMENT";
|
|
9
|
+
} | {
|
|
10
|
+
type: "GO_BACK";
|
|
11
|
+
} | {
|
|
12
|
+
type: "COMPLETE_SALE";
|
|
13
|
+
receipt: PosReceiptData | null;
|
|
14
|
+
} | {
|
|
15
|
+
type: "NEW_SALE";
|
|
16
|
+
} | {
|
|
17
|
+
type: "CLOSE_SHIFT";
|
|
18
|
+
} | {
|
|
19
|
+
type: "CONFIRM_CLOSE";
|
|
20
|
+
} | {
|
|
21
|
+
type: "CANCEL_CLOSE";
|
|
22
|
+
};
|
|
23
|
+
interface PosState {
|
|
24
|
+
screen: PosScreen;
|
|
25
|
+
/**
|
|
26
|
+
* Last completed order receipt (for ReceiptScreen).
|
|
27
|
+
*
|
|
28
|
+
* Typed, not `unknown`. It was `unknown` and every consumer cast it back
|
|
29
|
+
* (`state.lastReceipt as PosReceiptData | null` in ReceiptScreen), which is
|
|
30
|
+
* the worst of both: no compile-time contract AND the appearance of one. The
|
|
31
|
+
* shape is the SDK's — `transformOrderToReceipt` already produces exactly it,
|
|
32
|
+
* including a `null` for an unparseable response, so the null is real state
|
|
33
|
+
* and not a placeholder.
|
|
34
|
+
*/
|
|
35
|
+
lastReceipt: PosReceiptData | null;
|
|
36
|
+
/** Whether a shift is currently open */
|
|
37
|
+
shiftOpen: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Register (`@classytic/pos` Register `_id`) the open shift is bound to.
|
|
40
|
+
* Threads into `/current` scoping + POS order placement so a sale hits the
|
|
41
|
+
* right register's shift when a branch runs several. Null before open / when
|
|
42
|
+
* the branch has a single register the backend resolves implicitly.
|
|
43
|
+
*/
|
|
44
|
+
activeRegisterId: string | null;
|
|
45
|
+
}
|
|
46
|
+
declare const INITIAL_POS_STATE: PosState;
|
|
47
|
+
declare function posReducer(state: PosState, action: PosAction): PosState;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { INITIAL_POS_STATE, PosAction, PosScreen, PosState, posReducer };
|
package/dist/state/pos-state.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
const INITIAL_POS_STATE = {
|
|
3
3
|
screen: "idle",
|
|
4
4
|
lastReceipt: null,
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
shiftOpen: false,
|
|
6
|
+
activeRegisterId: null
|
|
7
7
|
};
|
|
8
8
|
function posReducer(state, action) {
|
|
9
9
|
switch (action.type) {
|
|
@@ -12,7 +12,8 @@ function posReducer(state, action) {
|
|
|
12
12
|
return {
|
|
13
13
|
...state,
|
|
14
14
|
screen: "products",
|
|
15
|
-
shiftOpen: true
|
|
15
|
+
shiftOpen: true,
|
|
16
|
+
activeRegisterId: action.registerId ?? state.activeRegisterId
|
|
16
17
|
};
|
|
17
18
|
case "GO_TO_PAYMENT":
|
|
18
19
|
if (state.screen !== "products") return state;
|
|
@@ -28,8 +29,7 @@ function posReducer(state, action) {
|
|
|
28
29
|
if (state.screen === "receipt") return {
|
|
29
30
|
...state,
|
|
30
31
|
screen: "products",
|
|
31
|
-
lastReceipt: null
|
|
32
|
-
lastCashReceived: null
|
|
32
|
+
lastReceipt: null
|
|
33
33
|
};
|
|
34
34
|
return state;
|
|
35
35
|
case "COMPLETE_SALE":
|
|
@@ -44,8 +44,7 @@ function posReducer(state, action) {
|
|
|
44
44
|
return {
|
|
45
45
|
...state,
|
|
46
46
|
screen: "products",
|
|
47
|
-
lastReceipt: null
|
|
48
|
-
lastCashReceived: null
|
|
47
|
+
lastReceipt: null
|
|
49
48
|
};
|
|
50
49
|
case "CLOSE_SHIFT":
|
|
51
50
|
if (!state.shiftOpen) return state;
|
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isValidPhone } from "@classytic/commerce-sdk/client";
|
|
1
|
+
import { TenantMinorUnits, formatMinor, minorPerMajorUnit, minorToMoney, toMajor } from "../lib/money.js";
|
|
2
|
+
import { getReceiptLocale, isValidPhone } from "@classytic/commerce-sdk/client";
|
|
3
3
|
|
|
4
4
|
//#region src/utils/pos-helpers.ts
|
|
5
|
-
/** Brand a
|
|
6
|
-
* expected to already be in
|
|
7
|
-
* just re-asserts the brand after a
|
|
8
|
-
|
|
5
|
+
/** Brand a MINOR-unit value read out of an unknown response. The number is
|
|
6
|
+
* expected to already be in minor units (the server's canonical unit — paisa
|
|
7
|
+
* on a BDT deployment); this helper just re-asserts the brand after a
|
|
8
|
+
* `typeof` guard. */
|
|
9
|
+
const minorOr0 = (v) => TenantMinorUnits(typeof v === "number" ? v : 0);
|
|
9
10
|
/**
|
|
10
|
-
* Parse cash
|
|
11
|
+
* Parse the cash-received field to a non-negative number.
|
|
12
|
+
*
|
|
13
|
+
* Clamps at 0 as well as guarding NaN. Negative cash is meaningless, and an
|
|
14
|
+
* unclamped negative propagates into `calculateAmountDue`, which then shows MORE
|
|
15
|
+
* owed than the sale total — a cashier reading "৳150 due" on a ৳100 sale. The
|
|
16
|
+
* function is named for a quantity that cannot be negative, so it should not be
|
|
17
|
+
* able to return one.
|
|
11
18
|
*/
|
|
12
19
|
function parseCashReceived(value) {
|
|
13
20
|
const n = Number(value);
|
|
14
|
-
|
|
21
|
+
if (!Number.isFinite(n)) return 0;
|
|
22
|
+
return n > 0 ? n : 0;
|
|
15
23
|
}
|
|
16
24
|
/**
|
|
17
25
|
* Parse input to positive number (min 0)
|
|
@@ -35,6 +43,23 @@ function calculateCartTotals(items, discountInput) {
|
|
|
35
43
|
itemCount: items.length
|
|
36
44
|
};
|
|
37
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Points per ONE MAJOR unit, derived from the config's `pointValueMinor`
|
|
48
|
+
* (minor units one point redeems for).
|
|
49
|
+
*
|
|
50
|
+
* ONE function because there were TWO copies of this derivation in this file —
|
|
51
|
+
* `validateRedemption` and `calculateOrderTotals` — each with its own hardcoded
|
|
52
|
+
* `100`. Two copies of one rate is how a redemption cap and a redemption charge
|
|
53
|
+
* come to disagree, and neither would throw. The factor now comes from the
|
|
54
|
+
* currency (KWD is 1000, JPY is 1), so the arithmetic is not BDT-only either.
|
|
55
|
+
*
|
|
56
|
+
* Returns 1 when the rate is absent or non-positive — there is no correct answer
|
|
57
|
+
* without a configured rate, which is why the UI must not print a money figure
|
|
58
|
+
* in that state (see `PointsRedemptionSection`).
|
|
59
|
+
*/
|
|
60
|
+
function pointsPerMajorUnit(pointValueMinor) {
|
|
61
|
+
return typeof pointValueMinor === "number" && pointValueMinor > 0 ? minorPerMajorUnit() / pointValueMinor : 1;
|
|
62
|
+
}
|
|
38
63
|
function validateRedemption(pointsToRedeem, customerPoints, orderTotal, membershipConfig) {
|
|
39
64
|
if (!membershipConfig?.enabled) return {
|
|
40
65
|
valid: false,
|
|
@@ -53,23 +78,38 @@ function validateRedemption(pointsToRedeem, customerPoints, orderTotal, membersh
|
|
|
53
78
|
valid: false,
|
|
54
79
|
error: `Insufficient points. Available: ${customerPoints}`
|
|
55
80
|
};
|
|
56
|
-
|
|
81
|
+
/**
|
|
82
|
+
* `minOrderAmount` is integer MINOR units (be-prod's `loyalty.membership`
|
|
83
|
+
* contract), while `orderTotal` reaching this function is MAJOR units — so the
|
|
84
|
+
* two were compared across denominations. A ৳500 minimum is `50000` paisa, and
|
|
85
|
+
* `orderTotal < 50000` is true for every realistic basket, so redemption was
|
|
86
|
+
* refused on essentially every sale while the message read "৳50000 required".
|
|
87
|
+
* Nothing threw; the cashier just saw an impossible threshold.
|
|
88
|
+
*
|
|
89
|
+
* Converted through primitives (`toMajor(minorToMoney(...))`) rather than a
|
|
90
|
+
* `/ 100`, so the factor is the CURRENCY's — KWD is 1000, JPY is 1 — and the
|
|
91
|
+
* message is formatted by the same `Intl`-backed formatter as every other
|
|
92
|
+
* amount instead of a hand-glued symbol.
|
|
93
|
+
*/
|
|
94
|
+
const minOrderMinor = config.minOrderAmount ?? 0;
|
|
95
|
+
if (orderTotal < toMajor(minorToMoney(minOrderMinor))) return {
|
|
57
96
|
valid: false,
|
|
58
|
-
error: `Minimum order of
|
|
97
|
+
error: `Minimum order of ${formatMinor(minOrderMinor)} required`
|
|
59
98
|
};
|
|
99
|
+
const pointsPerMajor = pointsPerMajorUnit(config.pointValueMinor);
|
|
60
100
|
const maxDiscount = Math.floor(orderTotal * (config.maxRedeemPercent ?? 100) / 100);
|
|
61
|
-
const requestedDiscount = Math.floor(pointsToRedeem /
|
|
101
|
+
const requestedDiscount = Math.floor(pointsToRedeem / pointsPerMajor);
|
|
62
102
|
let actualDiscount = requestedDiscount;
|
|
63
103
|
let actualPoints = pointsToRedeem;
|
|
64
104
|
if (requestedDiscount > maxDiscount) {
|
|
65
105
|
actualDiscount = maxDiscount;
|
|
66
|
-
actualPoints = maxDiscount *
|
|
106
|
+
actualPoints = maxDiscount * pointsPerMajor;
|
|
67
107
|
}
|
|
68
108
|
return {
|
|
69
109
|
valid: true,
|
|
70
110
|
discountAmount: actualDiscount,
|
|
71
111
|
pointsToRedeem: actualPoints,
|
|
72
|
-
maxAllowedPoints: maxDiscount *
|
|
112
|
+
maxAllowedPoints: maxDiscount * pointsPerMajor
|
|
73
113
|
};
|
|
74
114
|
}
|
|
75
115
|
function calculateOrderTotals({ items, manualDiscountInput, membershipConfig, customer, pointsToRedeemInput }) {
|
|
@@ -92,7 +132,7 @@ function calculateOrderTotals({ items, manualDiscountInput, membershipConfig, cu
|
|
|
92
132
|
maxAllowedPoints = result.maxAllowedPoints ?? 0;
|
|
93
133
|
} else redemptionError = result.error ?? "Redemption not available";
|
|
94
134
|
}
|
|
95
|
-
if (!maxAllowedPoints && membershipConfig?.enabled && membershipConfig?.redemption?.enabled) maxAllowedPoints = Math.floor(preliminaryTotal * (membershipConfig.redemption.maxRedeemPercent ?? 100) / 100) * (membershipConfig.redemption.
|
|
135
|
+
if (!maxAllowedPoints && membershipConfig?.enabled && membershipConfig?.redemption?.enabled) maxAllowedPoints = Math.floor(preliminaryTotal * (membershipConfig.redemption.maxRedeemPercent ?? 100) / 100) * pointsPerMajorUnit(membershipConfig.redemption.pointValueMinor);
|
|
96
136
|
const total = Math.max(0, preliminaryTotal - redemptionDiscount);
|
|
97
137
|
return {
|
|
98
138
|
subtotal,
|
|
@@ -129,23 +169,11 @@ function calculateAmountDue(cashReceived, total) {
|
|
|
129
169
|
function isValidBDPhone(phone) {
|
|
130
170
|
return isValidPhone(phone);
|
|
131
171
|
}
|
|
132
|
-
/**
|
|
133
|
-
* Check if a search string looks like a phone number. `exact` uses the
|
|
134
|
-
* deployment's phone pattern (locale-driven); `likely` is a loose digit-run
|
|
135
|
-
* heuristic that stays country-neutral.
|
|
136
|
-
*/
|
|
137
|
-
function isPhoneSearch(query) {
|
|
138
|
-
const trimmed = query.trim();
|
|
139
|
-
return {
|
|
140
|
-
exact: isValidPhone(trimmed),
|
|
141
|
-
likely: /^\d{4,}$/.test(trimmed)
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
172
|
/** Read paisa from a Money struct `{ amount, currency }` returned by the
|
|
145
173
|
* order kernel. Falls back to a bare number for legacy shapes. */
|
|
146
174
|
const moneyAmount = (m) => {
|
|
147
|
-
if (m && typeof m === "object" && "amount" in m && typeof m.amount === "number") return
|
|
148
|
-
return
|
|
175
|
+
if (m && typeof m === "object" && "amount" in m && typeof m.amount === "number") return TenantMinorUnits(m.amount);
|
|
176
|
+
return minorOr0(m);
|
|
149
177
|
};
|
|
150
178
|
/**
|
|
151
179
|
* Transform a POS order response to receipt format.
|
|
@@ -185,9 +213,18 @@ function transformOrderToReceipt(orderResponse, branchInfo) {
|
|
|
185
213
|
const meta = order.metadata ?? {};
|
|
186
214
|
const membership = meta.membership ?? {};
|
|
187
215
|
const vat = meta.vat ?? {};
|
|
188
|
-
const primaryPayment = (
|
|
216
|
+
const primaryPayment = (order.tenders ?? [])[0];
|
|
217
|
+
/**
|
|
218
|
+
* Change handed back, from the placement's own record. Over-tender is modelled rather than
|
|
219
|
+
* absorbed (`tenders[].amount` is the APPLIED share and the excess is change), so the
|
|
220
|
+
* receipt can finally show it — the one place the customer actually checks it.
|
|
221
|
+
*/
|
|
222
|
+
const tenderMeta = meta.tender ?? {};
|
|
223
|
+
const changeMinor = typeof tenderMeta.changeMinor === "number" && tenderMeta.changeMinor > 0 ? tenderMeta.changeMinor : void 0;
|
|
189
224
|
const itemVatAmount = (line) => {
|
|
190
|
-
|
|
225
|
+
const unitTax = Number(moneyAmount(line.unitTax));
|
|
226
|
+
const qty = typeof line.quantity === "number" ? line.quantity : 1;
|
|
227
|
+
return TenantMinorUnits(unitTax * qty);
|
|
191
228
|
};
|
|
192
229
|
return {
|
|
193
230
|
orderId: String(order._id || ""),
|
|
@@ -204,7 +241,7 @@ function transformOrderToReceipt(orderResponse, branchInfo) {
|
|
|
204
241
|
cardId: String(membership.cardId),
|
|
205
242
|
tier: membership.tier ? String(membership.tier) : void 0,
|
|
206
243
|
pointsEarned: typeof membership.pointsEarned === "number" ? membership.pointsEarned : void 0,
|
|
207
|
-
tierDiscount:
|
|
244
|
+
tierDiscount: minorOr0(membership.tierDiscountApplied)
|
|
208
245
|
} : void 0,
|
|
209
246
|
items: lines.map((line) => {
|
|
210
247
|
const snap = line.snapshot ?? {};
|
|
@@ -225,18 +262,84 @@ function transformOrderToReceipt(orderResponse, branchInfo) {
|
|
|
225
262
|
applicable: !!vat.applicable,
|
|
226
263
|
rate: typeof vat.rate === "number" ? vat.rate : 0,
|
|
227
264
|
amount: moneyAmount(totals.tax),
|
|
228
|
-
taxableAmount:
|
|
265
|
+
taxableAmount: minorOr0(vat.taxableAmount),
|
|
229
266
|
sellerBin: vat.sellerBin ? String(vat.sellerBin) : void 0,
|
|
230
267
|
pricesIncludeVat: typeof vat.pricesIncludeVat === "boolean" ? vat.pricesIncludeVat : true
|
|
231
268
|
},
|
|
232
269
|
total: moneyAmount(totals.grandTotal),
|
|
233
270
|
payment: {
|
|
271
|
+
/**
|
|
272
|
+
* The real tender method from `order.tenders[0]`. `'cash'` remains the fallback for an
|
|
273
|
+
* order with no tender at all (a comped or unpaid sale), NOT for one whose method simply
|
|
274
|
+
* lived somewhere this mapper had stopped looking.
|
|
275
|
+
*/
|
|
234
276
|
method: primaryPayment?.method ?? "cash",
|
|
235
277
|
...primaryPayment?.reference ? { reference: primaryPayment.reference } : {},
|
|
236
278
|
amount: moneyAmount(paymentState.totalCharged) || moneyAmount(totals.grandTotal)
|
|
237
|
-
}
|
|
279
|
+
},
|
|
280
|
+
/**
|
|
281
|
+
* Change, when there was any. Printing it is the point of modelling it — the customer
|
|
282
|
+
* verifies the change against the receipt at the counter.
|
|
283
|
+
*/
|
|
284
|
+
...changeMinor !== void 0 ? { changeDue: changeMinor } : {}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* `PosReceiptData` (either the fresh-checkout shape or `transformOrderToReceipt`'s
|
|
289
|
+
* reprint shape — both converge on this) → the structured `ReceiptPayload` the
|
|
290
|
+
* hardware port's agent/Tauri adapters need. Shared by `ReceiptScreen` (live
|
|
291
|
+
* checkout) and `ReceiptReprintDialog` (historical reprint) so both paths build
|
|
292
|
+
* the SAME payload shape for a real thermal printer — previously only the live
|
|
293
|
+
* screen went through the printer port at all; a reprint called
|
|
294
|
+
* `printDocument()` directly and could never reach a paired ESC/POS printer.
|
|
295
|
+
*/
|
|
296
|
+
function buildReceiptPayload(receipt, branchName, branchPhone) {
|
|
297
|
+
const items = receipt.items ?? [];
|
|
298
|
+
const customerName = receipt.customer?.name?.trim() ?? "";
|
|
299
|
+
const showCustomer = customerName.length > 0 && customerName !== "Walk-in Customer";
|
|
300
|
+
const customerPhoneRaw = receipt.customer?.phone ?? null;
|
|
301
|
+
const showPhone = Boolean(customerPhoneRaw && !customerPhoneRaw.startsWith("pending_"));
|
|
302
|
+
return {
|
|
303
|
+
branch: {
|
|
304
|
+
name: branchName,
|
|
305
|
+
phone: branchPhone
|
|
306
|
+
},
|
|
307
|
+
shift: {
|
|
308
|
+
id: "",
|
|
309
|
+
cashierId: "",
|
|
310
|
+
cashierName: receipt.cashier ?? ""
|
|
311
|
+
},
|
|
312
|
+
order: {
|
|
313
|
+
number: String(receipt.orderNumber ?? ""),
|
|
314
|
+
placedAt: receipt.date ? new Date(receipt.date) : /* @__PURE__ */ new Date(),
|
|
315
|
+
lines: items.map((item) => ({
|
|
316
|
+
name: item.name,
|
|
317
|
+
variant: item.variant,
|
|
318
|
+
qty: item.quantity,
|
|
319
|
+
unitPrice: item.unitPrice ?? 0,
|
|
320
|
+
total: item.total,
|
|
321
|
+
taxRate: item.vatRate,
|
|
322
|
+
taxAmount: item.vatAmount
|
|
323
|
+
})),
|
|
324
|
+
subtotal: receipt.subtotal ?? 0,
|
|
325
|
+
tax: receipt.vat?.amount ?? 0,
|
|
326
|
+
discount: receipt.discount,
|
|
327
|
+
deliveryFee: receipt.deliveryCharge,
|
|
328
|
+
grandTotal: receipt.total ?? 0,
|
|
329
|
+
currency: getReceiptLocale().currency
|
|
330
|
+
},
|
|
331
|
+
payments: receipt.payment ? [{
|
|
332
|
+
method: receipt.payment.method,
|
|
333
|
+
amount: receipt.payment.amount ?? receipt.total ?? 0,
|
|
334
|
+
reference: receipt.payment.reference
|
|
335
|
+
}] : [],
|
|
336
|
+
customer: showCustomer ? {
|
|
337
|
+
name: customerName,
|
|
338
|
+
phone: showPhone ? customerPhoneRaw ?? void 0 : void 0,
|
|
339
|
+
loyaltyId: receipt.membership?.cardId
|
|
340
|
+
} : void 0
|
|
238
341
|
};
|
|
239
342
|
}
|
|
240
343
|
|
|
241
344
|
//#endregion
|
|
242
|
-
export { calculateAmountDue, calculateCartTotals, calculateChange, calculateOrderTotals,
|
|
345
|
+
export { buildReceiptPayload, calculateAmountDue, calculateCartTotals, calculateChange, calculateOrderTotals, isValidBDPhone, parseCashReceived, parsePositiveNumber, transformOrderToReceipt, validateRedemption };
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
2
3
|
"name": "@classytic/pos-ui",
|
|
3
|
-
"version": "
|
|
4
|
+
"version": "1.2.0",
|
|
4
5
|
"private": false,
|
|
5
6
|
"type": "module",
|
|
6
7
|
"description": "Point-of-sale register UI — a self-contained, full-screen state-machine app (shift → products → payment → receipt → close) with a hardware DI port (printer/drawer/scanner/terminal). Multi-surface: web, Tauri desktop, React Native. Frontend sibling of the @classytic/pos kernel. Independent of the dashboard stack (no erp-shell).",
|
|
@@ -14,12 +15,17 @@
|
|
|
14
15
|
"build": "tsdown",
|
|
15
16
|
"dev": "tsdown --watch",
|
|
16
17
|
"typecheck": "tsc --noEmit",
|
|
17
|
-
"clean": "rimraf dist"
|
|
18
|
+
"clean": "rimraf dist",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"lint": "node ../../scripts/ui-guards/run.mjs",
|
|
22
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run build"
|
|
18
23
|
},
|
|
19
24
|
"peerDependencies": {
|
|
20
|
-
"@classytic/commerce-sdk": ">=0.
|
|
21
|
-
"@classytic/fluid": ">=1.
|
|
22
|
-
"@classytic/
|
|
25
|
+
"@classytic/commerce-sdk": ">=0.14.0",
|
|
26
|
+
"@classytic/fluid": ">=1.13.0",
|
|
27
|
+
"@classytic/pos-agent": ">=0.1.0",
|
|
28
|
+
"@classytic/primitives": ">=0.23.0",
|
|
23
29
|
"@tanstack/react-query": ">=5.0.0",
|
|
24
30
|
"clsx": ">=2.0.0",
|
|
25
31
|
"lucide-react": ">=1.0.0",
|
|
@@ -35,6 +41,9 @@
|
|
|
35
41
|
"@classytic/fluid": {
|
|
36
42
|
"optional": true
|
|
37
43
|
},
|
|
44
|
+
"@classytic/pos-agent": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
38
47
|
"@classytic/primitives": {
|
|
39
48
|
"optional": true
|
|
40
49
|
},
|
|
@@ -61,6 +70,10 @@
|
|
|
61
70
|
}
|
|
62
71
|
},
|
|
63
72
|
"devDependencies": {
|
|
73
|
+
"@classytic/commerce-sdk": ">=0.14.0",
|
|
74
|
+
"@classytic/fluid": ">=1.13.0",
|
|
75
|
+
"@classytic/pos-agent": ">=0.1.0",
|
|
76
|
+
"@classytic/primitives": ">=0.23.0",
|
|
64
77
|
"@tanstack/react-query": "^5.101.2",
|
|
65
78
|
"@types/node": "^26.1.0",
|
|
66
79
|
"@types/react": "^19.2.17",
|
|
@@ -73,7 +86,7 @@
|
|
|
73
86
|
"rimraf": "^6.1.3",
|
|
74
87
|
"sonner": "^2.0.7",
|
|
75
88
|
"tailwind-merge": "^3.6.0",
|
|
76
|
-
"tsdown": "^0.22.
|
|
89
|
+
"tsdown": "^0.22.14",
|
|
77
90
|
"typescript": "^6.0.3"
|
|
78
91
|
},
|
|
79
92
|
"publishConfig": {
|
|
@@ -81,12 +94,19 @@
|
|
|
81
94
|
},
|
|
82
95
|
"sideEffects": false,
|
|
83
96
|
"exports": {
|
|
97
|
+
"./components": "./dist/components/index.js",
|
|
84
98
|
"./hardware": "./dist/hardware/index.js",
|
|
99
|
+
"./hooks": "./dist/hooks/index.js",
|
|
85
100
|
"./runtime": "./dist/runtime/index.js",
|
|
101
|
+
"./screens": "./dist/screens/index.js",
|
|
86
102
|
"./shell": "./dist/shell/index.js",
|
|
103
|
+
"./state": "./dist/state/index.js",
|
|
87
104
|
"./package.json": "./package.json"
|
|
88
105
|
},
|
|
89
106
|
"inlinedDependencies": {
|
|
90
|
-
"react-hook-form": "7.
|
|
107
|
+
"react-hook-form": "7.86.0"
|
|
108
|
+
},
|
|
109
|
+
"dependencies": {
|
|
110
|
+
"uqr": "^0.1.3"
|
|
91
111
|
}
|
|
92
112
|
}
|