@classytic/pos-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/dist/components/CartSidebar.js +222 -0
- package/dist/components/PosTopBar.js +156 -0
- package/dist/components/PrinterSettingsDialog.js +163 -0
- package/dist/components/ReceiptReprintDialog.js +182 -0
- package/dist/dashboard/components/CustomerLookupDialog.js +120 -0
- package/dist/dashboard/components/CustomerQuickAddDialog.js +101 -0
- package/dist/dashboard/components/ManagerAuthDialog.js +77 -0
- package/dist/dashboard/components/ProductCard.js +98 -0
- package/dist/dashboard/components/ProductsPanel.js +119 -0
- package/dist/dashboard/components/SplitPaymentPanel.js +131 -0
- package/dist/dashboard/components/VariantSelectorDialog.js +150 -0
- package/dist/dashboard/components/cart/AddChargeDialog.js +99 -0
- package/dist/dashboard/components/cart/CartItems.js +103 -0
- package/dist/dashboard/components/cart/CartSummary.js +73 -0
- package/dist/dashboard/components/cart/CustomerSection.js +127 -0
- package/dist/dashboard/components/cart/DiscountSection.js +50 -0
- package/dist/dashboard/components/cart/PointsRedemptionSection.js +58 -0
- package/dist/hardware/context.d.ts +15 -0
- package/dist/hardware/context.js +33 -0
- package/dist/hardware/index.d.ts +7 -0
- package/dist/hardware/index.js +7 -0
- package/dist/hardware/ports.d.ts +116 -0
- package/dist/hardware/tauri-adapter.d.ts +7 -0
- package/dist/hardware/tauri-adapter.js +77 -0
- package/dist/hardware/use-printer-availability.d.ts +12 -0
- package/dist/hardware/use-printer-availability.js +50 -0
- package/dist/hardware/use-printer-config.d.ts +16 -0
- package/dist/hardware/use-printer-config.js +62 -0
- package/dist/hardware/web-adapter.d.ts +15 -0
- package/dist/hardware/web-adapter.js +80 -0
- package/dist/hooks/useManagerAuth.js +83 -0
- package/dist/hooks/usePosCart.js +142 -0
- package/dist/hooks/usePosCustomer.js +192 -0
- package/dist/hooks/usePosMultiOrder.js +48 -0
- package/dist/hooks/usePosPayment.js +194 -0
- package/dist/lib/cn.js +12 -0
- package/dist/lib/loyalty.js +30 -0
- package/dist/lib/money.js +41 -0
- package/dist/node_modules/react-hook-form/dist/index.esm.js +1661 -0
- package/dist/runtime/auth-port.d.ts +46 -0
- package/dist/runtime/auth-port.js +21 -0
- package/dist/runtime/branch-port.d.ts +38 -0
- package/dist/runtime/branch-port.js +26 -0
- package/dist/runtime/config.d.ts +24 -0
- package/dist/runtime/config.js +21 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +5 -0
- package/dist/screens/OrderHistoryDrawer.js +245 -0
- package/dist/screens/ParkedOrdersDrawer.js +128 -0
- package/dist/screens/PaymentScreen.js +397 -0
- package/dist/screens/ProductScreen.js +322 -0
- package/dist/screens/ReceiptScreen.js +288 -0
- package/dist/screens/ShiftCloseScreen.js +365 -0
- package/dist/screens/ShiftOpenScreen.js +176 -0
- package/dist/shell/index.d.ts +8 -0
- package/dist/shell/index.js +5 -0
- package/dist/shell/pos-shell.d.ts +23 -0
- package/dist/shell/pos-shell.js +132 -0
- package/dist/state/pos-context.js +33 -0
- package/dist/state/pos-state.js +70 -0
- package/dist/utils/customer-display.js +35 -0
- package/dist/utils/pos-helpers.js +242 -0
- package/package.json +92 -0
- package/styles.css +19 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { PosAuthProvider, usePosAuth } from "../runtime/auth-port.js";
|
|
4
|
+
import { PosRuntimeProvider } from "../runtime/config.js";
|
|
5
|
+
import { PosBranchProvider } from "../runtime/branch-port.js";
|
|
6
|
+
import { createWebHardware } from "../hardware/web-adapter.js";
|
|
7
|
+
import { HardwareProvider } from "../hardware/context.js";
|
|
8
|
+
import { PosProvider, usePosContext } from "../state/pos-context.js";
|
|
9
|
+
import { PosTopBar } from "../components/PosTopBar.js";
|
|
10
|
+
import { ProductScreen } from "../screens/ProductScreen.js";
|
|
11
|
+
import { PaymentScreen } from "../screens/PaymentScreen.js";
|
|
12
|
+
import { ReceiptScreen } from "../screens/ReceiptScreen.js";
|
|
13
|
+
import { ShiftOpenScreen } from "../screens/ShiftOpenScreen.js";
|
|
14
|
+
import { ShiftCloseScreen } from "../screens/ShiftCloseScreen.js";
|
|
15
|
+
import { OrderHistoryDrawer } from "../screens/OrderHistoryDrawer.js";
|
|
16
|
+
import { PrinterSettingsDialog } from "../components/PrinterSettingsDialog.js";
|
|
17
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
18
|
+
import { useKeyboardShortcut, useWakeLock } from "@classytic/fluid/client/hooks";
|
|
19
|
+
import { DensityProvider, HotkeyHelpDialog, HotkeyRegistryProvider } from "@classytic/fluid/client/core";
|
|
20
|
+
import { usePosCurrentShift } from "@classytic/commerce-sdk/sales";
|
|
21
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
22
|
+
|
|
23
|
+
//#region src/shell/pos-shell.tsx
|
|
24
|
+
/**
|
|
25
|
+
* PosShell — Top-level POS orchestrator.
|
|
26
|
+
*
|
|
27
|
+
* Renders the active screen based on the state machine.
|
|
28
|
+
* Mounts all DI providers (auth / branch / runtime / hardware) so a host
|
|
29
|
+
* needs a single import to stand up the register. Wraps everything in
|
|
30
|
+
* PosProvider for shared context.
|
|
31
|
+
*
|
|
32
|
+
* Screen flow: idle (shift open) → products → payment → receipt → products
|
|
33
|
+
*/
|
|
34
|
+
function PosScreenRouter() {
|
|
35
|
+
const { state, dispatch } = usePosContext();
|
|
36
|
+
const sessionUser = usePosAuth().useSessionUser();
|
|
37
|
+
const { shift: activeShift } = usePosCurrentShift();
|
|
38
|
+
const userName = sessionUser?.name || "Cashier";
|
|
39
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
40
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
41
|
+
const [printerSettingsOpen, setPrinterSettingsOpen] = useState(false);
|
|
42
|
+
useWakeLock({ enabled: state.shiftOpen });
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (state.screen === "idle" && activeShift?.state === "open" && !state.shiftOpen) dispatch({ type: "OPEN_SHIFT" });
|
|
45
|
+
}, [
|
|
46
|
+
state.screen,
|
|
47
|
+
state.shiftOpen,
|
|
48
|
+
activeShift,
|
|
49
|
+
dispatch
|
|
50
|
+
]);
|
|
51
|
+
useKeyboardShortcut("f2", () => {
|
|
52
|
+
if (state.screen === "receipt") dispatch({ type: "NEW_SALE" });
|
|
53
|
+
}, {
|
|
54
|
+
description: "New sale (after receipt)",
|
|
55
|
+
category: "Sale"
|
|
56
|
+
});
|
|
57
|
+
useKeyboardShortcut("escape", () => {
|
|
58
|
+
dispatch({ type: "GO_BACK" });
|
|
59
|
+
}, {
|
|
60
|
+
disableInInputs: false,
|
|
61
|
+
description: "Back / cancel current step",
|
|
62
|
+
category: "Navigation"
|
|
63
|
+
});
|
|
64
|
+
useKeyboardShortcut("f9", () => {
|
|
65
|
+
if (state.shiftOpen) setHistoryOpen(true);
|
|
66
|
+
}, {
|
|
67
|
+
description: "Open order history",
|
|
68
|
+
category: "Navigation"
|
|
69
|
+
});
|
|
70
|
+
useKeyboardShortcut("shift+/", () => setHelpOpen(true), {
|
|
71
|
+
description: "Show keyboard shortcuts",
|
|
72
|
+
category: "Help"
|
|
73
|
+
});
|
|
74
|
+
const handleNewSale = useCallback(() => {
|
|
75
|
+
if (state.screen === "receipt") dispatch({ type: "NEW_SALE" });
|
|
76
|
+
}, [state.screen, dispatch]);
|
|
77
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "flex flex-col h-screen",
|
|
79
|
+
children: [
|
|
80
|
+
state.screen !== "idle" && /* @__PURE__ */ jsx(PosTopBar, {
|
|
81
|
+
userName,
|
|
82
|
+
onNewSale: state.screen === "products" ? handleNewSale : void 0,
|
|
83
|
+
onOpenHistory: () => setHistoryOpen(true),
|
|
84
|
+
onOpenHelp: () => setHelpOpen(true),
|
|
85
|
+
onOpenPrinterSettings: () => setPrinterSettingsOpen(true)
|
|
86
|
+
}),
|
|
87
|
+
/* @__PURE__ */ jsxs("div", {
|
|
88
|
+
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
|
+
}),
|
|
97
|
+
/* @__PURE__ */ jsx(OrderHistoryDrawer, {
|
|
98
|
+
open: historyOpen,
|
|
99
|
+
onOpenChange: setHistoryOpen
|
|
100
|
+
}),
|
|
101
|
+
/* @__PURE__ */ jsx(HotkeyHelpDialog, {
|
|
102
|
+
open: helpOpen,
|
|
103
|
+
onOpenChange: setHelpOpen
|
|
104
|
+
}),
|
|
105
|
+
/* @__PURE__ */ jsx(PrinterSettingsDialog, {
|
|
106
|
+
open: printerSettingsOpen,
|
|
107
|
+
onOpenChange: setPrinterSettingsOpen
|
|
108
|
+
})
|
|
109
|
+
]
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function PosShell({ auth, branch, runtime, hardware }) {
|
|
113
|
+
return /* @__PURE__ */ jsx(PosAuthProvider, {
|
|
114
|
+
auth,
|
|
115
|
+
children: /* @__PURE__ */ jsx(PosBranchProvider, {
|
|
116
|
+
branch,
|
|
117
|
+
children: /* @__PURE__ */ jsx(PosRuntimeProvider, {
|
|
118
|
+
runtime,
|
|
119
|
+
children: /* @__PURE__ */ jsx(DensityProvider, {
|
|
120
|
+
density: "auto",
|
|
121
|
+
children: /* @__PURE__ */ jsx(HotkeyRegistryProvider, { children: /* @__PURE__ */ jsx(HardwareProvider, {
|
|
122
|
+
hardware: useMemo(() => hardware ?? createWebHardware(), [hardware]),
|
|
123
|
+
children: /* @__PURE__ */ jsx(PosProvider, { children: /* @__PURE__ */ jsx(PosScreenRouter, {}) })
|
|
124
|
+
}) })
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
//#endregion
|
|
132
|
+
export { PosShell };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { INITIAL_POS_STATE, posReducer } from "./pos-state.js";
|
|
4
|
+
import { createContext, useContext, useReducer } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
|
|
7
|
+
//#region src/state/pos-context.tsx
|
|
8
|
+
/**
|
|
9
|
+
* POS Context — Provides screen state, dispatch, and session info
|
|
10
|
+
* to all POS screens without prop drilling.
|
|
11
|
+
*
|
|
12
|
+
* Wrap PosShell with <PosProvider> so any screen can:
|
|
13
|
+
* const { screen, dispatch } = usePosContext();
|
|
14
|
+
*/
|
|
15
|
+
const PosContext = createContext(null);
|
|
16
|
+
function PosProvider({ children }) {
|
|
17
|
+
const [state, dispatch] = useReducer(posReducer, INITIAL_POS_STATE);
|
|
18
|
+
return /* @__PURE__ */ jsx(PosContext, {
|
|
19
|
+
value: {
|
|
20
|
+
state,
|
|
21
|
+
dispatch
|
|
22
|
+
},
|
|
23
|
+
children
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function usePosContext() {
|
|
27
|
+
const ctx = useContext(PosContext);
|
|
28
|
+
if (!ctx) throw new Error("usePosContext must be used within <PosProvider>");
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { PosProvider, usePosContext };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//#region src/state/pos-state.ts
|
|
2
|
+
const INITIAL_POS_STATE = {
|
|
3
|
+
screen: "idle",
|
|
4
|
+
lastReceipt: null,
|
|
5
|
+
lastCashReceived: null,
|
|
6
|
+
shiftOpen: false
|
|
7
|
+
};
|
|
8
|
+
function posReducer(state, action) {
|
|
9
|
+
switch (action.type) {
|
|
10
|
+
case "OPEN_SHIFT":
|
|
11
|
+
if (state.screen !== "idle") return state;
|
|
12
|
+
return {
|
|
13
|
+
...state,
|
|
14
|
+
screen: "products",
|
|
15
|
+
shiftOpen: true
|
|
16
|
+
};
|
|
17
|
+
case "GO_TO_PAYMENT":
|
|
18
|
+
if (state.screen !== "products") return state;
|
|
19
|
+
return {
|
|
20
|
+
...state,
|
|
21
|
+
screen: "payment"
|
|
22
|
+
};
|
|
23
|
+
case "GO_BACK":
|
|
24
|
+
if (state.screen === "payment") return {
|
|
25
|
+
...state,
|
|
26
|
+
screen: "products"
|
|
27
|
+
};
|
|
28
|
+
if (state.screen === "receipt") return {
|
|
29
|
+
...state,
|
|
30
|
+
screen: "products",
|
|
31
|
+
lastReceipt: null,
|
|
32
|
+
lastCashReceived: null
|
|
33
|
+
};
|
|
34
|
+
return state;
|
|
35
|
+
case "COMPLETE_SALE":
|
|
36
|
+
if (state.screen !== "payment") return state;
|
|
37
|
+
return {
|
|
38
|
+
...state,
|
|
39
|
+
screen: "receipt",
|
|
40
|
+
lastReceipt: action.receipt
|
|
41
|
+
};
|
|
42
|
+
case "NEW_SALE":
|
|
43
|
+
if (state.screen !== "receipt") return state;
|
|
44
|
+
return {
|
|
45
|
+
...state,
|
|
46
|
+
screen: "products",
|
|
47
|
+
lastReceipt: null,
|
|
48
|
+
lastCashReceived: null
|
|
49
|
+
};
|
|
50
|
+
case "CLOSE_SHIFT":
|
|
51
|
+
if (!state.shiftOpen) return state;
|
|
52
|
+
return {
|
|
53
|
+
...state,
|
|
54
|
+
screen: "closing"
|
|
55
|
+
};
|
|
56
|
+
case "CONFIRM_CLOSE":
|
|
57
|
+
if (state.screen !== "closing") return state;
|
|
58
|
+
return { ...INITIAL_POS_STATE };
|
|
59
|
+
case "CANCEL_CLOSE":
|
|
60
|
+
if (state.screen !== "closing") return state;
|
|
61
|
+
return {
|
|
62
|
+
...state,
|
|
63
|
+
screen: "products"
|
|
64
|
+
};
|
|
65
|
+
default: return state;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
export { INITIAL_POS_STATE, posReducer };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/utils/customer-display.ts
|
|
2
|
+
/**
|
|
3
|
+
* Best display name for a customer card. Falls back through:
|
|
4
|
+
* 1. Backend virtual `fullName`
|
|
5
|
+
* 2. Backend virtual `displayName`
|
|
6
|
+
* 3. Composed from structured `name.given` + `name.family`
|
|
7
|
+
* 4. Email local-part (before @)
|
|
8
|
+
* 5. Literal "Customer"
|
|
9
|
+
*/
|
|
10
|
+
function customerDisplayName(c) {
|
|
11
|
+
if (!c) return "Customer";
|
|
12
|
+
const v = (c.fullName ?? c.displayName ?? "").trim();
|
|
13
|
+
if (v) return v;
|
|
14
|
+
const composed = `${(c.name?.given ?? "").trim()} ${(c.name?.family ?? "").trim()}`.trim();
|
|
15
|
+
if (composed) return composed;
|
|
16
|
+
const email = c.contact?.email ?? "";
|
|
17
|
+
const local = email.includes("@") ? email.split("@")[0]?.trim() : "";
|
|
18
|
+
if (local) return local;
|
|
19
|
+
return "Customer";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Returns the customer's phone for display, or `null` if the value is
|
|
23
|
+
* a server-side placeholder like `pending_<userId>` (auto-generated when
|
|
24
|
+
* a user record exists without a real phone). Pre-release records used
|
|
25
|
+
* `pending_` prefix to satisfy the unique index without surfacing junk.
|
|
26
|
+
*/
|
|
27
|
+
function customerDisplayPhone(c) {
|
|
28
|
+
const raw = c?.contact?.phone?.trim();
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
if (raw.startsWith("pending_")) return null;
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { customerDisplayName, customerDisplayPhone };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Paisa } from "../lib/money.js";
|
|
2
|
+
import { isValidPhone } from "@classytic/commerce-sdk/client";
|
|
3
|
+
|
|
4
|
+
//#region src/utils/pos-helpers.ts
|
|
5
|
+
/** Brand a paisa value read out of an unknown response. The number is
|
|
6
|
+
* expected to already be in paisa (be-prod canonical unit); this helper
|
|
7
|
+
* just re-asserts the brand after a `typeof` guard. */
|
|
8
|
+
const paisaOr0 = (v) => Paisa(typeof v === "number" ? v : 0);
|
|
9
|
+
/**
|
|
10
|
+
* Parse cash received input to number
|
|
11
|
+
*/
|
|
12
|
+
function parseCashReceived(value) {
|
|
13
|
+
const n = Number(value);
|
|
14
|
+
return Number.isFinite(n) ? n : 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse input to positive number (min 0)
|
|
18
|
+
*/
|
|
19
|
+
function parsePositiveNumber(value) {
|
|
20
|
+
const n = Number(value);
|
|
21
|
+
if (!Number.isFinite(n)) return 0;
|
|
22
|
+
return n > 0 ? n : 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Calculate cart totals
|
|
26
|
+
*/
|
|
27
|
+
function calculateCartTotals(items, discountInput) {
|
|
28
|
+
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
|
29
|
+
const rawDiscount = parsePositiveNumber(discountInput);
|
|
30
|
+
const discount = subtotal <= 0 ? 0 : Math.min(rawDiscount, subtotal);
|
|
31
|
+
return {
|
|
32
|
+
subtotal,
|
|
33
|
+
discount,
|
|
34
|
+
total: Math.max(0, subtotal - discount),
|
|
35
|
+
itemCount: items.length
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function validateRedemption(pointsToRedeem, customerPoints, orderTotal, membershipConfig) {
|
|
39
|
+
if (!membershipConfig?.enabled) return {
|
|
40
|
+
valid: false,
|
|
41
|
+
error: "Membership program disabled"
|
|
42
|
+
};
|
|
43
|
+
const config = membershipConfig?.redemption;
|
|
44
|
+
if (!config?.enabled) return {
|
|
45
|
+
valid: false,
|
|
46
|
+
error: "Points redemption not enabled"
|
|
47
|
+
};
|
|
48
|
+
if (pointsToRedeem < (config.minRedeemPoints ?? 0)) return {
|
|
49
|
+
valid: false,
|
|
50
|
+
error: `Minimum ${config.minRedeemPoints} points required`
|
|
51
|
+
};
|
|
52
|
+
if (pointsToRedeem > customerPoints) return {
|
|
53
|
+
valid: false,
|
|
54
|
+
error: `Insufficient points. Available: ${customerPoints}`
|
|
55
|
+
};
|
|
56
|
+
if (orderTotal < (config.minOrderAmount ?? 0)) return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: `Minimum order of ৳${config.minOrderAmount} required`
|
|
59
|
+
};
|
|
60
|
+
const maxDiscount = Math.floor(orderTotal * (config.maxRedeemPercent ?? 100) / 100);
|
|
61
|
+
const requestedDiscount = Math.floor(pointsToRedeem / (config.pointsPerBdt ?? 1));
|
|
62
|
+
let actualDiscount = requestedDiscount;
|
|
63
|
+
let actualPoints = pointsToRedeem;
|
|
64
|
+
if (requestedDiscount > maxDiscount) {
|
|
65
|
+
actualDiscount = maxDiscount;
|
|
66
|
+
actualPoints = maxDiscount * (config.pointsPerBdt ?? 1);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
valid: true,
|
|
70
|
+
discountAmount: actualDiscount,
|
|
71
|
+
pointsToRedeem: actualPoints,
|
|
72
|
+
maxAllowedPoints: maxDiscount * (config.pointsPerBdt ?? 1)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function calculateOrderTotals({ items, manualDiscountInput, membershipConfig, customer, pointsToRedeemInput }) {
|
|
76
|
+
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
|
77
|
+
const manualDiscount = Math.min(parsePositiveNumber(manualDiscountInput), subtotal);
|
|
78
|
+
const tierName = customer?.membership?.tier || null;
|
|
79
|
+
const customerPoints = customer?.membership?.points?.current || 0;
|
|
80
|
+
const tierDiscount = 0;
|
|
81
|
+
const preliminaryTotal = Math.max(0, subtotal - manualDiscount);
|
|
82
|
+
const requestedPoints = Math.floor(parsePositiveNumber(pointsToRedeemInput));
|
|
83
|
+
let redemptionDiscount = 0;
|
|
84
|
+
let actualPointsRedeemed = 0;
|
|
85
|
+
let redemptionError = null;
|
|
86
|
+
let maxAllowedPoints = 0;
|
|
87
|
+
if (requestedPoints > 0) {
|
|
88
|
+
const result = validateRedemption(requestedPoints, customerPoints, preliminaryTotal, membershipConfig);
|
|
89
|
+
if (result.valid) {
|
|
90
|
+
redemptionDiscount = result.discountAmount ?? 0;
|
|
91
|
+
actualPointsRedeemed = result.pointsToRedeem ?? 0;
|
|
92
|
+
maxAllowedPoints = result.maxAllowedPoints ?? 0;
|
|
93
|
+
} else redemptionError = result.error ?? "Redemption not available";
|
|
94
|
+
}
|
|
95
|
+
if (!maxAllowedPoints && membershipConfig?.enabled && membershipConfig?.redemption?.enabled) maxAllowedPoints = Math.floor(preliminaryTotal * (membershipConfig.redemption.maxRedeemPercent ?? 100) / 100) * (membershipConfig.redemption.pointsPerBdt ?? 1);
|
|
96
|
+
const total = Math.max(0, preliminaryTotal - redemptionDiscount);
|
|
97
|
+
return {
|
|
98
|
+
subtotal,
|
|
99
|
+
manualDiscount,
|
|
100
|
+
tierDiscount,
|
|
101
|
+
redemptionDiscount,
|
|
102
|
+
totalDiscount: manualDiscount + tierDiscount + redemptionDiscount,
|
|
103
|
+
total,
|
|
104
|
+
pointsToEarn: 0,
|
|
105
|
+
pointsRedeemed: actualPointsRedeemed,
|
|
106
|
+
redemptionError,
|
|
107
|
+
maxAllowedPoints,
|
|
108
|
+
tierName
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Calculate change amount for cash payment
|
|
113
|
+
*/
|
|
114
|
+
function calculateChange(cashReceived, total) {
|
|
115
|
+
return Math.max(0, cashReceived - total);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Calculate amount due for cash payment
|
|
119
|
+
*/
|
|
120
|
+
function calculateAmountDue(cashReceived, total) {
|
|
121
|
+
return Math.max(0, total - cashReceived);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate a customer phone against the deployment's configured pattern
|
|
125
|
+
* (country-agnostic — `configureSDK({ receipt: { phonePattern } })`; defaults
|
|
126
|
+
* to Bangladesh). Kept the `BD` name for call-site compatibility; the check is
|
|
127
|
+
* now locale-driven, not hardcoded.
|
|
128
|
+
*/
|
|
129
|
+
function isValidBDPhone(phone) {
|
|
130
|
+
return isValidPhone(phone);
|
|
131
|
+
}
|
|
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
|
+
/** Read paisa from a Money struct `{ amount, currency }` returned by the
|
|
145
|
+
* order kernel. Falls back to a bare number for legacy shapes. */
|
|
146
|
+
const moneyAmount = (m) => {
|
|
147
|
+
if (m && typeof m === "object" && "amount" in m && typeof m.amount === "number") return Paisa(m.amount);
|
|
148
|
+
return paisaOr0(m);
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Transform a POS order response to receipt format.
|
|
152
|
+
*
|
|
153
|
+
* The order kernel (`@classytic/order`) returns:
|
|
154
|
+
* - `order.totals.{ subtotal, discount, tax, shipping, grandTotal }` — each is `Money = { amount, currency }`
|
|
155
|
+
* - `order.lines[]` — each line has `quantity`, `snapshot.{ sku, name, variantLabel }`,
|
|
156
|
+
* `unitPrice/lineTotal/unitTax: Money`
|
|
157
|
+
* - `order.customerSnapshot.{ name, phone? }` (no email for walk-in)
|
|
158
|
+
* - `order.paymentState.totalCharged: Money` (after capture)
|
|
159
|
+
* - POS-specific fields (membership, VAT) live in `order.metadata.pos`
|
|
160
|
+
*
|
|
161
|
+
* The wire format inside Money structs is integer paisa for BDT.
|
|
162
|
+
*/
|
|
163
|
+
/**
|
|
164
|
+
* The order's server-authored timestamp for the receipt. Never falls back to
|
|
165
|
+
* the register's clock — a fabricated time on a fiscal document is worse
|
|
166
|
+
* than a visibly blank one. Missing `createdAt` is logged as data corruption.
|
|
167
|
+
*/
|
|
168
|
+
function resolveOrderDate(order) {
|
|
169
|
+
const raw = order.createdAt || order.placedAt;
|
|
170
|
+
if (!raw) {
|
|
171
|
+
console.error("[pos-ui] order missing createdAt/placedAt — receipt date left blank", order._id ?? order.orderNumber);
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
return String(raw);
|
|
175
|
+
}
|
|
176
|
+
function transformOrderToReceipt(orderResponse, branchInfo) {
|
|
177
|
+
if (!orderResponse || typeof orderResponse !== "object") return null;
|
|
178
|
+
const response = orderResponse;
|
|
179
|
+
const order = response.data ?? response;
|
|
180
|
+
if (!order._id && !order.orderNumber) return null;
|
|
181
|
+
const totals = order.totals ?? {};
|
|
182
|
+
const customerSnap = order.customerSnapshot ?? {};
|
|
183
|
+
const lines = order.lines ?? [];
|
|
184
|
+
const paymentState = order.paymentState ?? {};
|
|
185
|
+
const meta = order.metadata ?? {};
|
|
186
|
+
const membership = meta.membership ?? {};
|
|
187
|
+
const vat = meta.vat ?? {};
|
|
188
|
+
const primaryPayment = (meta.payments ?? [])[0];
|
|
189
|
+
const itemVatAmount = (line) => {
|
|
190
|
+
return Paisa(Number(moneyAmount(line.unitTax)) * (typeof line.quantity === "number" ? line.quantity : 1));
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
orderId: String(order._id || ""),
|
|
194
|
+
orderNumber: String(order.orderNumber || order._id || "").slice(-8).toUpperCase(),
|
|
195
|
+
invoiceNumber: order.invoiceNumber ? String(order.invoiceNumber) : void 0,
|
|
196
|
+
date: resolveOrderDate(order),
|
|
197
|
+
branch: branchInfo,
|
|
198
|
+
cashier: order.cashierName ? String(order.cashierName) : void 0,
|
|
199
|
+
customer: {
|
|
200
|
+
name: customerSnap.name ? String(customerSnap.name) : void 0,
|
|
201
|
+
phone: customerSnap.phone ? String(customerSnap.phone) : null
|
|
202
|
+
},
|
|
203
|
+
membership: membership.cardId ? {
|
|
204
|
+
cardId: String(membership.cardId),
|
|
205
|
+
tier: membership.tier ? String(membership.tier) : void 0,
|
|
206
|
+
pointsEarned: typeof membership.pointsEarned === "number" ? membership.pointsEarned : void 0,
|
|
207
|
+
tierDiscount: paisaOr0(membership.tierDiscountApplied)
|
|
208
|
+
} : void 0,
|
|
209
|
+
items: lines.map((line) => {
|
|
210
|
+
const snap = line.snapshot ?? {};
|
|
211
|
+
return {
|
|
212
|
+
name: String(snap.name || line.productName || "Item"),
|
|
213
|
+
variant: snap.variantLabel ? String(snap.variantLabel) : snap.sku ? String(snap.sku) : void 0,
|
|
214
|
+
quantity: typeof line.quantity === "number" ? line.quantity : 1,
|
|
215
|
+
unitPrice: moneyAmount(line.unitPrice),
|
|
216
|
+
total: moneyAmount(line.lineTotal),
|
|
217
|
+
vatRate: typeof line.taxRate === "number" ? line.taxRate : 0,
|
|
218
|
+
vatAmount: itemVatAmount(line)
|
|
219
|
+
};
|
|
220
|
+
}),
|
|
221
|
+
subtotal: moneyAmount(totals.subtotal),
|
|
222
|
+
discount: moneyAmount(totals.discount),
|
|
223
|
+
deliveryCharge: moneyAmount(totals.shipping),
|
|
224
|
+
vat: {
|
|
225
|
+
applicable: !!vat.applicable,
|
|
226
|
+
rate: typeof vat.rate === "number" ? vat.rate : 0,
|
|
227
|
+
amount: moneyAmount(totals.tax),
|
|
228
|
+
taxableAmount: paisaOr0(vat.taxableAmount),
|
|
229
|
+
sellerBin: vat.sellerBin ? String(vat.sellerBin) : void 0,
|
|
230
|
+
pricesIncludeVat: typeof vat.pricesIncludeVat === "boolean" ? vat.pricesIncludeVat : true
|
|
231
|
+
},
|
|
232
|
+
total: moneyAmount(totals.grandTotal),
|
|
233
|
+
payment: {
|
|
234
|
+
method: primaryPayment?.method ?? "cash",
|
|
235
|
+
...primaryPayment?.reference ? { reference: primaryPayment.reference } : {},
|
|
236
|
+
amount: moneyAmount(paymentState.totalCharged) || moneyAmount(totals.grandTotal)
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
//#endregion
|
|
242
|
+
export { calculateAmountDue, calculateCartTotals, calculateChange, calculateOrderTotals, isPhoneSearch, isValidBDPhone, parseCashReceived, parsePositiveNumber, transformOrderToReceipt, validateRedemption };
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@classytic/pos-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"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).",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"styles.css",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsdown",
|
|
15
|
+
"dev": "tsdown --watch",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"clean": "rimraf dist"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@classytic/commerce-sdk": ">=0.9.0",
|
|
21
|
+
"@classytic/fluid": ">=1.7.0",
|
|
22
|
+
"@classytic/primitives": ">=0.9.0",
|
|
23
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
24
|
+
"clsx": ">=2.0.0",
|
|
25
|
+
"lucide-react": ">=1.0.0",
|
|
26
|
+
"next": ">=14.0.0",
|
|
27
|
+
"react": ">=19.0.0",
|
|
28
|
+
"sonner": ">=2.0.0",
|
|
29
|
+
"tailwind-merge": ">=2.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@classytic/commerce-sdk": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"@classytic/fluid": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"@classytic/primitives": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@tanstack/react-query": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"clsx": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"lucide-react": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"next": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"react": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"sonner": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"tailwind-merge": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@tanstack/react-query": "^5.101.2",
|
|
65
|
+
"@types/node": "^26.1.0",
|
|
66
|
+
"@types/react": "^19.2.17",
|
|
67
|
+
"clsx": "^2.1.1",
|
|
68
|
+
"lucide-react": "^1.23.0",
|
|
69
|
+
"next": "^16.2.9",
|
|
70
|
+
"publint": "^0.3.21",
|
|
71
|
+
"react": "^19.2.7",
|
|
72
|
+
"react-hook-form": "^7.80.0",
|
|
73
|
+
"rimraf": "^6.1.3",
|
|
74
|
+
"sonner": "^2.0.7",
|
|
75
|
+
"tailwind-merge": "^3.6.0",
|
|
76
|
+
"tsdown": "^0.22.3",
|
|
77
|
+
"typescript": "^6.0.3"
|
|
78
|
+
},
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public"
|
|
81
|
+
},
|
|
82
|
+
"sideEffects": false,
|
|
83
|
+
"exports": {
|
|
84
|
+
"./hardware": "./dist/hardware/index.js",
|
|
85
|
+
"./runtime": "./dist/runtime/index.js",
|
|
86
|
+
"./shell": "./dist/shell/index.js",
|
|
87
|
+
"./package.json": "./package.json"
|
|
88
|
+
},
|
|
89
|
+
"inlinedDependencies": {
|
|
90
|
+
"react-hook-form": "7.80.0"
|
|
91
|
+
}
|
|
92
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @classytic/pos-ui - Tailwind CSS Integration
|
|
3
|
+
*
|
|
4
|
+
* Import this file in your app's global CSS so Tailwind discovers the
|
|
5
|
+
* utility classes used inside the compiled POS components. Without it,
|
|
6
|
+
* classes that appear ONLY in pos-ui (e.g. the `flex-[65]` / `flex-[35]`
|
|
7
|
+
* product/cart split, `flex-[50]` payment columns) are never generated in
|
|
8
|
+
* the host's CSS bundle and the register layout collapses.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the fluid convention (`@classytic/fluid/styles.css`).
|
|
11
|
+
*
|
|
12
|
+
* Usage (in app/globals.css or similar, AFTER `@import "tailwindcss"`):
|
|
13
|
+
* @import "@classytic/pos-ui/styles.css";
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* Scan the compiled JS for Tailwind classes. pos-ui builds to `.js`
|
|
17
|
+
* (see package.json exports), so match that — not `.mjs`. Type defs are
|
|
18
|
+
* skipped by the extension filter. */
|
|
19
|
+
@source "./dist/**/*.js";
|