@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,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
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";
|
|
7
|
+
import { PrintableView, printDocument } from "@classytic/fluid/client/core";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { Loader2, Printer } from "lucide-react";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { getReceiptLocale, paymentMethodLabel } from "@classytic/commerce-sdk/client";
|
|
12
|
+
import { DocumentFooter, DocumentHeader, DocumentLineTable, DocumentPage, DocumentParty, DocumentSummary } from "@classytic/fluid/document";
|
|
13
|
+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
14
|
+
|
|
15
|
+
//#region src/components/ReceiptReprintDialog.tsx
|
|
16
|
+
/**
|
|
17
|
+
* ReceiptReprintDialog — Reprint a historical POS order's receipt.
|
|
18
|
+
*
|
|
19
|
+
* Takes an already-fetched raw order document (from `usePosOrderDetail`)
|
|
20
|
+
* and transforms it client-side via `transformOrderToReceipt`. This is the
|
|
21
|
+
* same helper the live ReceiptScreen uses after checkout, so a reprint
|
|
22
|
+
* matches the original visually.
|
|
23
|
+
*
|
|
24
|
+
* Receipt rendering is intentionally client-side. The Order doc has every
|
|
25
|
+
* field a receipt needs; running it through `transformOrderToReceipt`
|
|
26
|
+
* locally is faster, requires no extra round-trip, and gives the host
|
|
27
|
+
* full control over branding/format without involving a backend schema.
|
|
28
|
+
*
|
|
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
|
+
*/
|
|
34
|
+
const REPRINT_DOM_ID = "pos-receipt-reprint";
|
|
35
|
+
function isShownPhone(phone) {
|
|
36
|
+
return Boolean(phone && !phone.startsWith("pending_"));
|
|
37
|
+
}
|
|
38
|
+
function buildLines(receipt) {
|
|
39
|
+
return (receipt.items ?? []).map((item, idx) => {
|
|
40
|
+
const subtotal = (item.unitPrice ?? 0) * item.quantity;
|
|
41
|
+
return {
|
|
42
|
+
sequence: idx + 1,
|
|
43
|
+
description: item.name + (item.variant ? ` (${item.variant})` : ""),
|
|
44
|
+
quantity: item.quantity,
|
|
45
|
+
uom: "pcs",
|
|
46
|
+
unitPrice: item.unitPrice ?? 0,
|
|
47
|
+
discountType: "flat",
|
|
48
|
+
discountValue: 0,
|
|
49
|
+
subtotal,
|
|
50
|
+
taxRate: item.vatRate ?? 0,
|
|
51
|
+
taxAmount: item.vatAmount ?? 0,
|
|
52
|
+
lineTotal: item.total,
|
|
53
|
+
formatted: {
|
|
54
|
+
unitPrice: formatPaisa(item.unitPrice ?? 0),
|
|
55
|
+
subtotal: formatPaisa(subtotal),
|
|
56
|
+
taxAmount: formatPaisa(item.vatAmount ?? 0),
|
|
57
|
+
lineTotal: formatPaisa(item.total)
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function ReceiptReprintDialog({ order, open, onOpenChange }) {
|
|
63
|
+
const selectedBranch = usePosBranch();
|
|
64
|
+
const receipt = useMemo(() => {
|
|
65
|
+
if (!order) return null;
|
|
66
|
+
return transformOrderToReceipt(order, {
|
|
67
|
+
name: selectedBranch?.name ?? "Store",
|
|
68
|
+
...selectedBranch?.phone ? { phone: selectedBranch.phone } : {}
|
|
69
|
+
});
|
|
70
|
+
}, [
|
|
71
|
+
order,
|
|
72
|
+
selectedBranch?.name,
|
|
73
|
+
selectedBranch?.phone
|
|
74
|
+
]);
|
|
75
|
+
const handlePrint = () => {
|
|
76
|
+
if (!receipt) return;
|
|
77
|
+
printDocument(REPRINT_DOM_ID);
|
|
78
|
+
};
|
|
79
|
+
const customerName = receipt?.customer?.name?.trim() ?? "";
|
|
80
|
+
const showCustomer = customerName.length > 0 && customerName !== "Walk-in Customer";
|
|
81
|
+
const customerPhone = isShownPhone(receipt?.customer?.phone) ? receipt.customer.phone : null;
|
|
82
|
+
const locale = getReceiptLocale();
|
|
83
|
+
const summaryExtra = [];
|
|
84
|
+
if (receipt?.payment) summaryExtra.push({
|
|
85
|
+
label: `Paid (${paymentMethodLabel(receipt.payment.method)})`,
|
|
86
|
+
value: formatPaisa(receipt.payment.amount ?? receipt.total ?? 0),
|
|
87
|
+
muted: true,
|
|
88
|
+
separator: true
|
|
89
|
+
});
|
|
90
|
+
if (receipt?.membership?.pointsEarned && receipt.membership.pointsEarned > 0) summaryExtra.push({
|
|
91
|
+
label: "Points earned",
|
|
92
|
+
value: `+${receipt.membership.pointsEarned}`,
|
|
93
|
+
muted: true,
|
|
94
|
+
separator: false
|
|
95
|
+
});
|
|
96
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
97
|
+
open,
|
|
98
|
+
onOpenChange,
|
|
99
|
+
children: /* @__PURE__ */ jsxs(DialogContent, {
|
|
100
|
+
className: "max-w-md p-0 gap-0",
|
|
101
|
+
children: [
|
|
102
|
+
/* @__PURE__ */ jsx(DialogHeader, {
|
|
103
|
+
className: "px-4 py-3 border-b",
|
|
104
|
+
children: /* @__PURE__ */ jsx(DialogTitle, {
|
|
105
|
+
className: "text-base",
|
|
106
|
+
children: "Reprint Receipt"
|
|
107
|
+
})
|
|
108
|
+
}),
|
|
109
|
+
/* @__PURE__ */ jsx("div", {
|
|
110
|
+
className: "max-h-[70vh] overflow-y-auto p-4 bg-muted/30 flex justify-center",
|
|
111
|
+
children: !receipt ? /* @__PURE__ */ jsx("div", {
|
|
112
|
+
className: "flex items-center justify-center py-12 w-full",
|
|
113
|
+
children: /* @__PURE__ */ jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" })
|
|
114
|
+
}) : /* @__PURE__ */ jsx(PrintableView, {
|
|
115
|
+
id: REPRINT_DOM_ID,
|
|
116
|
+
className: "bg-white dark:bg-card shadow-sm",
|
|
117
|
+
children: /* @__PURE__ */ jsxs(DocumentPage, {
|
|
118
|
+
size: "receipt",
|
|
119
|
+
children: [
|
|
120
|
+
/* @__PURE__ */ jsx(DocumentHeader, {
|
|
121
|
+
companyName: receipt.branch?.name ?? "Store",
|
|
122
|
+
companyContact: receipt.branch?.phone,
|
|
123
|
+
...receipt.vat?.sellerBin ? { companyTaxId: `${locale.taxIdLabel}: ${receipt.vat.sellerBin}` } : {},
|
|
124
|
+
documentTitle: "Receipt (Reprint)",
|
|
125
|
+
documentNumber: receipt.orderNumber,
|
|
126
|
+
date: receipt.date ?? ""
|
|
127
|
+
}),
|
|
128
|
+
showCustomer && /* @__PURE__ */ jsx(DocumentParty, {
|
|
129
|
+
billTo: {
|
|
130
|
+
name: customerName,
|
|
131
|
+
...customerPhone ? { contact: customerPhone } : {},
|
|
132
|
+
...receipt.membership?.cardId ? { extra: { Loyalty: receipt.membership.cardId } } : {}
|
|
133
|
+
},
|
|
134
|
+
billToLabel: "Customer"
|
|
135
|
+
}),
|
|
136
|
+
/* @__PURE__ */ jsx(DocumentLineTable, {
|
|
137
|
+
lines: buildLines(receipt),
|
|
138
|
+
columns: [
|
|
139
|
+
"description",
|
|
140
|
+
"quantity",
|
|
141
|
+
"lineTotal"
|
|
142
|
+
],
|
|
143
|
+
showSequence: false,
|
|
144
|
+
compact: true
|
|
145
|
+
}),
|
|
146
|
+
/* @__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),
|
|
151
|
+
extraRows: summaryExtra,
|
|
152
|
+
currency: locale.currency,
|
|
153
|
+
labels: { tax: locale.taxLabel },
|
|
154
|
+
width: "w-full"
|
|
155
|
+
}),
|
|
156
|
+
/* @__PURE__ */ jsx(DocumentFooter, { notes: /* @__PURE__ */ jsx("p", {
|
|
157
|
+
className: "text-center text-xs text-muted-foreground",
|
|
158
|
+
children: "Duplicate copy"
|
|
159
|
+
}) })
|
|
160
|
+
]
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
}),
|
|
164
|
+
/* @__PURE__ */ jsxs(DialogFooter, {
|
|
165
|
+
className: "px-4 py-3 border-t",
|
|
166
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
167
|
+
variant: "outline",
|
|
168
|
+
onClick: () => onOpenChange(false),
|
|
169
|
+
children: "Close"
|
|
170
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
171
|
+
onClick: handlePrint,
|
|
172
|
+
disabled: !receipt,
|
|
173
|
+
children: [/* @__PURE__ */ jsx(Printer, { className: "h-4 w-4 mr-2" }), "Print"]
|
|
174
|
+
})]
|
|
175
|
+
})
|
|
176
|
+
]
|
|
177
|
+
})
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
export { ReceiptReprintDialog };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { customerDisplayName, customerDisplayPhone } from "../../utils/customer-display.js";
|
|
4
|
+
import { getReadableTextColor, getTierColor } from "../../lib/loyalty.js";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { DialogWrapper } from "@classytic/fluid/client/core";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { Search, UserPlus } from "lucide-react";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Badge } from "@/components/ui/badge";
|
|
11
|
+
import { Input } from "@/components/ui/input";
|
|
12
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
13
|
+
|
|
14
|
+
//#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, {
|
|
77
|
+
open,
|
|
78
|
+
onOpenChange,
|
|
79
|
+
title: "Find customer",
|
|
80
|
+
description: "Search by phone or name and attach loyalty info.",
|
|
81
|
+
size: "lg",
|
|
82
|
+
footer: /* @__PURE__ */ jsxs("div", {
|
|
83
|
+
className: "flex justify-end gap-2",
|
|
84
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
85
|
+
variant: "outline",
|
|
86
|
+
onClick: onCreate,
|
|
87
|
+
children: [/* @__PURE__ */ jsx(UserPlus, { className: "h-4 w-4 mr-2" }), "Add new"]
|
|
88
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
89
|
+
variant: "ghost",
|
|
90
|
+
onClick: () => onOpenChange(false),
|
|
91
|
+
children: "Close"
|
|
92
|
+
})]
|
|
93
|
+
}),
|
|
94
|
+
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"]
|
|
111
|
+
})]
|
|
112
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
113
|
+
className: "pt-3",
|
|
114
|
+
children: content
|
|
115
|
+
})]
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
//#endregion
|
|
120
|
+
export { CustomerLookupDialog };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePosAuth } from "../../runtime/auth-port.js";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { FormDialog } from "@classytic/fluid/client/core";
|
|
6
|
+
import { customerApi } from "@classytic/commerce-sdk/sales";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { getReceiptLocale, isValidPhone } from "@classytic/commerce-sdk/client";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
|
|
12
|
+
//#region src/dashboard/components/CustomerQuickAddDialog.tsx
|
|
13
|
+
function CustomerQuickAddDialog({ token, open, onOpenChange, defaultName, defaultPhone, onCreated }) {
|
|
14
|
+
const authToken = usePosAuth().getToken();
|
|
15
|
+
const resolvedToken = token || authToken || "";
|
|
16
|
+
const [name, setName] = useState("");
|
|
17
|
+
const [phone, setPhone] = useState("");
|
|
18
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!open) return;
|
|
21
|
+
setName(defaultName || "");
|
|
22
|
+
setPhone(defaultPhone || "");
|
|
23
|
+
}, [
|
|
24
|
+
open,
|
|
25
|
+
defaultName,
|
|
26
|
+
defaultPhone
|
|
27
|
+
]);
|
|
28
|
+
const canSubmit = useMemo(() => {
|
|
29
|
+
return name.trim().length > 1 && isValidPhone(phone.trim());
|
|
30
|
+
}, [name, phone]);
|
|
31
|
+
const handleSubmit = async () => {
|
|
32
|
+
const trimmedName = name.trim();
|
|
33
|
+
const trimmedPhone = phone.trim();
|
|
34
|
+
if (!trimmedName) {
|
|
35
|
+
toast.error("Customer name is required");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!isValidPhone(trimmedPhone)) {
|
|
39
|
+
toast.error(`Enter a valid phone number (e.g. ${getReceiptLocale().phonePlaceholder})`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
setIsSaving(true);
|
|
44
|
+
const parts = trimmedName.split(/\s+/);
|
|
45
|
+
const given = parts[0] ?? trimmedName;
|
|
46
|
+
const family = parts.length > 1 ? parts.slice(1).join(" ") : "";
|
|
47
|
+
const created = await customerApi.create({
|
|
48
|
+
token: resolvedToken,
|
|
49
|
+
data: {
|
|
50
|
+
name: {
|
|
51
|
+
given,
|
|
52
|
+
family
|
|
53
|
+
},
|
|
54
|
+
contact: { phone: trimmedPhone }
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (!created) {
|
|
58
|
+
toast.error("Customer saved, but response was empty");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
onCreated(created);
|
|
62
|
+
onOpenChange(false);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
toast.error(error instanceof Error ? error.message : "Failed to create customer");
|
|
65
|
+
} finally {
|
|
66
|
+
setIsSaving(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
return /* @__PURE__ */ jsx(FormDialog, {
|
|
70
|
+
open,
|
|
71
|
+
onOpenChange,
|
|
72
|
+
title: "Add customer",
|
|
73
|
+
description: "Quickly add a customer for POS loyalty tracking.",
|
|
74
|
+
size: "sm",
|
|
75
|
+
onSubmit: (e) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
handleSubmit();
|
|
78
|
+
},
|
|
79
|
+
onCancel: () => onOpenChange(false),
|
|
80
|
+
submitLabel: "Save Customer",
|
|
81
|
+
cancelLabel: "Cancel",
|
|
82
|
+
submitLoading: isSaving,
|
|
83
|
+
submitDisabled: !canSubmit,
|
|
84
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
85
|
+
className: "space-y-3",
|
|
86
|
+
children: [/* @__PURE__ */ jsx(Input, {
|
|
87
|
+
placeholder: "Full name",
|
|
88
|
+
value: name,
|
|
89
|
+
onChange: (e) => setName(e.target.value)
|
|
90
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
91
|
+
placeholder: `Phone (${getReceiptLocale().phonePlaceholder})`,
|
|
92
|
+
inputMode: "numeric",
|
|
93
|
+
value: phone,
|
|
94
|
+
onChange: (e) => setPhone(e.target.value)
|
|
95
|
+
})]
|
|
96
|
+
})
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
export { CustomerQuickAddDialog };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useState } from "react";
|
|
4
|
+
import { FormDialog, StatusBanner } from "@classytic/fluid/client/core";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { FormInput } from "@classytic/fluid/forms";
|
|
7
|
+
|
|
8
|
+
//#region src/dashboard/components/ManagerAuthDialog.tsx
|
|
9
|
+
const ManagerAuthDialog = memo(function ManagerAuthDialog({ open, onOpenChange, onAuthorize, isPending, error, title = "Manager Authorization Required", description = "Enter manager or admin credentials to authorize this action." }) {
|
|
10
|
+
const [email, setEmail] = useState("");
|
|
11
|
+
const [password, setPassword] = useState("");
|
|
12
|
+
const handleSubmit = useCallback(async (e) => {
|
|
13
|
+
e.preventDefault();
|
|
14
|
+
if (!email.trim() || !password.trim()) return;
|
|
15
|
+
try {
|
|
16
|
+
await onAuthorize(email.trim(), password);
|
|
17
|
+
setEmail("");
|
|
18
|
+
setPassword("");
|
|
19
|
+
onOpenChange(false);
|
|
20
|
+
} catch {}
|
|
21
|
+
}, [
|
|
22
|
+
email,
|
|
23
|
+
password,
|
|
24
|
+
onAuthorize,
|
|
25
|
+
onOpenChange
|
|
26
|
+
]);
|
|
27
|
+
const handleOpenChange = useCallback((nextOpen) => {
|
|
28
|
+
if (!nextOpen) {
|
|
29
|
+
setEmail("");
|
|
30
|
+
setPassword("");
|
|
31
|
+
}
|
|
32
|
+
onOpenChange(nextOpen);
|
|
33
|
+
}, [onOpenChange]);
|
|
34
|
+
return /* @__PURE__ */ jsxs(FormDialog, {
|
|
35
|
+
open,
|
|
36
|
+
onOpenChange: handleOpenChange,
|
|
37
|
+
title,
|
|
38
|
+
description,
|
|
39
|
+
size: "sm",
|
|
40
|
+
onSubmit: handleSubmit,
|
|
41
|
+
onCancel: () => handleOpenChange(false),
|
|
42
|
+
submitLabel: isPending ? "Verifying..." : "Authorize",
|
|
43
|
+
cancelLabel: "Cancel",
|
|
44
|
+
submitLoading: isPending,
|
|
45
|
+
submitDisabled: !email.trim() || !password.trim(),
|
|
46
|
+
children: [
|
|
47
|
+
error && /* @__PURE__ */ jsx(StatusBanner, {
|
|
48
|
+
variant: "error",
|
|
49
|
+
children: error
|
|
50
|
+
}),
|
|
51
|
+
/* @__PURE__ */ jsx(FormInput, {
|
|
52
|
+
name: "manager-email",
|
|
53
|
+
label: "Email",
|
|
54
|
+
type: "email",
|
|
55
|
+
placeholder: "manager@example.com",
|
|
56
|
+
value: email,
|
|
57
|
+
onChange: (value) => setEmail(value),
|
|
58
|
+
disabled: isPending,
|
|
59
|
+
autoComplete: "email",
|
|
60
|
+
autoFocus: true
|
|
61
|
+
}),
|
|
62
|
+
/* @__PURE__ */ jsx(FormInput, {
|
|
63
|
+
name: "manager-password",
|
|
64
|
+
label: "Password",
|
|
65
|
+
type: "password",
|
|
66
|
+
placeholder: "Enter password",
|
|
67
|
+
value: password,
|
|
68
|
+
onChange: (value) => setPassword(value),
|
|
69
|
+
disabled: isPending,
|
|
70
|
+
autoComplete: "current-password"
|
|
71
|
+
})
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
export { ManagerAuthDialog };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatBdt } from "../../lib/money.js";
|
|
4
|
+
import { cn } from "../../lib/cn.js";
|
|
5
|
+
import { calculateVariantPrice, getAvailableVariants, getPosPrice, getPosProductImage, isVariantProduct } from "@classytic/commerce-sdk/sales";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
9
|
+
|
|
10
|
+
//#region src/dashboard/components/ProductCard.tsx
|
|
11
|
+
function ProductCard({ product, onAddToCart, onOpenVariantSelector }) {
|
|
12
|
+
const hasVariants = isVariantProduct(product);
|
|
13
|
+
const inStock = product.branchStock?.inStock ?? false;
|
|
14
|
+
const lowStock = product.branchStock?.lowStock ?? false;
|
|
15
|
+
const stockQty = product.branchStock?.quantity ?? 0;
|
|
16
|
+
const getPriceDisplay = () => {
|
|
17
|
+
if (!hasVariants) return {
|
|
18
|
+
price: getPosPrice(product),
|
|
19
|
+
showFrom: false
|
|
20
|
+
};
|
|
21
|
+
const variants = getAvailableVariants(product);
|
|
22
|
+
if (variants.length === 0) return {
|
|
23
|
+
price: getPosPrice(product),
|
|
24
|
+
showFrom: false
|
|
25
|
+
};
|
|
26
|
+
const prices = variants.map((v) => calculateVariantPrice(getPosPrice(product), v.priceModifier || 0));
|
|
27
|
+
const minPrice = Math.min(...prices);
|
|
28
|
+
return {
|
|
29
|
+
price: minPrice,
|
|
30
|
+
showFrom: minPrice !== Math.max(...prices)
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const { price: displayPrice, showFrom } = getPriceDisplay();
|
|
34
|
+
const handleClick = () => {
|
|
35
|
+
if (!inStock) return;
|
|
36
|
+
if (hasVariants) {
|
|
37
|
+
const variants = getAvailableVariants(product);
|
|
38
|
+
if (variants.length === 1) onAddToCart(product, variants[0].sku);
|
|
39
|
+
else onOpenVariantSelector(product);
|
|
40
|
+
} else onAddToCart(product);
|
|
41
|
+
};
|
|
42
|
+
return /* @__PURE__ */ jsx(Card, {
|
|
43
|
+
className: cn("cursor-pointer transition-all hover:shadow-lg hover:scale-[1.02] group relative overflow-hidden", !inStock && "opacity-50 cursor-not-allowed hover:scale-100"),
|
|
44
|
+
onClick: handleClick,
|
|
45
|
+
children: /* @__PURE__ */ jsxs(CardContent, {
|
|
46
|
+
className: "p-0",
|
|
47
|
+
children: [
|
|
48
|
+
/* @__PURE__ */ jsxs("div", {
|
|
49
|
+
className: "absolute top-2 right-2 z-10 flex flex-col gap-1 items-end",
|
|
50
|
+
children: [!inStock ? /* @__PURE__ */ jsx(Badge, {
|
|
51
|
+
variant: "destructive",
|
|
52
|
+
className: "text-xs shadow-md",
|
|
53
|
+
children: "Out of Stock"
|
|
54
|
+
}) : lowStock ? /* @__PURE__ */ jsx(Badge, {
|
|
55
|
+
variant: "secondary",
|
|
56
|
+
className: "text-xs shadow-md",
|
|
57
|
+
children: "Low Stock"
|
|
58
|
+
}) : null, hasVariants && /* @__PURE__ */ jsxs(Badge, {
|
|
59
|
+
variant: "outline",
|
|
60
|
+
className: "text-xs bg-background/90 backdrop-blur-sm shadow-md",
|
|
61
|
+
children: [getAvailableVariants(product).length, " variants"]
|
|
62
|
+
})]
|
|
63
|
+
}),
|
|
64
|
+
/* @__PURE__ */ jsxs("div", {
|
|
65
|
+
className: "aspect-square bg-muted overflow-hidden relative",
|
|
66
|
+
children: [/* @__PURE__ */ jsx("img", {
|
|
67
|
+
src: getPosProductImage(product),
|
|
68
|
+
alt: product.name,
|
|
69
|
+
className: "w-full h-full object-cover transition-transform group-hover:scale-105"
|
|
70
|
+
}), /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" })]
|
|
71
|
+
}),
|
|
72
|
+
/* @__PURE__ */ jsxs("div", {
|
|
73
|
+
className: "p-3 space-y-1.5",
|
|
74
|
+
children: [/* @__PURE__ */ jsx("h3", {
|
|
75
|
+
className: "font-semibold text-sm leading-tight line-clamp-2 min-h-10",
|
|
76
|
+
children: product.name
|
|
77
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "flex items-center justify-between pt-1",
|
|
79
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
80
|
+
className: "text-base font-bold text-primary",
|
|
81
|
+
children: [showFrom && /* @__PURE__ */ jsx("span", {
|
|
82
|
+
className: "text-xs font-normal text-muted-foreground mr-1",
|
|
83
|
+
children: "From"
|
|
84
|
+
}), formatBdt(displayPrice)]
|
|
85
|
+
}), inStock && /* @__PURE__ */ jsxs("span", {
|
|
86
|
+
className: "text-xs text-muted-foreground",
|
|
87
|
+
children: ["Qty: ", stockQty]
|
|
88
|
+
})]
|
|
89
|
+
})]
|
|
90
|
+
}),
|
|
91
|
+
inStock && /* @__PURE__ */ jsx("div", { className: "absolute inset-x-0 bottom-0 h-1 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left" })
|
|
92
|
+
]
|
|
93
|
+
})
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { ProductCard };
|