@classytic/pos-ui 0.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +75 -0
  2. package/dist/components/CartSidebar.d.ts +56 -0
  3. package/dist/components/CartSidebar.js +10 -9
  4. package/dist/components/PosTopBar.d.ts +19 -0
  5. package/dist/components/PosTopBar.js +4 -4
  6. package/dist/components/PrinterSettingsDialog.d.ts +8 -0
  7. package/dist/components/PrinterSettingsDialog.js +506 -20
  8. package/dist/components/ReceiptDeliveryDialog.d.ts +11 -0
  9. package/dist/components/ReceiptDeliveryDialog.js +98 -0
  10. package/dist/components/ReceiptReprintDialog.d.ts +10 -0
  11. package/dist/components/ReceiptReprintDialog.js +49 -22
  12. package/dist/components/index.d.ts +6 -0
  13. package/dist/components/index.js +7 -0
  14. package/dist/dashboard/components/CustomerLookupDialog.js +108 -90
  15. package/dist/dashboard/components/CustomerQuickAddDialog.js +3 -1
  16. package/dist/dashboard/components/DeliveryPanel.js +134 -0
  17. package/dist/dashboard/components/ProductCard.js +17 -7
  18. package/dist/dashboard/components/ProductsPanel.js +4 -4
  19. package/dist/dashboard/components/SplitPaymentPanel.js +5 -5
  20. package/dist/dashboard/components/VariantSelectorDialog.js +53 -11
  21. package/dist/dashboard/components/cart/AddChargeDialog.js +3 -2
  22. package/dist/dashboard/components/cart/CartItems.js +5 -5
  23. package/dist/dashboard/components/cart/CartSummary.js +6 -6
  24. package/dist/dashboard/components/cart/CustomerSection.js +3 -3
  25. package/dist/dashboard/components/cart/DiscountSection.js +4 -3
  26. package/dist/dashboard/components/cart/PointsRedemptionSection.js +5 -4
  27. package/dist/dashboard/pos.types.d.ts +32 -0
  28. package/dist/hardware/agent-adapter.js +127 -0
  29. package/dist/hardware/agent-pairing.d.ts +18 -0
  30. package/dist/hardware/agent-pairing.js +63 -0
  31. package/dist/hardware/context.d.ts +1 -5
  32. package/dist/hardware/index.d.ts +4 -3
  33. package/dist/hardware/index.js +3 -2
  34. package/dist/hardware/ports.d.ts +21 -4
  35. package/dist/hardware/tauri-adapter.d.ts +0 -1
  36. package/dist/hardware/tauri-adapter.js +19 -5
  37. package/dist/hardware/web-adapter.d.ts +22 -7
  38. package/dist/hardware/web-adapter.js +56 -14
  39. package/dist/hooks/branch-scoped-key.js +64 -0
  40. package/dist/hooks/index.d.ts +6 -0
  41. package/dist/hooks/index.js +7 -0
  42. package/dist/hooks/sale-attempt.js +62 -0
  43. package/dist/hooks/useManagerAuth.js +9 -1
  44. package/dist/hooks/usePosCart.d.ts +55 -0
  45. package/dist/hooks/usePosCart.js +224 -117
  46. package/dist/hooks/usePosCustomer.d.ts +31 -0
  47. package/dist/hooks/usePosCustomer.js +32 -78
  48. package/dist/hooks/usePosDelivery.d.ts +28 -0
  49. package/dist/hooks/usePosDelivery.js +133 -0
  50. package/dist/hooks/usePosMultiOrder.d.ts +22 -0
  51. package/dist/hooks/usePosMultiOrder.js +14 -4
  52. package/dist/hooks/usePosPayment.d.ts +22 -0
  53. package/dist/lib/delivery-payload.js +83 -0
  54. package/dist/lib/money.js +96 -23
  55. package/dist/node_modules/react-hook-form/dist/index.esm.js +468 -332
  56. package/dist/runtime/auth-port.d.ts +1 -5
  57. package/dist/runtime/branch-port.d.ts +1 -5
  58. package/dist/runtime/config.d.ts +43 -6
  59. package/dist/runtime/config.js +18 -1
  60. package/dist/runtime/index.d.ts +2 -2
  61. package/dist/runtime/index.js +2 -2
  62. package/dist/screens/OrderHistoryDrawer.d.ts +8 -0
  63. package/dist/screens/OrderHistoryDrawer.js +15 -15
  64. package/dist/screens/ParkedOrdersDrawer.d.ts +12 -0
  65. package/dist/screens/ParkedOrdersDrawer.js +3 -3
  66. package/dist/screens/PaymentScreen.d.ts +4 -0
  67. package/dist/screens/PaymentScreen.js +194 -33
  68. package/dist/screens/ProductScreen.d.ts +4 -0
  69. package/dist/screens/ProductScreen.js +13 -10
  70. package/dist/screens/ReceiptScreen.d.ts +4 -0
  71. package/dist/screens/ReceiptScreen.js +96 -72
  72. package/dist/screens/ShiftCloseScreen.d.ts +4 -0
  73. package/dist/screens/ShiftCloseScreen.js +18 -15
  74. package/dist/screens/ShiftOpenScreen.d.ts +4 -0
  75. package/dist/screens/ShiftOpenScreen.js +75 -19
  76. package/dist/screens/index.d.ts +8 -0
  77. package/dist/screens/index.js +9 -0
  78. package/dist/shell/index.d.ts +2 -0
  79. package/dist/shell/pos-shell.d.ts +41 -8
  80. package/dist/shell/pos-shell.js +25 -18
  81. package/dist/state/index.d.ts +3 -0
  82. package/dist/state/index.js +4 -0
  83. package/dist/state/pos-context.d.ts +13 -0
  84. package/dist/state/pos-state.d.ts +49 -0
  85. package/dist/state/pos-state.js +6 -7
  86. package/dist/utils/pos-helpers.js +137 -34
  87. package/package.json +27 -7
@@ -1,14 +1,18 @@
1
1
  "use client";
2
2
 
3
3
  import { usePosBranch } from "../runtime/branch-port.js";
4
- import { formatPaisa } from "../lib/money.js";
5
- import { transformOrderToReceipt } from "../utils/pos-helpers.js";
6
- import { useMemo } from "react";
4
+ import { isTauri } from "../hardware/tauri-adapter.js";
5
+ import { readAgentPairing } from "../hardware/agent-pairing.js";
6
+ import { formatMinor } from "../lib/money.js";
7
+ import { useHardware } from "../hardware/context.js";
8
+ import { buildReceiptPayload, transformOrderToReceipt } from "../utils/pos-helpers.js";
9
+ import { useCallback, useMemo } from "react";
7
10
  import { PrintableView, printDocument } from "@classytic/fluid/client/core";
8
11
  import { jsx, jsxs } from "react/jsx-runtime";
9
12
  import { Loader2, Printer } from "lucide-react";
10
13
  import { Button } from "@/components/ui/button";
11
14
  import { getReceiptLocale, paymentMethodLabel } from "@classytic/commerce-sdk/client";
15
+ import { toast } from "sonner";
12
16
  import { DocumentFooter, DocumentHeader, DocumentLineTable, DocumentPage, DocumentParty, DocumentSummary } from "@classytic/fluid/document";
13
17
  import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
14
18
 
@@ -26,10 +30,18 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
26
30
  * locally is faster, requires no extra round-trip, and gives the host
27
31
  * full control over branding/format without involving a backend schema.
28
32
  *
29
- * Print path: `printDocument("pos-receipt-reprint")` isolates the rendered
30
- * `<PrintableView>` and triggers `window.print()` (or ESC/POS via Tauri).
31
- * The DOM id is intentionally *different* from the live ReceiptScreen's
32
- * `pos-receipt` so a reprint never clobbers an in-flight sale.
33
+ * Print path: for the plain browser (no agent paired, no Tauri),
34
+ * `printDocument("pos-receipt-reprint")` isolates the rendered
35
+ * `<PrintableView>` and triggers `window.print()` directly the DOM id is
36
+ * intentionally *different* from the live ReceiptScreen's `pos-receipt` so
37
+ * a reprint never clobbers an in-flight sale, and the web adapter's port
38
+ * always targets the LIVE screen's DOM id, not this one.
39
+ *
40
+ * When an agent is paired or Tauri is active, this instead routes through
41
+ * `hardware.printer.print(payload, "reprint")` — those adapters use the
42
+ * structured `ReceiptPayload`, not DOM, so they reach the real thermal
43
+ * printer correctly. Before this, a reprint ALWAYS called `printDocument()`
44
+ * regardless of adapter, so a paired ESC/POS printer never received one.
33
45
  */
34
46
  const REPRINT_DOM_ID = "pos-receipt-reprint";
35
47
  function isShownPhone(phone) {
@@ -51,31 +63,46 @@ function buildLines(receipt) {
51
63
  taxAmount: item.vatAmount ?? 0,
52
64
  lineTotal: item.total,
53
65
  formatted: {
54
- unitPrice: formatPaisa(item.unitPrice ?? 0),
55
- subtotal: formatPaisa(subtotal),
56
- taxAmount: formatPaisa(item.vatAmount ?? 0),
57
- lineTotal: formatPaisa(item.total)
66
+ unitPrice: formatMinor(item.unitPrice ?? 0),
67
+ subtotal: formatMinor(subtotal),
68
+ taxAmount: formatMinor(item.vatAmount ?? 0),
69
+ lineTotal: formatMinor(item.total)
58
70
  }
59
71
  };
60
72
  });
61
73
  }
62
74
  function ReceiptReprintDialog({ order, open, onOpenChange }) {
63
75
  const selectedBranch = usePosBranch();
76
+ const hardware = useHardware();
64
77
  const receipt = useMemo(() => {
65
78
  if (!order) return null;
66
- return transformOrderToReceipt(order, {
79
+ const branchInfo = {
67
80
  name: selectedBranch?.name ?? "Store",
68
81
  ...selectedBranch?.phone ? { phone: selectedBranch.phone } : {}
69
- });
82
+ };
83
+ return transformOrderToReceipt(order, branchInfo);
70
84
  }, [
71
85
  order,
72
86
  selectedBranch?.name,
73
87
  selectedBranch?.phone
74
88
  ]);
75
- const handlePrint = () => {
89
+ const handlePrint = useCallback(() => {
76
90
  if (!receipt) return;
77
- printDocument(REPRINT_DOM_ID);
78
- };
91
+ if (!isTauri() && !readAgentPairing()) {
92
+ printDocument(REPRINT_DOM_ID);
93
+ return;
94
+ }
95
+ const payload = buildReceiptPayload(receipt, selectedBranch?.name ?? receipt.branch?.name ?? "Store", selectedBranch?.phone ?? receipt.branch?.phone);
96
+ hardware.printer.print(payload, "reprint").then((outcome) => {
97
+ if (outcome === "confirmed") return;
98
+ toast.error(outcome === "unknown" ? "Printer status unknown — it may have already printed." : "Couldn't reach the printer.");
99
+ });
100
+ }, [
101
+ receipt,
102
+ hardware.printer,
103
+ selectedBranch?.name,
104
+ selectedBranch?.phone
105
+ ]);
79
106
  const customerName = receipt?.customer?.name?.trim() ?? "";
80
107
  const showCustomer = customerName.length > 0 && customerName !== "Walk-in Customer";
81
108
  const customerPhone = isShownPhone(receipt?.customer?.phone) ? receipt.customer.phone : null;
@@ -83,7 +110,7 @@ function ReceiptReprintDialog({ order, open, onOpenChange }) {
83
110
  const summaryExtra = [];
84
111
  if (receipt?.payment) summaryExtra.push({
85
112
  label: `Paid (${paymentMethodLabel(receipt.payment.method)})`,
86
- value: formatPaisa(receipt.payment.amount ?? receipt.total ?? 0),
113
+ value: formatMinor(receipt.payment.amount ?? receipt.total ?? 0),
87
114
  muted: true,
88
115
  separator: true
89
116
  });
@@ -144,10 +171,10 @@ function ReceiptReprintDialog({ order, open, onOpenChange }) {
144
171
  compact: true
145
172
  }),
146
173
  /* @__PURE__ */ jsx(DocumentSummary, {
147
- subtotal: formatPaisa(receipt.subtotal ?? 0),
148
- ...receipt.discount && receipt.discount > 0 ? { discount: formatPaisa(receipt.discount) } : {},
149
- ...receipt.vat?.amount && receipt.vat.amount > 0 ? { tax: formatPaisa(receipt.vat.amount) } : {},
150
- total: formatPaisa(receipt.total ?? 0),
174
+ subtotal: formatMinor(receipt.subtotal ?? 0),
175
+ ...receipt.discount && receipt.discount > 0 ? { discount: formatMinor(receipt.discount) } : {},
176
+ ...receipt.vat?.amount && receipt.vat.amount > 0 ? { tax: formatMinor(receipt.vat.amount) } : {},
177
+ total: formatMinor(receipt.total ?? 0),
151
178
  extraRows: summaryExtra,
152
179
  currency: locale.currency,
153
180
  labels: { tax: locale.taxLabel },
@@ -170,7 +197,7 @@ function ReceiptReprintDialog({ order, open, onOpenChange }) {
170
197
  }), /* @__PURE__ */ jsxs(Button, {
171
198
  onClick: handlePrint,
172
199
  disabled: !receipt,
173
- children: [/* @__PURE__ */ jsx(Printer, { className: "h-4 w-4 mr-2" }), "Print"]
200
+ children: [/* @__PURE__ */ jsx(Printer, { className: "h-4 w-4 me-2" }), "Print"]
174
201
  })]
175
202
  })
176
203
  ]
@@ -0,0 +1,6 @@
1
+ import { CartSidebar } from "./CartSidebar.js";
2
+ import { PosTopBar, PosTopBarProps } from "./PosTopBar.js";
3
+ import { PrinterSettingsDialog } from "./PrinterSettingsDialog.js";
4
+ import { ReceiptDeliveryDialog } from "./ReceiptDeliveryDialog.js";
5
+ import { ReceiptReprintDialog } from "./ReceiptReprintDialog.js";
6
+ export { CartSidebar, PosTopBar, type PosTopBarProps, PrinterSettingsDialog, ReceiptDeliveryDialog, ReceiptReprintDialog };
@@ -0,0 +1,7 @@
1
+ import { PosTopBar } from "./PosTopBar.js";
2
+ import { CartSidebar } from "./CartSidebar.js";
3
+ import { ReceiptReprintDialog } from "./ReceiptReprintDialog.js";
4
+ import { PrinterSettingsDialog } from "./PrinterSettingsDialog.js";
5
+ import { ReceiptDeliveryDialog } from "./ReceiptDeliveryDialog.js";
6
+
7
+ export { CartSidebar, PosTopBar, PrinterSettingsDialog, ReceiptDeliveryDialog, ReceiptReprintDialog };
@@ -3,116 +3,134 @@
3
3
  import { customerDisplayName, customerDisplayPhone } from "../../utils/customer-display.js";
4
4
  import { getReadableTextColor, getTierColor } from "../../lib/loyalty.js";
5
5
  import { useMemo } from "react";
6
- import { DialogWrapper } from "@classytic/fluid/client/core";
6
+ import { useCustomers } from "@classytic/commerce-sdk/sales";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
- import { Search, UserPlus } from "lucide-react";
8
+ import { UserPlus } from "lucide-react";
9
9
  import { Button } from "@/components/ui/button";
10
10
  import { Badge } from "@/components/ui/badge";
11
- import { Input } from "@/components/ui/input";
12
- import { Skeleton } from "@/components/ui/skeleton";
11
+ import { PickerQuery, PickerResults, PickerRow, SearchPickerDialog, useLocalSearch } from "@classytic/fluid/search";
13
12
 
14
13
  //#region src/dashboard/components/CustomerLookupDialog.tsx
15
- function CustomerLookupDialog({ open, onOpenChange, searchValue, onSearchValueChange, onSearch, results, isLoading, onSelect, onCreate }) {
16
- const showEmpty = searchValue.trim().length < 2;
17
- const content = useMemo(() => {
18
- if (showEmpty) return /* @__PURE__ */ jsx("div", {
19
- className: "rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground",
20
- children: "Type at least 2 characters to search customers."
21
- });
22
- if (isLoading) return /* @__PURE__ */ jsx("div", {
23
- className: "space-y-2",
24
- children: [
25
- 1,
26
- 2,
27
- 3
28
- ].map((i) => /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" }, i))
29
- });
30
- if (results.length === 0) return /* @__PURE__ */ jsx("div", {
31
- className: "rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground",
32
- children: "No customer found."
33
- });
34
- return /* @__PURE__ */ jsx("div", {
35
- className: "space-y-2",
36
- children: results.map((customer) => /* @__PURE__ */ jsxs(Button, {
37
- type: "button",
38
- variant: "outline",
39
- className: "w-full h-auto justify-between px-3 py-2",
40
- onClick: () => onSelect(customer),
41
- children: [/* @__PURE__ */ jsxs("div", {
42
- className: "text-left",
43
- children: [/* @__PURE__ */ jsx("p", {
44
- className: "text-sm font-medium",
45
- children: customerDisplayName(customer)
46
- }), (() => {
47
- const phone = customerDisplayPhone(customer);
48
- const email = customer.contact?.email;
49
- return /* @__PURE__ */ jsx("p", {
50
- className: "text-xs text-muted-foreground",
51
- children: phone ?? email ?? "No contact info"
52
- });
53
- })()]
54
- }), (customer.membership?.tier || customer.revenueTier) && (() => {
55
- const tierName = customer.membership?.tier || customer.revenueTier;
56
- const tierColor = getTierColor(tierName);
57
- const tierTextColor = getReadableTextColor(tierColor);
58
- return /* @__PURE__ */ jsx(Badge, {
59
- variant: "secondary",
60
- className: "capitalize",
61
- style: tierColor ? {
62
- backgroundColor: tierColor,
63
- color: tierTextColor,
64
- borderColor: "transparent"
65
- } : void 0,
66
- children: tierName
67
- });
68
- })()]
69
- }, customer._id))
70
- });
71
- }, [
72
- isLoading,
73
- results,
74
- showEmpty
75
- ]);
76
- return /* @__PURE__ */ jsxs(DialogWrapper, {
14
+ /**
15
+ * Which field a cashier is searching. PHONE leads because a till is
16
+ * phone-first the number is what a returning customer recites.
17
+ *
18
+ * ## Why the field is CHOSEN, not guessed
19
+ *
20
+ * This used to infer the field from the text: `isValidPhone()` → exact phone,
21
+ * `/^\d{4,}$/` phone contains, otherwise name. Two problems. A guess that
22
+ * lands wrong returns an empty list, which is indistinguishable from "this
23
+ * customer is not in the system" — the cashier creates a duplicate. And the
24
+ * inference lived in the POS hook while the accounting picker inferred nothing,
25
+ * so one product had two answers to "how do I find a customer". Now both
26
+ * declare their fields the same way and the operator picks.
27
+ */
28
+ const SEARCH_TYPES = [
29
+ {
30
+ value: "phone",
31
+ label: "Phone"
32
+ },
33
+ {
34
+ value: "name",
35
+ label: "Name"
36
+ },
37
+ {
38
+ value: "email",
39
+ label: "Email"
40
+ }
41
+ ];
42
+ /** type API param. The same `searchFields` grammar the list pages use. */
43
+ const SEARCH_FIELDS = {
44
+ phone: "contact.phone[contains]",
45
+ name: "name.given[like]",
46
+ email: "contact.email[like]"
47
+ };
48
+ /**
49
+ * Find a customer at the till.
50
+ *
51
+ * Composed from fluid's picker parts, so the till, the invoice screen and the
52
+ * partner statement all search through ONE implementation. What stays local is
53
+ * the row: POS shows the loyalty tier, which nothing else does and that is
54
+ * exactly the part a shared `renderRow` prop would have made awkward.
55
+ *
56
+ * Nothing is fetched until the cashier presses Search (`enabled:
57
+ * hasActiveSearch`), so opening the dialog costs no query and the customer
58
+ * collection is never listed.
59
+ */
60
+ function CustomerLookupDialog({ open, onOpenChange, onSelect, onCreate }) {
61
+ const search = useLocalSearch({
62
+ searchFields: SEARCH_FIELDS,
63
+ defaultSearchType: "phone",
64
+ baseParams: { limit: "10" }
65
+ });
66
+ const params = search.getSearchParams();
67
+ const { items, isLoading, isFetching } = useCustomers(params, {
68
+ enabled: open && search.hasActiveSearch,
69
+ refetchOnWindowFocus: false
70
+ });
71
+ const results = useMemo(() => items ?? [], [items]);
72
+ const typed = search.searchValue.trim();
73
+ const prefill = search.searchType === "phone" ? { phone: typed } : { name: typed };
74
+ return /* @__PURE__ */ jsxs(SearchPickerDialog, {
77
75
  open,
78
76
  onOpenChange,
77
+ search,
79
78
  title: "Find customer",
80
- description: "Search by phone or name and attach loyalty info.",
81
- size: "lg",
79
+ description: "Search by phone, name or email, then attach loyalty.",
82
80
  footer: /* @__PURE__ */ jsxs("div", {
83
81
  className: "flex justify-end gap-2",
84
82
  children: [/* @__PURE__ */ jsxs(Button, {
85
83
  variant: "outline",
86
- onClick: onCreate,
87
- children: [/* @__PURE__ */ jsx(UserPlus, { className: "h-4 w-4 mr-2" }), "Add new"]
84
+ onClick: () => onCreate(prefill),
85
+ children: [/* @__PURE__ */ jsx(UserPlus, { className: "h-4 w-4 me-2" }), "Add new"]
88
86
  }), /* @__PURE__ */ jsx(Button, {
89
87
  variant: "ghost",
90
88
  onClick: () => onOpenChange(false),
91
89
  children: "Close"
92
90
  })]
93
91
  }),
92
+ children: [/* @__PURE__ */ jsx(PickerQuery, {
93
+ types: SEARCH_TYPES,
94
+ placeholder: "Phone, name or email"
95
+ }), /* @__PURE__ */ jsx(PickerResults, {
96
+ isLoading: isLoading || isFetching,
97
+ isEmpty: results.length === 0,
98
+ prompt: "Enter a phone number or name and press Search.",
99
+ empty: "No customer found — use Add new to create one.",
100
+ children: results.map((customer) => /* @__PURE__ */ jsx(PickerRow, {
101
+ onSelect: () => onSelect(customer),
102
+ children: /* @__PURE__ */ jsx(CustomerRow, { customer })
103
+ }, customer._id))
104
+ })]
105
+ });
106
+ }
107
+ /** The till's own row: identity plus loyalty tier. */
108
+ function CustomerRow({ customer }) {
109
+ const phone = customerDisplayPhone(customer);
110
+ const email = customer.contact?.email;
111
+ const tierName = customer.membership?.tier || customer.revenueTier;
112
+ const tierColor = tierName ? getTierColor(tierName) : void 0;
113
+ return /* @__PURE__ */ jsxs("div", {
114
+ className: "flex items-center justify-between gap-3",
94
115
  children: [/* @__PURE__ */ jsxs("div", {
95
- className: "flex gap-2",
96
- children: [/* @__PURE__ */ jsx(Input, {
97
- placeholder: "Phone or name",
98
- value: searchValue,
99
- onChange: (e) => onSearchValueChange(e.target.value),
100
- onKeyDown: (e) => {
101
- if (e.key === "Enter") {
102
- e.preventDefault();
103
- onSearch();
104
- }
105
- }
106
- }), /* @__PURE__ */ jsxs(Button, {
107
- type: "button",
108
- onClick: onSearch,
109
- className: "shrink-0",
110
- children: [/* @__PURE__ */ jsx(Search, { className: "h-4 w-4 mr-2" }), "Search"]
116
+ className: "min-w-0",
117
+ children: [/* @__PURE__ */ jsx("p", {
118
+ className: "truncate text-sm font-medium",
119
+ children: customerDisplayName(customer)
120
+ }), /* @__PURE__ */ jsx("p", {
121
+ className: "truncate text-xs text-muted-foreground",
122
+ children: phone ?? email ?? "No contact info"
111
123
  })]
112
- }), /* @__PURE__ */ jsx("div", {
113
- className: "pt-3",
114
- children: content
115
- })]
124
+ }), tierName ? /* @__PURE__ */ jsx(Badge, {
125
+ variant: "secondary",
126
+ className: "capitalize shrink-0",
127
+ style: tierColor ? {
128
+ backgroundColor: tierColor,
129
+ color: getReadableTextColor(tierColor),
130
+ borderColor: "transparent"
131
+ } : void 0,
132
+ children: tierName
133
+ }) : null]
116
134
  });
117
135
  }
118
136
 
@@ -7,6 +7,7 @@ import { customerApi } from "@classytic/commerce-sdk/sales";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import { getReceiptLocale, isValidPhone } from "@classytic/commerce-sdk/client";
9
9
  import { toast } from "sonner";
10
+ import { presentError } from "@classytic/fluid/error-contract";
10
11
  import { Input } from "@/components/ui/input";
11
12
 
12
13
  //#region src/dashboard/components/CustomerQuickAddDialog.tsx
@@ -61,7 +62,8 @@ function CustomerQuickAddDialog({ token, open, onOpenChange, defaultName, defaul
61
62
  onCreated(created);
62
63
  onOpenChange(false);
63
64
  } catch (error) {
64
- toast.error(error instanceof Error ? error.message : "Failed to create customer");
65
+ const { title, message } = presentError(error, "The customer was not saved. Try again.", { fallbackTitle: "Could not add the customer" });
66
+ toast.error(title, { description: message });
65
67
  } finally {
66
68
  setIsSaving(false);
67
69
  }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Banknote, Store, Truck } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+
8
+ //#region src/dashboard/components/DeliveryPanel.tsx
9
+ /**
10
+ * Delivery + payment-term controls for the sale being rung.
11
+ *
12
+ * Two decisions, deliberately rendered as two separate choices because they are
13
+ * orthogonal: WHERE the goods go, and WHEN the customer pays. A phone order can be
14
+ * prepaid by bKash and shipped; a walk-in can pay cash for something delivered
15
+ * tomorrow.
16
+ *
17
+ * ## Collect-on-delivery is shown only where the deployment allows it
18
+ *
19
+ * `collectOnDeliveryEnabled` comes from the host (`PosShellProps.sales`), and it
20
+ * decides whether the CONTROL RENDERS — never whether the sale is permitted. The
21
+ * server is the authority and refuses an undeclared COD sale with
22
+ * `PAYMENT_TERM_NOT_ENABLED`, so a host flag that disagrees produces a visible
23
+ * error, not a sale that quietly books wrong. That asymmetry is the point: this is
24
+ * a UI affordance, not a second copy of the policy.
25
+ *
26
+ * The option also only appears for a DELIVERY sale. "Collect on delivery" names a
27
+ * delivery that has not happened yet; on a carryout the customer is standing at the
28
+ * counter, so there is no later collection and the term is meaningless.
29
+ */
30
+ function DeliveryPanel({ delivery, collectOnDeliveryEnabled, disabled }) {
31
+ const isDelivery = delivery.method === "delivery";
32
+ return /* @__PURE__ */ jsxs("div", {
33
+ className: "space-y-3 rounded-lg border p-3",
34
+ children: [/* @__PURE__ */ jsxs("div", {
35
+ className: "grid grid-cols-2 gap-2",
36
+ children: [/* @__PURE__ */ jsxs(Button, {
37
+ type: "button",
38
+ variant: delivery.method === "pickup" ? "default" : "outline",
39
+ className: "h-11 text-sm",
40
+ disabled,
41
+ onClick: () => delivery.setMethod("pickup"),
42
+ children: [/* @__PURE__ */ jsx(Store, { className: "h-4 w-4 me-2" }), "Carryout"]
43
+ }), /* @__PURE__ */ jsxs(Button, {
44
+ type: "button",
45
+ variant: isDelivery ? "default" : "outline",
46
+ className: "h-11 text-sm",
47
+ disabled,
48
+ onClick: () => delivery.setMethod("delivery"),
49
+ children: [/* @__PURE__ */ jsx(Truck, { className: "h-4 w-4 me-2" }), "Delivery"]
50
+ })]
51
+ }), isDelivery && /* @__PURE__ */ jsxs("div", {
52
+ className: "space-y-2",
53
+ children: [
54
+ /* @__PURE__ */ jsx(Input, {
55
+ placeholder: "Recipient name",
56
+ value: delivery.address.recipientName,
57
+ disabled,
58
+ onChange: (e) => delivery.setAddressField("recipientName", e.target.value)
59
+ }),
60
+ /* @__PURE__ */ jsx(Input, {
61
+ placeholder: "Recipient phone",
62
+ inputMode: "tel",
63
+ value: delivery.address.recipientPhone,
64
+ disabled,
65
+ onChange: (e) => delivery.setAddressField("recipientPhone", e.target.value)
66
+ }),
67
+ /* @__PURE__ */ jsx(Input, {
68
+ placeholder: "Address *",
69
+ value: delivery.address.addressLine1,
70
+ disabled,
71
+ onChange: (e) => delivery.setAddressField("addressLine1", e.target.value)
72
+ }),
73
+ /* @__PURE__ */ jsxs("div", {
74
+ className: "grid grid-cols-2 gap-2",
75
+ children: [/* @__PURE__ */ jsx(Input, {
76
+ placeholder: "City / town *",
77
+ value: delivery.address.city,
78
+ disabled,
79
+ onChange: (e) => delivery.setAddressField("city", e.target.value)
80
+ }), /* @__PURE__ */ jsx(Input, {
81
+ placeholder: "Area",
82
+ value: delivery.address.area,
83
+ disabled,
84
+ onChange: (e) => delivery.setAddressField("area", e.target.value)
85
+ })]
86
+ }),
87
+ /* @__PURE__ */ jsx(Input, {
88
+ placeholder: "Delivery note (landmark, timing)",
89
+ value: delivery.address.note,
90
+ disabled,
91
+ onChange: (e) => delivery.setAddressField("note", e.target.value)
92
+ }),
93
+ /* @__PURE__ */ jsx(Input, {
94
+ placeholder: "Delivery charge",
95
+ inputMode: "decimal",
96
+ value: delivery.feeInput,
97
+ disabled,
98
+ onChange: (e) => delivery.setFeeInput(e.target.value)
99
+ }),
100
+ collectOnDeliveryEnabled && /* @__PURE__ */ jsxs("div", {
101
+ className: "space-y-2 pt-1",
102
+ children: [/* @__PURE__ */ jsxs("div", {
103
+ className: "grid grid-cols-2 gap-2",
104
+ children: [/* @__PURE__ */ jsx(Button, {
105
+ type: "button",
106
+ variant: delivery.paymentTerm === "prepaid" ? "default" : "outline",
107
+ className: "h-10 text-sm",
108
+ disabled,
109
+ onClick: () => delivery.setPaymentTerm("prepaid"),
110
+ children: "Pay now"
111
+ }), /* @__PURE__ */ jsxs(Button, {
112
+ type: "button",
113
+ variant: delivery.paymentTerm === "collect_on_delivery" ? "default" : "outline",
114
+ className: "h-10 text-sm",
115
+ disabled,
116
+ onClick: () => delivery.setPaymentTerm("collect_on_delivery"),
117
+ children: [/* @__PURE__ */ jsx(Banknote, { className: "h-4 w-4 me-2" }), "Cash on delivery"]
118
+ })]
119
+ }), delivery.paymentTerm === "collect_on_delivery" && /* @__PURE__ */ jsxs("p", {
120
+ className: "text-xs text-muted-foreground",
121
+ children: [
122
+ "No payment is taken now. The courier collects ",
123
+ delivery.fee > 0 ? "the goods and the delivery charge" : "the full amount",
124
+ " on delivery, and this sale adds nothing to the drawer."
125
+ ]
126
+ })]
127
+ })
128
+ ]
129
+ })]
130
+ });
131
+ }
132
+
133
+ //#endregion
134
+ export { DeliveryPanel };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { formatBdt } from "../../lib/money.js";
3
+ import { formatMajor } from "../../lib/money.js";
4
4
  import { cn } from "../../lib/cn.js";
5
5
  import { calculateVariantPrice, getAvailableVariants, getPosPrice, getPosProductImage, isVariantProduct } from "@classytic/commerce-sdk/sales";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -10,8 +10,18 @@ import { Card, CardContent } from "@/components/ui/card";
10
10
  //#region src/dashboard/components/ProductCard.tsx
11
11
  function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
12
12
  const hasVariants = isVariantProduct(product);
13
- const inStock = product.branchStock?.inStock ?? false;
14
- const lowStock = product.branchStock?.lowStock ?? false;
13
+ /**
14
+ * Is this product COUNTED at all? Per-product, absent ⇒ tracked (the catalog default).
15
+ *
16
+ * A membership month, a PT session and a haircut hold no stock, so every stock signal
17
+ * below is meaningless for them — and the defaults all resolve to "unavailable":
18
+ * `inStock ?? false` refused them outright, and `quantity ?? 0` rendered "Qty: 0" beside
19
+ * a ৳3,000 membership. An untracked product is always sellable and has no quantity to
20
+ * show, which is what `isInStock` in the SDK now says too.
21
+ */
22
+ const tracked = product.inventory?.stockTracked !== false;
23
+ const inStock = tracked ? product.branchStock?.inStock ?? false : true;
24
+ const lowStock = tracked ? product.branchStock?.lowStock ?? false : false;
15
25
  const stockQty = product.branchStock?.quantity ?? 0;
16
26
  const getPriceDisplay = () => {
17
27
  if (!hasVariants) return {
@@ -46,7 +56,7 @@ function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
46
56
  className: "p-0",
47
57
  children: [
48
58
  /* @__PURE__ */ jsxs("div", {
49
- className: "absolute top-2 right-2 z-10 flex flex-col gap-1 items-end",
59
+ className: "absolute top-2 end-2 z-10 flex flex-col gap-1 items-end",
50
60
  children: [!inStock ? /* @__PURE__ */ jsx(Badge, {
51
61
  variant: "destructive",
52
62
  className: "text-xs shadow-md",
@@ -79,10 +89,10 @@ function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
79
89
  children: [/* @__PURE__ */ jsxs("span", {
80
90
  className: "text-base font-bold text-primary",
81
91
  children: [showFrom && /* @__PURE__ */ jsx("span", {
82
- className: "text-xs font-normal text-muted-foreground mr-1",
92
+ className: "text-xs font-normal text-muted-foreground me-1",
83
93
  children: "From"
84
- }), formatBdt(displayPrice)]
85
- }), inStock && /* @__PURE__ */ jsxs("span", {
94
+ }), formatMajor(displayPrice)]
95
+ }), tracked && inStock && /* @__PURE__ */ jsxs("span", {
86
96
  className: "text-xs text-muted-foreground",
87
97
  children: ["Qty: ", stockQty]
88
98
  })]
@@ -24,11 +24,11 @@ const ProductsPanel = memo(function ProductsPanel({ searchQuery, selectedCategor
24
24
  children: [
25
25
  /* @__PURE__ */ jsxs("div", {
26
26
  className: "relative flex-1",
27
- children: [/* @__PURE__ */ jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), /* @__PURE__ */ jsx(Input, {
27
+ children: [/* @__PURE__ */ jsx(Search, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), /* @__PURE__ */ jsx(Input, {
28
28
  placeholder: "Search products...",
29
29
  value: searchQuery,
30
30
  onChange: (e) => onSearchChange(e.target.value),
31
- className: "pl-9"
31
+ className: "ps-9"
32
32
  })]
33
33
  }),
34
34
  /* @__PURE__ */ jsx(Button, {
@@ -52,11 +52,11 @@ const ProductsPanel = memo(function ProductsPanel({ searchQuery, selectedCategor
52
52
  className: "flex items-center gap-2",
53
53
  children: [/* @__PURE__ */ jsxs("div", {
54
54
  className: "relative flex-1",
55
- children: [/* @__PURE__ */ jsx(Barcode, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), /* @__PURE__ */ jsx(Input, {
55
+ children: [/* @__PURE__ */ jsx(Barcode, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), /* @__PURE__ */ jsx(Input, {
56
56
  placeholder: "Scan barcode or SKU...",
57
57
  value: barcodeInput,
58
58
  onChange: (e) => onBarcodeInputChange(e.target.value),
59
- className: "pl-9",
59
+ className: "ps-9",
60
60
  disabled: isLookingUp
61
61
  })]
62
62
  }), /* @__PURE__ */ jsx(Button, {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { formatBdt } from "../../lib/money.js";
3
+ import { formatMajor } from "../../lib/money.js";
4
4
  import { cn } from "../../lib/cn.js";
5
5
  import { useMemo } from "react";
6
6
  import { SelectDropdown } from "@classytic/fluid/client/core";
@@ -40,7 +40,7 @@ function SplitPaymentPanel({ options, entries, total, remaining, onAdd, onRemove
40
40
  size: "sm",
41
41
  className: "h-8",
42
42
  onClick: () => onAdd(),
43
- children: [/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5 mr-1" }), "Add"]
43
+ children: [/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5 me-1" }), "Add"]
44
44
  })]
45
45
  }),
46
46
  /* @__PURE__ */ jsx("div", {
@@ -92,7 +92,7 @@ function SplitPaymentPanel({ options, entries, total, remaining, onAdd, onRemove
92
92
  size: "sm",
93
93
  className: "h-6 px-2",
94
94
  onClick: () => onUpdate(entry.id, { amount: String(remainingForRow) }),
95
- children: ["Fill ", formatBdt(remainingForRow)]
95
+ children: ["Fill ", formatMajor(remainingForRow)]
96
96
  })]
97
97
  }),
98
98
  needsRef && /* @__PURE__ */ jsx(Input, {
@@ -113,13 +113,13 @@ function SplitPaymentPanel({ options, entries, total, remaining, onAdd, onRemove
113
113
  className: "flex items-center justify-between text-xs text-muted-foreground",
114
114
  children: [/* @__PURE__ */ jsxs("span", { children: ["Total: ", /* @__PURE__ */ jsx("span", {
115
115
  className: "font-medium text-foreground",
116
- children: formatBdt(total)
116
+ children: formatMajor(total)
117
117
  })] }), /* @__PURE__ */ jsxs("span", { children: [
118
118
  "Remaining:",
119
119
  " ",
120
120
  /* @__PURE__ */ jsx("span", {
121
121
  className: remaining === 0 ? "font-medium text-foreground" : "text-destructive font-medium",
122
- children: formatBdt(remaining)
122
+ children: formatMajor(remaining)
123
123
  })
124
124
  ] })]
125
125
  })