@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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1113 -83
  3. package/dist/index.js +4936 -353
  4. package/package.json +27 -5
  5. package/src/cart/cart-line-actions.tsx +40 -0
  6. package/src/cart/cart-line.tsx +18 -32
  7. package/src/cart/cart-note-input.tsx +46 -0
  8. package/src/cart/cart-panel.tsx +39 -26
  9. package/src/cart/cart-total.tsx +43 -47
  10. package/src/cart/discount-badge.tsx +19 -0
  11. package/src/cart/index.ts +4 -1
  12. package/src/checkout/cash-tendered.tsx +101 -0
  13. package/src/checkout/change-display.tsx +19 -0
  14. package/src/checkout/index.ts +3 -0
  15. package/src/checkout/order-summary-line.tsx +7 -8
  16. package/src/checkout/order-summary.tsx +8 -7
  17. package/src/checkout/payment-method-card.tsx +10 -9
  18. package/src/checkout/payment-selector.tsx +5 -5
  19. package/src/checkout/receipt-preview.tsx +78 -0
  20. package/src/customer/customer-card.tsx +16 -12
  21. package/src/customer/customer-form.tsx +72 -0
  22. package/src/customer/customer-order-history.tsx +34 -0
  23. package/src/customer/customer-picker.tsx +124 -0
  24. package/src/customer/customer-select.tsx +80 -31
  25. package/src/customer/index.ts +3 -0
  26. package/src/index.ts +114 -1
  27. package/src/input/filter-chip.tsx +85 -27
  28. package/src/input/index.ts +1 -0
  29. package/src/input/quantity-stepper.tsx +64 -0
  30. package/src/input/search-input.tsx +30 -7
  31. package/src/layout/checkout-layout.tsx +46 -0
  32. package/src/layout/index.ts +5 -0
  33. package/src/layout/live-tab-screen.tsx +54 -0
  34. package/src/layout/pos-layout.tsx +64 -0
  35. package/src/layout/settings-layout.tsx +58 -0
  36. package/src/layout/store-settings-choice-screen.tsx +142 -0
  37. package/src/order/index.ts +4 -0
  38. package/src/order/order-card.tsx +48 -0
  39. package/src/order/order-detail.tsx +82 -0
  40. package/src/order/order-list.tsx +39 -0
  41. package/src/order/order-status-badge.tsx +32 -0
  42. package/src/product/category-nav.tsx +70 -0
  43. package/src/product/index.ts +3 -0
  44. package/src/product/product-card.tsx +36 -8
  45. package/src/product/product-grid.tsx +46 -13
  46. package/src/product/product-image.tsx +28 -7
  47. package/src/product/product-list.tsx +44 -0
  48. package/src/product/product-price.tsx +77 -22
  49. package/src/product/product-sku.tsx +5 -5
  50. package/src/product/product-stock-badge.tsx +41 -28
  51. package/src/product/product-title.tsx +2 -3
  52. package/src/product/product-variant-picker.tsx +61 -0
  53. package/src/register/cash-count-input.tsx +85 -0
  54. package/src/register/closure-sheet.tsx +161 -0
  55. package/src/register/index.ts +12 -0
  56. package/src/register/movement-sheet.tsx +118 -0
  57. package/src/register/open-register-card.tsx +109 -0
  58. package/src/register/register-bar.helpers.ts +57 -0
  59. package/src/register/register-bar.tsx +85 -0
  60. package/src/register/register-column.tsx +117 -0
  61. package/src/register/register-count.tsx +330 -0
  62. package/src/register/register-open-close.tsx +52 -0
  63. package/src/register/register-panel.tsx +151 -0
  64. package/src/register/register-picker.tsx +60 -0
  65. package/src/register/register-summary.tsx +72 -0
  66. package/src/sale/cart-bar.tsx +18 -0
  67. package/src/sale/cart.tsx +66 -0
  68. package/src/sale/catalogue.tsx +139 -0
  69. package/src/sale/discount-form.tsx +85 -0
  70. package/src/sale/index.ts +9 -0
  71. package/src/sale/orders-list.tsx +108 -0
  72. package/src/sale/print-style.ts +11 -0
  73. package/src/sale/receipt.tsx +62 -0
  74. package/src/sale/sync-status.tsx +141 -0
  75. package/src/sale/tender.tsx +38 -0
  76. package/src/settings/connector-status.tsx +65 -0
  77. package/src/settings/index.ts +3 -0
  78. package/src/settings/settings-group.tsx +23 -0
  79. package/src/settings/settings-row.tsx +23 -0
  80. package/src/ui/accordion/index.tsx +59 -0
  81. package/src/ui/alert-dialog/index.tsx +91 -0
  82. package/src/ui/avatar/index.tsx +50 -0
  83. package/src/ui/badge/index.tsx +60 -0
  84. package/src/ui/button/index.tsx +90 -0
  85. package/src/ui/card/index.tsx +60 -0
  86. package/src/ui/checkbox/index.tsx +29 -0
  87. package/src/ui/collapsible/index.tsx +24 -0
  88. package/src/ui/combobox/index.tsx +98 -0
  89. package/src/ui/context-menu/index.tsx +159 -0
  90. package/src/ui/dialog/index.tsx +92 -0
  91. package/src/ui/dropdown-menu/index.tsx +161 -0
  92. package/src/ui/hover-card/index.tsx +31 -0
  93. package/src/ui/hstack/index.tsx +43 -0
  94. package/src/ui/icon/index.tsx +44 -0
  95. package/src/ui/icon/search-icon.tsx +9 -0
  96. package/src/ui/icon/svg-icon.tsx +36 -0
  97. package/src/ui/icon-button/index.tsx +58 -0
  98. package/src/ui/index.ts +99 -0
  99. package/src/ui/input/index.tsx +100 -0
  100. package/src/ui/label/index.tsx +26 -0
  101. package/src/ui/loader/index.tsx +37 -0
  102. package/src/ui/popover/index.tsx +31 -0
  103. package/src/ui/progress/index.tsx +28 -0
  104. package/src/ui/radio-group/index.tsx +38 -0
  105. package/src/ui/select/index.tsx +96 -0
  106. package/src/ui/separator/index.tsx +29 -0
  107. package/src/ui/slider/index.tsx +28 -0
  108. package/src/ui/switch/index.tsx +31 -0
  109. package/src/ui/table/index.tsx +59 -0
  110. package/src/ui/tabs/index.tsx +53 -0
  111. package/src/ui/text/index.tsx +36 -0
  112. package/src/ui/textarea/index.tsx +29 -0
  113. package/src/ui/toast/index.tsx +52 -0
  114. package/src/ui/toggle/index.tsx +69 -0
  115. package/src/ui/toggle-group/index.tsx +66 -0
  116. package/src/ui/tooltip/index.tsx +30 -0
  117. package/src/ui/vstack/index.tsx +43 -0
  118. package/src/uniwind-env.d.ts +9 -1
  119. package/src/__tests__/cart-line.test.tsx +0 -53
  120. package/src/__tests__/cart-panel.test.tsx +0 -55
  121. package/src/__tests__/cart-total.test.tsx +0 -60
  122. package/src/__tests__/customer-card.test.tsx +0 -47
  123. package/src/__tests__/customer-select.test.tsx +0 -33
  124. package/src/__tests__/filter-chip.test.tsx +0 -40
  125. package/src/__tests__/helpers.ts +0 -111
  126. package/src/__tests__/order-summary-line.test.tsx +0 -18
  127. package/src/__tests__/order-summary.test.tsx +0 -32
  128. package/src/__tests__/payment-method-card.test.tsx +0 -24
  129. package/src/__tests__/payment-selector.test.tsx +0 -23
  130. package/src/__tests__/product-card.test.tsx +0 -31
  131. package/src/__tests__/product-grid.test.tsx +0 -37
  132. package/src/__tests__/product-image.test.tsx +0 -54
  133. package/src/__tests__/product-price.test.tsx +0 -68
  134. package/src/__tests__/product-sku.test.tsx +0 -47
  135. package/src/__tests__/product-stock-badge.test.tsx +0 -58
  136. package/src/__tests__/product-title.test.tsx +0 -38
  137. package/src/__tests__/search-input.test.tsx +0 -22
@@ -1,9 +1,10 @@
1
- import { Pressable, Text, View, type ViewProps } from 'react-native';
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<ViewProps, 'children'> {
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, ...viewProps }: PaymentMethodCardProps) {
15
+ export function PaymentMethodCard({ label, selected, onPress, icon, className, ...props }: PaymentMethodCardProps) {
15
16
  return (
16
- <Pressable onPress={onPress} {...viewProps}>
17
- <View
17
+ <Pressable onPress={onPress} {...props}>
18
+ <HStack
18
19
  className={cn(
19
- 'flex-row items-center gap-3 rounded-lg border px-4 py-3',
20
- selected ? 'border-primary bg-primary/5' : 'border-border bg-surface',
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' : 'text-foreground')}>
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
- </View>
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<ViewProps, 'children'> {
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
- ...viewProps
40
+ ...props
41
41
  }: PaymentSelectorProps) {
42
42
  return (
43
- <View className={cn('gap-2', className)} {...viewProps}>
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
- </View>
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 { Text, View, type ViewProps } from 'react-native';
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<ViewProps, 'children'> {
7
- doc: any;
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
- export function CustomerCard({ doc, className, ...viewProps }: CustomerCardProps) {
11
+ function CustomerCardFromContext<Doc>(props: CustomerCardProps<Doc>) {
12
12
  const traits = useCustomerTraits();
13
- if (!traits) return null;
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
- <View className={cn('gap-0.5', className)} {...viewProps}>
21
- <Text className="text-sm font-semibold text-foreground">{name}</Text>
22
- {email && <Text className="text-xs text-muted">{email}</Text>}
23
- {address && <Text className="text-xs text-muted">{address}</Text>}
24
- </View>
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, Text, View, type ViewProps } from 'react-native';
2
- import { useCustomerTraits } from '@tallyui/core';
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<ViewProps, 'children'> {
8
+ export interface CustomerSelectProps<Doc = any> extends Omit<VStackProps, 'children'> {
7
9
  /** Customer documents to display as options */
8
- customers: any[];
10
+ customers: Doc[];
9
11
  /** Currently selected customer (if any) */
10
- selected?: any;
12
+ selected?: Doc | null;
11
13
  /** Called when a customer is selected */
12
- onSelect: (customer: any) => void;
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
- ...viewProps
45
- }: CustomerSelectProps) {
46
- const traits = useCustomerTraits();
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
- <View className={cn('gap-2', className)} {...viewProps}>
50
- {selected && traits && (
51
- <View className="rounded-lg border border-primary bg-primary/5 px-3 py-2">
52
- <CustomerCard doc={selected} />
53
- </View>
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
- {customers.length > 0 && (
57
- <ScrollView className="max-h-60">
58
- {customers.map((customer, index) => {
59
- const id = traits?.getId(customer) ?? index;
60
- return (
61
- <Pressable key={id} onPress={() => onSelect(customer)}>
62
- <View className="border-b border-border px-3 py-2">
63
- <CustomerCard doc={customer} />
64
- </View>
65
- </Pressable>
66
- );
67
- })}
68
- </ScrollView>
69
- )}
70
- </View>
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
  }
@@ -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';