@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
|
@@ -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,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { ScrollView, View
|
|
2
|
+
import { ScrollView, 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<
|
|
7
|
+
export interface ProductGridProps extends Omit<VStackProps, 'children'> {
|
|
6
8
|
/** Array of product documents to render */
|
|
7
9
|
items: any[];
|
|
8
10
|
/** Render function for each product */
|
|
@@ -15,6 +17,12 @@ export interface ProductGridProps extends Omit<ViewProps, 'children'> {
|
|
|
15
17
|
filterSlot?: ReactNode;
|
|
16
18
|
/** Content shown when items is empty */
|
|
17
19
|
emptyState?: ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Products the channel doesn't sell (`traits.product.isSellable` false,
|
|
22
|
+
* for example a Medusa product outside the sales channel) are hidden
|
|
23
|
+
* unless `showUnsellable`.
|
|
24
|
+
*/
|
|
25
|
+
showUnsellable?: boolean;
|
|
18
26
|
className?: string;
|
|
19
27
|
}
|
|
20
28
|
|
|
@@ -40,19 +48,30 @@ export function ProductGrid({
|
|
|
40
48
|
searchSlot,
|
|
41
49
|
filterSlot,
|
|
42
50
|
emptyState,
|
|
51
|
+
showUnsellable = false,
|
|
43
52
|
className,
|
|
44
53
|
...viewProps
|
|
45
54
|
}: ProductGridProps) {
|
|
46
|
-
|
|
55
|
+
// No <ConnectorProvider> above (e.g. a fixture with no connector): traits
|
|
56
|
+
// are unavailable, so nothing is filtered.
|
|
57
|
+
let traits: ReturnType<typeof useProductTraits> | undefined;
|
|
58
|
+
try {
|
|
59
|
+
traits = useProductTraits();
|
|
60
|
+
} catch {
|
|
61
|
+
traits = undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const visibleItems = showUnsellable || !traits ? items : items.filter((item) => traits!.isSellable(item));
|
|
65
|
+
const hasItems = visibleItems.length > 0;
|
|
47
66
|
|
|
48
67
|
return (
|
|
49
|
-
<
|
|
68
|
+
<VStack space="none" className={cn('flex-1', className)} {...viewProps}>
|
|
50
69
|
{searchSlot && <View className="px-3 py-2">{searchSlot}</View>}
|
|
51
70
|
{filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
|
|
52
71
|
|
|
53
72
|
{hasItems ? (
|
|
54
73
|
<ScrollView contentContainerClassName="flex-row flex-wrap p-1">
|
|
55
|
-
{
|
|
74
|
+
{visibleItems.map((item, index) => (
|
|
56
75
|
<View key={item.id ?? index} style={{ width: `${100 / numColumns}%` }} className="p-1">
|
|
57
76
|
{renderItem(item, index)}
|
|
58
77
|
</View>
|
|
@@ -61,6 +80,6 @@ export function ProductGrid({
|
|
|
61
80
|
) : (
|
|
62
81
|
emptyState ?? null
|
|
63
82
|
)}
|
|
64
|
-
</
|
|
83
|
+
</VStack>
|
|
65
84
|
);
|
|
66
85
|
}
|
|
@@ -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
|
/>
|
|
@@ -0,0 +1,44 @@
|
|
|
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 ProductListProps extends Omit<ViewProps, 'children'> {
|
|
7
|
+
items: any[];
|
|
8
|
+
renderItem: (item: any, index: number) => ReactNode;
|
|
9
|
+
searchSlot?: ReactNode;
|
|
10
|
+
filterSlot?: ReactNode;
|
|
11
|
+
emptyState?: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ProductList({
|
|
16
|
+
items,
|
|
17
|
+
renderItem,
|
|
18
|
+
searchSlot,
|
|
19
|
+
filterSlot,
|
|
20
|
+
emptyState,
|
|
21
|
+
className,
|
|
22
|
+
...viewProps
|
|
23
|
+
}: ProductListProps) {
|
|
24
|
+
const hasItems = items.length > 0;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View className={cn('flex-1', className)} {...viewProps}>
|
|
28
|
+
{searchSlot && <View className="px-3 py-2">{searchSlot}</View>}
|
|
29
|
+
{filterSlot && <View className="px-3 pb-2">{filterSlot}</View>}
|
|
30
|
+
|
|
31
|
+
{hasItems ? (
|
|
32
|
+
<ScrollView className="flex-1">
|
|
33
|
+
{items.map((item, index) => (
|
|
34
|
+
<View key={item.id ?? index} className="border-b border-border">
|
|
35
|
+
{renderItem(item, index)}
|
|
36
|
+
</View>
|
|
37
|
+
))}
|
|
38
|
+
</ScrollView>
|
|
39
|
+
) : (
|
|
40
|
+
emptyState ?? null
|
|
41
|
+
)}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -1,53 +1,108 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { useProductTraits } from '@tallyui/core';
|
|
1
|
+
import { formatMoney, minorUnitDigits, moneyToMajor, resolvePrice, resolvePriceRange, useProductTraits, useTraitContext } from '@tallyui/core';
|
|
2
|
+
import type { Money } from '@tallyui/core';
|
|
4
3
|
import { cn } from '@tallyui/theme';
|
|
4
|
+
import { Text, type TextProps } from '../ui';
|
|
5
5
|
|
|
6
6
|
export interface ProductPriceProps extends Omit<TextProps, 'children'> {
|
|
7
7
|
/** The raw RxDB product document (connector-specific shape) */
|
|
8
8
|
doc: any;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Currency to show when the product is priced in several. Defaults to the
|
|
11
|
+
* provider's store currency, then to the first currency listed.
|
|
12
|
+
*/
|
|
13
|
+
currency?: string;
|
|
14
|
+
/** BCP 47 locale for number formatting. Defaults to the runtime locale. */
|
|
15
|
+
locale?: string;
|
|
16
|
+
/** Symbol to prepend when the currency is unknown (defaults to '$') */
|
|
10
17
|
currencySymbol?: string;
|
|
18
|
+
/**
|
|
19
|
+
* When the connector's variants have differing prices, show
|
|
20
|
+
* `{fromLabel} <lowest price>` instead of the default variant's price.
|
|
21
|
+
* Defaults to true.
|
|
22
|
+
*/
|
|
23
|
+
showFromPrice?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Label shown before the lowest price when variant prices differ.
|
|
26
|
+
* @deprecated Use formatFrom.
|
|
27
|
+
*/
|
|
28
|
+
fromLabel?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Formats the lowest price when variant prices differ, for languages
|
|
31
|
+
* whose word order puts the label after the price. Defaults to
|
|
32
|
+
* `` (price) => `from ${price}` ``. Wins over `fromLabel` when both are given.
|
|
33
|
+
*/
|
|
34
|
+
formatFrom?: (price: string) => string;
|
|
11
35
|
className?: string;
|
|
12
36
|
}
|
|
13
37
|
|
|
14
38
|
/**
|
|
15
|
-
* Displays a product's price.
|
|
39
|
+
* Displays a product's price, and the price it replaces while on sale.
|
|
16
40
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
41
|
+
* Reads the backend-neutral price list (`getPrices`), so a WooCommerce sale
|
|
42
|
+
* price, a Medusa sale price list and a Shopify compare-at price all render
|
|
43
|
+
* the same way.
|
|
20
44
|
*
|
|
21
45
|
* ```tsx
|
|
22
|
-
* <ProductPrice doc={productDocument}
|
|
46
|
+
* <ProductPrice doc={productDocument} currency="EUR" />
|
|
23
47
|
* ```
|
|
24
48
|
*/
|
|
25
|
-
export function ProductPrice({ doc, currencySymbol = '$', className, ...textProps }: ProductPriceProps) {
|
|
26
|
-
const {
|
|
27
|
-
const
|
|
49
|
+
export function ProductPrice({ doc, currency, locale, currencySymbol = '$', className, showFromPrice = true, fromLabel, formatFrom, ...textProps }: ProductPriceProps) {
|
|
50
|
+
const { getPrices, getVariants } = useProductTraits();
|
|
51
|
+
const traitContext = useTraitContext();
|
|
52
|
+
const effectiveCurrency = currency ?? traitContext.currency;
|
|
53
|
+
|
|
54
|
+
// Unknown currency ('XXX'): plain decimal behind the fallback symbol.
|
|
55
|
+
const format = (money: Money) =>
|
|
56
|
+
formatMoney(money, locale)
|
|
57
|
+
?? `${currencySymbol}${moneyToMajor(money).toFixed(minorUnitDigits(money.currency))}`;
|
|
58
|
+
|
|
59
|
+
const variants = showFromPrice ? getVariants?.(doc, traitContext) : undefined;
|
|
60
|
+
const range = variants && variants.length >= 2 ? resolvePriceRange(variants, effectiveCurrency) : undefined;
|
|
61
|
+
|
|
62
|
+
const applyFormatFrom = formatFrom ?? (fromLabel !== undefined ? (price: string) => `${fromLabel} ${price}` : (price: string) => `from ${price}`);
|
|
63
|
+
|
|
64
|
+
if (range && range.min.amount !== range.max.amount) {
|
|
65
|
+
return (
|
|
66
|
+
<Text
|
|
67
|
+
className={cn('text-sm font-medium text-price', className)}
|
|
68
|
+
{...textProps}
|
|
69
|
+
style={[{ fontVariant: ['tabular-nums'] }, textProps.style]}
|
|
70
|
+
>
|
|
71
|
+
{applyFormatFrom(format(range.min))}
|
|
72
|
+
</Text>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const resolved = resolvePrice(getPrices(doc, traitContext), effectiveCurrency);
|
|
28
77
|
|
|
29
|
-
if (!
|
|
78
|
+
if (!resolved) {
|
|
30
79
|
return (
|
|
31
|
-
<Text className={cn('text-sm text-muted', className)} {...textProps}>
|
|
80
|
+
<Text className={cn('text-sm text-muted-foreground', className)} {...textProps}>
|
|
32
81
|
-
|
|
33
82
|
</Text>
|
|
34
83
|
);
|
|
35
84
|
}
|
|
36
85
|
|
|
37
|
-
if (
|
|
38
|
-
const salePrice = getSalePrice(doc);
|
|
39
|
-
const regularPrice = getRegularPrice(doc);
|
|
86
|
+
if (resolved.was) {
|
|
40
87
|
return (
|
|
41
|
-
<Text
|
|
42
|
-
{
|
|
43
|
-
{
|
|
88
|
+
<Text
|
|
89
|
+
className={cn('text-sm font-semibold text-sale', className)}
|
|
90
|
+
{...textProps}
|
|
91
|
+
style={[{ fontVariant: ['tabular-nums'] }, textProps.style]}
|
|
92
|
+
>
|
|
93
|
+
{format(resolved.current)}{' '}
|
|
94
|
+
<Text className="text-muted-foreground line-through" style={{ textDecorationLine: 'line-through' }}>(was {format(resolved.was)})</Text>
|
|
44
95
|
</Text>
|
|
45
96
|
);
|
|
46
97
|
}
|
|
47
98
|
|
|
48
99
|
return (
|
|
49
|
-
<Text
|
|
50
|
-
{
|
|
100
|
+
<Text
|
|
101
|
+
className={cn('text-sm font-medium text-price', className)}
|
|
102
|
+
{...textProps}
|
|
103
|
+
style={[{ fontVariant: ['tabular-nums'] }, textProps.style]}
|
|
104
|
+
>
|
|
105
|
+
{format(resolved.current)}
|
|
51
106
|
</Text>
|
|
52
107
|
);
|
|
53
108
|
}
|