@tallyui/components 1.0.0 → 3.0.0-next.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/LICENSE +21 -0
- package/dist/index.d.ts +1113 -83
- package/dist/index.js +4936 -353
- package/package.json +27 -5
- package/src/cart/cart-line-actions.tsx +40 -0
- package/src/cart/cart-line.tsx +18 -32
- package/src/cart/cart-note-input.tsx +46 -0
- package/src/cart/cart-panel.tsx +39 -26
- package/src/cart/cart-total.tsx +43 -47
- package/src/cart/discount-badge.tsx +19 -0
- package/src/cart/index.ts +4 -1
- package/src/checkout/cash-tendered.tsx +101 -0
- package/src/checkout/change-display.tsx +19 -0
- package/src/checkout/index.ts +3 -0
- package/src/checkout/order-summary-line.tsx +7 -8
- package/src/checkout/order-summary.tsx +8 -7
- package/src/checkout/payment-method-card.tsx +10 -9
- package/src/checkout/payment-selector.tsx +5 -5
- package/src/checkout/receipt-preview.tsx +78 -0
- package/src/customer/customer-card.tsx +16 -12
- package/src/customer/customer-form.tsx +72 -0
- package/src/customer/customer-order-history.tsx +34 -0
- package/src/customer/customer-picker.tsx +124 -0
- package/src/customer/customer-select.tsx +80 -31
- package/src/customer/index.ts +3 -0
- package/src/index.ts +114 -1
- package/src/input/filter-chip.tsx +85 -27
- package/src/input/index.ts +1 -0
- package/src/input/quantity-stepper.tsx +64 -0
- package/src/input/search-input.tsx +30 -7
- package/src/layout/checkout-layout.tsx +46 -0
- package/src/layout/index.ts +5 -0
- package/src/layout/live-tab-screen.tsx +54 -0
- package/src/layout/pos-layout.tsx +64 -0
- package/src/layout/settings-layout.tsx +58 -0
- package/src/layout/store-settings-choice-screen.tsx +142 -0
- package/src/order/index.ts +4 -0
- package/src/order/order-card.tsx +48 -0
- package/src/order/order-detail.tsx +82 -0
- package/src/order/order-list.tsx +39 -0
- package/src/order/order-status-badge.tsx +32 -0
- package/src/product/category-nav.tsx +70 -0
- package/src/product/index.ts +3 -0
- package/src/product/product-card.tsx +36 -8
- package/src/product/product-grid.tsx +46 -13
- package/src/product/product-image.tsx +28 -7
- package/src/product/product-list.tsx +44 -0
- package/src/product/product-price.tsx +77 -22
- package/src/product/product-sku.tsx +5 -5
- package/src/product/product-stock-badge.tsx +41 -28
- package/src/product/product-title.tsx +2 -3
- package/src/product/product-variant-picker.tsx +61 -0
- package/src/register/cash-count-input.tsx +85 -0
- package/src/register/closure-sheet.tsx +161 -0
- package/src/register/index.ts +12 -0
- package/src/register/movement-sheet.tsx +118 -0
- package/src/register/open-register-card.tsx +109 -0
- package/src/register/register-bar.helpers.ts +57 -0
- package/src/register/register-bar.tsx +85 -0
- package/src/register/register-column.tsx +117 -0
- package/src/register/register-count.tsx +330 -0
- package/src/register/register-open-close.tsx +52 -0
- package/src/register/register-panel.tsx +151 -0
- package/src/register/register-picker.tsx +60 -0
- package/src/register/register-summary.tsx +72 -0
- package/src/sale/cart-bar.tsx +18 -0
- package/src/sale/cart.tsx +66 -0
- package/src/sale/catalogue.tsx +139 -0
- package/src/sale/discount-form.tsx +85 -0
- package/src/sale/index.ts +9 -0
- package/src/sale/orders-list.tsx +108 -0
- package/src/sale/print-style.ts +11 -0
- package/src/sale/receipt.tsx +62 -0
- package/src/sale/sync-status.tsx +141 -0
- package/src/sale/tender.tsx +38 -0
- package/src/settings/connector-status.tsx +65 -0
- package/src/settings/index.ts +3 -0
- package/src/settings/settings-group.tsx +23 -0
- package/src/settings/settings-row.tsx +23 -0
- package/src/ui/accordion/index.tsx +59 -0
- package/src/ui/alert-dialog/index.tsx +91 -0
- package/src/ui/avatar/index.tsx +50 -0
- package/src/ui/badge/index.tsx +60 -0
- package/src/ui/button/index.tsx +90 -0
- package/src/ui/card/index.tsx +60 -0
- package/src/ui/checkbox/index.tsx +29 -0
- package/src/ui/collapsible/index.tsx +24 -0
- package/src/ui/combobox/index.tsx +98 -0
- package/src/ui/context-menu/index.tsx +159 -0
- package/src/ui/dialog/index.tsx +92 -0
- package/src/ui/dropdown-menu/index.tsx +161 -0
- package/src/ui/hover-card/index.tsx +31 -0
- package/src/ui/hstack/index.tsx +43 -0
- package/src/ui/icon/index.tsx +44 -0
- package/src/ui/icon/search-icon.tsx +9 -0
- package/src/ui/icon/svg-icon.tsx +36 -0
- package/src/ui/icon-button/index.tsx +58 -0
- package/src/ui/index.ts +99 -0
- package/src/ui/input/index.tsx +100 -0
- package/src/ui/label/index.tsx +26 -0
- package/src/ui/loader/index.tsx +37 -0
- package/src/ui/popover/index.tsx +31 -0
- package/src/ui/progress/index.tsx +28 -0
- package/src/ui/radio-group/index.tsx +38 -0
- package/src/ui/select/index.tsx +96 -0
- package/src/ui/separator/index.tsx +29 -0
- package/src/ui/slider/index.tsx +28 -0
- package/src/ui/switch/index.tsx +31 -0
- package/src/ui/table/index.tsx +59 -0
- package/src/ui/tabs/index.tsx +53 -0
- package/src/ui/text/index.tsx +36 -0
- package/src/ui/textarea/index.tsx +29 -0
- package/src/ui/toast/index.tsx +52 -0
- package/src/ui/toggle/index.tsx +69 -0
- package/src/ui/toggle-group/index.tsx +66 -0
- package/src/ui/tooltip/index.tsx +30 -0
- package/src/ui/vstack/index.tsx +43 -0
- package/src/uniwind-env.d.ts +9 -1
- package/src/__tests__/cart-line.test.tsx +0 -53
- package/src/__tests__/cart-panel.test.tsx +0 -55
- package/src/__tests__/cart-total.test.tsx +0 -60
- package/src/__tests__/customer-card.test.tsx +0 -47
- package/src/__tests__/customer-select.test.tsx +0 -33
- package/src/__tests__/filter-chip.test.tsx +0 -40
- package/src/__tests__/helpers.ts +0 -111
- package/src/__tests__/order-summary-line.test.tsx +0 -18
- package/src/__tests__/order-summary.test.tsx +0 -32
- package/src/__tests__/payment-method-card.test.tsx +0 -24
- package/src/__tests__/payment-selector.test.tsx +0 -23
- package/src/__tests__/product-card.test.tsx +0 -31
- package/src/__tests__/product-grid.test.tsx +0 -37
- package/src/__tests__/product-image.test.tsx +0 -54
- package/src/__tests__/product-price.test.tsx +0 -68
- package/src/__tests__/product-sku.test.tsx +0 -47
- package/src/__tests__/product-stock-badge.test.tsx +0 -58
- package/src/__tests__/product-title.test.tsx +0 -38
- package/src/__tests__/search-input.test.tsx +0 -22
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { Pressable,
|
|
1
|
+
import { Pressable, View, type PressableProps } from 'react-native';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
|
|
4
4
|
import { cn } from '@tallyui/theme';
|
|
5
|
+
import { Text, HStack } from '../ui';
|
|
5
6
|
|
|
6
|
-
export interface PaymentMethodCardProps extends Omit<
|
|
7
|
+
export interface PaymentMethodCardProps extends Omit<PressableProps, 'children'> {
|
|
7
8
|
label: string;
|
|
8
9
|
selected: boolean;
|
|
9
10
|
onPress: () => void;
|
|
@@ -11,18 +12,18 @@ export interface PaymentMethodCardProps extends Omit<ViewProps, 'children'> {
|
|
|
11
12
|
className?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function PaymentMethodCard({ label, selected, onPress, icon, className, ...
|
|
15
|
+
export function PaymentMethodCard({ label, selected, onPress, icon, className, ...props }: PaymentMethodCardProps) {
|
|
15
16
|
return (
|
|
16
|
-
<Pressable onPress={onPress} {...
|
|
17
|
-
<
|
|
17
|
+
<Pressable onPress={onPress} {...props}>
|
|
18
|
+
<HStack
|
|
18
19
|
className={cn(
|
|
19
|
-
'
|
|
20
|
-
selected ? 'border-primary bg-primary/5' : 'border-border bg-
|
|
20
|
+
'rounded-lg border px-4 py-3',
|
|
21
|
+
selected ? 'border-primary bg-primary/5' : 'border-border bg-card',
|
|
21
22
|
className,
|
|
22
23
|
)}
|
|
23
24
|
>
|
|
24
25
|
{icon && <View>{icon}</View>}
|
|
25
|
-
<Text className={cn('flex-1 text-sm font-medium', selected ? 'text-primary' : '
|
|
26
|
+
<Text className={cn('flex-1 text-sm font-medium', selected ? 'text-primary' : '')}>
|
|
26
27
|
{label}
|
|
27
28
|
</Text>
|
|
28
29
|
{selected && (
|
|
@@ -30,7 +31,7 @@ export function PaymentMethodCard({ label, selected, onPress, icon, className, .
|
|
|
30
31
|
<Text className="text-xs text-primary-foreground">{'\u2713'}</Text>
|
|
31
32
|
</View>
|
|
32
33
|
)}
|
|
33
|
-
</
|
|
34
|
+
</HStack>
|
|
34
35
|
</Pressable>
|
|
35
36
|
);
|
|
36
37
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { View, type ViewProps } from 'react-native';
|
|
3
2
|
import { cn } from '@tallyui/theme';
|
|
3
|
+
import { VStack, type VStackProps } from '../ui';
|
|
4
4
|
import { PaymentMethodCard } from './payment-method-card';
|
|
5
5
|
|
|
6
6
|
export interface PaymentMethod {
|
|
@@ -9,7 +9,7 @@ export interface PaymentMethod {
|
|
|
9
9
|
icon?: ReactNode;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface PaymentSelectorProps extends Omit<
|
|
12
|
+
export interface PaymentSelectorProps extends Omit<VStackProps, 'children'> {
|
|
13
13
|
/** Available payment methods */
|
|
14
14
|
methods: PaymentMethod[];
|
|
15
15
|
/** ID of the currently selected method */
|
|
@@ -37,10 +37,10 @@ export function PaymentSelector({
|
|
|
37
37
|
selected,
|
|
38
38
|
onSelect,
|
|
39
39
|
className,
|
|
40
|
-
...
|
|
40
|
+
...props
|
|
41
41
|
}: PaymentSelectorProps) {
|
|
42
42
|
return (
|
|
43
|
-
<
|
|
43
|
+
<VStack space="sm" className={cn(className)} {...props}>
|
|
44
44
|
{methods.map((method) => (
|
|
45
45
|
<PaymentMethodCard
|
|
46
46
|
key={method.id}
|
|
@@ -50,6 +50,6 @@ export function PaymentSelector({
|
|
|
50
50
|
icon={method.icon}
|
|
51
51
|
/>
|
|
52
52
|
))}
|
|
53
|
-
</
|
|
53
|
+
</VStack>
|
|
54
54
|
);
|
|
55
55
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Text, View, type ViewProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@tallyui/theme';
|
|
5
|
+
|
|
6
|
+
export interface ReceiptItem {
|
|
7
|
+
name: string;
|
|
8
|
+
quantity: number;
|
|
9
|
+
total: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ReceiptPreviewProps extends Omit<ViewProps, 'children'> {
|
|
13
|
+
items: ReceiptItem[];
|
|
14
|
+
subtotal: string;
|
|
15
|
+
tax: string;
|
|
16
|
+
total: string;
|
|
17
|
+
payments?: Array<{ method: string; amount: string }>;
|
|
18
|
+
headerSlot?: ReactNode;
|
|
19
|
+
footerSlot?: ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ReceiptPreview({
|
|
24
|
+
items,
|
|
25
|
+
subtotal,
|
|
26
|
+
tax,
|
|
27
|
+
total,
|
|
28
|
+
payments,
|
|
29
|
+
headerSlot,
|
|
30
|
+
footerSlot,
|
|
31
|
+
className,
|
|
32
|
+
...viewProps
|
|
33
|
+
}: ReceiptPreviewProps) {
|
|
34
|
+
return (
|
|
35
|
+
<View className={cn('rounded-lg border border-border bg-card p-4', className)} {...viewProps}>
|
|
36
|
+
{headerSlot && <View className="mb-3 items-center border-b border-dashed border-border pb-3">{headerSlot}</View>}
|
|
37
|
+
|
|
38
|
+
<View className="gap-1">
|
|
39
|
+
{items.map((item, i) => (
|
|
40
|
+
<View key={i} className="flex-row justify-between">
|
|
41
|
+
<Text className="flex-1 text-sm text-foreground">
|
|
42
|
+
{item.name} x{item.quantity}
|
|
43
|
+
</Text>
|
|
44
|
+
<Text className="text-sm text-foreground">{item.total}</Text>
|
|
45
|
+
</View>
|
|
46
|
+
))}
|
|
47
|
+
</View>
|
|
48
|
+
|
|
49
|
+
<View className="mt-3 gap-1 border-t border-dashed border-border pt-3">
|
|
50
|
+
<View className="flex-row justify-between">
|
|
51
|
+
<Text className="text-sm text-muted-foreground">Subtotal</Text>
|
|
52
|
+
<Text className="text-sm text-foreground">{subtotal}</Text>
|
|
53
|
+
</View>
|
|
54
|
+
<View className="flex-row justify-between">
|
|
55
|
+
<Text className="text-sm text-muted-foreground">Tax</Text>
|
|
56
|
+
<Text className="text-sm text-foreground">{tax}</Text>
|
|
57
|
+
</View>
|
|
58
|
+
<View className="flex-row justify-between border-t border-border pt-1">
|
|
59
|
+
<Text className="text-sm font-bold text-foreground">Total</Text>
|
|
60
|
+
<Text className="text-sm font-bold text-foreground">{total}</Text>
|
|
61
|
+
</View>
|
|
62
|
+
</View>
|
|
63
|
+
|
|
64
|
+
{payments && payments.length > 0 && (
|
|
65
|
+
<View className="mt-3 gap-1 border-t border-dashed border-border pt-3">
|
|
66
|
+
{payments.map((p, i) => (
|
|
67
|
+
<View key={i} className="flex-row justify-between">
|
|
68
|
+
<Text className="text-sm text-muted-foreground">{p.method}</Text>
|
|
69
|
+
<Text className="text-sm text-foreground">{p.amount}</Text>
|
|
70
|
+
</View>
|
|
71
|
+
))}
|
|
72
|
+
</View>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{footerSlot && <View className="mt-3 items-center border-t border-dashed border-border pt-3">{footerSlot}</View>}
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -1,26 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { useCustomerTraits } from '@tallyui/core';
|
|
1
|
+
import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
|
|
4
2
|
import { cn } from '@tallyui/theme';
|
|
3
|
+
import { Text, VStack, type VStackProps } from '../ui';
|
|
5
4
|
|
|
6
|
-
export interface CustomerCardProps extends Omit<
|
|
7
|
-
doc:
|
|
5
|
+
export interface CustomerCardProps<Doc = any> extends Omit<VStackProps, 'children'> {
|
|
6
|
+
doc: Doc;
|
|
7
|
+
traits?: CustomerTraits<Doc>;
|
|
8
8
|
className?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
function CustomerCardFromContext<Doc>(props: CustomerCardProps<Doc>) {
|
|
12
12
|
const traits = useCustomerTraits();
|
|
13
|
-
|
|
13
|
+
return traits ? <CustomerCard {...props} traits={traits} /> : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CustomerCard<Doc>({ doc, traits, className, ...props }: CustomerCardProps<Doc>) {
|
|
17
|
+
if (!traits) return <CustomerCardFromContext doc={doc} className={className} {...props} />;
|
|
14
18
|
|
|
15
19
|
const name = traits.getName(doc);
|
|
16
20
|
const email = traits.getEmail(doc);
|
|
17
21
|
const address = traits.getAddressSummary(doc);
|
|
18
22
|
|
|
19
23
|
return (
|
|
20
|
-
<
|
|
21
|
-
<Text className="text-sm font-semibold
|
|
22
|
-
{email && <Text className="text-xs text-muted">{email}</Text>}
|
|
23
|
-
{address && <Text className="text-xs text-muted">{address}</Text>}
|
|
24
|
-
</
|
|
24
|
+
<VStack space="none" className={cn('gap-0.5', className)} {...props}>
|
|
25
|
+
<Text className="text-sm font-semibold">{name}</Text>
|
|
26
|
+
{email && <Text className="text-xs text-muted-foreground">{email}</Text>}
|
|
27
|
+
{address && <Text className="text-xs text-muted-foreground">{address}</Text>}
|
|
28
|
+
</VStack>
|
|
25
29
|
);
|
|
26
30
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Pressable, Text, TextInput, View, type ViewProps } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@tallyui/theme';
|
|
4
|
+
|
|
5
|
+
export interface CustomerFormValues {
|
|
6
|
+
firstName: string;
|
|
7
|
+
lastName: string;
|
|
8
|
+
email: string;
|
|
9
|
+
phone: string;
|
|
10
|
+
address: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CustomerFormProps extends Omit<ViewProps, 'children'> {
|
|
14
|
+
values: CustomerFormValues;
|
|
15
|
+
onChangeField: (field: keyof CustomerFormValues, value: string) => void;
|
|
16
|
+
onSubmit?: () => void;
|
|
17
|
+
submitLabel?: string;
|
|
18
|
+
showAddress?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function FormField({
|
|
23
|
+
label,
|
|
24
|
+
value,
|
|
25
|
+
onChangeText,
|
|
26
|
+
...props
|
|
27
|
+
}: { label: string; value: string; onChangeText: (t: string) => void } & Record<string, any>) {
|
|
28
|
+
return (
|
|
29
|
+
<View className="gap-1">
|
|
30
|
+
<Text className="text-xs font-medium text-muted-foreground">{label}</Text>
|
|
31
|
+
<TextInput
|
|
32
|
+
value={value}
|
|
33
|
+
onChangeText={onChangeText}
|
|
34
|
+
placeholderTextColorClassName="accent-muted-foreground"
|
|
35
|
+
className="rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground"
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function CustomerForm({
|
|
43
|
+
values,
|
|
44
|
+
onChangeField,
|
|
45
|
+
onSubmit,
|
|
46
|
+
submitLabel = 'Save Customer',
|
|
47
|
+
showAddress = true,
|
|
48
|
+
className,
|
|
49
|
+
...viewProps
|
|
50
|
+
}: CustomerFormProps) {
|
|
51
|
+
return (
|
|
52
|
+
<View className={cn('gap-3 p-4', className)} {...viewProps}>
|
|
53
|
+
<View className="flex-row gap-3">
|
|
54
|
+
<View className="flex-1">
|
|
55
|
+
<FormField label="First Name" value={values.firstName} onChangeText={(v) => onChangeField('firstName', v)} placeholder="First name" />
|
|
56
|
+
</View>
|
|
57
|
+
<View className="flex-1">
|
|
58
|
+
<FormField label="Last Name" value={values.lastName} onChangeText={(v) => onChangeField('lastName', v)} placeholder="Last name" />
|
|
59
|
+
</View>
|
|
60
|
+
</View>
|
|
61
|
+
<FormField label="Email" value={values.email} onChangeText={(v) => onChangeField('email', v)} placeholder="email@example.com" keyboardType="email-address" />
|
|
62
|
+
<FormField label="Phone" value={values.phone} onChangeText={(v) => onChangeField('phone', v)} placeholder="Phone number" keyboardType="phone-pad" />
|
|
63
|
+
{showAddress && <FormField label="Address" value={values.address} onChangeText={(v) => onChangeField('address', v)} placeholder="Street address" multiline />}
|
|
64
|
+
|
|
65
|
+
{onSubmit && (
|
|
66
|
+
<Pressable onPress={onSubmit} className="items-center rounded-lg bg-primary px-4 py-3">
|
|
67
|
+
<Text className="text-sm font-semibold text-primary-foreground">{submitLabel}</Text>
|
|
68
|
+
</Pressable>
|
|
69
|
+
)}
|
|
70
|
+
</View>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { ScrollView, Text, View, type ViewProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@tallyui/theme';
|
|
5
|
+
|
|
6
|
+
export interface CustomerOrderHistoryProps extends Omit<ViewProps, 'children'> {
|
|
7
|
+
orders: any[];
|
|
8
|
+
renderItem: (order: any, index: number) => ReactNode;
|
|
9
|
+
emptyState?: ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CustomerOrderHistory({
|
|
14
|
+
orders,
|
|
15
|
+
renderItem,
|
|
16
|
+
emptyState,
|
|
17
|
+
className,
|
|
18
|
+
...viewProps
|
|
19
|
+
}: CustomerOrderHistoryProps) {
|
|
20
|
+
return (
|
|
21
|
+
<View className={cn('flex-1', className)} {...viewProps}>
|
|
22
|
+
<Text className="px-3 py-2 text-xs font-semibold text-muted-foreground">Order History</Text>
|
|
23
|
+
{orders.length > 0 ? (
|
|
24
|
+
<ScrollView contentContainerClassName="gap-2 px-3 pb-3">
|
|
25
|
+
{orders.map((order, index) => (
|
|
26
|
+
<View key={order.id ?? index}>{renderItem(order, index)}</View>
|
|
27
|
+
))}
|
|
28
|
+
</ScrollView>
|
|
29
|
+
) : (
|
|
30
|
+
emptyState ?? null
|
|
31
|
+
)}
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { customerTraits, CustomerServiceError, ConnectorUnauthorizedError, type Customer, type CustomerInput } from '@tallyui/core';
|
|
4
|
+
import { cn } from '@tallyui/theme';
|
|
5
|
+
import { CustomerCard } from './customer-card';
|
|
6
|
+
import { CustomerSelect } from './customer-select';
|
|
7
|
+
import { CustomerForm, type CustomerFormValues } from './customer-form';
|
|
8
|
+
|
|
9
|
+
export interface CustomerPickerProps {
|
|
10
|
+
/** The connector's searchCustomers, bound to its context by the app. */
|
|
11
|
+
search: (query: string) => Promise<Customer[]>;
|
|
12
|
+
/** The connector's createCustomer, bound; absent hides "New customer". */
|
|
13
|
+
create?: (input: CustomerInput) => Promise<Customer>;
|
|
14
|
+
selected: Customer | null;
|
|
15
|
+
onSelect: (customer: Customer | null) => void;
|
|
16
|
+
/** Called for errors the picker can't explain to the cashier, including ConnectorUnauthorizedError, which means the app should sign in again. */
|
|
17
|
+
onError?: (error: unknown) => void;
|
|
18
|
+
/** false disables search and create (online only in v1). */
|
|
19
|
+
online: boolean;
|
|
20
|
+
/** Debounce before a search, ms. */
|
|
21
|
+
debounceMs?: number;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `@tallyui/core/server`'s `payloadBoundErrors` bound on `customer.email`, in UTF-16 code units. */
|
|
26
|
+
const EMAIL_MAX = 254;
|
|
27
|
+
/** A simple shape, not RFC 5322: one `@`, a non-empty local part, a domain with text either side of a dot, no whitespace or NUL. */
|
|
28
|
+
const EMAIL_SHAPE = /^[^\s@\u0000]+@[^\s@\u0000]+\.[^\s@\u0000]+$/u;
|
|
29
|
+
|
|
30
|
+
export function CustomerPicker({ search, create, selected, onSelect, onError, online, debounceMs = 250, className }: CustomerPickerProps) {
|
|
31
|
+
const [query, setQuery] = useState('');
|
|
32
|
+
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
const [showForm, setShowForm] = useState(false);
|
|
35
|
+
const [creating, setCreating] = useState(false);
|
|
36
|
+
const [values, setValues] = useState<CustomerFormValues>({ firstName: '', lastName: '', email: '', phone: '', address: '' });
|
|
37
|
+
const sequence = useRef(0);
|
|
38
|
+
const searchRef = useRef(search);
|
|
39
|
+
searchRef.current = search;
|
|
40
|
+
const [noResults, setNoResults] = useState(false);
|
|
41
|
+
|
|
42
|
+
function handleError(error: unknown) {
|
|
43
|
+
if (error instanceof CustomerServiceError) {
|
|
44
|
+
setError(error.code === 'network' ? "Couldn't reach the store. Try again."
|
|
45
|
+
: error.code === 'server' ? "The store couldn't do that. Try again." : error.message);
|
|
46
|
+
} else {
|
|
47
|
+
setError(error instanceof ConnectorUnauthorizedError ? 'Sign in again to continue.' : 'Something went wrong. Try again.');
|
|
48
|
+
onError?.(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const current = ++sequence.current;
|
|
54
|
+
setNoResults(false);
|
|
55
|
+
setError(null);
|
|
56
|
+
if (!online || !query.trim()) { setCustomers([]); return; }
|
|
57
|
+
const timer = setTimeout(async () => {
|
|
58
|
+
try {
|
|
59
|
+
const results = await searchRef.current(query);
|
|
60
|
+
if (current === sequence.current) { setCustomers(results); setNoResults(results.length === 0); }
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (current === sequence.current) handleError(error);
|
|
63
|
+
}
|
|
64
|
+
}, debounceMs);
|
|
65
|
+
return () => { clearTimeout(timer); sequence.current++; };
|
|
66
|
+
}, [query, online, debounceMs]);
|
|
67
|
+
|
|
68
|
+
async function submit() {
|
|
69
|
+
const email = values.email.trim();
|
|
70
|
+
if (!online || !create || creating || !email) return;
|
|
71
|
+
// The till sends this email in order.create, whose shape check refuses one over 254 characters: refuse it here instead.
|
|
72
|
+
if (email.length > EMAIL_MAX || !EMAIL_SHAPE.test(email)) return setError(`Enter a valid email address (up to ${EMAIL_MAX} characters)`);
|
|
73
|
+
setCreating(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
try {
|
|
76
|
+
const created = await create({ email,
|
|
77
|
+
...(values.firstName.trim() ? { firstName: values.firstName.trim() } : {}),
|
|
78
|
+
...(values.lastName.trim() ? { lastName: values.lastName.trim() } : {}),
|
|
79
|
+
...(values.phone.trim() ? { phone: values.phone.trim() } : {}),
|
|
80
|
+
});
|
|
81
|
+
onSelect(created);
|
|
82
|
+
setValues({ firstName: '', lastName: '', email: '', phone: '', address: '' });
|
|
83
|
+
setShowForm(false);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
handleError(error);
|
|
86
|
+
} finally {
|
|
87
|
+
setCreating(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return <View className={cn('gap-3', className)}>
|
|
92
|
+
{selected && <View>
|
|
93
|
+
<CustomerCard doc={selected} traits={customerTraits} />
|
|
94
|
+
<Pressable accessibilityRole="button" className="items-center rounded-lg border border-border px-4 py-3" onPress={() => onSelect(null)}><Text className="text-sm font-semibold text-foreground">Remove customer</Text></Pressable>
|
|
95
|
+
</View>}
|
|
96
|
+
<Text className="text-xs font-medium text-muted-foreground">Search customers</Text>
|
|
97
|
+
<TextInput accessibilityLabel="Search customers" value={query} onChangeText={setQuery} editable={online}
|
|
98
|
+
className="rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground"
|
|
99
|
+
placeholderTextColorClassName="accent-muted-foreground" placeholder="Name or email" />
|
|
100
|
+
{!online && <Text className="text-sm text-muted-foreground">Connect to search or add customers.</Text>}
|
|
101
|
+
{noResults && <Text className="text-sm text-muted-foreground">No customers found.</Text>}
|
|
102
|
+
{error && <Text accessibilityRole="alert" className="text-sm text-destructive">{error}</Text>}
|
|
103
|
+
<CustomerSelect customers={customers} traits={customerTraits} onSearch={setQuery}
|
|
104
|
+
onSelect={(customer) => { onSelect(customer); setQuery(''); }} />
|
|
105
|
+
{create && <Pressable accessibilityRole="button" disabled={!online || creating} onPress={() => setShowForm(true)}
|
|
106
|
+
className={cn('items-center rounded-lg border border-border px-4 py-3', (!online || creating) && 'opacity-50')}>
|
|
107
|
+
<Text className="text-sm font-semibold text-foreground">New customer</Text>
|
|
108
|
+
</Pressable>}
|
|
109
|
+
{showForm && <View>
|
|
110
|
+
<CustomerForm values={values} showAddress={false}
|
|
111
|
+
onChangeField={(field, value) => setValues((current) => ({ ...current, [field]: value }))} />
|
|
112
|
+
<View className="flex-row gap-3">
|
|
113
|
+
<Pressable accessibilityRole="button" disabled={!online || creating || !values.email.trim()} onPress={submit}
|
|
114
|
+
className={cn('items-center rounded-lg bg-primary px-4 py-3', (!online || creating || !values.email.trim()) && 'opacity-50')}>
|
|
115
|
+
<Text className="text-sm font-semibold text-primary-foreground">Save Customer</Text>
|
|
116
|
+
</Pressable>
|
|
117
|
+
<Pressable accessibilityRole="button" className="items-center rounded-lg border border-border px-4 py-3"
|
|
118
|
+
onPress={() => { setShowForm(false); setValues({ firstName: '', lastName: '', email: '', phone: '', address: '' }); }}>
|
|
119
|
+
<Text className="text-sm font-semibold text-foreground">Cancel</Text>
|
|
120
|
+
</Pressable>
|
|
121
|
+
</View>
|
|
122
|
+
</View>}
|
|
123
|
+
</View>;
|
|
124
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { Pressable, ScrollView,
|
|
2
|
-
import {
|
|
1
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
2
|
+
import { Select } from '@tallyui/primitives';
|
|
3
|
+
import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
|
|
3
4
|
import { cn } from '@tallyui/theme';
|
|
5
|
+
import { VStack, type VStackProps } from '../ui';
|
|
4
6
|
import { CustomerCard } from './customer-card';
|
|
5
7
|
|
|
6
|
-
export interface CustomerSelectProps extends Omit<
|
|
8
|
+
export interface CustomerSelectProps<Doc = any> extends Omit<VStackProps, 'children'> {
|
|
7
9
|
/** Customer documents to display as options */
|
|
8
|
-
customers:
|
|
10
|
+
customers: Doc[];
|
|
9
11
|
/** Currently selected customer (if any) */
|
|
10
|
-
selected?:
|
|
12
|
+
selected?: Doc | null;
|
|
11
13
|
/** Called when a customer is selected */
|
|
12
|
-
onSelect: (customer:
|
|
14
|
+
onSelect: (customer: Doc) => void;
|
|
15
|
+
traits?: CustomerTraits<Doc>;
|
|
13
16
|
/** Called when the search text changes */
|
|
14
17
|
onSearch: (query: string) => void;
|
|
15
18
|
/** Placeholder text for the search input */
|
|
@@ -25,6 +28,9 @@ export interface CustomerSelectProps extends Omit<ViewProps, 'children'> {
|
|
|
25
28
|
* calls onSearch with the query text and renders whatever customers
|
|
26
29
|
* are passed in.
|
|
27
30
|
*
|
|
31
|
+
* Built on top of the Select primitive for proper accessibility
|
|
32
|
+
* semantics (combobox role, option roles, aria-expanded, aria-checked).
|
|
33
|
+
*
|
|
28
34
|
* ```tsx
|
|
29
35
|
* <CustomerSelect
|
|
30
36
|
* customers={filteredCustomers}
|
|
@@ -34,39 +40,82 @@ export interface CustomerSelectProps extends Omit<ViewProps, 'children'> {
|
|
|
34
40
|
* />
|
|
35
41
|
* ```
|
|
36
42
|
*/
|
|
37
|
-
export function CustomerSelect({
|
|
43
|
+
export function CustomerSelect<Doc>(props: CustomerSelectProps<Doc>) {
|
|
44
|
+
return props.traits ? <CustomerSelectView {...props} /> : <CustomerSelectFromContext {...props} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function CustomerSelectFromContext<Doc>(props: CustomerSelectProps<Doc>) {
|
|
48
|
+
return <CustomerSelectView {...props} traits={useCustomerTraits()} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function CustomerSelectView<Doc>({
|
|
38
52
|
customers,
|
|
39
53
|
selected,
|
|
40
54
|
onSelect,
|
|
41
55
|
onSearch,
|
|
42
56
|
placeholder = 'Search customers...',
|
|
43
57
|
className,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
traits,
|
|
59
|
+
...props
|
|
60
|
+
}: CustomerSelectProps<Doc>) {
|
|
61
|
+
|
|
62
|
+
// Map selected customer to Select primitive's Option format
|
|
63
|
+
const selectedOption = selected && traits
|
|
64
|
+
? { value: traits.getId(selected), label: traits.getName(selected) }
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
67
|
+
// Build a lookup so we can resolve the original customer doc on selection
|
|
68
|
+
const customerById = new Map<string, Doc>();
|
|
69
|
+
if (traits) {
|
|
70
|
+
for (const customer of customers) {
|
|
71
|
+
customerById.set(traits.getId(customer), customer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleValueChange(option: { value: string; label: string }) {
|
|
76
|
+
const doc = customerById.get(option.value);
|
|
77
|
+
if (doc) onSelect(doc);
|
|
78
|
+
}
|
|
47
79
|
|
|
48
80
|
return (
|
|
49
|
-
<
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)}
|
|
81
|
+
<Select.Root
|
|
82
|
+
value={selectedOption}
|
|
83
|
+
onValueChange={handleValueChange}
|
|
84
|
+
defaultOpen={true}
|
|
85
|
+
>
|
|
86
|
+
<VStack space="sm" className={cn(className)} {...props}>
|
|
87
|
+
{selected && traits && (
|
|
88
|
+
<Select.Trigger asChild>
|
|
89
|
+
<View className="rounded-lg border border-primary bg-primary/5 px-3 py-2">
|
|
90
|
+
<CustomerCard doc={selected} traits={traits} />
|
|
91
|
+
</View>
|
|
92
|
+
</Select.Trigger>
|
|
93
|
+
)}
|
|
55
94
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
{customers.length > 0 && (
|
|
96
|
+
<Select.Content asChild forceMount disablePositioningStyle>
|
|
97
|
+
<ScrollView className="max-h-60">
|
|
98
|
+
{customers.map((customer, index) => {
|
|
99
|
+
const id = traits?.getId(customer) ?? String(index);
|
|
100
|
+
const label = traits?.getName(customer) ?? '';
|
|
101
|
+
const email = traits?.getEmail(customer);
|
|
102
|
+
return (
|
|
103
|
+
<Select.Item
|
|
104
|
+
key={id}
|
|
105
|
+
value={id}
|
|
106
|
+
label={label}
|
|
107
|
+
asChild
|
|
108
|
+
>
|
|
109
|
+
<Pressable accessibilityLabel={email ? `${label}, ${email}` : label} className="border-b border-border px-3 py-2">
|
|
110
|
+
<CustomerCard doc={customer} traits={traits} />
|
|
111
|
+
</Pressable>
|
|
112
|
+
</Select.Item>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</ScrollView>
|
|
116
|
+
</Select.Content>
|
|
117
|
+
)}
|
|
118
|
+
</VStack>
|
|
119
|
+
</Select.Root>
|
|
71
120
|
);
|
|
72
121
|
}
|
package/src/customer/index.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export { CustomerCard, type CustomerCardProps } from './customer-card';
|
|
2
2
|
export { CustomerSelect, type CustomerSelectProps } from './customer-select';
|
|
3
|
+
export { CustomerForm, type CustomerFormProps, type CustomerFormValues } from './customer-form';
|
|
4
|
+
export { CustomerOrderHistory, type CustomerOrderHistoryProps } from './customer-order-history';
|
|
5
|
+
export { CustomerPicker, type CustomerPickerProps } from './customer-picker';
|