@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1076 -76
- package/dist/index.js +4662 -355
- 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 +8 -9
- package/src/customer/customer-form.tsx +70 -0
- package/src/customer/customer-order-history.tsx +34 -0
- package/src/customer/customer-select.tsx +63 -24
- package/src/customer/index.ts +2 -0
- package/src/index.ts +112 -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 +25 -6
- 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 +155 -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 +131 -0
- package/src/sale/discount-form.tsx +85 -0
- package/src/sale/index.ts +9 -0
- package/src/sale/orders-list.tsx +66 -0
- package/src/sale/print-style.ts +11 -0
- package/src/sale/receipt.tsx +61 -0
- package/src/sale/sync-status.tsx +20 -0
- package/src/sale/tender.tsx +35 -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
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
87
|
+
<ToggleGroup.Root
|
|
88
|
+
type="multiple"
|
|
89
|
+
value={activeValues}
|
|
90
|
+
onValueChange={handleValueChange}
|
|
91
|
+
asChild
|
|
47
92
|
>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
}
|
package/src/input/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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';
|