@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
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @classytic/pos-ui
2
+
3
+ > 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). Frontend sibling of the [@classytic/pos](https://npmjs.com/package/@classytic/pos) kernel.
4
+
5
+ Multi-surface by design: web (browser), Tauri desktop, and React Native share the same screens and state machine; only the runtime + hardware adapters differ. Independent of the dashboard stack — no `@classytic/erp-shell`, no route framework. The host mounts one component and injects adapters.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @classytic/pos-ui
11
+ ```
12
+
13
+ Peers: React 19, Next 14+, `@classytic/commerce-sdk` ≥0.9, `@classytic/fluid` ≥1.7, `@classytic/primitives` ≥0.9, TanStack Query 5, sonner, lucide-react. Host-owned `@/components/ui/*` shadcn primitives (same ambient-stub convention as `@classytic/commerce-ui`).
14
+
15
+ ## Subpath exports
16
+
17
+ | Subpath | What it gives you |
18
+ |---|---|
19
+ | `@classytic/pos-ui/shell` | `<PosShell>` + props + the DI port types the host implements |
20
+ | `@classytic/pos-ui/hardware` | `PosHardware` ports, `createWebHardware()`, Tauri adapter, printer hooks |
21
+ | `@classytic/pos-ui/runtime` | Surface adapters (web, Tauri) — storage, focus, fullscreen, keyboard |
22
+
23
+ Internal layers (`screens/`, `state/`, `dashboard/`, `components/`) are not part of the public surface.
24
+
25
+ ## Quick start (web)
26
+
27
+ ```tsx
28
+ import { PosShell } from '@classytic/pos-ui/shell';
29
+ import { createWebHardware } from '@classytic/pos-ui/hardware';
30
+
31
+ <PosShell hardware={createWebHardware()} />
32
+ ```
33
+
34
+ Hardware adapters are a port — implement the interface for your printer/drawer/terminal vendor and inject it; the screens never talk to devices directly.
35
+
36
+ ## Releasing
37
+
38
+ See sibling packages' `RELEASING.md` — standard `@classytic/*` flow (`npm run release`).
@@ -0,0 +1,222 @@
1
+ "use client";
2
+
3
+ import { formatBdt } from "../lib/money.js";
4
+ import { getReadableTextColor, getTierColor } from "../lib/loyalty.js";
5
+ import { CartItems } from "../dashboard/components/cart/CartItems.js";
6
+ import { CartSummary } from "../dashboard/components/cart/CartSummary.js";
7
+ import { CustomerSection } from "../dashboard/components/cart/CustomerSection.js";
8
+ import { DiscountSection } from "../dashboard/components/cart/DiscountSection.js";
9
+ import { PointsRedemptionSection } from "../dashboard/components/cart/PointsRedemptionSection.js";
10
+ import { AddChargeDialog } from "../dashboard/components/cart/AddChargeDialog.js";
11
+ import { useMemo } from "react";
12
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
+ import { Inbox, PauseCircle, ShoppingCart, Trash2 } from "lucide-react";
14
+ import { Button } from "@/components/ui/button";
15
+ import { Badge } from "@/components/ui/badge";
16
+
17
+ //#region src/components/CartSidebar.tsx
18
+ /**
19
+ * CartSidebar — Compact cart panel for the product screen.
20
+ *
21
+ * Shows: cart items, customer, discount, points, totals, checkout button.
22
+ * Payment and receipt are separate screens — not inline here.
23
+ */
24
+ function CartSidebar({ cart, customer, membership, orderTotals, discountAuth, onCheckout, onRequestDiscountAuth, onClearDiscountAuth, onParkSale, onOpenParked, parkedCount = 0, canPark = true }) {
25
+ const isEmpty = cart.cart.length === 0;
26
+ useMemo(() => ({
27
+ items: cart.cart,
28
+ subtotal: orderTotals.subtotal,
29
+ discount: orderTotals.manualDiscount,
30
+ tierDiscount: orderTotals.tierDiscount,
31
+ tierName: orderTotals.tierName,
32
+ redemptionDiscount: orderTotals.redemptionDiscount,
33
+ total: orderTotals.total,
34
+ pointsToEarn: orderTotals.pointsToEarn
35
+ }), [cart.cart, orderTotals]);
36
+ const customerViewModel = useMemo(() => {
37
+ const tierName = customer.selectedCustomer?.membership?.tier || null;
38
+ const tierColor = getTierColor(tierName);
39
+ const tierTextColor = getReadableTextColor(tierColor);
40
+ return {
41
+ selected: customer.selectedCustomer,
42
+ name: customer.customerName,
43
+ phone: customer.customerPhone,
44
+ membershipCardId: customer.membershipCardId,
45
+ membershipStatus: customer.membershipLookupStatus,
46
+ tierName,
47
+ tierColor,
48
+ tierTextColor
49
+ };
50
+ }, [
51
+ customer.selectedCustomer,
52
+ customer.customerName,
53
+ customer.customerPhone,
54
+ customer.membershipCardId,
55
+ customer.membershipLookupStatus
56
+ ]);
57
+ const discountAuthViewModel = useMemo(() => ({
58
+ isAuthorized: discountAuth.isAuthorized,
59
+ authorizedByName: discountAuth.authorizedBy?.name || null
60
+ }), [discountAuth.isAuthorized, discountAuth.authorizedBy]);
61
+ const redemption = useMemo(() => ({
62
+ enabled: !!membership?.enabled && !!membership?.redemption?.enabled && !!customer.selectedCustomer?.membership?.cardId,
63
+ pointsBalance: customer.selectedCustomer?.membership?.points?.current ?? 0,
64
+ minRedeemPoints: membership?.redemption?.minRedeemPoints ?? 0,
65
+ maxRedeemPoints: Math.min(orderTotals.maxAllowedPoints, customer.selectedCustomer?.membership?.points?.current ?? 0),
66
+ pointsPerBdt: membership?.redemption?.pointsPerBdt ?? 1,
67
+ estimatedDiscount: orderTotals.redemptionDiscount,
68
+ error: orderTotals.redemptionError
69
+ }), [
70
+ membership,
71
+ customer.selectedCustomer,
72
+ orderTotals
73
+ ]);
74
+ return /* @__PURE__ */ jsxs("div", {
75
+ className: "flex flex-col h-full",
76
+ children: [
77
+ /* @__PURE__ */ jsxs("div", {
78
+ className: "flex items-center justify-between px-4 py-3 border-b",
79
+ children: [/* @__PURE__ */ jsxs("div", {
80
+ className: "flex items-center gap-2",
81
+ children: [
82
+ /* @__PURE__ */ jsx(ShoppingCart, { className: "h-4 w-4" }),
83
+ /* @__PURE__ */ jsx("span", {
84
+ className: "font-semibold text-sm",
85
+ children: "Cart"
86
+ }),
87
+ !isEmpty && /* @__PURE__ */ jsx(Badge, {
88
+ variant: "secondary",
89
+ className: "text-xs",
90
+ children: cart.cart.length
91
+ })
92
+ ]
93
+ }), /* @__PURE__ */ jsxs("div", {
94
+ className: "flex items-center gap-1",
95
+ children: [
96
+ onOpenParked && /* @__PURE__ */ jsxs(Button, {
97
+ variant: "ghost",
98
+ size: "sm",
99
+ className: "h-7 text-xs",
100
+ onClick: onOpenParked,
101
+ "aria-label": `Parked sales (${parkedCount})`,
102
+ children: [
103
+ /* @__PURE__ */ jsx(Inbox, { className: "h-3.5 w-3.5 mr-1" }),
104
+ "Parked",
105
+ parkedCount > 0 && /* @__PURE__ */ jsx(Badge, {
106
+ variant: "secondary",
107
+ className: "ml-1.5 h-4 px-1.5 text-[10px]",
108
+ children: parkedCount
109
+ })
110
+ ]
111
+ }),
112
+ onParkSale && !isEmpty && /* @__PURE__ */ jsxs(Button, {
113
+ variant: "ghost",
114
+ size: "sm",
115
+ className: "h-7 text-xs",
116
+ onClick: onParkSale,
117
+ disabled: !canPark,
118
+ title: canPark ? "Park sale (F6)" : "Max 5 parked — resume one first",
119
+ children: [/* @__PURE__ */ jsx(PauseCircle, { className: "h-3.5 w-3.5 mr-1" }), "Park"]
120
+ }),
121
+ !isEmpty && /* @__PURE__ */ jsxs(Button, {
122
+ variant: "ghost",
123
+ size: "sm",
124
+ className: "h-7 text-xs text-destructive",
125
+ onClick: cart.clearCart,
126
+ children: [/* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5 mr-1" }), "Clear"]
127
+ })
128
+ ]
129
+ })]
130
+ }),
131
+ /* @__PURE__ */ jsx("div", {
132
+ className: "flex-1 overflow-y-auto min-h-0 px-4 py-3 space-y-4",
133
+ children: isEmpty ? /* @__PURE__ */ jsxs("div", {
134
+ className: "flex flex-col items-center justify-center h-full text-center text-muted-foreground",
135
+ children: [
136
+ /* @__PURE__ */ jsx(ShoppingCart, { className: "h-10 w-10 mb-3 opacity-30" }),
137
+ /* @__PURE__ */ jsx("p", {
138
+ className: "text-sm",
139
+ children: "Scan or select products"
140
+ }),
141
+ /* @__PURE__ */ jsx("p", {
142
+ className: "text-xs mt-1",
143
+ children: "Press F3 to search, F4 for barcode"
144
+ })
145
+ ]
146
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
147
+ /* @__PURE__ */ jsx(CartItems, {
148
+ items: cart.cart,
149
+ onUpdateQuantity: cart.updateQuantity,
150
+ onRemoveItem: cart.removeItem,
151
+ onClear: cart.clearCart
152
+ }),
153
+ /* @__PURE__ */ jsx(CartSummary, {
154
+ subtotal: orderTotals.subtotal,
155
+ discount: orderTotals.manualDiscount,
156
+ tierDiscount: orderTotals.tierDiscount,
157
+ tierName: orderTotals.tierName ?? void 0,
158
+ redemptionDiscount: orderTotals.redemptionDiscount,
159
+ total: orderTotals.total,
160
+ pointsToEarn: orderTotals.pointsToEarn
161
+ }),
162
+ /* @__PURE__ */ jsx(CustomerSection, {
163
+ selected: customerViewModel.selected,
164
+ name: customerViewModel.name,
165
+ phone: customerViewModel.phone,
166
+ membershipCardId: customerViewModel.membershipCardId,
167
+ membershipStatus: customerViewModel.membershipStatus,
168
+ tierName: customerViewModel.tierName,
169
+ tierColor: customerViewModel.tierColor,
170
+ tierTextColor: customerViewModel.tierTextColor,
171
+ onNameChange: customer.setCustomerName,
172
+ onPhoneChange: customer.setCustomerPhone,
173
+ onMembershipChange: (value) => {
174
+ customer.setMembershipCardId(value);
175
+ cart.setMembershipCardId(value);
176
+ },
177
+ onMembershipLookup: customer.triggerMembershipLookup,
178
+ onClear: customer.clearCustomer,
179
+ onOpenLookup: () => customer.setLookupDialogOpen(true)
180
+ }),
181
+ /* @__PURE__ */ jsx(AddChargeDialog, {
182
+ disabled: isEmpty,
183
+ onAddCharge: cart.addCharge
184
+ }),
185
+ /* @__PURE__ */ jsx(DiscountSection, {
186
+ value: cart.discountInput,
187
+ onChange: cart.setDiscountInput,
188
+ isAuthorized: discountAuthViewModel.isAuthorized,
189
+ authorizedByName: discountAuthViewModel.authorizedByName,
190
+ onRequestAuth: onRequestDiscountAuth,
191
+ onClearAuth: onClearDiscountAuth
192
+ }),
193
+ redemption.enabled && /* @__PURE__ */ jsx(PointsRedemptionSection, {
194
+ enabled: redemption.enabled,
195
+ pointsBalance: redemption.pointsBalance,
196
+ minRedeemPoints: redemption.minRedeemPoints,
197
+ maxRedeemPoints: redemption.maxRedeemPoints,
198
+ pointsPerBdt: redemption.pointsPerBdt,
199
+ estimatedDiscount: redemption.estimatedDiscount,
200
+ error: redemption.error,
201
+ value: cart.pointsToRedeemInput,
202
+ onChange: cart.setPointsToRedeemInput,
203
+ onUseMax: () => {
204
+ if (orderTotals.maxAllowedPoints > 0) cart.setPointsToRedeemInput(String(orderTotals.maxAllowedPoints));
205
+ }
206
+ })
207
+ ] })
208
+ }),
209
+ !isEmpty && /* @__PURE__ */ jsx("div", {
210
+ className: "px-4 py-3 border-t bg-card",
211
+ children: /* @__PURE__ */ jsxs(Button, {
212
+ className: "w-full h-12 text-base font-semibold",
213
+ onClick: onCheckout,
214
+ children: ["Checkout ", formatBdt(orderTotals.total)]
215
+ })
216
+ })
217
+ ]
218
+ });
219
+ }
220
+
221
+ //#endregion
222
+ export { CartSidebar };
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import { usePosRuntime } from "../runtime/config.js";
4
+ import { usePosBranch } from "../runtime/branch-port.js";
5
+ import { usePosContext } from "../state/pos-context.js";
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { ActionDropdown } from "@classytic/fluid/client/core";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { ChevronDown, Clock, History, Keyboard, LayoutDashboard, LogOut, Plus, Printer, Store, XCircle } from "lucide-react";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Badge } from "@/components/ui/badge";
12
+
13
+ //#region src/components/PosTopBar.tsx
14
+ /**
15
+ * PosTopBar — Minimal chrome for the full-screen POS terminal.
16
+ *
17
+ * Uses fluid's ActionDropdown for the menu — no raw Base UI primitives.
18
+ */
19
+ function useClock() {
20
+ const [time, setTime] = useState("");
21
+ useEffect(() => {
22
+ function tick() {
23
+ setTime((/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", {
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ hour12: true
27
+ }));
28
+ }
29
+ tick();
30
+ const id = setInterval(tick, 3e4);
31
+ return () => clearInterval(id);
32
+ }, []);
33
+ return time;
34
+ }
35
+ function PosTopBar({ userName, onNewSale, onOpenHistory, onOpenHelp, onOpenPrinterSettings }) {
36
+ const selectedBranch = usePosBranch();
37
+ const { onNavigate } = usePosRuntime();
38
+ const { state, dispatch } = usePosContext();
39
+ const time = useClock();
40
+ const branchName = selectedBranch?.name || "POS Terminal";
41
+ const menuItems = useMemo(() => {
42
+ const items = [];
43
+ if (onOpenHistory) items.push({
44
+ key: "history",
45
+ label: "Order History",
46
+ icon: History,
47
+ shortcut: "F9",
48
+ onClick: () => onOpenHistory()
49
+ });
50
+ if (state.shiftOpen) items.push({
51
+ key: "close",
52
+ label: "Close Register",
53
+ icon: XCircle,
54
+ onClick: () => dispatch({ type: "CLOSE_SHIFT" })
55
+ });
56
+ if (onOpenPrinterSettings) items.push({
57
+ key: "printer-settings",
58
+ label: "Printer Settings",
59
+ icon: Printer,
60
+ onClick: () => onOpenPrinterSettings()
61
+ });
62
+ items.push({
63
+ type: "separator",
64
+ key: "sep"
65
+ });
66
+ items.push({
67
+ key: "dashboard",
68
+ label: "Back to Dashboard",
69
+ icon: LayoutDashboard,
70
+ onClick: () => {
71
+ onNavigate("/dashboard");
72
+ }
73
+ });
74
+ items.push({
75
+ key: "logout",
76
+ label: "Logout",
77
+ icon: LogOut,
78
+ onClick: () => {
79
+ onNavigate("/sign-in");
80
+ }
81
+ });
82
+ return items;
83
+ }, [
84
+ onOpenHistory,
85
+ onOpenPrinterSettings,
86
+ state.shiftOpen,
87
+ dispatch,
88
+ onNavigate
89
+ ]);
90
+ return /* @__PURE__ */ jsxs("div", {
91
+ className: "flex items-center justify-between h-12 px-4 bg-card border-b shrink-0",
92
+ children: [
93
+ /* @__PURE__ */ jsxs("div", {
94
+ className: "flex items-center gap-2 min-w-0",
95
+ children: [
96
+ /* @__PURE__ */ jsx(Store, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
97
+ /* @__PURE__ */ jsx("span", {
98
+ className: "font-semibold text-sm truncate",
99
+ children: branchName
100
+ }),
101
+ state.shiftOpen && /* @__PURE__ */ jsx(Badge, {
102
+ variant: "outline",
103
+ className: "text-xs font-normal shrink-0",
104
+ children: "Shift Open"
105
+ })
106
+ ]
107
+ }),
108
+ /* @__PURE__ */ jsx("div", {
109
+ className: "flex-1 flex items-center justify-center gap-1 px-4",
110
+ children: state.screen === "products" && onNewSale && /* @__PURE__ */ jsxs(Button, {
111
+ variant: "ghost",
112
+ size: "sm",
113
+ onClick: onNewSale,
114
+ className: "h-8 text-xs gap-1",
115
+ children: [/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5" }), "New Sale"]
116
+ })
117
+ }),
118
+ /* @__PURE__ */ jsxs("div", {
119
+ className: "flex items-center gap-3 shrink-0",
120
+ children: [
121
+ onOpenHelp && /* @__PURE__ */ jsxs(Button, {
122
+ variant: "ghost",
123
+ size: "sm",
124
+ onClick: onOpenHelp,
125
+ className: "h-8 px-2 text-xs gap-1 text-muted-foreground",
126
+ title: "Keyboard shortcuts (?)",
127
+ "aria-label": "Keyboard shortcuts",
128
+ children: [/* @__PURE__ */ jsx(Keyboard, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ jsx("span", {
129
+ className: "hidden sm:inline",
130
+ children: "?"
131
+ })]
132
+ }),
133
+ /* @__PURE__ */ jsxs("div", {
134
+ className: "flex items-center gap-1.5 text-muted-foreground text-sm",
135
+ children: [/* @__PURE__ */ jsx(Clock, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ jsx("span", {
136
+ className: "tabular-nums",
137
+ children: time
138
+ })]
139
+ }),
140
+ /* @__PURE__ */ jsx(ActionDropdown, {
141
+ items: menuItems,
142
+ triggerIcon: ChevronDown,
143
+ triggerLabel: userName || "Cashier",
144
+ triggerVariant: "ghost",
145
+ triggerSize: "sm",
146
+ triggerClassName: "h-8 text-xs",
147
+ align: "end"
148
+ })
149
+ ]
150
+ })
151
+ ]
152
+ });
153
+ }
154
+
155
+ //#endregion
156
+ export { PosTopBar };
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { usePrinterConfig } from "../hardware/use-printer-config.js";
4
+ import { isTauri } from "../hardware/tauri-adapter.js";
5
+ import { useHardware } from "../hardware/context.js";
6
+ import { useForm } from "../node_modules/react-hook-form/dist/index.esm.js";
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { DialogWrapper } from "@classytic/fluid/client/core";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { AlertTriangle, Check, Loader2, Printer } from "lucide-react";
11
+ import { Button } from "@/components/ui/button";
12
+ import { FormInput } from "@classytic/fluid/forms";
13
+
14
+ //#region src/components/PrinterSettingsDialog.tsx
15
+ /**
16
+ * PrinterSettingsDialog — runtime printer config for the cashier.
17
+ *
18
+ * Persists `{ host, port, timeoutMs }` to localStorage via
19
+ * `usePrinterConfig`. The Tauri adapter reads the same key on every
20
+ * invoke, so saved settings take effect immediately on the next print
21
+ * — no app restart, no env-var changes.
22
+ *
23
+ * Browser users see this dialog too (via the topbar action menu) but
24
+ * it has no effect on browser printing — `window.print()` runs against
25
+ * whatever printer the OS has configured. The dialog is informational
26
+ * for browser, functional for Tauri.
27
+ */
28
+ function PrinterSettingsDialog({ open, onOpenChange }) {
29
+ const { config, setConfig } = usePrinterConfig();
30
+ const { printer } = useHardware();
31
+ const [probe, setProbe] = useState({ kind: "idle" });
32
+ const form = useForm({ defaultValues: {
33
+ host: config.host,
34
+ port: config.port?.toString() ?? "",
35
+ timeoutMs: config.timeoutMs?.toString() ?? ""
36
+ } });
37
+ useEffect(() => {
38
+ if (!open) return;
39
+ form.reset({
40
+ host: config.host,
41
+ port: config.port?.toString() ?? "",
42
+ timeoutMs: config.timeoutMs?.toString() ?? ""
43
+ });
44
+ setProbe({ kind: "idle" });
45
+ }, [
46
+ open,
47
+ config,
48
+ form
49
+ ]);
50
+ const handleSave = useCallback((values) => {
51
+ const next = { host: values.host.trim() };
52
+ const portNum = Number(values.port);
53
+ if (Number.isFinite(portNum) && portNum > 0) next.port = portNum;
54
+ const timeoutNum = Number(values.timeoutMs);
55
+ if (Number.isFinite(timeoutNum) && timeoutNum > 0) next.timeoutMs = timeoutNum;
56
+ setConfig(next);
57
+ onOpenChange(false);
58
+ }, [setConfig, onOpenChange]);
59
+ const handleTest = useCallback(async () => {
60
+ const values = form.getValues();
61
+ const trimmed = values.host.trim();
62
+ if (!trimmed) {
63
+ setProbe({
64
+ kind: "fail",
65
+ reason: "Set a host first"
66
+ });
67
+ return;
68
+ }
69
+ const prior = config;
70
+ const tentative = { host: trimmed };
71
+ const portNum = Number(values.port);
72
+ if (Number.isFinite(portNum) && portNum > 0) tentative.port = portNum;
73
+ setConfig(tentative);
74
+ setProbe({ kind: "probing" });
75
+ try {
76
+ if (await printer.isAvailable()) setProbe({ kind: "ok" });
77
+ else {
78
+ setProbe({
79
+ kind: "fail",
80
+ reason: "No response from printer"
81
+ });
82
+ setConfig(prior);
83
+ }
84
+ } catch (err) {
85
+ setProbe({
86
+ kind: "fail",
87
+ reason: err instanceof Error ? err.message : "Probe failed"
88
+ });
89
+ setConfig(prior);
90
+ }
91
+ }, [
92
+ form,
93
+ config,
94
+ printer,
95
+ setConfig
96
+ ]);
97
+ return /* @__PURE__ */ jsx(DialogWrapper, {
98
+ open,
99
+ onOpenChange,
100
+ title: "Printer settings",
101
+ description: typeof window !== "undefined" && isTauri() ? "Configure the network thermal printer for receipts and cash-drawer kicks." : "Browser POS uses the OS print dialog. These settings only apply in the desktop (Tauri) shell.",
102
+ footer: /* @__PURE__ */ jsxs("div", {
103
+ className: "flex justify-between gap-2 w-full",
104
+ children: [/* @__PURE__ */ jsxs(Button, {
105
+ variant: "outline",
106
+ onClick: handleTest,
107
+ disabled: probe.kind === "probing",
108
+ children: [probe.kind === "probing" ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }) : /* @__PURE__ */ jsx(Printer, { className: "h-4 w-4 mr-2" }), "Test connection"]
109
+ }), /* @__PURE__ */ jsxs("div", {
110
+ className: "flex gap-2",
111
+ children: [/* @__PURE__ */ jsx(Button, {
112
+ variant: "ghost",
113
+ onClick: () => onOpenChange(false),
114
+ children: "Cancel"
115
+ }), /* @__PURE__ */ jsx(Button, {
116
+ onClick: form.handleSubmit(handleSave),
117
+ children: "Save"
118
+ })]
119
+ })]
120
+ }),
121
+ children: /* @__PURE__ */ jsxs("form", {
122
+ onSubmit: form.handleSubmit(handleSave),
123
+ className: "space-y-4",
124
+ children: [
125
+ /* @__PURE__ */ jsx(FormInput, {
126
+ control: form.control,
127
+ name: "host",
128
+ label: "Printer host",
129
+ placeholder: "192.168.1.100",
130
+ helperText: "LAN IP of the network thermal printer (Epson TM-T82, Xprinter, etc.)",
131
+ required: true
132
+ }),
133
+ /* @__PURE__ */ jsxs("div", {
134
+ className: "grid grid-cols-2 gap-3",
135
+ children: [/* @__PURE__ */ jsx(FormInput, {
136
+ control: form.control,
137
+ name: "port",
138
+ label: "Port",
139
+ placeholder: "9100",
140
+ helperText: "Default 9100"
141
+ }), /* @__PURE__ */ jsx(FormInput, {
142
+ control: form.control,
143
+ name: "timeoutMs",
144
+ label: "Timeout (ms)",
145
+ placeholder: "2000",
146
+ helperText: "Default 2000"
147
+ })]
148
+ }),
149
+ probe.kind === "ok" && /* @__PURE__ */ jsxs("div", {
150
+ className: "flex items-center gap-2 text-sm text-green-700 dark:text-green-300",
151
+ children: [/* @__PURE__ */ jsx(Check, { className: "h-4 w-4" }), "Printer responded — saved."]
152
+ }),
153
+ probe.kind === "fail" && /* @__PURE__ */ jsxs("div", {
154
+ className: "flex items-start gap-2 text-sm text-amber-700 dark:text-amber-300",
155
+ children: [/* @__PURE__ */ jsx(AlertTriangle, { className: "h-4 w-4 mt-0.5 shrink-0" }), /* @__PURE__ */ jsx("span", { children: probe.reason })]
156
+ })
157
+ ]
158
+ })
159
+ });
160
+ }
161
+
162
+ //#endregion
163
+ export { PrinterSettingsDialog };