@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.
Files changed (65) hide show
  1. package/README.md +38 -0
  2. package/dist/components/CartSidebar.js +222 -0
  3. package/dist/components/PosTopBar.js +156 -0
  4. package/dist/components/PrinterSettingsDialog.js +163 -0
  5. package/dist/components/ReceiptReprintDialog.js +182 -0
  6. package/dist/dashboard/components/CustomerLookupDialog.js +120 -0
  7. package/dist/dashboard/components/CustomerQuickAddDialog.js +101 -0
  8. package/dist/dashboard/components/ManagerAuthDialog.js +77 -0
  9. package/dist/dashboard/components/ProductCard.js +98 -0
  10. package/dist/dashboard/components/ProductsPanel.js +119 -0
  11. package/dist/dashboard/components/SplitPaymentPanel.js +131 -0
  12. package/dist/dashboard/components/VariantSelectorDialog.js +150 -0
  13. package/dist/dashboard/components/cart/AddChargeDialog.js +99 -0
  14. package/dist/dashboard/components/cart/CartItems.js +103 -0
  15. package/dist/dashboard/components/cart/CartSummary.js +73 -0
  16. package/dist/dashboard/components/cart/CustomerSection.js +127 -0
  17. package/dist/dashboard/components/cart/DiscountSection.js +50 -0
  18. package/dist/dashboard/components/cart/PointsRedemptionSection.js +58 -0
  19. package/dist/hardware/context.d.ts +15 -0
  20. package/dist/hardware/context.js +33 -0
  21. package/dist/hardware/index.d.ts +7 -0
  22. package/dist/hardware/index.js +7 -0
  23. package/dist/hardware/ports.d.ts +116 -0
  24. package/dist/hardware/tauri-adapter.d.ts +7 -0
  25. package/dist/hardware/tauri-adapter.js +77 -0
  26. package/dist/hardware/use-printer-availability.d.ts +12 -0
  27. package/dist/hardware/use-printer-availability.js +50 -0
  28. package/dist/hardware/use-printer-config.d.ts +16 -0
  29. package/dist/hardware/use-printer-config.js +62 -0
  30. package/dist/hardware/web-adapter.d.ts +15 -0
  31. package/dist/hardware/web-adapter.js +80 -0
  32. package/dist/hooks/useManagerAuth.js +83 -0
  33. package/dist/hooks/usePosCart.js +142 -0
  34. package/dist/hooks/usePosCustomer.js +192 -0
  35. package/dist/hooks/usePosMultiOrder.js +48 -0
  36. package/dist/hooks/usePosPayment.js +194 -0
  37. package/dist/lib/cn.js +12 -0
  38. package/dist/lib/loyalty.js +30 -0
  39. package/dist/lib/money.js +41 -0
  40. package/dist/node_modules/react-hook-form/dist/index.esm.js +1661 -0
  41. package/dist/runtime/auth-port.d.ts +46 -0
  42. package/dist/runtime/auth-port.js +21 -0
  43. package/dist/runtime/branch-port.d.ts +38 -0
  44. package/dist/runtime/branch-port.js +26 -0
  45. package/dist/runtime/config.d.ts +24 -0
  46. package/dist/runtime/config.js +21 -0
  47. package/dist/runtime/index.d.ts +4 -0
  48. package/dist/runtime/index.js +5 -0
  49. package/dist/screens/OrderHistoryDrawer.js +245 -0
  50. package/dist/screens/ParkedOrdersDrawer.js +128 -0
  51. package/dist/screens/PaymentScreen.js +397 -0
  52. package/dist/screens/ProductScreen.js +322 -0
  53. package/dist/screens/ReceiptScreen.js +288 -0
  54. package/dist/screens/ShiftCloseScreen.js +365 -0
  55. package/dist/screens/ShiftOpenScreen.js +176 -0
  56. package/dist/shell/index.d.ts +8 -0
  57. package/dist/shell/index.js +5 -0
  58. package/dist/shell/pos-shell.d.ts +23 -0
  59. package/dist/shell/pos-shell.js +132 -0
  60. package/dist/state/pos-context.js +33 -0
  61. package/dist/state/pos-state.js +70 -0
  62. package/dist/utils/customer-display.js +35 -0
  63. package/dist/utils/pos-helpers.js +242 -0
  64. package/package.json +92 -0
  65. 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,7 @@
1
+ import { PosHardware } from "./ports.js";
2
+
3
+ //#region src/hardware/tauri-adapter.d.ts
4
+ declare function isTauri(): boolean;
5
+ declare function createTauriHardware(): PosHardware;
6
+ //#endregion
7
+ export { createTauriHardware, isTauri };
@@ -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 };