@tallyui/components 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/components",
3
- "version": "2.0.0",
3
+ "version": "3.0.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Universal POS UI components for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -27,26 +27,26 @@
27
27
  "directory": "packages/components"
28
28
  },
29
29
  "peerDependencies": {
30
- "@tallyui/core": "2.0.0",
31
- "@tallyui/primitives": "2.0.0",
32
- "@tallyui/theme": "2.0.0",
30
+ "@tallyui/core": "^3.0.0-next.0",
31
+ "@tallyui/primitives": "^3.0.0-next.0",
32
+ "@tallyui/theme": "^3.0.0-next.0",
33
33
  "react": ">=18",
34
34
  "react-native": ">=0.74",
35
35
  "react-native-svg": ">=15"
36
36
  },
37
37
  "dependencies": {
38
38
  "class-variance-authority": "^0.7.1",
39
- "@tallyui/pos": "2.0.0"
39
+ "@tallyui/pos": "3.0.0-next.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@tallyui/connector-medusa": "2.0.0",
43
- "@tallyui/connector-vendure": "2.0.0",
44
- "@tallyui/connector-woocommerce": "2.0.0",
45
- "@tallyui/mock-api": "2.0.0",
42
+ "@tallyui/connector-medusa": "3.0.0-next.0",
43
+ "@tallyui/connector-vendure": "3.0.0-next.0",
44
+ "@tallyui/connector-woocommerce": "3.0.0-next.0",
45
+ "@tallyui/mock-api": "3.0.0-next.0",
46
46
  "@types/react": ">=18",
47
47
  "react": "^19.2.4",
48
48
  "react-native-svg": "15.12.1",
49
- "rxdb": "16.21.1",
49
+ "rxdb": "17.5.0",
50
50
  "rxjs": "7.8.2"
51
51
  },
52
52
  "scripts": {
@@ -1,15 +1,20 @@
1
- import { useCustomerTraits } from '@tallyui/core';
1
+ import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
2
2
  import { cn } from '@tallyui/theme';
3
3
  import { Text, VStack, type VStackProps } from '../ui';
4
4
 
5
- export interface CustomerCardProps extends Omit<VStackProps, 'children'> {
6
- doc: any;
5
+ export interface CustomerCardProps<Doc = any> extends Omit<VStackProps, 'children'> {
6
+ doc: Doc;
7
+ traits?: CustomerTraits<Doc>;
7
8
  className?: string;
8
9
  }
9
10
 
10
- export function CustomerCard({ doc, className, ...props }: CustomerCardProps) {
11
+ function CustomerCardFromContext<Doc>(props: CustomerCardProps<Doc>) {
11
12
  const traits = useCustomerTraits();
12
- 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} />;
13
18
 
14
19
  const name = traits.getName(doc);
15
20
  const email = traits.getEmail(doc);
@@ -15,6 +15,7 @@ export interface CustomerFormProps extends Omit<ViewProps, 'children'> {
15
15
  onChangeField: (field: keyof CustomerFormValues, value: string) => void;
16
16
  onSubmit?: () => void;
17
17
  submitLabel?: string;
18
+ showAddress?: boolean;
18
19
  className?: string;
19
20
  }
20
21
 
@@ -43,6 +44,7 @@ export function CustomerForm({
43
44
  onChangeField,
44
45
  onSubmit,
45
46
  submitLabel = 'Save Customer',
47
+ showAddress = true,
46
48
  className,
47
49
  ...viewProps
48
50
  }: CustomerFormProps) {
@@ -58,7 +60,7 @@ export function CustomerForm({
58
60
  </View>
59
61
  <FormField label="Email" value={values.email} onChangeText={(v) => onChangeField('email', v)} placeholder="email@example.com" keyboardType="email-address" />
60
62
  <FormField label="Phone" value={values.phone} onChangeText={(v) => onChangeField('phone', v)} placeholder="Phone number" keyboardType="phone-pad" />
61
- <FormField label="Address" value={values.address} onChangeText={(v) => onChangeField('address', v)} placeholder="Street address" multiline />
63
+ {showAddress && <FormField label="Address" value={values.address} onChangeText={(v) => onChangeField('address', v)} placeholder="Street address" multiline />}
62
64
 
63
65
  {onSubmit && (
64
66
  <Pressable onPress={onSubmit} className="items-center rounded-lg bg-primary px-4 py-3">
@@ -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,17 +1,18 @@
1
- import { ScrollView, View } from 'react-native';
1
+ import { Pressable, ScrollView, View } from 'react-native';
2
2
  import { Select } from '@tallyui/primitives';
3
- import { useCustomerTraits } from '@tallyui/core';
3
+ import { useCustomerTraits, type CustomerTraits } from '@tallyui/core';
4
4
  import { cn } from '@tallyui/theme';
5
5
  import { VStack, type VStackProps } from '../ui';
6
6
  import { CustomerCard } from './customer-card';
7
7
 
8
- export interface CustomerSelectProps extends Omit<VStackProps, 'children'> {
8
+ export interface CustomerSelectProps<Doc = any> extends Omit<VStackProps, 'children'> {
9
9
  /** Customer documents to display as options */
10
- customers: any[];
10
+ customers: Doc[];
11
11
  /** Currently selected customer (if any) */
12
- selected?: any;
12
+ selected?: Doc | null;
13
13
  /** Called when a customer is selected */
14
- onSelect: (customer: any) => void;
14
+ onSelect: (customer: Doc) => void;
15
+ traits?: CustomerTraits<Doc>;
15
16
  /** Called when the search text changes */
16
17
  onSearch: (query: string) => void;
17
18
  /** Placeholder text for the search input */
@@ -39,16 +40,24 @@ export interface CustomerSelectProps extends Omit<VStackProps, 'children'> {
39
40
  * />
40
41
  * ```
41
42
  */
42
- 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>({
43
52
  customers,
44
53
  selected,
45
54
  onSelect,
46
55
  onSearch,
47
56
  placeholder = 'Search customers...',
48
57
  className,
58
+ traits,
49
59
  ...props
50
- }: CustomerSelectProps) {
51
- const traits = useCustomerTraits();
60
+ }: CustomerSelectProps<Doc>) {
52
61
 
53
62
  // Map selected customer to Select primitive's Option format
54
63
  const selectedOption = selected && traits
@@ -56,7 +65,7 @@ export function CustomerSelect({
56
65
  : undefined;
57
66
 
58
67
  // Build a lookup so we can resolve the original customer doc on selection
59
- const customerById = new Map<string, any>();
68
+ const customerById = new Map<string, Doc>();
60
69
  if (traits) {
61
70
  for (const customer of customers) {
62
71
  customerById.set(traits.getId(customer), customer);
@@ -78,7 +87,7 @@ export function CustomerSelect({
78
87
  {selected && traits && (
79
88
  <Select.Trigger asChild>
80
89
  <View className="rounded-lg border border-primary bg-primary/5 px-3 py-2">
81
- <CustomerCard doc={selected} />
90
+ <CustomerCard doc={selected} traits={traits} />
82
91
  </View>
83
92
  </Select.Trigger>
84
93
  )}
@@ -89,6 +98,7 @@ export function CustomerSelect({
89
98
  {customers.map((customer, index) => {
90
99
  const id = traits?.getId(customer) ?? String(index);
91
100
  const label = traits?.getName(customer) ?? '';
101
+ const email = traits?.getEmail(customer);
92
102
  return (
93
103
  <Select.Item
94
104
  key={id}
@@ -96,9 +106,9 @@ export function CustomerSelect({
96
106
  label={label}
97
107
  asChild
98
108
  >
99
- <View className="border-b border-border px-3 py-2">
100
- <CustomerCard doc={customer} />
101
- </View>
109
+ <Pressable accessibilityLabel={email ? `${label}, ${email}` : label} className="border-b border-border px-3 py-2">
110
+ <CustomerCard doc={customer} traits={traits} />
111
+ </Pressable>
102
112
  </Select.Item>
103
113
  );
104
114
  })}
@@ -2,3 +2,4 @@ export { CustomerCard, type CustomerCardProps } from './customer-card';
2
2
  export { CustomerSelect, type CustomerSelectProps } from './customer-select';
3
3
  export { CustomerForm, type CustomerFormProps, type CustomerFormValues } from './customer-form';
4
4
  export { CustomerOrderHistory, type CustomerOrderHistoryProps } from './customer-order-history';
5
+ export { CustomerPicker, type CustomerPickerProps } from './customer-picker';
package/src/index.ts CHANGED
@@ -63,6 +63,8 @@ export {
63
63
  export {
64
64
  CustomerCard,
65
65
  CustomerSelect,
66
+ CustomerPicker,
67
+ type CustomerPickerProps,
66
68
  type CustomerCardProps,
67
69
  type CustomerSelectProps,
68
70
  CustomerForm,
@@ -1,9 +1,16 @@
1
1
  import type { ReactNode } from 'react';
2
- import { ScrollView, View } from 'react-native';
2
+ import { FlatList, View } from 'react-native';
3
3
  import { cn } from '@tallyui/theme';
4
4
  import { useProductTraits } from '@tallyui/core';
5
5
  import { VStack, type VStackProps } from '../ui';
6
6
 
7
+ // Limit initial mounts for a 2,000-product catalogue to eight rows.
8
+ const INITIAL_ROWS = 8;
9
+ // Keep each render batch small for a 2,000-product catalogue.
10
+ const BATCH_ROWS = 8;
11
+ // Keep nearby rows mounted without rendering the whole 2,000-product catalogue.
12
+ const WINDOW_SIZE = 5;
13
+
7
14
  export interface ProductGridProps extends Omit<VStackProps, 'children'> {
8
15
  /** Array of product documents to render */
9
16
  items: any[];
@@ -27,10 +34,9 @@ export interface ProductGridProps extends Omit<VStackProps, 'children'> {
27
34
  }
28
35
 
29
36
  /**
30
- * A scrollable product grid with optional search and filter slots.
37
+ * A virtualized product grid with optional search and filter slots.
31
38
  *
32
- * Uses ScrollView + flexWrap for cross-platform grid layout rather than
33
- * FlatList numColumns, which keeps things predictable in both native and web.
39
+ * Uses FlatList numColumns to render rows near the viewport on native and web.
34
40
  *
35
41
  * ```tsx
36
42
  * <ProductGrid
@@ -70,13 +76,21 @@ export function ProductGrid({
70
76
  {filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
71
77
 
72
78
  {hasItems ? (
73
- <ScrollView contentContainerClassName="flex-row flex-wrap p-1">
74
- {visibleItems.map((item, index) => (
75
- <View key={item.id ?? index} style={{ width: `${100 / numColumns}%` }} className="p-1">
79
+ <FlatList
80
+ key={numColumns}
81
+ data={visibleItems}
82
+ numColumns={numColumns}
83
+ keyExtractor={(item, index) => String(item.id ?? index)}
84
+ contentContainerClassName="p-1"
85
+ initialNumToRender={INITIAL_ROWS}
86
+ maxToRenderPerBatch={BATCH_ROWS}
87
+ windowSize={WINDOW_SIZE}
88
+ renderItem={({ item, index }) => (
89
+ <View style={{ width: `${100 / numColumns}%` }} className="p-1">
76
90
  {renderItem(item, index)}
77
91
  </View>
78
- ))}
79
- </ScrollView>
92
+ )}
93
+ />
80
94
  ) : (
81
95
  emptyState ?? null
82
96
  )}
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { AccessibilityInfo, Animated, View } from 'react-native';
3
3
  import type { useRegisterSession } from '@tallyui/pos';
4
- import { countVariance, varianceText } from '@tallyui/pos';
4
+ import { countVariance, TAX_ROUNDING_MIXED_NOTE, varianceText } from '@tallyui/pos';
5
5
 
6
6
  import { formatMoney, minorUnitDigits } from '@tallyui/core';
7
7
  import { Button } from '../ui/button';
@@ -136,6 +136,12 @@ export function ClosureSheet({ register, currency, onPrint, onDone }: ClosureShe
136
136
  )}
137
137
  {/* Not a figure: shows who approved the close even while blind. */}
138
138
  {!!approvedBy && <Text testID="closure-approved-by">Approved by {approvedBy}</Text>}
139
+ {/* Not a figure either (#287). The wording is approved by the Front desk, 2026-09-30. */}
140
+ {closure.breakdowns.tax_rounding_mixed === true && (
141
+ <Text testID="closure-tax-rounding-mixed" className="text-muted-foreground">
142
+ {TAX_ROUNDING_MIXED_NOTE}
143
+ </Text>
144
+ )}
139
145
  {!!error && (
140
146
  <Text testID="closure-print-error" className="text-destructive">
141
147
  {error}
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useMemo, useState, type ReactNode } from 'react';
2
2
  import { Pressable, Text, View } from 'react-native';
3
- import type { ProductTraits } from '@tallyui/core';
3
+ import { useStockOverlaid, useStockOverlayAsOf, type ProductTraits } from '@tallyui/core';
4
4
  import { catalogueEntries, findEntryByCode, searchProducts, variantPriceLabel, type CatalogueEntry } from '@tallyui/pos';
5
5
  import { ProductGrid, ProductImage, ProductPrice, ProductStockBadge, ProductTitle } from '../product';
6
6
  import { SearchInput } from '../input';
@@ -36,12 +36,14 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
36
36
  products: Doc[];
37
37
  traits: ProductTraits<Doc>;
38
38
  currency: string;
39
+ /** Receives the chosen product and variant with the nearest ConnectorProvider's stock overlay applied (a copy). */
39
40
  onSelect: (entry: CatalogueEntry<Doc>) => void;
40
41
  statusText?: string;
41
42
  /** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
42
43
  statusAccessory?: ReactNode;
43
44
  lastSyncedAt: Date | null;
44
- /** The last completed stock reconcile pass, persisted across restarts; the later of this and lastSyncedAt wins. */
45
+ /** The last completed stock reconcile pass, persisted across restarts. The "stock as of" time shown is the
46
+ * latest of this, the provider's stockOverlayAsOf (when valid) and lastSyncedAt; null and omitted alike. */
45
47
  lastStockCheckAt?: Date | null;
46
48
  /** Whether to show a 12- or 24-hour clock in the stock-as-of time; undefined keeps the locale default. A platform
47
49
  * reads this off its own device APIs (e.g. expo-localization's getCalendars()) and passes it in. */
@@ -51,7 +53,13 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
51
53
  * in its unfocused wedge listener — not this prop's job. */
52
54
  minCodeLength?: number;
53
55
  }) {
54
- const stockAsOf = laterOf(lastStockCheckAt, lastSyncedAt);
56
+ // Idempotent (the adapter's overlay returns just the fields the stock map sets), so an app that already
57
+ // merges the overlay itself keeps working, and can drop its own merge.
58
+ const shown = useStockOverlaid(products) as Doc[];
59
+ const overlayAsOf = useStockOverlayAsOf();
60
+ const parsedOverlayAsOf = overlayAsOf ? new Date(overlayAsOf) : undefined;
61
+ const validOverlayAsOf = parsedOverlayAsOf && !isNaN(parsedOverlayAsOf.getTime()) ? parsedOverlayAsOf : undefined;
62
+ const stockAsOf = laterOf(laterOf(lastStockCheckAt, validOverlayAsOf), lastSyncedAt);
55
63
  const [query, setQuery] = useState('');
56
64
  // The chooser is live (medusapos ADR 0007): it holds only the chosen product's id and derives its choices
57
65
  // from the current entries below, so an open chooser shows each reconcile pass as it lands.
@@ -59,8 +67,8 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
59
67
  const [width, setWidth] = useState(0);
60
68
  // ProductGrid has 4 px padding on each side of the content and each cell.
61
69
  const columns = Math.max(2, Math.min(6, Math.floor((width - 8) / (MIN_TILE_WIDTH + 8))));
62
- const entries = useMemo(() => catalogueEntries(products, traits), [products, traits]);
63
- const results = useMemo(() => searchProducts(products, query, traits), [products, query, traits]);
70
+ const entries = useMemo(() => catalogueEntries(shown, traits), [shown, traits]);
71
+ const results = useMemo(() => searchProducts(shown, query, traits), [shown, query, traits]);
64
72
  const choices = useMemo(() => (chooserId === null ? []
65
73
  : entries.filter((entry) => traits.getId(entry.product) === chooserId)), [entries, chooserId, traits]);
66
74
  // A product that leaves the catalogue (a resync, or no longer sellable) closes its chooser.
@@ -1,9 +1,29 @@
1
1
  import { useEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { Pressable, ScrollView, Text, View } from 'react-native';
3
- import { formatMoney } from '@tallyui/core';
4
- import { needsAttention, type PosOrder } from '@tallyui/pos';
3
+ import { formatMoney, knownWarnings } from '@tallyui/core';
4
+ import { needsAttention, type OutboxState, type PosOrder } from '@tallyui/pos';
5
5
 
6
6
  const STATUS_LABEL = { pending: 'Waiting to sync', applied: 'Synced', rejected: 'Not accepted' };
7
+ /** `figures_mismatch`'s labels; a field a newer store sends that isn't here is shown by its raw name. */
8
+ const FIGURE_LABELS = new Map([['subtotalMinor', 'Subtotal'], ['taxMinor', 'Tax'], ['discountMinor', 'Discount']]);
9
+ /** A rejected sale's line in the cashier's words, by the store's error code (#269). The store's own message goes to the
10
+ * sync log (the outbox logs it), never here. */
11
+ const REFUSAL_SENTENCES = new Map([
12
+ ['invalid_payload', "The online store refused this sale: this till sent it in a form the store can't read. Ask the store owner to look at the till's sync log."],
13
+ ['unsupported_version', "The online store refused this sale: the store's software is older than this till's. Ask the store owner to update the POS plugin."],
14
+ ['idempotency_mismatch', "The online store has a different sale under this sale's number. Don't send it again; ask the store owner to compare the two."],
15
+ ['store_configuration', 'The online store refused this sale: a setting on the store needs changing. Once the store owner fixes it, press Retry.'],
16
+ ['platform_error', "The online store refused this sale. Ask the store owner to look at the till's sync log."],
17
+ ['internal_error', "The online store hit a fault in its POS plugin and refused this sale. Ask the store owner to look at the till's sync log."],
18
+ ['insufficient_stock', "The online store refused this sale: it doesn't have enough stock of one of the items."],
19
+ ['unsupported_tax_mode', "The online store refused this sale: its tax settings can't take a sale like this one. Ask the store owner to check them."],
20
+ ['unknown_variant', 'The online store refused this sale: that product variation no longer exists.'],
21
+ ['invalid_quantity', "The online store refused this sale: a quantity or a discount on it isn't allowed."],
22
+ ['underpaid', 'The online store refused this sale: the payments add up to less than the total.'],
23
+ ['unsupported_currency', "The online store refused this sale: the store doesn't take this currency."],
24
+ ]);
25
+ // Any other code, or none: platform_error's sentence, as the generic fallback (approved by the Front desk, 2026-09-30).
26
+ const refusalSentence = (code: string | undefined) => REFUSAL_SENTENCES.get(code ?? '') ?? REFUSAL_SENTENCES.get('platform_error')!;
7
27
 
8
28
  /** Keeps an unparseable value as is, like medusapos's date util, since `Intl` throws on an invalid date. */
9
29
  function defaultFormatDate(iso: string) {
@@ -13,9 +33,11 @@ function defaultFormatDate(iso: string) {
13
33
 
14
34
  /** The "Needs attention" (when not empty) and "Recent" orders, lifted from medusapos/app's orders screen (ADR-052).
15
35
  * `onRetry` is the outbox's `requeue`; `formatDate` replaces the app's date util; `footer` is the app's own content
16
- * (medusapos: the feedback link). The router and session redirect stay in the app. */
17
- export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, footer }: {
36
+ * (medusapos: the feedback link). The router and session redirect stay in the app. `stuck` is the outbox's
37
+ * `state.stuck` (`useOrderOutbox`): those pending orders need attention too, each with its own entry's reason and since. */
38
+ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, footer, stuck }: {
18
39
  orders: PosOrder[]; onRetry(orderIds: string[]): Promise<number>; formatDate?: (iso: string) => string; footer?: ReactNode;
40
+ stuck?: OutboxState['stuck'];
19
41
  }) {
20
42
  const retrying = useRef(new Set<string>());
21
43
  const [retryingIds, setRetryingIds] = useState(new Set<string>());
@@ -26,21 +48,33 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
26
48
  setRetryingIds(new Set(retrying.current));
27
49
  }, [orders]);
28
50
  return <ScrollView className="flex-1 bg-background p-4">
29
- {[{ title: 'Needs attention', orders: needsAttention(orders) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
51
+ {[{ title: 'Needs attention', orders: needsAttention(orders, { stuckCommandIds: stuck?.commandIds }) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
30
52
  <View key={section.title} className="mb-6 gap-3">
31
53
  <Text accessibilityRole="header" className="text-xl font-semibold text-foreground">{section.title}</Text>
32
54
  {section.orders.map((order) => {
33
55
  const count = order.lines.reduce((sum, line) => sum + line.quantity, 0);
56
+ // The order's own stuck entry: its clock and latest reason, not the earliest across all stuck orders.
57
+ const stuckEntry = order.syncStatus === 'pending' ? stuck?.orders.find((entry) => entry.commandId === order.commandId) : undefined;
34
58
  return (
35
59
  <View key={order.id} className="gap-1 rounded-md border border-border bg-card p-3">
36
60
  <Text className="text-foreground">{order.serverRefs?.displayId ? `Order #${order.serverRefs.displayId} · ` : ''}{count} {count === 1 ? 'item' : 'items'}</Text>
37
61
  <Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
38
- {order.syncStatus === 'rejected' && order.error ? <Text className="text-destructive">{order.error.code}: {order.error.message}</Text> : null}
62
+ {stuckEntry ? <Text className="text-destructive">
63
+ {/* The same words for any reason, with no reason code; the hour as the status line has it (numeric, #245). */}
64
+ {`Hasn't reached the online store since ${new Date(stuckEntry.since).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}.`}
65
+ </Text> : null}
66
+ {/* The code's sentence alone: the cashier sees no error code and never the store's message. */}
67
+ {order.syncStatus === 'rejected' ? <Text className="text-destructive">{refusalSentence(order.error?.code)}</Text> : null}
39
68
  {/* A late sale (ADR-032) needs no Retry of its own; a rejected one still gets its Retry below. */}
40
69
  {order.lateSessionId !== undefined ? <Text className="text-foreground">Taken after the register closed. It is not in that register's closure.</Text> : null}
41
- {section.title === 'Needs attention' && order.syncStatus === 'rejected' ? order.error?.code === 'idempotency_mismatch'
42
- ? <Text className="text-foreground">This sale needs checking against the store before it can be sent again.</Text>
43
- : <Pressable accessibilityRole="button" disabled={retryingIds.has(order.id)} onPress={async () => {
70
+ {order.localWarnings?.map((warning, index) => <Text key={index} className="text-foreground">
71
+ {warning.code === 'customer_omitted'
72
+ ? `The customer's ${warning.field} couldn't be sent to the store, so the order isn't linked to them.`
73
+ : "The terminal's payment reference couldn't be kept; the payment is recorded without it."}
74
+ </Text>)}
75
+ {/* idempotency_mismatch isn't requeueable (the outbox's NOT_REQUEUEABLE): its sentence says not to send it again, and no Retry. */}
76
+ {section.title === 'Needs attention' && order.syncStatus === 'rejected' && order.error?.code !== 'idempotency_mismatch'
77
+ ? <Pressable accessibilityRole="button" disabled={retryingIds.has(order.id)} onPress={async () => {
44
78
  if (retrying.current.has(order.id)) return;
45
79
  retrying.current.add(order.id);
46
80
  setRetryingIds(new Set(retrying.current));
@@ -51,9 +85,17 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
51
85
  }} className="rounded-md bg-primary px-4 py-2">
52
86
  <Text className="text-center font-semibold text-primary-foreground">Retry</Text>
53
87
  </Pressable> : null}
54
- {order.warnings?.map((warning, index) => <View key={index} className="border-l-4 border-warning pl-2"><Text className="text-foreground">
88
+ {knownWarnings(order.warnings).map((warning, index) => <View key={index} className="border-l-4 border-warning pl-2"><Text className="text-foreground">
55
89
  {warning.code === 'insufficient_stock'
56
90
  ? `Stock short by ${warning.quantity} for ${order.lines.find((line) => line.variantId === warning.variantId)?.name}`
91
+ : warning.code === 'tax_rate_mismatch'
92
+ ? `Tax at ${(warning.ratePpm / 10000).toLocaleString(undefined, { maximumFractionDigits: 4 })}%: store ${formatMoney({ amount: warning.serverMinor, currency: order.currency })} vs POS ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`
93
+ : warning.code === 'customer_ignored'
94
+ ? `The online store didn't recognise the customer on this sale, so it was saved as a guest sale. Customer id: ${warning.customerId}.`
95
+ : warning.code === 'figures_mismatch'
96
+ ? `The online store worked out different figures for this sale. ${warning.fields.map(({ field, tillMinor, serverMinor }) => `${FIGURE_LABELS.get(field) ?? field}: till ${formatMoney({ amount: tillMinor, currency: order.currency })}, store ${formatMoney({ amount: serverMinor, currency: order.currency })}.`).join(' ')}`
97
+ : warning.bridgeMinor !== undefined
98
+ ? `Store calculated ${formatMoney({ amount: warning.serverMinor, currency: order.currency })}; a rounding line of ${warning.bridgeMinor > 0 ? '+' : ''}${formatMoney({ amount: warning.bridgeMinor, currency: order.currency })} brought it to ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`
57
99
  : `Store total ${formatMoney({ amount: warning.serverMinor, currency: order.currency })} vs POS ${formatMoney({ amount: warning.expectedMinor, currency: order.currency })}`}
58
100
  </Text></View>)}
59
101
  </View>
@@ -1,7 +1,7 @@
1
1
  import { useEffect } from 'react';
2
2
  import { Pressable, Text, View } from 'react-native';
3
3
  import { formatMoney } from '@tallyui/core';
4
- import { buildReceiptData, type Order } from '@tallyui/pos';
4
+ import { buildReceiptData, type SentOrder } from '@tallyui/pos';
5
5
  import { injectPrintStyle } from './print-style';
6
6
  import { discountLabel } from './discount-form';
7
7
 
@@ -11,7 +11,7 @@ const defaultFormatDate = (iso: string) => new Intl.DateTimeFormat(undefined, {
11
11
  * TV6a's `Cart`), outbox strip and date util, so `Receipt` stays platform-neutral (ADR-052). */
12
12
  export function Receipt({ order, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
13
13
  topInset = 0, formatDate = defaultFormatDate }: {
14
- order: Order; store: { name: string; address?: string }; cashier: string; registerId: string; newSale: () => void;
14
+ order: SentOrder; store: { name: string; address?: string }; cashier: string; registerId: string; newSale: () => void;
15
15
  taxLabel?: (ratePpm: number) => string; topInset?: number; formatDate?: (iso: string) => string;
16
16
  }) {
17
17
  useEffect(injectPrintStyle, []);
@@ -34,6 +34,7 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
34
34
  <Text className="text-foreground">Order {receipt.header.orderNumber.slice(-8)}</Text>
35
35
  <Text className="text-muted-foreground">{formatDate(receipt.header.date)}</Text>
36
36
  <Text className="text-muted-foreground">Cashier: {receipt.header.cashier}</Text>
37
+ {receipt.header.customer && <Text className="text-muted-foreground">Customer: {receipt.header.customer}</Text>}
37
38
  {receipt.lineItems.map((line, index) => <View key={index}>
38
39
  <Text className="text-foreground">{line.name}</Text>
39
40
  {/* Before any discount, then the line's own discounts as sub-rows (TallyUI ADR-063); they add up to the subtotal. */}