@tallyui/components 1.0.0 → 2.0.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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1076 -76
  3. package/dist/index.js +4662 -355
  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 +8 -9
  21. package/src/customer/customer-form.tsx +70 -0
  22. package/src/customer/customer-order-history.tsx +34 -0
  23. package/src/customer/customer-select.tsx +63 -24
  24. package/src/customer/index.ts +2 -0
  25. package/src/index.ts +112 -1
  26. package/src/input/filter-chip.tsx +85 -27
  27. package/src/input/index.ts +1 -0
  28. package/src/input/quantity-stepper.tsx +64 -0
  29. package/src/input/search-input.tsx +30 -7
  30. package/src/layout/checkout-layout.tsx +46 -0
  31. package/src/layout/index.ts +5 -0
  32. package/src/layout/live-tab-screen.tsx +54 -0
  33. package/src/layout/pos-layout.tsx +64 -0
  34. package/src/layout/settings-layout.tsx +58 -0
  35. package/src/layout/store-settings-choice-screen.tsx +142 -0
  36. package/src/order/index.ts +4 -0
  37. package/src/order/order-card.tsx +48 -0
  38. package/src/order/order-detail.tsx +82 -0
  39. package/src/order/order-list.tsx +39 -0
  40. package/src/order/order-status-badge.tsx +32 -0
  41. package/src/product/category-nav.tsx +70 -0
  42. package/src/product/index.ts +3 -0
  43. package/src/product/product-card.tsx +36 -8
  44. package/src/product/product-grid.tsx +25 -6
  45. package/src/product/product-image.tsx +28 -7
  46. package/src/product/product-list.tsx +44 -0
  47. package/src/product/product-price.tsx +77 -22
  48. package/src/product/product-sku.tsx +5 -5
  49. package/src/product/product-stock-badge.tsx +41 -28
  50. package/src/product/product-title.tsx +2 -3
  51. package/src/product/product-variant-picker.tsx +61 -0
  52. package/src/register/cash-count-input.tsx +85 -0
  53. package/src/register/closure-sheet.tsx +155 -0
  54. package/src/register/index.ts +12 -0
  55. package/src/register/movement-sheet.tsx +118 -0
  56. package/src/register/open-register-card.tsx +109 -0
  57. package/src/register/register-bar.helpers.ts +57 -0
  58. package/src/register/register-bar.tsx +85 -0
  59. package/src/register/register-column.tsx +117 -0
  60. package/src/register/register-count.tsx +330 -0
  61. package/src/register/register-open-close.tsx +52 -0
  62. package/src/register/register-panel.tsx +151 -0
  63. package/src/register/register-picker.tsx +60 -0
  64. package/src/register/register-summary.tsx +72 -0
  65. package/src/sale/cart-bar.tsx +18 -0
  66. package/src/sale/cart.tsx +66 -0
  67. package/src/sale/catalogue.tsx +131 -0
  68. package/src/sale/discount-form.tsx +85 -0
  69. package/src/sale/index.ts +9 -0
  70. package/src/sale/orders-list.tsx +66 -0
  71. package/src/sale/print-style.ts +11 -0
  72. package/src/sale/receipt.tsx +61 -0
  73. package/src/sale/sync-status.tsx +20 -0
  74. package/src/sale/tender.tsx +35 -0
  75. package/src/settings/connector-status.tsx +65 -0
  76. package/src/settings/index.ts +3 -0
  77. package/src/settings/settings-group.tsx +23 -0
  78. package/src/settings/settings-row.tsx +23 -0
  79. package/src/ui/accordion/index.tsx +59 -0
  80. package/src/ui/alert-dialog/index.tsx +91 -0
  81. package/src/ui/avatar/index.tsx +50 -0
  82. package/src/ui/badge/index.tsx +60 -0
  83. package/src/ui/button/index.tsx +90 -0
  84. package/src/ui/card/index.tsx +60 -0
  85. package/src/ui/checkbox/index.tsx +29 -0
  86. package/src/ui/collapsible/index.tsx +24 -0
  87. package/src/ui/combobox/index.tsx +98 -0
  88. package/src/ui/context-menu/index.tsx +159 -0
  89. package/src/ui/dialog/index.tsx +92 -0
  90. package/src/ui/dropdown-menu/index.tsx +161 -0
  91. package/src/ui/hover-card/index.tsx +31 -0
  92. package/src/ui/hstack/index.tsx +43 -0
  93. package/src/ui/icon/index.tsx +44 -0
  94. package/src/ui/icon/search-icon.tsx +9 -0
  95. package/src/ui/icon/svg-icon.tsx +36 -0
  96. package/src/ui/icon-button/index.tsx +58 -0
  97. package/src/ui/index.ts +99 -0
  98. package/src/ui/input/index.tsx +100 -0
  99. package/src/ui/label/index.tsx +26 -0
  100. package/src/ui/loader/index.tsx +37 -0
  101. package/src/ui/popover/index.tsx +31 -0
  102. package/src/ui/progress/index.tsx +28 -0
  103. package/src/ui/radio-group/index.tsx +38 -0
  104. package/src/ui/select/index.tsx +96 -0
  105. package/src/ui/separator/index.tsx +29 -0
  106. package/src/ui/slider/index.tsx +28 -0
  107. package/src/ui/switch/index.tsx +31 -0
  108. package/src/ui/table/index.tsx +59 -0
  109. package/src/ui/tabs/index.tsx +53 -0
  110. package/src/ui/text/index.tsx +36 -0
  111. package/src/ui/textarea/index.tsx +29 -0
  112. package/src/ui/toast/index.tsx +52 -0
  113. package/src/ui/toggle/index.tsx +69 -0
  114. package/src/ui/toggle-group/index.tsx +66 -0
  115. package/src/ui/tooltip/index.tsx +30 -0
  116. package/src/ui/vstack/index.tsx +43 -0
  117. package/src/uniwind-env.d.ts +9 -1
  118. package/src/__tests__/cart-line.test.tsx +0 -53
  119. package/src/__tests__/cart-panel.test.tsx +0 -55
  120. package/src/__tests__/cart-total.test.tsx +0 -60
  121. package/src/__tests__/customer-card.test.tsx +0 -47
  122. package/src/__tests__/customer-select.test.tsx +0 -33
  123. package/src/__tests__/filter-chip.test.tsx +0 -40
  124. package/src/__tests__/helpers.ts +0 -111
  125. package/src/__tests__/order-summary-line.test.tsx +0 -18
  126. package/src/__tests__/order-summary.test.tsx +0 -32
  127. package/src/__tests__/payment-method-card.test.tsx +0 -24
  128. package/src/__tests__/payment-selector.test.tsx +0 -23
  129. package/src/__tests__/product-card.test.tsx +0 -31
  130. package/src/__tests__/product-grid.test.tsx +0 -37
  131. package/src/__tests__/product-image.test.tsx +0 -54
  132. package/src/__tests__/product-price.test.tsx +0 -68
  133. package/src/__tests__/product-sku.test.tsx +0 -47
  134. package/src/__tests__/product-stock-badge.test.tsx +0 -58
  135. package/src/__tests__/product-title.test.tsx +0 -38
  136. package/src/__tests__/search-input.test.tsx +0 -22
@@ -1,6 +1,8 @@
1
- import { Pressable, ScrollView, Text, type ViewProps } from 'react-native';
2
-
1
+ import { useMemo } from 'react';
2
+ import { Pressable, ScrollView, type ViewProps } from 'react-native';
3
+ import { ToggleGroup } from '@tallyui/primitives';
3
4
  import { cn } from '@tallyui/theme';
5
+ import { Text } from '../ui';
4
6
 
5
7
  export interface FilterChipProps {
6
8
  label: string;
@@ -9,20 +11,33 @@ export interface FilterChipProps {
9
11
  className?: string;
10
12
  }
11
13
 
14
+ /**
15
+ * A single toggle chip. When used standalone, renders as a simple
16
+ * ToggleGroup with one item.
17
+ */
12
18
  export function FilterChip({ label, active, onPress, className }: FilterChipProps) {
19
+ const value = useMemo(() => (active ? [label] : []), [active, label]);
20
+
13
21
  return (
14
- <Pressable
15
- onPress={onPress}
16
- className={cn(
17
- 'rounded-full px-3 py-1.5',
18
- active ? 'bg-primary' : 'bg-surface-alt',
19
- className,
20
- )}
22
+ <ToggleGroup.Root
23
+ type="multiple"
24
+ value={value}
25
+ onValueChange={() => onPress()}
21
26
  >
22
- <Text className={cn('text-xs font-semibold', active ? 'text-primary-foreground' : 'text-muted')}>
23
- {label}
24
- </Text>
25
- </Pressable>
27
+ <ToggleGroup.Item value={label} asChild>
28
+ <Pressable
29
+ className={cn(
30
+ 'rounded-full px-3 py-1.5',
31
+ active ? 'bg-primary' : 'bg-accent',
32
+ className,
33
+ )}
34
+ >
35
+ <Text className={cn('text-xs font-semibold', active ? 'text-primary-foreground' : 'text-muted-foreground')}>
36
+ {label}
37
+ </Text>
38
+ </Pressable>
39
+ </ToggleGroup.Item>
40
+ </ToggleGroup.Root>
26
41
  );
27
42
  }
28
43
 
@@ -37,22 +52,65 @@ export interface FilterChipGroupProps extends Omit<ViewProps, 'children'> {
37
52
  className?: string;
38
53
  }
39
54
 
55
+ /**
56
+ * A horizontal scrollable row of toggleable filter chips.
57
+ *
58
+ * Built on top of the ToggleGroup primitive for proper `aria-pressed`
59
+ * semantics and accessible toggle group behavior.
60
+ */
40
61
  export function FilterChipGroup({ chips, onChipPress, className, ...viewProps }: FilterChipGroupProps) {
62
+ // Derive the controlled value from which chips are active
63
+ const activeValues = useMemo(
64
+ () => chips.filter((c) => c.active).map((c) => c.label),
65
+ [chips],
66
+ );
67
+
68
+ // Build a label-to-index lookup so we can translate back to onChipPress(index)
69
+ const labelToIndex = useMemo(() => {
70
+ const map = new Map<string, number>();
71
+ chips.forEach((chip, i) => map.set(chip.label, i));
72
+ return map;
73
+ }, [chips]);
74
+
75
+ function handleValueChange(newValues: string[]) {
76
+ // Find which chip was toggled by diffing against current active set
77
+ const added = newValues.find((v) => !activeValues.includes(v));
78
+ const removed = activeValues.find((v) => !newValues.includes(v));
79
+ const toggledLabel = added ?? removed;
80
+ if (toggledLabel !== undefined) {
81
+ const index = labelToIndex.get(toggledLabel);
82
+ if (index !== undefined) onChipPress(index);
83
+ }
84
+ }
85
+
41
86
  return (
42
- <ScrollView
43
- horizontal
44
- showsHorizontalScrollIndicator={false}
45
- contentContainerClassName={cn('flex-row gap-2', className)}
46
- {...viewProps}
87
+ <ToggleGroup.Root
88
+ type="multiple"
89
+ value={activeValues}
90
+ onValueChange={handleValueChange}
91
+ asChild
47
92
  >
48
- {chips.map((chip, index) => (
49
- <FilterChip
50
- key={chip.label}
51
- label={chip.label}
52
- active={chip.active}
53
- onPress={() => onChipPress(index)}
54
- />
55
- ))}
56
- </ScrollView>
93
+ <ScrollView
94
+ horizontal
95
+ showsHorizontalScrollIndicator={false}
96
+ contentContainerClassName={cn('flex-row gap-2', className)}
97
+ {...viewProps}
98
+ >
99
+ {chips.map((chip) => (
100
+ <ToggleGroup.Item key={chip.label} value={chip.label} asChild>
101
+ <Pressable
102
+ className={cn(
103
+ 'rounded-full px-3 py-1.5',
104
+ chip.active ? 'bg-primary' : 'bg-accent',
105
+ )}
106
+ >
107
+ <Text className={cn('text-xs font-semibold', chip.active ? 'text-primary-foreground' : 'text-muted-foreground')}>
108
+ {chip.label}
109
+ </Text>
110
+ </Pressable>
111
+ </ToggleGroup.Item>
112
+ ))}
113
+ </ScrollView>
114
+ </ToggleGroup.Root>
57
115
  );
58
116
  }
@@ -1,2 +1,3 @@
1
1
  export { SearchInput, type SearchInputProps } from './search-input';
2
2
  export { FilterChip, FilterChipGroup, type FilterChipProps, type FilterChipGroupProps, type ChipItem } from './filter-chip';
3
+ export { QuantityStepper, type QuantityStepperProps } from './quantity-stepper';
@@ -0,0 +1,64 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface QuantityStepperProps extends Omit<ViewProps, 'children'> {
7
+ defaultQuantity?: number;
8
+ quantity?: number;
9
+ onChangeQuantity?: (quantity: number) => void;
10
+ min?: number;
11
+ max?: number;
12
+ className?: string;
13
+ }
14
+
15
+ export function QuantityStepper({
16
+ defaultQuantity = 1,
17
+ quantity: controlledQuantity,
18
+ onChangeQuantity,
19
+ min = 1,
20
+ max = 999,
21
+ className,
22
+ ...viewProps
23
+ }: QuantityStepperProps) {
24
+ const [internalQuantity, setInternalQuantity] = useState(defaultQuantity);
25
+ const quantity = controlledQuantity ?? internalQuantity;
26
+
27
+ const handleChange = (next: number) => {
28
+ if (next < min || next > max) return;
29
+ if (controlledQuantity === undefined) setInternalQuantity(next);
30
+ onChangeQuantity?.(next);
31
+ };
32
+
33
+ return (
34
+ <View className={cn('flex-row items-center gap-2', className)} {...viewProps}>
35
+ <Pressable
36
+ onPress={() => handleChange(quantity - 1)}
37
+ disabled={quantity <= min}
38
+ className={cn(
39
+ 'h-8 w-8 items-center justify-center rounded-lg',
40
+ quantity <= min ? 'bg-muted/50' : 'bg-muted',
41
+ )}
42
+ >
43
+ <Text className={cn('text-base font-semibold', quantity <= min ? 'text-muted-foreground' : 'text-foreground')}>
44
+ {'\u2212'}
45
+ </Text>
46
+ </Pressable>
47
+ <Text className="min-w-[2rem] text-center text-base font-semibold text-foreground">
48
+ {quantity}
49
+ </Text>
50
+ <Pressable
51
+ onPress={() => handleChange(quantity + 1)}
52
+ disabled={quantity >= max}
53
+ className={cn(
54
+ 'h-8 w-8 items-center justify-center rounded-lg',
55
+ quantity >= max ? 'bg-muted/50' : 'bg-muted',
56
+ )}
57
+ >
58
+ <Text className={cn('text-base font-semibold', quantity >= max ? 'text-muted-foreground' : 'text-foreground')}>
59
+ +
60
+ </Text>
61
+ </Pressable>
62
+ </View>
63
+ );
64
+ }
@@ -1,6 +1,8 @@
1
- import { TextInput, View, Text, type TextInputProps } from 'react-native';
2
-
1
+ import { useId, useState } from 'react';
2
+ import { TextInput, View, type TextInputProps } from 'react-native';
3
+ import { Label } from '@tallyui/primitives';
3
4
  import { cn } from '@tallyui/theme';
5
+ import { SearchIcon, HStack } from '../ui';
4
6
 
5
7
  export interface SearchInputProps extends Omit<TextInputProps, 'value' | 'onChangeText'> {
6
8
  value: string;
@@ -8,19 +10,40 @@ export interface SearchInputProps extends Omit<TextInputProps, 'value' | 'onChan
8
10
  className?: string;
9
11
  }
10
12
 
11
- export function SearchInput({ value, onChangeText, className, placeholder = 'Search...', ...props }: SearchInputProps) {
13
+ export function SearchInput({ value, onChangeText, className, placeholder = 'Search...', style, onFocus, onBlur, ...props }: SearchInputProps) {
14
+ const labelId = useId();
15
+ const [focused, setFocused] = useState(false);
16
+
12
17
  return (
13
- <View className={cn('flex-row items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2', className)}>
14
- <Text className="text-muted">{'\u2315'}</Text>
18
+ <HStack space="sm" className={cn(
19
+ 'rounded-lg border bg-card px-3 py-2.5',
20
+ focused ? 'border-ring' : 'border-border',
21
+ className,
22
+ )}>
23
+ <Label.Root nativeID={labelId} asChild>
24
+ <View aria-hidden className="text-muted-foreground">
25
+ <SearchIcon size={16} />
26
+ </View>
27
+ </Label.Root>
15
28
  <TextInput
16
29
  role="searchbox"
30
+ aria-labelledby={labelId}
17
31
  value={value}
18
32
  onChangeText={onChangeText}
19
33
  placeholder={placeholder}
20
- placeholderTextColor="#9ca3af"
34
+ placeholderTextColorClassName="accent-muted-foreground"
21
35
  className="flex-1 text-sm text-foreground"
36
+ style={[{ outlineStyle: 'none' } as any, style]}
37
+ onFocus={(event) => {
38
+ setFocused(true);
39
+ onFocus?.(event);
40
+ }}
41
+ onBlur={(event) => {
42
+ setFocused(false);
43
+ onBlur?.(event);
44
+ }}
22
45
  {...props}
23
46
  />
24
- </View>
47
+ </HStack>
25
48
  );
26
49
  }
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from 'react';
2
+ import { ScrollView, View, useWindowDimensions, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface CheckoutLayoutProps extends Omit<ViewProps, 'children'> {
7
+ summarySlot: ReactNode;
8
+ paymentSlot: ReactNode;
9
+ headerSlot?: ReactNode;
10
+ layout?: 'split' | 'stacked';
11
+ breakpoint?: number;
12
+ className?: string;
13
+ }
14
+
15
+ export function CheckoutLayout({
16
+ summarySlot,
17
+ paymentSlot,
18
+ headerSlot,
19
+ layout: layoutProp,
20
+ breakpoint = 768,
21
+ className,
22
+ ...viewProps
23
+ }: CheckoutLayoutProps) {
24
+ const { width } = useWindowDimensions();
25
+ const layout = layoutProp ?? (width >= breakpoint ? 'split' : 'stacked');
26
+
27
+ return (
28
+ <View className={cn('flex-1 bg-bg', className)} {...viewProps}>
29
+ {headerSlot && <View className="border-b border-border">{headerSlot}</View>}
30
+
31
+ {layout === 'split' ? (
32
+ <View className="flex-1 flex-row">
33
+ <View className="flex-1 border-r border-border">{summarySlot}</View>
34
+ <View className="flex-1">{paymentSlot}</View>
35
+ </View>
36
+ ) : (
37
+ <ScrollView className="flex-1">
38
+ <View className="gap-4 p-4">
39
+ {summarySlot}
40
+ {paymentSlot}
41
+ </View>
42
+ </ScrollView>
43
+ )}
44
+ </View>
45
+ );
46
+ }
@@ -0,0 +1,5 @@
1
+ export { POSLayout, type POSLayoutProps } from './pos-layout';
2
+ export { CheckoutLayout, type CheckoutLayoutProps } from './checkout-layout';
3
+ export { SettingsLayout, type SettingsLayoutProps } from './settings-layout';
4
+ export { LiveTabScreen, type LiveTabScreenProps, type LiveTabScreenState } from './live-tab-screen';
5
+ export { StoreSettingsChoiceScreen, type StoreSettingsChoiceScreenProps } from './store-settings-choice-screen';
@@ -0,0 +1,54 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ /** Mirrors `LiveTabState` from `@tallyui/database`, without depending on that package. */
6
+ export type LiveTabScreenState = 'acquiring' | 'live' | 'parked' | 'blocked';
7
+
8
+ export interface LiveTabScreenProps extends Omit<ViewProps, 'children'> {
9
+ state: LiveTabScreenState;
10
+ /** Called when the parked screen's "Use here" button is pressed. */
11
+ onUseHere: () => void;
12
+ /** Called when the blocked screen's "Reload" button is pressed. */
13
+ onReload: () => void;
14
+ /** Title for both the parked and the blocked screen. */
15
+ parkedTitle?: string;
16
+ parkedBody?: string;
17
+ useHereLabel?: string;
18
+ blockedBody?: string;
19
+ reloadLabel?: string;
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * ADR-061: what a tab shows once it is no longer the live one. Renders
25
+ * nothing for `acquiring` and `live`.
26
+ */
27
+ export function LiveTabScreen({
28
+ state,
29
+ onUseHere,
30
+ onReload,
31
+ parkedTitle = 'POS is open in another tab',
32
+ parkedBody = 'This tab stopped so the other one can work. Use it here instead?',
33
+ useHereLabel = 'Use here',
34
+ blockedBody = 'The POS is open in another tab. Close that tab to use it here, or reload this one.',
35
+ reloadLabel = 'Reload',
36
+ className,
37
+ ...viewProps
38
+ }: LiveTabScreenProps) {
39
+ if (state !== 'parked' && state !== 'blocked') return null;
40
+
41
+ const body = state === 'parked' ? parkedBody : blockedBody;
42
+ const buttonLabel = state === 'parked' ? useHereLabel : reloadLabel;
43
+ const onPress = state === 'parked' ? onUseHere : onReload;
44
+
45
+ return (
46
+ <View className={cn('flex-1 items-center justify-center gap-4 bg-bg p-6', className)} {...viewProps}>
47
+ <Text className="text-center text-lg font-bold text-foreground">{parkedTitle}</Text>
48
+ <Text className="text-center text-sm text-muted-foreground">{body}</Text>
49
+ <Pressable onPress={onPress} className="items-center rounded-lg bg-primary px-4 py-3">
50
+ <Text className="text-sm font-semibold text-primary-foreground">{buttonLabel}</Text>
51
+ </Pressable>
52
+ </View>
53
+ );
54
+ }
@@ -0,0 +1,64 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Pressable, Text, View, useWindowDimensions, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface POSLayoutProps extends Omit<ViewProps, 'children'> {
7
+ browseSlot: ReactNode;
8
+ cartSlot: ReactNode;
9
+ headerSlot?: ReactNode;
10
+ layout?: 'split' | 'stacked';
11
+ breakpoint?: number;
12
+ className?: string;
13
+ }
14
+
15
+ export function POSLayout({
16
+ browseSlot,
17
+ cartSlot,
18
+ headerSlot,
19
+ layout: layoutProp,
20
+ breakpoint = 768,
21
+ className,
22
+ ...viewProps
23
+ }: POSLayoutProps) {
24
+ const { width } = useWindowDimensions();
25
+ const layout = layoutProp ?? (width >= breakpoint ? 'split' : 'stacked');
26
+ const [activeTab, setActiveTab] = useState<'browse' | 'cart'>('browse');
27
+
28
+ return (
29
+ <View className={cn('flex-1 bg-bg', className)} {...viewProps}>
30
+ {headerSlot && <View className="border-b border-border">{headerSlot}</View>}
31
+
32
+ {layout === 'split' ? (
33
+ <View className="flex-1 flex-row">
34
+ <View className="flex-[3]">{browseSlot}</View>
35
+ <View className="flex-[2] border-l border-border">{cartSlot}</View>
36
+ </View>
37
+ ) : (
38
+ <>
39
+ <View className="flex-row border-b border-border">
40
+ <Pressable
41
+ onPress={() => setActiveTab('browse')}
42
+ className={cn('flex-1 py-3', activeTab === 'browse' && 'border-b-2 border-primary')}
43
+ >
44
+ <Text className={cn('text-center text-sm font-semibold', activeTab === 'browse' ? 'text-primary' : 'text-muted-foreground')}>
45
+ Browse
46
+ </Text>
47
+ </Pressable>
48
+ <Pressable
49
+ onPress={() => setActiveTab('cart')}
50
+ className={cn('flex-1 py-3', activeTab === 'cart' && 'border-b-2 border-primary')}
51
+ >
52
+ <Text className={cn('text-center text-sm font-semibold', activeTab === 'cart' ? 'text-primary' : 'text-muted-foreground')}>
53
+ Cart
54
+ </Text>
55
+ </Pressable>
56
+ </View>
57
+ <View className="flex-1">
58
+ {activeTab === 'browse' ? browseSlot : cartSlot}
59
+ </View>
60
+ </>
61
+ )}
62
+ </View>
63
+ );
64
+ }
@@ -0,0 +1,58 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Pressable, Text, View, useWindowDimensions, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface SettingsLayoutProps extends Omit<ViewProps, 'children'> {
7
+ navSlot: ReactNode;
8
+ contentSlot: ReactNode;
9
+ headerSlot?: ReactNode;
10
+ layout?: 'split' | 'stacked';
11
+ breakpoint?: number;
12
+ className?: string;
13
+ }
14
+
15
+ export function SettingsLayout({
16
+ navSlot,
17
+ contentSlot,
18
+ headerSlot,
19
+ layout: layoutProp,
20
+ breakpoint = 768,
21
+ className,
22
+ ...viewProps
23
+ }: SettingsLayoutProps) {
24
+ const { width } = useWindowDimensions();
25
+ const layout = layoutProp ?? (width >= breakpoint ? 'split' : 'stacked');
26
+ const [showNav, setShowNav] = useState(false);
27
+
28
+ return (
29
+ <View className={cn('flex-1 bg-bg', className)} {...viewProps}>
30
+ {headerSlot && <View className="border-b border-border">{headerSlot}</View>}
31
+
32
+ {layout === 'split' ? (
33
+ <View className="flex-1 flex-row">
34
+ <View className="w-64 border-r border-border">{navSlot}</View>
35
+ <View className="flex-1">{contentSlot}</View>
36
+ </View>
37
+ ) : (
38
+ <View className="flex-1">
39
+ {showNav ? (
40
+ <View className="flex-1">
41
+ {navSlot}
42
+ <Pressable onPress={() => setShowNav(false)} className="border-t border-border p-3">
43
+ <Text className="text-center text-sm font-medium text-primary">Done</Text>
44
+ </Pressable>
45
+ </View>
46
+ ) : (
47
+ <View className="flex-1">
48
+ <Pressable onPress={() => setShowNav(true)} className="border-b border-border px-4 py-2">
49
+ <Text className="text-sm font-medium text-primary">Menu</Text>
50
+ </Pressable>
51
+ {contentSlot}
52
+ </View>
53
+ )}
54
+ </View>
55
+ )}
56
+ </View>
57
+ );
58
+ }
@@ -0,0 +1,142 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+ import type { StoreSettingsChoice, StoreSettingsChoices } from '@tallyui/core';
6
+
7
+ export interface StoreSettingsChoiceScreenProps extends Omit<ViewProps, 'children'> {
8
+ /** From `StoreSettingsError.choices`. Only the sections present are shown. */
9
+ choices: StoreSettingsChoices;
10
+ /** A previous choice to pre-select (for example, a stale stored one); fields that aren't in `choices` are ignored. */
11
+ initial?: StoreSettingsChoice;
12
+ /** Called with the full choice when the user confirms. The app persists it per store and retries `storeSettings`. */
13
+ onSubmit: (choice: StoreSettingsChoice) => void;
14
+ /** Display name for an ISO 3166-1 alpha-2 code. Default: `Intl.DisplayNames` in `locale`, falling back to the upper-cased code. */
15
+ countryName?: (code: string) => string;
16
+ locale?: string;
17
+ title?: string;
18
+ body?: string;
19
+ regionLabel?: string;
20
+ countryLabel?: string;
21
+ channelLabel?: string;
22
+ submitLabel?: string;
23
+ className?: string;
24
+ }
25
+
26
+ type Option = { value: string; label: string };
27
+
28
+ /** The single option if there is only one, else a matching `initial` value, else none. */
29
+ function useSectionSelection(options: Option[], initialValue: string | undefined) {
30
+ const preset = options.length === 1 ? options[0].value : options.find((o) => o.value === initialValue)?.value;
31
+ return useState<string | undefined>(preset);
32
+ }
33
+
34
+ function defaultCountryName(code: string, locale?: string): string {
35
+ try {
36
+ return new Intl.DisplayNames([locale ?? 'en'], { type: 'region' }).of(code.toUpperCase()) ?? code.toUpperCase();
37
+ } catch {
38
+ return code.toUpperCase();
39
+ }
40
+ }
41
+
42
+ /**
43
+ * TV4: shown when `storeSettings` rejects with `choice_required`. The app
44
+ * persists the returned choice per store and calls `storeSettings(context,
45
+ * choice)` again; a stored choice that no longer resolves raises
46
+ * `choice_required` again, and this screen pre-selects what still matches.
47
+ */
48
+ export function StoreSettingsChoiceScreen({
49
+ choices,
50
+ initial,
51
+ onSubmit,
52
+ countryName,
53
+ locale,
54
+ title = 'Set up this till',
55
+ body = 'This store sells in more than one place. Choose where this till is.',
56
+ regionLabel = 'Region',
57
+ countryLabel = 'Country',
58
+ channelLabel = 'Sales channel',
59
+ submitLabel = 'Continue',
60
+ className,
61
+ ...viewProps
62
+ }: StoreSettingsChoiceScreenProps) {
63
+ const regionOptions: Option[] = (choices.regions ?? []).map((r) => ({ value: r.id, label: r.name }));
64
+ const channelOptions: Option[] = (choices.channels ?? []).map((c) => ({ value: c.id, label: c.name }));
65
+ const nameForCountry = countryName ?? ((code: string) => defaultCountryName(code, locale));
66
+ const countryOptions: Option[] = (choices.countries ?? []).map((code) => ({ value: code, label: nameForCountry(code) }));
67
+
68
+ const [region, setRegion] = useSectionSelection(regionOptions, initial?.region);
69
+ const [country, setCountry] = useSectionSelection(countryOptions, initial?.country);
70
+ const [channel, setChannel] = useSectionSelection(channelOptions, initial?.channel);
71
+
72
+ const showRegions = regionOptions.length > 0;
73
+ const showCountries = countryOptions.length > 0;
74
+ const showChannels = channelOptions.length > 0;
75
+ const complete =
76
+ (!showRegions || region !== undefined) &&
77
+ (!showCountries || country !== undefined) &&
78
+ (!showChannels || channel !== undefined);
79
+
80
+ const handleSubmit = () => {
81
+ if (!complete) return;
82
+ const choice: StoreSettingsChoice = {};
83
+ if (showRegions) choice.region = region;
84
+ else if (initial?.region !== undefined) choice.region = initial.region;
85
+ if (showCountries) choice.country = country?.toLowerCase();
86
+ else if (initial?.country !== undefined) choice.country = initial.country;
87
+ if (showChannels) choice.channel = channel;
88
+ else if (initial?.channel !== undefined) choice.channel = initial.channel;
89
+ onSubmit(choice);
90
+ };
91
+
92
+ return (
93
+ <View className={cn('w-full max-w-[560px] flex-1 self-center gap-6 bg-bg p-6', className)} {...viewProps}>
94
+ <View className="gap-2">
95
+ <Text className="text-lg font-bold text-foreground">{title}</Text>
96
+ <Text className="text-sm text-muted-foreground">{body}</Text>
97
+ </View>
98
+ {showRegions && <ChoiceSection label={regionLabel} options={regionOptions} selected={region} onSelect={setRegion} />}
99
+ {showCountries && <ChoiceSection label={countryLabel} options={countryOptions} selected={country} onSelect={setCountry} />}
100
+ {showChannels && <ChoiceSection label={channelLabel} options={channelOptions} selected={channel} onSelect={setChannel} />}
101
+ <Pressable
102
+ onPress={handleSubmit}
103
+ disabled={!complete}
104
+ // Dim the whole button when disabled, so the label keeps its contrast in both themes.
105
+ className={cn('items-center rounded-lg bg-primary px-4 py-3', !complete && 'opacity-50')}
106
+ >
107
+ <Text className="text-sm font-semibold text-primary-foreground">{submitLabel}</Text>
108
+ </Pressable>
109
+ </View>
110
+ );
111
+ }
112
+
113
+ type ChoiceSectionProps = { label: string; options: Option[]; selected: string | undefined; onSelect: (value: string) => void };
114
+
115
+ function ChoiceSection({ label, options, selected, onSelect }: ChoiceSectionProps) {
116
+ return (
117
+ <View className="gap-1.5">
118
+ <Text className="text-xs font-semibold text-muted-foreground">{label}</Text>
119
+ <View className="rounded-lg border border-border">
120
+ {options.map((option, index) => {
121
+ const checked = option.value === selected;
122
+ return (
123
+ <Pressable
124
+ key={option.value}
125
+ onPress={() => onSelect(option.value)}
126
+ accessibilityRole="radio"
127
+ aria-checked={checked}
128
+ testID={`choice-option-${option.value}`}
129
+ className={cn('flex-row items-center gap-3 px-4 py-3', index > 0 && 'border-t border-border', checked && 'bg-primary/10')}
130
+ >
131
+ {/* Same radio visual as @tallyui/components/ui/radio-group: ring + dot, so the choice reads without colour. */}
132
+ <View className="h-4 w-4 items-center justify-center rounded-full border border-primary">
133
+ {checked && <View testID={`choice-option-${option.value}-dot`} className="h-2.5 w-2.5 rounded-full bg-primary" />}
134
+ </View>
135
+ <Text className={cn('text-sm', checked ? 'font-semibold text-primary' : 'text-foreground')}>{option.label}</Text>
136
+ </Pressable>
137
+ );
138
+ })}
139
+ </View>
140
+ </View>
141
+ );
142
+ }
@@ -0,0 +1,4 @@
1
+ export { OrderStatusBadge, type OrderStatusBadgeProps, type OrderStatus } from './order-status-badge';
2
+ export { OrderCard, type OrderCardProps } from './order-card';
3
+ export { OrderList, type OrderListProps } from './order-list';
4
+ export { OrderDetail, type OrderDetailProps, type OrderDetailLineItem } from './order-detail';