@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,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { Loader2, ScanLine, UserSearch, X } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { getReceiptLocale } from "@classytic/commerce-sdk/client";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
|
|
10
|
+
//#region src/dashboard/components/cart/CustomerSection.tsx
|
|
11
|
+
function CustomerSection({ selected, name, phone, membershipCardId, membershipStatus, tierName, tierColor, tierTextColor, onNameChange, onPhoneChange, onMembershipChange, onMembershipLookup, onClear, onOpenLookup }) {
|
|
12
|
+
const membershipBadge = membershipStatus === "found" ? "Member found" : membershipStatus === "not_found" ? "Not found" : membershipStatus === "searching" ? "Searching..." : "";
|
|
13
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
14
|
+
className: "space-y-2",
|
|
15
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
16
|
+
className: "flex items-center justify-between",
|
|
17
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
18
|
+
className: "text-sm font-medium",
|
|
19
|
+
children: "Customer"
|
|
20
|
+
}), selected && /* @__PURE__ */ jsxs(Button, {
|
|
21
|
+
variant: "ghost",
|
|
22
|
+
size: "sm",
|
|
23
|
+
className: "h-7 px-2",
|
|
24
|
+
onClick: onClear,
|
|
25
|
+
children: [/* @__PURE__ */ jsx(X, { className: "h-3.5 w-3.5 mr-1" }), "Clear"]
|
|
26
|
+
})]
|
|
27
|
+
}), selected ? /* @__PURE__ */ jsxs("div", {
|
|
28
|
+
className: "rounded-lg border bg-muted/30 p-3",
|
|
29
|
+
children: [
|
|
30
|
+
/* @__PURE__ */ jsxs("div", {
|
|
31
|
+
className: "flex items-center justify-between",
|
|
32
|
+
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
|
|
33
|
+
className: "text-sm font-semibold",
|
|
34
|
+
children: selected.displayName || selected.fullName || "Customer"
|
|
35
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
36
|
+
className: "text-xs text-muted-foreground",
|
|
37
|
+
children: selected.contact?.phone
|
|
38
|
+
})] }), (tierName || selected.revenueTier) && /* @__PURE__ */ jsx(Badge, {
|
|
39
|
+
variant: "secondary",
|
|
40
|
+
className: "capitalize",
|
|
41
|
+
style: tierColor ? {
|
|
42
|
+
backgroundColor: tierColor,
|
|
43
|
+
color: tierTextColor || "#111827",
|
|
44
|
+
borderColor: "transparent"
|
|
45
|
+
} : void 0,
|
|
46
|
+
children: tierName || selected.revenueTier
|
|
47
|
+
})]
|
|
48
|
+
}),
|
|
49
|
+
selected.stats?.orders?.total != null && /* @__PURE__ */ jsxs("p", {
|
|
50
|
+
className: "text-xs text-muted-foreground mt-2",
|
|
51
|
+
children: ["Orders: ", selected.stats.orders.total]
|
|
52
|
+
}),
|
|
53
|
+
selected.membership?.cardId && /* @__PURE__ */ jsxs("p", {
|
|
54
|
+
className: "text-xs text-muted-foreground mt-1",
|
|
55
|
+
children: [
|
|
56
|
+
"Card: ",
|
|
57
|
+
selected.membership.cardId,
|
|
58
|
+
selected.membership?.tier ? ` • ${selected.membership.tier}` : ""
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
]
|
|
62
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
63
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
64
|
+
type: "button",
|
|
65
|
+
variant: "outline",
|
|
66
|
+
className: "w-full h-10 justify-between",
|
|
67
|
+
onClick: onOpenLookup,
|
|
68
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
69
|
+
className: "text-sm text-muted-foreground",
|
|
70
|
+
children: "Find customer"
|
|
71
|
+
}), /* @__PURE__ */ jsx(UserSearch, { className: "h-4 w-4 text-muted-foreground" })]
|
|
72
|
+
}),
|
|
73
|
+
/* @__PURE__ */ jsxs("div", {
|
|
74
|
+
className: "space-y-2",
|
|
75
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
76
|
+
className: "flex items-center gap-2",
|
|
77
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "relative flex-1",
|
|
79
|
+
children: [/* @__PURE__ */ jsx(ScanLine, {
|
|
80
|
+
className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground",
|
|
81
|
+
"aria-hidden": true
|
|
82
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
83
|
+
"aria-label": "Scan loyalty card",
|
|
84
|
+
placeholder: "Scan card or type ID",
|
|
85
|
+
className: "pl-8",
|
|
86
|
+
value: membershipCardId,
|
|
87
|
+
disabled: membershipStatus === "searching",
|
|
88
|
+
onChange: (e) => onMembershipChange(e.target.value),
|
|
89
|
+
onKeyDown: (e) => {
|
|
90
|
+
if (e.key === "Enter") {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
onMembershipLookup();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})]
|
|
96
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
97
|
+
type: "button",
|
|
98
|
+
variant: "outline",
|
|
99
|
+
onClick: onMembershipLookup,
|
|
100
|
+
disabled: membershipStatus === "searching" || !membershipCardId.trim(),
|
|
101
|
+
children: membershipStatus === "searching" ? /* @__PURE__ */ jsx(Loader2, {
|
|
102
|
+
className: "h-4 w-4 animate-spin",
|
|
103
|
+
"aria-label": "Looking up card"
|
|
104
|
+
}) : "Lookup"
|
|
105
|
+
})]
|
|
106
|
+
}), membershipBadge && /* @__PURE__ */ jsx("p", {
|
|
107
|
+
className: "text-xs text-muted-foreground",
|
|
108
|
+
children: membershipBadge
|
|
109
|
+
})]
|
|
110
|
+
}),
|
|
111
|
+
/* @__PURE__ */ jsx(Input, {
|
|
112
|
+
placeholder: "Customer name (optional)",
|
|
113
|
+
value: name,
|
|
114
|
+
onChange: (e) => onNameChange(e.target.value)
|
|
115
|
+
}),
|
|
116
|
+
/* @__PURE__ */ jsx(Input, {
|
|
117
|
+
placeholder: `Phone (${getReceiptLocale().phonePlaceholder})`,
|
|
118
|
+
inputMode: "numeric",
|
|
119
|
+
value: phone,
|
|
120
|
+
onChange: (e) => onPhoneChange(e.target.value)
|
|
121
|
+
})
|
|
122
|
+
] })]
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
export { CustomerSection };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../../lib/cn.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { Lock, ShieldCheck, X } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
|
|
10
|
+
//#region src/dashboard/components/cart/DiscountSection.tsx
|
|
11
|
+
function DiscountSection({ value, onChange, isAuthorized, authorizedByName, onRequestAuth, onClearAuth }) {
|
|
12
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
13
|
+
className: "space-y-2",
|
|
14
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
15
|
+
className: "flex items-center justify-between",
|
|
16
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
17
|
+
className: "text-sm font-medium",
|
|
18
|
+
children: "Discount"
|
|
19
|
+
}), isAuthorized && authorizedByName && /* @__PURE__ */ jsxs(Badge, {
|
|
20
|
+
variant: "outline",
|
|
21
|
+
className: "text-xs gap-1 text-green-600 border-green-200 bg-green-50",
|
|
22
|
+
children: [
|
|
23
|
+
/* @__PURE__ */ jsx(ShieldCheck, { className: "h-3 w-3" }),
|
|
24
|
+
authorizedByName,
|
|
25
|
+
onClearAuth && /* @__PURE__ */ jsx("button", {
|
|
26
|
+
type: "button",
|
|
27
|
+
onClick: onClearAuth,
|
|
28
|
+
className: "ml-1 hover:text-red-500 transition-colors",
|
|
29
|
+
"aria-label": "Clear authorization",
|
|
30
|
+
children: /* @__PURE__ */ jsx(X, { className: "h-3 w-3" })
|
|
31
|
+
})
|
|
32
|
+
]
|
|
33
|
+
})]
|
|
34
|
+
}), isAuthorized ? /* @__PURE__ */ jsx(Input, {
|
|
35
|
+
placeholder: "Discount (BDT)",
|
|
36
|
+
inputMode: "numeric",
|
|
37
|
+
value,
|
|
38
|
+
onChange: (e) => onChange(e.target.value)
|
|
39
|
+
}) : /* @__PURE__ */ jsxs(Button, {
|
|
40
|
+
type: "button",
|
|
41
|
+
variant: "outline",
|
|
42
|
+
className: cn("w-full justify-start text-muted-foreground font-normal h-10", "hover:border-amber-300 hover:bg-amber-50/50"),
|
|
43
|
+
onClick: onRequestAuth,
|
|
44
|
+
children: [/* @__PURE__ */ jsx(Lock, { className: "h-4 w-4 mr-2 text-amber-500" }), "Manager authorization required"]
|
|
45
|
+
})]
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { DiscountSection };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatBdt } from "../../../lib/money.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
|
|
8
|
+
//#region src/dashboard/components/cart/PointsRedemptionSection.tsx
|
|
9
|
+
function PointsRedemptionSection({ enabled, pointsBalance, minRedeemPoints, maxRedeemPoints, pointsPerBdt, estimatedDiscount, error, value, onChange, onUseMax }) {
|
|
10
|
+
if (!enabled) return null;
|
|
11
|
+
const helperText = maxRedeemPoints > 0 ? `Up to ${maxRedeemPoints} pts (${formatBdt(maxRedeemPoints / pointsPerBdt)})` : "Not eligible for redemption";
|
|
12
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
13
|
+
className: "space-y-2",
|
|
14
|
+
children: [
|
|
15
|
+
/* @__PURE__ */ jsxs("div", {
|
|
16
|
+
className: "flex items-center justify-between",
|
|
17
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
18
|
+
className: "text-sm font-medium",
|
|
19
|
+
children: "Redeem Points"
|
|
20
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
21
|
+
className: "text-xs text-muted-foreground",
|
|
22
|
+
children: [
|
|
23
|
+
"Balance: ",
|
|
24
|
+
pointsBalance,
|
|
25
|
+
" pts"
|
|
26
|
+
]
|
|
27
|
+
})]
|
|
28
|
+
}),
|
|
29
|
+
/* @__PURE__ */ jsxs("div", {
|
|
30
|
+
className: "flex items-center gap-2",
|
|
31
|
+
children: [/* @__PURE__ */ jsx(Input, {
|
|
32
|
+
placeholder: `Min ${minRedeemPoints} pts`,
|
|
33
|
+
inputMode: "numeric",
|
|
34
|
+
value,
|
|
35
|
+
onChange: (e) => onChange(e.target.value),
|
|
36
|
+
disabled: maxRedeemPoints <= 0
|
|
37
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
38
|
+
type: "button",
|
|
39
|
+
variant: "outline",
|
|
40
|
+
onClick: onUseMax,
|
|
41
|
+
disabled: maxRedeemPoints <= 0,
|
|
42
|
+
children: "Use Max"
|
|
43
|
+
})]
|
|
44
|
+
}),
|
|
45
|
+
/* @__PURE__ */ jsxs("div", {
|
|
46
|
+
className: "flex items-center justify-between text-xs text-muted-foreground",
|
|
47
|
+
children: [/* @__PURE__ */ jsx("span", { children: helperText }), estimatedDiscount > 0 && /* @__PURE__ */ jsxs("span", { children: ["-", formatBdt(estimatedDiscount)] })]
|
|
48
|
+
}),
|
|
49
|
+
error && /* @__PURE__ */ jsx("p", {
|
|
50
|
+
className: "text-xs text-destructive",
|
|
51
|
+
children: error
|
|
52
|
+
})
|
|
53
|
+
]
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
export { PointsRedemptionSection };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PosHardware } from "./ports.js";
|
|
2
|
+
import { useBarcodeScan } from "@classytic/fluid/client/hooks";
|
|
3
|
+
|
|
4
|
+
//#region src/hardware/context.d.ts
|
|
5
|
+
interface HardwareProviderProps {
|
|
6
|
+
hardware: PosHardware;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
declare function HardwareProvider({
|
|
10
|
+
hardware,
|
|
11
|
+
children
|
|
12
|
+
}: HardwareProviderProps): import("react").JSX.Element;
|
|
13
|
+
declare function useHardware(): PosHardware;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { HardwareProvider, HardwareProviderProps, useBarcodeScan, useHardware };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import { useBarcodeScan } from "@classytic/fluid/client/hooks";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
|
|
7
|
+
//#region src/hardware/context.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Hardware Provider — supplies the active `PosHardware` adapter to the
|
|
10
|
+
* POS React tree. The browser POS wraps with `createWebHardware()`; a
|
|
11
|
+
* future Tauri shell wraps with `createTauriHardware()`. Same hooks
|
|
12
|
+
* downstream — no consumer code changes between hosts.
|
|
13
|
+
*
|
|
14
|
+
* Barcode scanning is handled by fluid's `useBarcodeScan` hook (re-exported
|
|
15
|
+
* here for convenience). The hardware port `BarcodeScanner` exists for
|
|
16
|
+
* native shells (Tauri / React Native) that use platform APIs instead of
|
|
17
|
+
* keystroke-burst detection.
|
|
18
|
+
*/
|
|
19
|
+
const HardwareContext = createContext(null);
|
|
20
|
+
function HardwareProvider({ hardware, children }) {
|
|
21
|
+
return /* @__PURE__ */ jsx(HardwareContext.Provider, {
|
|
22
|
+
value: hardware,
|
|
23
|
+
children
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function useHardware() {
|
|
27
|
+
const ctx = useContext(HardwareContext);
|
|
28
|
+
if (!ctx) throw new Error("useHardware must be used inside <HardwareProvider>");
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { HardwareProvider, useBarcodeScan, useHardware };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { BarcodeScanner, CashDrawer, PaymentTerminal, PosHardware, ReceiptLine, ReceiptPayload, ReceiptPayment, ReceiptPrinter, ScanEvent, TerminalChargeInput, TerminalChargeResult } from "./ports.js";
|
|
2
|
+
import { RECEIPT_DOM_ID, createWebHardware } from "./web-adapter.js";
|
|
3
|
+
import { createTauriHardware, isTauri } from "./tauri-adapter.js";
|
|
4
|
+
import { HardwareProvider, useBarcodeScan, useHardware } from "./context.js";
|
|
5
|
+
import { PrinterAvailability, usePrinterAvailability } from "./use-printer-availability.js";
|
|
6
|
+
import { PrinterConfig, readPrinterConfig, usePrinterConfig } from "./use-printer-config.js";
|
|
7
|
+
export { type BarcodeScanner, type CashDrawer, HardwareProvider, type PaymentTerminal, type PosHardware, type PrinterAvailability, type PrinterConfig, RECEIPT_DOM_ID, type ReceiptLine, type ReceiptPayload, type ReceiptPayment, type ReceiptPrinter, type ScanEvent, type TerminalChargeInput, type TerminalChargeResult, createTauriHardware, createWebHardware, isTauri, readPrinterConfig, useBarcodeScan, useHardware, usePrinterAvailability, usePrinterConfig };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readPrinterConfig, usePrinterConfig } from "./use-printer-config.js";
|
|
2
|
+
import { createTauriHardware, isTauri } from "./tauri-adapter.js";
|
|
3
|
+
import { RECEIPT_DOM_ID, createWebHardware } from "./web-adapter.js";
|
|
4
|
+
import { HardwareProvider, useBarcodeScan, useHardware } from "./context.js";
|
|
5
|
+
import { usePrinterAvailability } from "./use-printer-availability.js";
|
|
6
|
+
|
|
7
|
+
export { HardwareProvider, RECEIPT_DOM_ID, createTauriHardware, createWebHardware, isTauri, readPrinterConfig, useBarcodeScan, useHardware, usePrinterAvailability, usePrinterConfig };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//#region src/hardware/ports.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* POS Hardware Abstraction Layer — port interfaces.
|
|
4
|
+
*
|
|
5
|
+
* Four ports cover the register-floor hardware surface. Each is a small,
|
|
6
|
+
* promise-returning contract so any adapter (browser stub, local print
|
|
7
|
+
* agent, Tauri native, React Native module, EMV terminal SDK) can plug in.
|
|
8
|
+
*
|
|
9
|
+
* Domain-specific (cart shape, order shape) so fluid stays generic.
|
|
10
|
+
* Adapters live next to the ports; hosts wire them via `HardwareProvider`.
|
|
11
|
+
*/
|
|
12
|
+
interface ReceiptLine {
|
|
13
|
+
name: string;
|
|
14
|
+
variant?: string;
|
|
15
|
+
qty: number;
|
|
16
|
+
unitPrice: number;
|
|
17
|
+
total: number;
|
|
18
|
+
taxRate?: number;
|
|
19
|
+
taxAmount?: number;
|
|
20
|
+
}
|
|
21
|
+
interface ReceiptPayment {
|
|
22
|
+
method: string;
|
|
23
|
+
amount: number;
|
|
24
|
+
reference?: string;
|
|
25
|
+
}
|
|
26
|
+
interface ReceiptPayload {
|
|
27
|
+
branch: {
|
|
28
|
+
name: string;
|
|
29
|
+
address?: string;
|
|
30
|
+
bin?: string;
|
|
31
|
+
phone?: string;
|
|
32
|
+
};
|
|
33
|
+
shift: {
|
|
34
|
+
id: string;
|
|
35
|
+
cashierId: string;
|
|
36
|
+
cashierName: string;
|
|
37
|
+
};
|
|
38
|
+
order: {
|
|
39
|
+
number: string;
|
|
40
|
+
placedAt: Date;
|
|
41
|
+
lines: ReceiptLine[];
|
|
42
|
+
subtotal: number;
|
|
43
|
+
tax: number;
|
|
44
|
+
discount?: number;
|
|
45
|
+
deliveryFee?: number;
|
|
46
|
+
grandTotal: number;
|
|
47
|
+
currency: string;
|
|
48
|
+
};
|
|
49
|
+
payments: ReceiptPayment[];
|
|
50
|
+
changeDue?: number;
|
|
51
|
+
customer?: {
|
|
52
|
+
name?: string;
|
|
53
|
+
phone?: string;
|
|
54
|
+
loyaltyId?: string;
|
|
55
|
+
};
|
|
56
|
+
qrCodeUrl?: string;
|
|
57
|
+
notes?: string;
|
|
58
|
+
}
|
|
59
|
+
interface ReceiptPrinter {
|
|
60
|
+
/** Print a structured receipt. Adapter handles ESC/POS encoding or browser print. */
|
|
61
|
+
print(payload: ReceiptPayload): Promise<void>;
|
|
62
|
+
/** Whether this adapter can talk to a printer right now (false for browser without agent). */
|
|
63
|
+
isAvailable(): Promise<boolean>;
|
|
64
|
+
}
|
|
65
|
+
interface CashDrawer {
|
|
66
|
+
/** Pulse the kick signal. Most adapters drive this through the printer's RJ11. */
|
|
67
|
+
open(): Promise<void>;
|
|
68
|
+
isAvailable(): Promise<boolean>;
|
|
69
|
+
}
|
|
70
|
+
interface ScanEvent {
|
|
71
|
+
/** Decoded code value (UPC / EAN / CODE128 etc. — the scanner does the decoding). */
|
|
72
|
+
code: string;
|
|
73
|
+
/** Raw keystrokes captured (for debugging). */
|
|
74
|
+
raw: string;
|
|
75
|
+
scannedAt: Date;
|
|
76
|
+
}
|
|
77
|
+
interface BarcodeScanner {
|
|
78
|
+
/** Subscribe to scan events. Returns an unsubscribe function. */
|
|
79
|
+
onScan(handler: (event: ScanEvent) => void): () => void;
|
|
80
|
+
isAvailable(): Promise<boolean>;
|
|
81
|
+
}
|
|
82
|
+
interface TerminalChargeInput {
|
|
83
|
+
amountMinor: number;
|
|
84
|
+
currency: string;
|
|
85
|
+
orderRef: string;
|
|
86
|
+
}
|
|
87
|
+
interface TerminalChargeResult {
|
|
88
|
+
/** Provider-issued reference (auth code, payment id, etc.). */
|
|
89
|
+
ref: string;
|
|
90
|
+
/** Provider name (`stripe-terminal`, `verifone`, `dbbl-pos`, etc.). */
|
|
91
|
+
provider: string;
|
|
92
|
+
/** Optional last-4 / brand for receipt display — never PAN. */
|
|
93
|
+
cardBrand?: string;
|
|
94
|
+
cardLast4?: string;
|
|
95
|
+
}
|
|
96
|
+
interface PaymentTerminal {
|
|
97
|
+
charge(input: TerminalChargeInput): Promise<TerminalChargeResult>;
|
|
98
|
+
refund(input: {
|
|
99
|
+
amountMinor: number;
|
|
100
|
+
currency: string;
|
|
101
|
+
originalRef: string;
|
|
102
|
+
}): Promise<{
|
|
103
|
+
ref: string;
|
|
104
|
+
}>;
|
|
105
|
+
/** Cancel an in-progress prompt on the terminal (cashier hits ESC). */
|
|
106
|
+
cancel(): Promise<void>;
|
|
107
|
+
isAvailable(): Promise<boolean>;
|
|
108
|
+
}
|
|
109
|
+
interface PosHardware {
|
|
110
|
+
printer: ReceiptPrinter;
|
|
111
|
+
drawer: CashDrawer;
|
|
112
|
+
scanner: BarcodeScanner;
|
|
113
|
+
terminal: PaymentTerminal;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { BarcodeScanner, CashDrawer, PaymentTerminal, PosHardware, ReceiptLine, ReceiptPayload, ReceiptPayment, ReceiptPrinter, ScanEvent, TerminalChargeInput, TerminalChargeResult };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readPrinterConfig } from "./use-printer-config.js";
|
|
2
|
+
|
|
3
|
+
//#region src/hardware/tauri-adapter.ts
|
|
4
|
+
function isTauri() {
|
|
5
|
+
if (typeof window === "undefined") return false;
|
|
6
|
+
const g = window;
|
|
7
|
+
return Boolean(g.__TAURI__?.core || g.__TAURI_INTERNALS__);
|
|
8
|
+
}
|
|
9
|
+
function getInvoke() {
|
|
10
|
+
const core = window.__TAURI__?.core;
|
|
11
|
+
if (!core) throw new Error("[pos/hardware] Tauri invoke unavailable — not running inside cashier-tauri shell");
|
|
12
|
+
return core.invoke.bind(core);
|
|
13
|
+
}
|
|
14
|
+
var TauriReceiptPrinter = class {
|
|
15
|
+
async print(payload) {
|
|
16
|
+
await getInvoke()("printer_print", {
|
|
17
|
+
payload,
|
|
18
|
+
config: readPrinterConfig() ?? void 0
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async isAvailable() {
|
|
22
|
+
try {
|
|
23
|
+
return await getInvoke()("printer_is_available", { config: readPrinterConfig() ?? void 0 });
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var TauriCashDrawer = class {
|
|
30
|
+
async open() {
|
|
31
|
+
await getInvoke()("drawer_open", { config: readPrinterConfig() ?? void 0 });
|
|
32
|
+
}
|
|
33
|
+
async isAvailable() {
|
|
34
|
+
try {
|
|
35
|
+
return await getInvoke()("drawer_is_available", { config: readPrinterConfig() ?? void 0 });
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var TauriBarcodeScannerStub = class {
|
|
42
|
+
onScan() {
|
|
43
|
+
return () => {};
|
|
44
|
+
}
|
|
45
|
+
async isAvailable() {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var TauriPaymentTerminal = class {
|
|
50
|
+
async charge(input) {
|
|
51
|
+
return getInvoke()("terminal_charge", { input });
|
|
52
|
+
}
|
|
53
|
+
async refund(input) {
|
|
54
|
+
return getInvoke()("terminal_refund", { input });
|
|
55
|
+
}
|
|
56
|
+
async cancel() {
|
|
57
|
+
await getInvoke()("terminal_cancel");
|
|
58
|
+
}
|
|
59
|
+
async isAvailable() {
|
|
60
|
+
try {
|
|
61
|
+
return await getInvoke()("terminal_is_available");
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function createTauriHardware() {
|
|
68
|
+
return {
|
|
69
|
+
printer: new TauriReceiptPrinter(),
|
|
70
|
+
drawer: new TauriCashDrawer(),
|
|
71
|
+
scanner: new TauriBarcodeScannerStub(),
|
|
72
|
+
terminal: new TauriPaymentTerminal()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
export { createTauriHardware, isTauri };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/hardware/use-printer-availability.d.ts
|
|
2
|
+
interface PrinterAvailability {
|
|
3
|
+
/** `true` if the port reports a reachable printer. Defaults to `true`
|
|
4
|
+
* while the first probe is in flight so the initial render of a
|
|
5
|
+
* print button isn't briefly disabled-then-enabled (visual flicker). */
|
|
6
|
+
available: boolean;
|
|
7
|
+
/** `true` while the first probe is in flight. */
|
|
8
|
+
checking: boolean;
|
|
9
|
+
}
|
|
10
|
+
declare function usePrinterAvailability(): PrinterAvailability;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { PrinterAvailability, usePrinterAvailability };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useHardware } from "./context.js";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
//#region src/hardware/use-printer-availability.ts
|
|
7
|
+
/**
|
|
8
|
+
* usePrinterAvailability — reflects the printer port's `isAvailable()`
|
|
9
|
+
* state into the React tree so consumers can disable / annotate the
|
|
10
|
+
* print button when no hardware is reachable.
|
|
11
|
+
*
|
|
12
|
+
* - Browser host: `WebReceiptPrinter.isAvailable()` returns `true`
|
|
13
|
+
* whenever a `window` exists — browser users can always print to
|
|
14
|
+
* whatever printer the OS has configured. Button stays enabled.
|
|
15
|
+
* - Tauri host: probes the configured network printer over TCP with
|
|
16
|
+
* a 500ms timeout. Returns `false` when `POS_PRINTER_HOST` isn't
|
|
17
|
+
* set or the printer doesn't answer. Button greys out, cashier
|
|
18
|
+
* gets a clear hint.
|
|
19
|
+
*
|
|
20
|
+
* Intentionally NOT a polling hook — printers don't appear/disappear
|
|
21
|
+
* during a sale, so one check per consumer mount is enough. If a shop
|
|
22
|
+
* needs live status (e.g. printer-out-of-paper light on the dashboard),
|
|
23
|
+
* extend with `refetchInterval` later.
|
|
24
|
+
*/
|
|
25
|
+
function usePrinterAvailability() {
|
|
26
|
+
const { printer } = useHardware();
|
|
27
|
+
const [available, setAvailable] = useState(true);
|
|
28
|
+
const [checking, setChecking] = useState(true);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
setChecking(true);
|
|
32
|
+
printer.isAvailable().then((ok) => {
|
|
33
|
+
if (!cancelled) setAvailable(ok);
|
|
34
|
+
}).catch(() => {
|
|
35
|
+
if (!cancelled) setAvailable(false);
|
|
36
|
+
}).finally(() => {
|
|
37
|
+
if (!cancelled) setChecking(false);
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
cancelled = true;
|
|
41
|
+
};
|
|
42
|
+
}, [printer]);
|
|
43
|
+
return {
|
|
44
|
+
available,
|
|
45
|
+
checking
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { usePrinterAvailability };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/hardware/use-printer-config.d.ts
|
|
2
|
+
/** Same shape as Rust's `PrinterConfig` — see cashier-tauri transport.rs. */
|
|
3
|
+
interface PrinterConfig {
|
|
4
|
+
host: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Synchronous read — used by the Tauri adapter on each invoke call. */
|
|
9
|
+
declare function readPrinterConfig(): PrinterConfig | null;
|
|
10
|
+
declare function usePrinterConfig(): {
|
|
11
|
+
config: PrinterConfig;
|
|
12
|
+
setConfig: (next: PrinterConfig) => void;
|
|
13
|
+
clearConfig: () => void;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { PrinterConfig, readPrinterConfig, usePrinterConfig };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/hardware/use-printer-config.ts
|
|
6
|
+
/**
|
|
7
|
+
* usePrinterConfig — localStorage-backed runtime printer config.
|
|
8
|
+
*
|
|
9
|
+
* Persists the cashier's printer host/port/timeout in the browser's
|
|
10
|
+
* localStorage. The Tauri adapter reads the same key synchronously
|
|
11
|
+
* inside its `invoke` calls (see `tauri-adapter.ts:readPrinterConfig`)
|
|
12
|
+
* so the cashier can change the printer IP from the settings dialog
|
|
13
|
+
* without restarting the app.
|
|
14
|
+
*
|
|
15
|
+
* Browser users (no Tauri) still benefit from the dialog — it's a
|
|
16
|
+
* no-op for them because `window.print()` is what runs on the web
|
|
17
|
+
* adapter path. The dialog is hidden when Tauri isn't detected.
|
|
18
|
+
*/
|
|
19
|
+
const STORAGE_KEY = "pos:printer:config";
|
|
20
|
+
const EMPTY = { host: "" };
|
|
21
|
+
/** Synchronous read — used by the Tauri adapter on each invoke call. */
|
|
22
|
+
function readPrinterConfig() {
|
|
23
|
+
if (typeof window === "undefined") return null;
|
|
24
|
+
try {
|
|
25
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (typeof parsed.host !== "string" || parsed.host.trim().length === 0) return null;
|
|
29
|
+
return parsed;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function usePrinterConfig() {
|
|
35
|
+
const [config, setConfigState] = useState(EMPTY);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const stored = readPrinterConfig();
|
|
38
|
+
if (stored) setConfigState(stored);
|
|
39
|
+
const onStorage = (e) => {
|
|
40
|
+
if (e.key !== STORAGE_KEY) return;
|
|
41
|
+
setConfigState(readPrinterConfig() ?? EMPTY);
|
|
42
|
+
};
|
|
43
|
+
window.addEventListener("storage", onStorage);
|
|
44
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
45
|
+
}, []);
|
|
46
|
+
const setConfig = useCallback((next) => {
|
|
47
|
+
setConfigState(next);
|
|
48
|
+
if (typeof window === "undefined") return;
|
|
49
|
+
if (next.host.trim().length === 0) window.localStorage.removeItem(STORAGE_KEY);
|
|
50
|
+
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
51
|
+
}, []);
|
|
52
|
+
return {
|
|
53
|
+
config,
|
|
54
|
+
setConfig,
|
|
55
|
+
clearConfig: useCallback(() => {
|
|
56
|
+
setConfig(EMPTY);
|
|
57
|
+
}, [setConfig])
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { readPrinterConfig, usePrinterConfig };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PosHardware } from "./ports.js";
|
|
2
|
+
|
|
3
|
+
//#region src/hardware/web-adapter.d.ts
|
|
4
|
+
/** DOM id of the rendered receipt — used by `printDocument` for print isolation. */
|
|
5
|
+
declare const RECEIPT_DOM_ID = "pos-receipt";
|
|
6
|
+
/**
|
|
7
|
+
* Auto-detects the host: returns native Tauri adapters when the React
|
|
8
|
+
* tree is running inside the `cashier-tauri` webview, otherwise returns
|
|
9
|
+
* the browser stubs. Consumers stay agnostic — `<HardwareProvider>` in
|
|
10
|
+
* `pos-shell.tsx` calls this once at mount and the same React tree
|
|
11
|
+
* works on both surfaces with no per-component branching.
|
|
12
|
+
*/
|
|
13
|
+
declare function createWebHardware(): PosHardware;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { RECEIPT_DOM_ID, createWebHardware };
|