@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
@@ -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';
@@ -0,0 +1,48 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ import { OrderStatusBadge, type OrderStatus } from './order-status-badge';
6
+
7
+ export interface OrderCardProps extends Omit<ViewProps, 'children'> {
8
+ orderNumber: string;
9
+ date: string;
10
+ total: string;
11
+ status: OrderStatus;
12
+ customerName?: string;
13
+ onPress?: () => void;
14
+ className?: string;
15
+ }
16
+
17
+ export function OrderCard({
18
+ orderNumber,
19
+ date,
20
+ total,
21
+ status,
22
+ customerName,
23
+ onPress,
24
+ className,
25
+ ...viewProps
26
+ }: OrderCardProps) {
27
+ const Wrapper = onPress ? Pressable : View;
28
+
29
+ return (
30
+ <Wrapper
31
+ onPress={onPress}
32
+ className={cn('gap-2 rounded-lg border border-border bg-card p-3', className)}
33
+ {...viewProps}
34
+ >
35
+ <View className="flex-row items-center justify-between">
36
+ <Text className="text-sm font-semibold text-foreground">{orderNumber}</Text>
37
+ <OrderStatusBadge status={status} />
38
+ </View>
39
+ <View className="flex-row items-center justify-between">
40
+ <View className="gap-0.5">
41
+ <Text className="text-xs text-muted-foreground">{date}</Text>
42
+ {customerName && <Text className="text-xs text-muted-foreground">{customerName}</Text>}
43
+ </View>
44
+ <Text className="text-sm font-semibold text-foreground">{total}</Text>
45
+ </View>
46
+ </Wrapper>
47
+ );
48
+ }
@@ -0,0 +1,82 @@
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
+ import { OrderStatusBadge, type OrderStatus } from './order-status-badge';
7
+
8
+ export interface OrderDetailLineItem {
9
+ name: string;
10
+ quantity: number;
11
+ total: string;
12
+ }
13
+
14
+ export interface OrderDetailProps extends Omit<ViewProps, 'children'> {
15
+ orderNumber: string;
16
+ status: OrderStatus;
17
+ lineItems: OrderDetailLineItem[];
18
+ subtotal: string;
19
+ tax: string;
20
+ total: string;
21
+ customerSlot?: ReactNode;
22
+ paymentSlot?: ReactNode;
23
+ footerSlot?: ReactNode;
24
+ className?: string;
25
+ }
26
+
27
+ export function OrderDetail({
28
+ orderNumber,
29
+ status,
30
+ lineItems,
31
+ subtotal,
32
+ tax,
33
+ total,
34
+ customerSlot,
35
+ paymentSlot,
36
+ footerSlot,
37
+ className,
38
+ ...viewProps
39
+ }: OrderDetailProps) {
40
+ return (
41
+ <ScrollView className={cn('flex-1', className)} {...viewProps}>
42
+ <View className="gap-4 p-4">
43
+ <View className="flex-row items-center justify-between">
44
+ <Text className="text-lg font-bold text-foreground">{orderNumber}</Text>
45
+ <OrderStatusBadge status={status} />
46
+ </View>
47
+
48
+ {customerSlot && <View className="border-b border-border pb-3">{customerSlot}</View>}
49
+
50
+ <View className="gap-1">
51
+ <Text className="text-xs font-semibold text-muted-foreground">Items</Text>
52
+ {lineItems.map((item, i) => (
53
+ <View key={i} className="flex-row items-center justify-between py-1">
54
+ <Text className="flex-1 text-sm text-foreground">
55
+ {item.name} x{item.quantity}
56
+ </Text>
57
+ <Text className="text-sm text-foreground">{item.total}</Text>
58
+ </View>
59
+ ))}
60
+ </View>
61
+
62
+ <View className="gap-1 border-t border-border pt-3">
63
+ <View className="flex-row justify-between">
64
+ <Text className="text-sm text-muted-foreground">Subtotal</Text>
65
+ <Text className="text-sm text-foreground">{subtotal}</Text>
66
+ </View>
67
+ <View className="flex-row justify-between">
68
+ <Text className="text-sm text-muted-foreground">Tax</Text>
69
+ <Text className="text-sm text-foreground">{tax}</Text>
70
+ </View>
71
+ <View className="flex-row justify-between pt-1">
72
+ <Text className="text-sm font-bold text-foreground">Total</Text>
73
+ <Text className="text-sm font-bold text-foreground">{total}</Text>
74
+ </View>
75
+ </View>
76
+
77
+ {paymentSlot && <View className="border-t border-border pt-3">{paymentSlot}</View>}
78
+ {footerSlot && <View className="border-t border-border pt-3">{footerSlot}</View>}
79
+ </View>
80
+ </ScrollView>
81
+ );
82
+ }
@@ -0,0 +1,39 @@
1
+ import type { ReactNode } from 'react';
2
+ import { ScrollView, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface OrderListProps extends Omit<ViewProps, 'children'> {
7
+ items: any[];
8
+ renderItem: (item: any, index: number) => ReactNode;
9
+ headerSlot?: ReactNode;
10
+ emptyState?: ReactNode;
11
+ className?: string;
12
+ }
13
+
14
+ export function OrderList({
15
+ items,
16
+ renderItem,
17
+ headerSlot,
18
+ emptyState,
19
+ className,
20
+ ...viewProps
21
+ }: OrderListProps) {
22
+ const hasItems = items.length > 0;
23
+
24
+ return (
25
+ <View className={cn('flex-1', className)} {...viewProps}>
26
+ {headerSlot && <View className="px-3 py-2">{headerSlot}</View>}
27
+
28
+ {hasItems ? (
29
+ <ScrollView contentContainerClassName="gap-2 p-3">
30
+ {items.map((item, index) => (
31
+ <View key={item.id ?? index}>{renderItem(item, index)}</View>
32
+ ))}
33
+ </ScrollView>
34
+ ) : (
35
+ emptyState ?? null
36
+ )}
37
+ </View>
38
+ );
39
+ }
@@ -0,0 +1,32 @@
1
+ import { Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export type OrderStatus = 'pending' | 'processing' | 'completed' | 'refunded' | 'cancelled';
6
+
7
+ export interface OrderStatusBadgeProps extends Omit<ViewProps, 'children'> {
8
+ status: OrderStatus;
9
+ className?: string;
10
+ }
11
+
12
+ const STATUS_CONFIG: Record<OrderStatus, { label: string; badge: string; dot: string; text: string }> = {
13
+ pending: { label: 'Pending', badge: 'bg-warning/15', dot: 'bg-warning', text: 'text-foreground' },
14
+ processing: { label: 'Processing', badge: 'bg-info/15', dot: 'bg-info', text: 'text-foreground' },
15
+ completed: { label: 'Completed', badge: 'bg-success/15', dot: 'bg-success', text: 'text-foreground' },
16
+ refunded: { label: 'Refunded', badge: 'bg-destructive/15', dot: 'bg-destructive', text: 'text-foreground' },
17
+ cancelled: { label: 'Cancelled', badge: 'bg-muted/15', dot: 'bg-muted', text: 'text-muted-foreground' },
18
+ };
19
+
20
+ export function OrderStatusBadge({ status, className, ...viewProps }: OrderStatusBadgeProps) {
21
+ const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
22
+
23
+ return (
24
+ <View
25
+ className={cn('flex-row items-center self-start gap-1.5 rounded-xl px-2 py-1', config.badge, className)}
26
+ {...viewProps}
27
+ >
28
+ <View className={cn('h-1.5 w-1.5 rounded-full', config.dot)} />
29
+ <Text className={cn('text-xs font-semibold', config.text)}>{config.label}</Text>
30
+ </View>
31
+ );
32
+ }
@@ -0,0 +1,70 @@
1
+ import { Pressable, ScrollView, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export interface CategoryItem {
6
+ id: string;
7
+ name: string;
8
+ }
9
+
10
+ export interface CategoryNavProps extends Omit<ViewProps, 'children'> {
11
+ categories: CategoryItem[];
12
+ selectedId: string;
13
+ onSelect: (id: string) => void;
14
+ orientation?: 'horizontal' | 'vertical';
15
+ className?: string;
16
+ }
17
+
18
+ export function CategoryNav({
19
+ categories,
20
+ selectedId,
21
+ onSelect,
22
+ orientation = 'horizontal',
23
+ className,
24
+ ...viewProps
25
+ }: CategoryNavProps) {
26
+ const isHorizontal = orientation === 'horizontal';
27
+
28
+ const content = categories.map((cat) => {
29
+ const active = cat.id === selectedId;
30
+ return (
31
+ <Pressable
32
+ key={cat.id}
33
+ onPress={() => onSelect(cat.id)}
34
+ className={cn(
35
+ 'rounded-full px-3 py-1.5',
36
+ active ? 'bg-primary' : 'bg-muted',
37
+ )}
38
+ >
39
+ <Text
40
+ className={cn(
41
+ 'text-sm font-medium',
42
+ active ? 'text-primary-foreground' : 'text-foreground',
43
+ )}
44
+ >
45
+ {cat.name}
46
+ </Text>
47
+ </Pressable>
48
+ );
49
+ });
50
+
51
+ if (isHorizontal) {
52
+ return (
53
+ <ScrollView
54
+ horizontal
55
+ showsHorizontalScrollIndicator={false}
56
+ contentContainerClassName="flex-row gap-2 px-3 py-2"
57
+ className={className}
58
+ {...viewProps}
59
+ >
60
+ {content}
61
+ </ScrollView>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <View className={cn('gap-1 p-2', className)} {...viewProps}>
67
+ {content}
68
+ </View>
69
+ );
70
+ }
@@ -5,3 +5,6 @@ export { ProductSku, type ProductSkuProps } from './product-sku';
5
5
  export { ProductStockBadge, type ProductStockBadgeProps } from './product-stock-badge';
6
6
  export { ProductCard, type ProductCardProps } from './product-card';
7
7
  export { ProductGrid, type ProductGridProps } from './product-grid';
8
+ export { ProductList, type ProductListProps } from './product-list';
9
+ export { CategoryNav, type CategoryNavProps, type CategoryItem } from './category-nav';
10
+ export { ProductVariantPicker, type ProductVariantPickerProps, type VariantOption } from './product-variant-picker';
@@ -1,26 +1,54 @@
1
- import { Pressable, View, type ViewProps } from 'react-native';
1
+ import { Pressable } from 'react-native';
2
2
  import { cn } from '@tallyui/theme';
3
+ import { useProductTraits } from '@tallyui/core';
4
+ import { VStack, Text, type VStackProps } from '../ui';
3
5
  import { ProductImage } from './product-image';
4
6
  import { ProductTitle } from './product-title';
5
7
  import { ProductPrice } from './product-price';
6
8
 
7
- export interface ProductCardProps extends Omit<ViewProps, 'children'> {
9
+ export interface ProductCardProps extends Omit<VStackProps, 'children'> {
8
10
  doc: any;
9
11
  onPress?: () => void;
10
12
  imageSize?: number;
11
13
  currencySymbol?: string;
14
+ /** Shown in place of the price when the channel doesn't sell this product. */
15
+ notSoldLabel?: string;
12
16
  className?: string;
13
17
  }
14
18
 
15
- export function ProductCard({ doc, onPress, imageSize = 80, currencySymbol, className, ...viewProps }: ProductCardProps) {
19
+ export function ProductCard({ doc, onPress, imageSize = 80, currencySymbol, notSoldLabel = 'Not sold here', className, ...props }: ProductCardProps) {
20
+ // No <ConnectorProvider> above: traits are unavailable, so the card
21
+ // renders as an ordinary, sellable product.
22
+ let traits: ReturnType<typeof useProductTraits> | undefined;
23
+ try {
24
+ traits = useProductTraits();
25
+ } catch {
26
+ traits = undefined;
27
+ }
28
+ const sellable = !traits || traits.isSellable(doc);
29
+
16
30
  const content = (
17
- <View className={cn('items-center gap-2 rounded-lg bg-surface p-3', className)} {...viewProps}>
18
- <ProductImage doc={doc} size={imageSize} className="rounded-md" />
19
- <ProductTitle doc={doc} className="text-sm" numberOfLines={2} />
20
- <ProductPrice doc={doc} currencySymbol={currencySymbol} />
21
- </View>
31
+ <VStack
32
+ space="sm"
33
+ className={cn('items-center rounded-lg border border-border bg-card p-3', className)}
34
+ {...props}
35
+ >
36
+ {/* Unsellable: the card keeps its opaque surface and only its contents dim, so
37
+ it reads as lower contrast than its neighbours on any backdrop, in both themes. */}
38
+ <ProductImage doc={doc} size={imageSize} className={cn('rounded-md', !sellable && 'opacity-50')} />
39
+ <ProductTitle doc={doc} className={cn('text-sm', !sellable && 'opacity-50')} numberOfLines={2} />
40
+ {sellable ? (
41
+ <ProductPrice doc={doc} currencySymbol={currencySymbol} />
42
+ ) : (
43
+ <Text className="text-sm text-muted-foreground">{notSoldLabel}</Text>
44
+ )}
45
+ </VStack>
22
46
  );
23
47
 
48
+ // Not sold in this channel: shown for awareness only (opt-in or the demo), never tappable.
49
+ if (!sellable) {
50
+ return <Pressable disabled aria-disabled onPress={undefined}>{content}</Pressable>;
51
+ }
24
52
  if (onPress) {
25
53
  return <Pressable onPress={onPress}>{content}</Pressable>;
26
54
  }
@@ -1,8 +1,17 @@
1
1
  import type { ReactNode } from 'react';
2
- import { ScrollView, View, type ViewProps } from 'react-native';
2
+ import { FlatList, View } from 'react-native';
3
3
  import { cn } from '@tallyui/theme';
4
+ import { useProductTraits } from '@tallyui/core';
5
+ import { VStack, type VStackProps } from '../ui';
4
6
 
5
- export interface ProductGridProps extends Omit<ViewProps, 'children'> {
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
+
14
+ export interface ProductGridProps extends Omit<VStackProps, 'children'> {
6
15
  /** Array of product documents to render */
7
16
  items: any[];
8
17
  /** Render function for each product */
@@ -15,14 +24,19 @@ export interface ProductGridProps extends Omit<ViewProps, 'children'> {
15
24
  filterSlot?: ReactNode;
16
25
  /** Content shown when items is empty */
17
26
  emptyState?: ReactNode;
27
+ /**
28
+ * Products the channel doesn't sell (`traits.product.isSellable` false,
29
+ * for example a Medusa product outside the sales channel) are hidden
30
+ * unless `showUnsellable`.
31
+ */
32
+ showUnsellable?: boolean;
18
33
  className?: string;
19
34
  }
20
35
 
21
36
  /**
22
- * A scrollable product grid with optional search and filter slots.
37
+ * A virtualized product grid with optional search and filter slots.
23
38
  *
24
- * Uses ScrollView + flexWrap for cross-platform grid layout rather than
25
- * 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.
26
40
  *
27
41
  * ```tsx
28
42
  * <ProductGrid
@@ -40,27 +54,46 @@ export function ProductGrid({
40
54
  searchSlot,
41
55
  filterSlot,
42
56
  emptyState,
57
+ showUnsellable = false,
43
58
  className,
44
59
  ...viewProps
45
60
  }: ProductGridProps) {
46
- const hasItems = items.length > 0;
61
+ // No <ConnectorProvider> above (e.g. a fixture with no connector): traits
62
+ // are unavailable, so nothing is filtered.
63
+ let traits: ReturnType<typeof useProductTraits> | undefined;
64
+ try {
65
+ traits = useProductTraits();
66
+ } catch {
67
+ traits = undefined;
68
+ }
69
+
70
+ const visibleItems = showUnsellable || !traits ? items : items.filter((item) => traits!.isSellable(item));
71
+ const hasItems = visibleItems.length > 0;
47
72
 
48
73
  return (
49
- <View className={cn('flex-1', className)} {...viewProps}>
74
+ <VStack space="none" className={cn('flex-1', className)} {...viewProps}>
50
75
  {searchSlot && <View className="px-3 py-2">{searchSlot}</View>}
51
76
  {filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
52
77
 
53
78
  {hasItems ? (
54
- <ScrollView contentContainerClassName="flex-row flex-wrap p-1">
55
- {items.map((item, index) => (
56
- <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">
57
90
  {renderItem(item, index)}
58
91
  </View>
59
- ))}
60
- </ScrollView>
92
+ )}
93
+ />
61
94
  ) : (
62
95
  emptyState ?? null
63
96
  )}
64
- </View>
97
+ </VStack>
65
98
  );
66
99
  }
@@ -1,15 +1,22 @@
1
- import { Image, type ImageProps, type ImageStyle, type StyleProp } from 'react-native';
1
+ import { View, type ImageStyle, type StyleProp } from 'react-native';
2
+ import { Image } from '@tallyui/primitives';
2
3
 
3
4
  import { useProductTraits } from '@tallyui/core';
4
5
  import { cn } from '@tallyui/theme';
6
+ import { Text } from '../ui';
5
7
 
6
- export interface ProductImageProps extends Omit<ImageProps, 'source'> {
8
+ export interface ProductImageProps extends Omit<React.ComponentPropsWithoutRef<typeof Image.Root>, 'source'> {
7
9
  /** The raw RxDB product document */
8
10
  doc: any;
9
11
  /** Image dimensions */
10
12
  size?: number;
11
13
  /** Style overrides (for dynamic sizing) */
12
14
  style?: StyleProp<ImageStyle>;
15
+ /**
16
+ * Render a same-size tile with the product's initial when there is no
17
+ * image, so rows and grids stay aligned. Defaults to false (render nothing).
18
+ */
19
+ showPlaceholder?: boolean;
13
20
  className?: string;
14
21
  }
15
22
 
@@ -22,18 +29,32 @@ export interface ProductImageProps extends Omit<ImageProps, 'source'> {
22
29
  * <ProductImage doc={productDocument} size={80} className="rounded-lg" />
23
30
  * ```
24
31
  */
25
- export function ProductImage({ doc, size = 60, style, className, ...imageProps }: ProductImageProps) {
26
- const { getImageUrl } = useProductTraits();
32
+ export function ProductImage({ doc, size = 60, style, showPlaceholder = false, className, ...imageProps }: ProductImageProps) {
33
+ const { getImageUrl, getName } = useProductTraits();
27
34
  const imageUrl = getImageUrl(doc);
28
35
 
29
36
  if (!imageUrl) {
30
- return null; // TODO: placeholder image
37
+ if (!showPlaceholder) return null;
38
+ const name = getName(doc);
39
+ return (
40
+ <View
41
+ role="img"
42
+ aria-label={name}
43
+ className={cn('items-center justify-center rounded border border-border bg-primary/10', className)}
44
+ style={{ width: size, height: size }}
45
+ >
46
+ <Text className="font-semibold text-primary" style={{ fontSize: size * 0.4 }}>
47
+ {name.trim().charAt(0).toUpperCase() || '?'}
48
+ </Text>
49
+ </View>
50
+ );
31
51
  }
32
52
 
33
53
  return (
34
- <Image
54
+ <Image.Root
35
55
  source={{ uri: imageUrl }}
36
- className={cn('rounded', className)}
56
+ alt={getName(doc)}
57
+ className={cn('rounded border border-border bg-card', className)}
37
58
  style={[{ width: size, height: size }, style]}
38
59
  {...imageProps}
39
60
  />