@tallyui/components 1.0.0 → 3.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1113 -83
- package/dist/index.js +4936 -353
- package/package.json +27 -5
- package/src/cart/cart-line-actions.tsx +40 -0
- package/src/cart/cart-line.tsx +18 -32
- package/src/cart/cart-note-input.tsx +46 -0
- package/src/cart/cart-panel.tsx +39 -26
- package/src/cart/cart-total.tsx +43 -47
- package/src/cart/discount-badge.tsx +19 -0
- package/src/cart/index.ts +4 -1
- package/src/checkout/cash-tendered.tsx +101 -0
- package/src/checkout/change-display.tsx +19 -0
- package/src/checkout/index.ts +3 -0
- package/src/checkout/order-summary-line.tsx +7 -8
- package/src/checkout/order-summary.tsx +8 -7
- package/src/checkout/payment-method-card.tsx +10 -9
- package/src/checkout/payment-selector.tsx +5 -5
- package/src/checkout/receipt-preview.tsx +78 -0
- package/src/customer/customer-card.tsx +16 -12
- package/src/customer/customer-form.tsx +72 -0
- package/src/customer/customer-order-history.tsx +34 -0
- package/src/customer/customer-picker.tsx +124 -0
- package/src/customer/customer-select.tsx +80 -31
- package/src/customer/index.ts +3 -0
- package/src/index.ts +114 -1
- package/src/input/filter-chip.tsx +85 -27
- package/src/input/index.ts +1 -0
- package/src/input/quantity-stepper.tsx +64 -0
- package/src/input/search-input.tsx +30 -7
- package/src/layout/checkout-layout.tsx +46 -0
- package/src/layout/index.ts +5 -0
- package/src/layout/live-tab-screen.tsx +54 -0
- package/src/layout/pos-layout.tsx +64 -0
- package/src/layout/settings-layout.tsx +58 -0
- package/src/layout/store-settings-choice-screen.tsx +142 -0
- package/src/order/index.ts +4 -0
- package/src/order/order-card.tsx +48 -0
- package/src/order/order-detail.tsx +82 -0
- package/src/order/order-list.tsx +39 -0
- package/src/order/order-status-badge.tsx +32 -0
- package/src/product/category-nav.tsx +70 -0
- package/src/product/index.ts +3 -0
- package/src/product/product-card.tsx +36 -8
- package/src/product/product-grid.tsx +46 -13
- package/src/product/product-image.tsx +28 -7
- package/src/product/product-list.tsx +44 -0
- package/src/product/product-price.tsx +77 -22
- package/src/product/product-sku.tsx +5 -5
- package/src/product/product-stock-badge.tsx +41 -28
- package/src/product/product-title.tsx +2 -3
- package/src/product/product-variant-picker.tsx +61 -0
- package/src/register/cash-count-input.tsx +85 -0
- package/src/register/closure-sheet.tsx +161 -0
- package/src/register/index.ts +12 -0
- package/src/register/movement-sheet.tsx +118 -0
- package/src/register/open-register-card.tsx +109 -0
- package/src/register/register-bar.helpers.ts +57 -0
- package/src/register/register-bar.tsx +85 -0
- package/src/register/register-column.tsx +117 -0
- package/src/register/register-count.tsx +330 -0
- package/src/register/register-open-close.tsx +52 -0
- package/src/register/register-panel.tsx +151 -0
- package/src/register/register-picker.tsx +60 -0
- package/src/register/register-summary.tsx +72 -0
- package/src/sale/cart-bar.tsx +18 -0
- package/src/sale/cart.tsx +66 -0
- package/src/sale/catalogue.tsx +139 -0
- package/src/sale/discount-form.tsx +85 -0
- package/src/sale/index.ts +9 -0
- package/src/sale/orders-list.tsx +108 -0
- package/src/sale/print-style.ts +11 -0
- package/src/sale/receipt.tsx +62 -0
- package/src/sale/sync-status.tsx +141 -0
- package/src/sale/tender.tsx +38 -0
- package/src/settings/connector-status.tsx +65 -0
- package/src/settings/index.ts +3 -0
- package/src/settings/settings-group.tsx +23 -0
- package/src/settings/settings-row.tsx +23 -0
- package/src/ui/accordion/index.tsx +59 -0
- package/src/ui/alert-dialog/index.tsx +91 -0
- package/src/ui/avatar/index.tsx +50 -0
- package/src/ui/badge/index.tsx +60 -0
- package/src/ui/button/index.tsx +90 -0
- package/src/ui/card/index.tsx +60 -0
- package/src/ui/checkbox/index.tsx +29 -0
- package/src/ui/collapsible/index.tsx +24 -0
- package/src/ui/combobox/index.tsx +98 -0
- package/src/ui/context-menu/index.tsx +159 -0
- package/src/ui/dialog/index.tsx +92 -0
- package/src/ui/dropdown-menu/index.tsx +161 -0
- package/src/ui/hover-card/index.tsx +31 -0
- package/src/ui/hstack/index.tsx +43 -0
- package/src/ui/icon/index.tsx +44 -0
- package/src/ui/icon/search-icon.tsx +9 -0
- package/src/ui/icon/svg-icon.tsx +36 -0
- package/src/ui/icon-button/index.tsx +58 -0
- package/src/ui/index.ts +99 -0
- package/src/ui/input/index.tsx +100 -0
- package/src/ui/label/index.tsx +26 -0
- package/src/ui/loader/index.tsx +37 -0
- package/src/ui/popover/index.tsx +31 -0
- package/src/ui/progress/index.tsx +28 -0
- package/src/ui/radio-group/index.tsx +38 -0
- package/src/ui/select/index.tsx +96 -0
- package/src/ui/separator/index.tsx +29 -0
- package/src/ui/slider/index.tsx +28 -0
- package/src/ui/switch/index.tsx +31 -0
- package/src/ui/table/index.tsx +59 -0
- package/src/ui/tabs/index.tsx +53 -0
- package/src/ui/text/index.tsx +36 -0
- package/src/ui/textarea/index.tsx +29 -0
- package/src/ui/toast/index.tsx +52 -0
- package/src/ui/toggle/index.tsx +69 -0
- package/src/ui/toggle-group/index.tsx +66 -0
- package/src/ui/tooltip/index.tsx +30 -0
- package/src/ui/vstack/index.tsx +43 -0
- package/src/uniwind-env.d.ts +9 -1
- package/src/__tests__/cart-line.test.tsx +0 -53
- package/src/__tests__/cart-panel.test.tsx +0 -55
- package/src/__tests__/cart-total.test.tsx +0 -60
- package/src/__tests__/customer-card.test.tsx +0 -47
- package/src/__tests__/customer-select.test.tsx +0 -33
- package/src/__tests__/filter-chip.test.tsx +0 -40
- package/src/__tests__/helpers.ts +0 -111
- package/src/__tests__/order-summary-line.test.tsx +0 -18
- package/src/__tests__/order-summary.test.tsx +0 -32
- package/src/__tests__/payment-method-card.test.tsx +0 -24
- package/src/__tests__/payment-selector.test.tsx +0 -23
- package/src/__tests__/product-card.test.tsx +0 -31
- package/src/__tests__/product-grid.test.tsx +0 -37
- package/src/__tests__/product-image.test.tsx +0 -54
- package/src/__tests__/product-price.test.tsx +0 -68
- package/src/__tests__/product-sku.test.tsx +0 -47
- package/src/__tests__/product-stock-badge.test.tsx +0 -58
- package/src/__tests__/product-title.test.tsx +0 -38
- package/src/__tests__/search-input.test.tsx +0 -22
|
@@ -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
|
+
}
|
package/src/product/index.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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, ...
|
|
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
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
37
|
+
* A virtualized product grid with optional search and filter slots.
|
|
23
38
|
*
|
|
24
|
-
* Uses
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
55
|
-
{
|
|
56
|
-
|
|
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
|
-
|
|
92
|
+
)}
|
|
93
|
+
/>
|
|
61
94
|
) : (
|
|
62
95
|
emptyState ?? null
|
|
63
96
|
)}
|
|
64
|
-
</
|
|
97
|
+
</VStack>
|
|
65
98
|
);
|
|
66
99
|
}
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import {
|
|
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<
|
|
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;
|
|
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
|
-
|
|
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
|
/>
|