@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,80 @@
|
|
|
1
|
+
import { createTauriHardware, isTauri } from "./tauri-adapter.js";
|
|
2
|
+
import { printDocument } from "@classytic/fluid/client/core";
|
|
3
|
+
|
|
4
|
+
//#region src/hardware/web-adapter.ts
|
|
5
|
+
/**
|
|
6
|
+
* Web (browser) hardware adapter — the default for the dashboard POS.
|
|
7
|
+
*
|
|
8
|
+
* Printer → `window.print()` with print isolation (existing behavior).
|
|
9
|
+
* Future: POST to `@classytic/pos-agent` on `localhost:9100`
|
|
10
|
+
* for real ESC/POS thermal printing.
|
|
11
|
+
* Drawer → not available; logs a warning. Real drawer needs the agent.
|
|
12
|
+
* Scanner → handled at the consumer layer via fluid's `useBarcodeScan`
|
|
13
|
+
* hook (capture-phase HID burst detector with prefix routing).
|
|
14
|
+
* The port adapter here is a NO-OP stub kept for parity with
|
|
15
|
+
* the Tauri / RN shells that wire native scanners.
|
|
16
|
+
* Terminal → not available; charges go through bank standalone terminals
|
|
17
|
+
* with cashier-typed reference (out-of-PCI-scope by design).
|
|
18
|
+
*
|
|
19
|
+
* The Tauri / React Native shells will replace this with native adapters
|
|
20
|
+
* implementing the same `PosHardware` shape. Same React tree, different
|
|
21
|
+
* hardware backend.
|
|
22
|
+
*/
|
|
23
|
+
/** DOM id of the rendered receipt — used by `printDocument` for print isolation. */
|
|
24
|
+
const RECEIPT_DOM_ID = "pos-receipt";
|
|
25
|
+
var WebReceiptPrinter = class {
|
|
26
|
+
async print() {
|
|
27
|
+
if (typeof window === "undefined") throw new Error("WebReceiptPrinter requires a browser window");
|
|
28
|
+
printDocument(RECEIPT_DOM_ID);
|
|
29
|
+
}
|
|
30
|
+
async isAvailable() {
|
|
31
|
+
return typeof window !== "undefined";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var WebCashDrawer = class {
|
|
35
|
+
async open() {
|
|
36
|
+
console.warn("[pos/hardware] Cash drawer kick is unavailable in the browser adapter. Install @classytic/pos-agent or use the Tauri shell.");
|
|
37
|
+
}
|
|
38
|
+
async isAvailable() {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var WebBarcodeScannerStub = class {
|
|
43
|
+
onScan() {
|
|
44
|
+
return () => {};
|
|
45
|
+
}
|
|
46
|
+
async isAvailable() {
|
|
47
|
+
return typeof window !== "undefined";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var WebPaymentTerminal = class {
|
|
51
|
+
async charge() {
|
|
52
|
+
throw new Error("[pos/hardware] EMV payment terminal not available in browser adapter — capture cashier-typed reference instead.");
|
|
53
|
+
}
|
|
54
|
+
async refund() {
|
|
55
|
+
throw new Error("[pos/hardware] EMV payment terminal not available in browser adapter.");
|
|
56
|
+
}
|
|
57
|
+
async cancel() {}
|
|
58
|
+
async isAvailable() {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Auto-detects the host: returns native Tauri adapters when the React
|
|
64
|
+
* tree is running inside the `cashier-tauri` webview, otherwise returns
|
|
65
|
+
* the browser stubs. Consumers stay agnostic — `<HardwareProvider>` in
|
|
66
|
+
* `pos-shell.tsx` calls this once at mount and the same React tree
|
|
67
|
+
* works on both surfaces with no per-component branching.
|
|
68
|
+
*/
|
|
69
|
+
function createWebHardware() {
|
|
70
|
+
if (isTauri()) return createTauriHardware();
|
|
71
|
+
return {
|
|
72
|
+
printer: new WebReceiptPrinter(),
|
|
73
|
+
drawer: new WebCashDrawer(),
|
|
74
|
+
scanner: new WebBarcodeScannerStub(),
|
|
75
|
+
terminal: new WebPaymentTerminal()
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
export { RECEIPT_DOM_ID, createWebHardware };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePosAuth } from "../runtime/auth-port.js";
|
|
4
|
+
import { useCallback, useState } from "react";
|
|
5
|
+
import { useMutation } from "@tanstack/react-query";
|
|
6
|
+
|
|
7
|
+
//#region src/hooks/useManagerAuth.ts
|
|
8
|
+
/** Roles allowed to authorize discounts */
|
|
9
|
+
const DISCOUNT_ALLOWED_ROLES = [
|
|
10
|
+
"superadmin",
|
|
11
|
+
"admin",
|
|
12
|
+
"branch_manager"
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Hook for temporary manager authentication in POS.
|
|
16
|
+
* Uses Better Auth's signIn.email() directly — same as the sign-in page.
|
|
17
|
+
* Does NOT create a persistent session (the token is discarded).
|
|
18
|
+
*/
|
|
19
|
+
function useManagerAuth(options = {}) {
|
|
20
|
+
const { allowedRoles = DISCOUNT_ALLOWED_ROLES, sessionDuration = 30 } = options;
|
|
21
|
+
const { signInManager } = usePosAuth();
|
|
22
|
+
const [authState, setAuthState] = useState({
|
|
23
|
+
isAuthorized: false,
|
|
24
|
+
authorizedBy: null,
|
|
25
|
+
authorizedAt: null,
|
|
26
|
+
authorizedAtMono: null
|
|
27
|
+
});
|
|
28
|
+
const isSessionValid = useCallback(() => {
|
|
29
|
+
if (!authState.isAuthorized || authState.authorizedAtMono === null) return false;
|
|
30
|
+
return performance.now() - authState.authorizedAtMono < sessionDuration * 60 * 1e3;
|
|
31
|
+
}, [
|
|
32
|
+
authState.isAuthorized,
|
|
33
|
+
authState.authorizedAtMono,
|
|
34
|
+
sessionDuration
|
|
35
|
+
]);
|
|
36
|
+
const verifyMutation = useMutation({
|
|
37
|
+
mutationFn: async (credentials) => {
|
|
38
|
+
const { user: sessionUser } = await signInManager({
|
|
39
|
+
email: credentials.email,
|
|
40
|
+
password: credentials.password
|
|
41
|
+
});
|
|
42
|
+
const user = sessionUser;
|
|
43
|
+
if (!user) throw new Error("Authentication failed");
|
|
44
|
+
if (!(Array.isArray(user.role) ? user.role : []).some((role) => allowedRoles.includes(role))) throw new Error("You don't have permission to authorize discounts");
|
|
45
|
+
return user;
|
|
46
|
+
},
|
|
47
|
+
onSuccess: (user) => {
|
|
48
|
+
setAuthState({
|
|
49
|
+
isAuthorized: true,
|
|
50
|
+
authorizedBy: user,
|
|
51
|
+
authorizedAt: /* @__PURE__ */ new Date(),
|
|
52
|
+
authorizedAtMono: performance.now()
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const clearAuth = useCallback(() => {
|
|
57
|
+
setAuthState({
|
|
58
|
+
isAuthorized: false,
|
|
59
|
+
authorizedBy: null,
|
|
60
|
+
authorizedAt: null,
|
|
61
|
+
authorizedAtMono: null
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
const authorize = useCallback(async (email, password) => {
|
|
65
|
+
return verifyMutation.mutateAsync({
|
|
66
|
+
email,
|
|
67
|
+
password
|
|
68
|
+
});
|
|
69
|
+
}, [verifyMutation]);
|
|
70
|
+
return {
|
|
71
|
+
isAuthorized: authState.isAuthorized && isSessionValid(),
|
|
72
|
+
authorizedBy: authState.authorizedBy,
|
|
73
|
+
authorizedAt: authState.authorizedAt,
|
|
74
|
+
authorize,
|
|
75
|
+
clearAuth,
|
|
76
|
+
isPending: verifyMutation.isPending,
|
|
77
|
+
error: verifyMutation.error?.message || null,
|
|
78
|
+
reset: verifyMutation.reset
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { DISCOUNT_ALLOWED_ROLES, useManagerAuth };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
5
|
+
import { calculateVariantPrice, formatVariantLabel, getPosPrice, getPosProductImage, getVariantStock } from "@classytic/commerce-sdk/sales";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
|
|
8
|
+
//#region src/hooks/usePosCart.ts
|
|
9
|
+
const CART_TTL = 1440 * 60 * 1e3;
|
|
10
|
+
function usePosCart(branchId) {
|
|
11
|
+
const suffix = branchId ?? "default";
|
|
12
|
+
const [cart, setCart, clearStoredCart] = useLocalStorage(`pos:cart:${suffix}`, [], CART_TTL);
|
|
13
|
+
const [discountInput, setDiscountInput, clearDiscountInput] = useLocalStorage(`pos:discount:${suffix}`, "", CART_TTL);
|
|
14
|
+
const [membershipCardId, setMembershipCardId, clearMembershipCardId] = useLocalStorage(`pos:membership:${suffix}`, "", CART_TTL);
|
|
15
|
+
const [pointsToRedeemInput, setPointsToRedeemInput, clearPointsToRedeem] = useLocalStorage(`pos:points:${suffix}`, "", CART_TTL);
|
|
16
|
+
return {
|
|
17
|
+
cart,
|
|
18
|
+
membershipCardId,
|
|
19
|
+
pointsToRedeemInput,
|
|
20
|
+
discountInput,
|
|
21
|
+
setDiscountInput,
|
|
22
|
+
setMembershipCardId,
|
|
23
|
+
setPointsToRedeemInput,
|
|
24
|
+
addToCart: useCallback((product, variantSku) => {
|
|
25
|
+
const variant = variantSku ? product.variants?.find((v) => v.sku === variantSku) : null;
|
|
26
|
+
if (variantSku) {
|
|
27
|
+
if (getVariantStock(product, variantSku) <= 0) {
|
|
28
|
+
toast.error("Out of stock");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
} else if (!product.branchStock?.inStock) {
|
|
32
|
+
toast.error("Out of stock");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const basePrice = getPosPrice(product);
|
|
36
|
+
const unitPrice = variant ? calculateVariantPrice(basePrice, variant.priceModifier ?? 0) : basePrice;
|
|
37
|
+
setCart((prev) => {
|
|
38
|
+
const existingIndex = prev.findIndex((item) => item.productId === product._id && item.variantSku === variantSku);
|
|
39
|
+
if (existingIndex >= 0) {
|
|
40
|
+
const next = [...prev];
|
|
41
|
+
const existing = next[existingIndex];
|
|
42
|
+
const quantity = existing.quantity + 1;
|
|
43
|
+
next[existingIndex] = {
|
|
44
|
+
...existing,
|
|
45
|
+
quantity,
|
|
46
|
+
lineTotal: quantity * unitPrice
|
|
47
|
+
};
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
const newItem = {
|
|
51
|
+
productId: product._id,
|
|
52
|
+
productName: product.name,
|
|
53
|
+
variantSku,
|
|
54
|
+
variantLabel: variant?.attributes ? formatVariantLabel(variant.attributes) : void 0,
|
|
55
|
+
quantity: 1,
|
|
56
|
+
unitPrice,
|
|
57
|
+
lineTotal: unitPrice,
|
|
58
|
+
image: getPosProductImage(product)
|
|
59
|
+
};
|
|
60
|
+
return [...prev, newItem];
|
|
61
|
+
});
|
|
62
|
+
toast.success("Added to cart");
|
|
63
|
+
}, []),
|
|
64
|
+
addCharge: useCallback((label, amountBdt) => {
|
|
65
|
+
const trimmedLabel = label.trim();
|
|
66
|
+
if (!trimmedLabel) {
|
|
67
|
+
toast.error("Label is required");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isFinite(amountBdt) || amountBdt < 0) {
|
|
71
|
+
toast.error("Amount must be 0 or greater");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const code = trimmedLabel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "misc";
|
|
75
|
+
setCart((prev) => {
|
|
76
|
+
const existingIndex = prev.findIndex((item) => item.kind === "service-fee" && item.serviceCode === code);
|
|
77
|
+
if (existingIndex >= 0) {
|
|
78
|
+
const next = [...prev];
|
|
79
|
+
const existing = next[existingIndex];
|
|
80
|
+
const quantity = existing.quantity + 1;
|
|
81
|
+
next[existingIndex] = {
|
|
82
|
+
...existing,
|
|
83
|
+
quantity,
|
|
84
|
+
lineTotal: quantity * existing.unitPrice
|
|
85
|
+
};
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
const newItem = {
|
|
89
|
+
kind: "service-fee",
|
|
90
|
+
serviceCode: code,
|
|
91
|
+
productId: `service:${code}`,
|
|
92
|
+
productName: trimmedLabel,
|
|
93
|
+
quantity: 1,
|
|
94
|
+
unitPrice: amountBdt,
|
|
95
|
+
lineTotal: amountBdt
|
|
96
|
+
};
|
|
97
|
+
return [...prev, newItem];
|
|
98
|
+
});
|
|
99
|
+
toast.success(`Added ${trimmedLabel}`);
|
|
100
|
+
}, [setCart]),
|
|
101
|
+
updateQuantity: useCallback((index, delta) => {
|
|
102
|
+
setCart((prev) => {
|
|
103
|
+
const next = [...prev];
|
|
104
|
+
const item = next[index];
|
|
105
|
+
if (!item) return prev;
|
|
106
|
+
const quantity = Math.max(1, item.quantity + delta);
|
|
107
|
+
next[index] = {
|
|
108
|
+
...item,
|
|
109
|
+
quantity,
|
|
110
|
+
lineTotal: quantity * item.unitPrice
|
|
111
|
+
};
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}, []),
|
|
115
|
+
removeItem: useCallback((index) => {
|
|
116
|
+
setCart((prev) => prev.filter((_, i) => i !== index));
|
|
117
|
+
}, []),
|
|
118
|
+
clearCart: useCallback(() => {
|
|
119
|
+
if (window.confirm("Clear all items from cart?")) {
|
|
120
|
+
setCart([]);
|
|
121
|
+
clearPointsToRedeem();
|
|
122
|
+
}
|
|
123
|
+
}, [setCart, clearPointsToRedeem]),
|
|
124
|
+
resetCart: useCallback(() => {
|
|
125
|
+
clearStoredCart();
|
|
126
|
+
clearDiscountInput();
|
|
127
|
+
clearMembershipCardId();
|
|
128
|
+
clearPointsToRedeem();
|
|
129
|
+
}, [
|
|
130
|
+
clearStoredCart,
|
|
131
|
+
clearDiscountInput,
|
|
132
|
+
clearMembershipCardId,
|
|
133
|
+
clearPointsToRedeem
|
|
134
|
+
]),
|
|
135
|
+
replaceCart: useCallback((items) => {
|
|
136
|
+
setCart(items);
|
|
137
|
+
}, [setCart])
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
export { usePosCart };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePosAuth } from "../runtime/auth-port.js";
|
|
4
|
+
import { isPhoneSearch } from "../utils/pos-helpers.js";
|
|
5
|
+
import { customerDisplayName, customerDisplayPhone } from "../utils/customer-display.js";
|
|
6
|
+
import { useCallback, useMemo, useState } from "react";
|
|
7
|
+
import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
8
|
+
import { useCustomers, useLookupMemberByCard } from "@classytic/commerce-sdk/sales";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
import { buildFilterParams, getApiParams } from "@classytic/fluid";
|
|
11
|
+
|
|
12
|
+
//#region src/hooks/usePosCustomer.ts
|
|
13
|
+
const CUSTOMER_TTL = 1440 * 60 * 1e3;
|
|
14
|
+
function usePosCustomer(token) {
|
|
15
|
+
const authToken = usePosAuth().getToken();
|
|
16
|
+
const resolvedToken = token || authToken || "";
|
|
17
|
+
const [selectedCustomer, setSelectedCustomer] = useLocalStorage("pos:customer:selected", null, CUSTOMER_TTL);
|
|
18
|
+
const [customerName, setCustomerNameState] = useLocalStorage("pos:customer:name", "", CUSTOMER_TTL);
|
|
19
|
+
const [customerPhone, setCustomerPhoneState] = useLocalStorage("pos:customer:phone", "", CUSTOMER_TTL);
|
|
20
|
+
const [membershipCardId, setMembershipCardIdState] = useLocalStorage("pos:customer:membershipCard", "", CUSTOMER_TTL);
|
|
21
|
+
const [membershipLookupStatus, setMembershipLookupStatus] = useState("idle");
|
|
22
|
+
const [customerSearch, setCustomerSearch] = useState("");
|
|
23
|
+
const [searchApplied, setSearchApplied] = useState("");
|
|
24
|
+
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
25
|
+
const [lookupDialogOpen, setLookupDialogOpen] = useState(false);
|
|
26
|
+
const { lookup: lookupMemberByCard, isLooking: membershipLookupPending } = useLookupMemberByCard();
|
|
27
|
+
const normalizedSearch = searchApplied.trim();
|
|
28
|
+
const phoneCheck = isPhoneSearch(normalizedSearch);
|
|
29
|
+
const { items: customerResultsRaw, isLoading: customersLoading, isFetching: customersFetching, refetch: refetchCustomers } = useCustomers(resolvedToken, useMemo(() => {
|
|
30
|
+
const params = new URLSearchParams();
|
|
31
|
+
params.set("limit", "5");
|
|
32
|
+
if (!normalizedSearch) return getApiParams(params);
|
|
33
|
+
if (phoneCheck.exact) {
|
|
34
|
+
const filterParams = buildFilterParams({ phone: normalizedSearch }, { phone: {
|
|
35
|
+
paramName: "contact.phone",
|
|
36
|
+
type: "string",
|
|
37
|
+
defaultValue: ""
|
|
38
|
+
} });
|
|
39
|
+
for (const [key, value] of filterParams) params.set(key, value);
|
|
40
|
+
} else if (phoneCheck.likely) {
|
|
41
|
+
const phoneParams = buildFilterParams({ phone: normalizedSearch }, { phone: {
|
|
42
|
+
paramName: "contact.phone[contains]",
|
|
43
|
+
type: "string",
|
|
44
|
+
defaultValue: ""
|
|
45
|
+
} });
|
|
46
|
+
for (const [key, value] of phoneParams) params.set(key, value);
|
|
47
|
+
} else {
|
|
48
|
+
const nameParams = buildFilterParams({ name: normalizedSearch }, { name: {
|
|
49
|
+
paramName: "name.given[contains]",
|
|
50
|
+
type: "string",
|
|
51
|
+
defaultValue: ""
|
|
52
|
+
} });
|
|
53
|
+
for (const [key, value] of nameParams) params.set(key, value);
|
|
54
|
+
}
|
|
55
|
+
return getApiParams(params);
|
|
56
|
+
}, [
|
|
57
|
+
normalizedSearch,
|
|
58
|
+
phoneCheck.exact,
|
|
59
|
+
phoneCheck.likely
|
|
60
|
+
]), {
|
|
61
|
+
enabled: !!resolvedToken && normalizedSearch.length >= 2,
|
|
62
|
+
refetchOnWindowFocus: false
|
|
63
|
+
});
|
|
64
|
+
const customerResults = useMemo(() => normalizedSearch.length >= 2 ? customerResultsRaw || [] : [], [customerResultsRaw, normalizedSearch.length]);
|
|
65
|
+
const setCustomerName = useCallback((value) => {
|
|
66
|
+
setCustomerNameState(value);
|
|
67
|
+
if (selectedCustomer) setSelectedCustomer(null);
|
|
68
|
+
}, [selectedCustomer]);
|
|
69
|
+
const setCustomerPhone = useCallback((value) => {
|
|
70
|
+
setCustomerPhoneState(value);
|
|
71
|
+
if (selectedCustomer) setSelectedCustomer(null);
|
|
72
|
+
}, [selectedCustomer]);
|
|
73
|
+
const setMembershipCardId = useCallback((value) => {
|
|
74
|
+
setMembershipCardIdState(value);
|
|
75
|
+
setMembershipLookupStatus("idle");
|
|
76
|
+
}, []);
|
|
77
|
+
const selectCustomer = useCallback((customer) => {
|
|
78
|
+
setSelectedCustomer(customer);
|
|
79
|
+
setCustomerNameState(customerDisplayName(customer));
|
|
80
|
+
setCustomerPhoneState(customerDisplayPhone(customer) ?? "");
|
|
81
|
+
setMembershipCardIdState(customer.membership?.cardId || "");
|
|
82
|
+
setMembershipLookupStatus(customer.membership?.cardId ? "found" : "idle");
|
|
83
|
+
setCustomerSearch("");
|
|
84
|
+
setSearchApplied("");
|
|
85
|
+
setLookupDialogOpen(false);
|
|
86
|
+
}, []);
|
|
87
|
+
const clearCustomer = useCallback(() => {
|
|
88
|
+
setSelectedCustomer(null);
|
|
89
|
+
setCustomerNameState("");
|
|
90
|
+
setCustomerPhoneState("");
|
|
91
|
+
setMembershipCardIdState("");
|
|
92
|
+
setMembershipLookupStatus("idle");
|
|
93
|
+
setCustomerSearch("");
|
|
94
|
+
setSearchApplied("");
|
|
95
|
+
}, []);
|
|
96
|
+
const handleCustomerCreated = useCallback((customer) => {
|
|
97
|
+
setSelectedCustomer(customer);
|
|
98
|
+
setCustomerNameState(customerDisplayName(customer));
|
|
99
|
+
setCustomerPhoneState(customerDisplayPhone(customer) ?? "");
|
|
100
|
+
setMembershipCardIdState(customer.membership?.cardId || "");
|
|
101
|
+
setMembershipLookupStatus(customer.membership?.cardId ? "found" : "idle");
|
|
102
|
+
setCustomerSearch("");
|
|
103
|
+
setSearchApplied("");
|
|
104
|
+
setLookupDialogOpen(false);
|
|
105
|
+
}, []);
|
|
106
|
+
const triggerSearch = useCallback(() => {
|
|
107
|
+
const trimmed = customerSearch.trim();
|
|
108
|
+
if (trimmed.length < 2) {
|
|
109
|
+
toast.error("Enter at least 2 characters to search");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setSearchApplied(trimmed);
|
|
113
|
+
if (trimmed === searchApplied) refetchCustomers();
|
|
114
|
+
}, [
|
|
115
|
+
customerSearch,
|
|
116
|
+
searchApplied,
|
|
117
|
+
refetchCustomers
|
|
118
|
+
]);
|
|
119
|
+
/**
|
|
120
|
+
* POS card-scan path. Hardware barcode scanners type the card id and emit
|
|
121
|
+
* Enter, so this is invoked from the input's onKeyDown=Enter or the
|
|
122
|
+
* adjacent Lookup button. Calls the dedicated
|
|
123
|
+
* `GET /loyalty/members/by-card/:cardId` endpoint and reuses
|
|
124
|
+
* `selectCustomer()` so the cart-attach behavior is identical to picking
|
|
125
|
+
* a customer from the search dialog.
|
|
126
|
+
*/
|
|
127
|
+
const triggerMembershipLookup = useCallback(async () => {
|
|
128
|
+
const trimmed = membershipCardId.trim();
|
|
129
|
+
if (!trimmed) {
|
|
130
|
+
setMembershipLookupStatus("idle");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setMembershipLookupStatus("searching");
|
|
134
|
+
try {
|
|
135
|
+
const customer = (await lookupMemberByCard(trimmed))?.customer;
|
|
136
|
+
if (!customer) {
|
|
137
|
+
setMembershipLookupStatus("not_found");
|
|
138
|
+
setMembershipCardIdState("");
|
|
139
|
+
toast.error("Card not found");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
selectCustomer(customer);
|
|
143
|
+
setMembershipLookupStatus("found");
|
|
144
|
+
} catch (err) {
|
|
145
|
+
setMembershipLookupStatus("not_found");
|
|
146
|
+
setMembershipCardIdState("");
|
|
147
|
+
const message = err instanceof Error ? err.message : "Card not found";
|
|
148
|
+
if (message.includes("404") || /not found/i.test(message)) return;
|
|
149
|
+
toast.error(message);
|
|
150
|
+
}
|
|
151
|
+
}, [
|
|
152
|
+
membershipCardId,
|
|
153
|
+
lookupMemberByCard,
|
|
154
|
+
selectCustomer
|
|
155
|
+
]);
|
|
156
|
+
const resetCustomer = useCallback(() => {
|
|
157
|
+
setSelectedCustomer(null);
|
|
158
|
+
setCustomerNameState("");
|
|
159
|
+
setCustomerPhoneState("");
|
|
160
|
+
setMembershipCardIdState("");
|
|
161
|
+
setMembershipLookupStatus("idle");
|
|
162
|
+
setCustomerSearch("");
|
|
163
|
+
setSearchApplied("");
|
|
164
|
+
}, []);
|
|
165
|
+
return {
|
|
166
|
+
selectedCustomer,
|
|
167
|
+
customerName,
|
|
168
|
+
customerPhone,
|
|
169
|
+
membershipCardId,
|
|
170
|
+
membershipLookupStatus: membershipLookupPending ? "searching" : membershipLookupStatus,
|
|
171
|
+
customerSearch,
|
|
172
|
+
customerResults,
|
|
173
|
+
isSearching: customersLoading || customersFetching,
|
|
174
|
+
createDialogOpen,
|
|
175
|
+
lookupDialogOpen,
|
|
176
|
+
setCustomerName,
|
|
177
|
+
setCustomerPhone,
|
|
178
|
+
setMembershipCardId,
|
|
179
|
+
setCustomerSearch,
|
|
180
|
+
setCreateDialogOpen,
|
|
181
|
+
setLookupDialogOpen,
|
|
182
|
+
selectCustomer,
|
|
183
|
+
clearCustomer,
|
|
184
|
+
handleCustomerCreated,
|
|
185
|
+
triggerSearch,
|
|
186
|
+
triggerMembershipLookup,
|
|
187
|
+
resetCustomer
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
export { usePosCustomer };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
5
|
+
|
|
6
|
+
//#region src/hooks/usePosMultiOrder.ts
|
|
7
|
+
/**
|
|
8
|
+
* usePosMultiOrder — Park and resume multiple orders.
|
|
9
|
+
*
|
|
10
|
+
* Max 5 parked orders stored in localStorage per branch.
|
|
11
|
+
* Each parked order saves: cart items, customer info, discount state.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_PARKED_ORDERS = 5;
|
|
14
|
+
const PARKED_TTL = 480 * 60 * 1e3;
|
|
15
|
+
function usePosMultiOrder(branchId) {
|
|
16
|
+
const [orders, setOrders] = useLocalStorage(branchId ? `pos:parked:${branchId}` : "pos:parked:default", [], PARKED_TTL);
|
|
17
|
+
const canPark = orders.length < MAX_PARKED_ORDERS;
|
|
18
|
+
return {
|
|
19
|
+
orders,
|
|
20
|
+
parkOrder: useCallback((order) => {
|
|
21
|
+
const id = `parked_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
22
|
+
const parked = {
|
|
23
|
+
...order,
|
|
24
|
+
id,
|
|
25
|
+
parkedAt: Date.now()
|
|
26
|
+
};
|
|
27
|
+
setOrders((prev) => [...prev, parked]);
|
|
28
|
+
return id;
|
|
29
|
+
}, [setOrders]),
|
|
30
|
+
resumeOrder: useCallback((id) => {
|
|
31
|
+
let found = null;
|
|
32
|
+
setOrders((prev) => {
|
|
33
|
+
const idx = prev.findIndex((o) => o.id === id);
|
|
34
|
+
if (idx === -1) return prev;
|
|
35
|
+
found = prev[idx];
|
|
36
|
+
return prev.filter((_, i) => i !== idx);
|
|
37
|
+
});
|
|
38
|
+
return found;
|
|
39
|
+
}, [setOrders]),
|
|
40
|
+
deleteOrder: useCallback((id) => {
|
|
41
|
+
setOrders((prev) => prev.filter((o) => o.id !== id));
|
|
42
|
+
}, [setOrders]),
|
|
43
|
+
canPark
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { usePosMultiOrder };
|