@tallyui/components 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1076 -76
  3. package/dist/index.js +4662 -355
  4. package/package.json +27 -5
  5. package/src/cart/cart-line-actions.tsx +40 -0
  6. package/src/cart/cart-line.tsx +18 -32
  7. package/src/cart/cart-note-input.tsx +46 -0
  8. package/src/cart/cart-panel.tsx +39 -26
  9. package/src/cart/cart-total.tsx +43 -47
  10. package/src/cart/discount-badge.tsx +19 -0
  11. package/src/cart/index.ts +4 -1
  12. package/src/checkout/cash-tendered.tsx +101 -0
  13. package/src/checkout/change-display.tsx +19 -0
  14. package/src/checkout/index.ts +3 -0
  15. package/src/checkout/order-summary-line.tsx +7 -8
  16. package/src/checkout/order-summary.tsx +8 -7
  17. package/src/checkout/payment-method-card.tsx +10 -9
  18. package/src/checkout/payment-selector.tsx +5 -5
  19. package/src/checkout/receipt-preview.tsx +78 -0
  20. package/src/customer/customer-card.tsx +8 -9
  21. package/src/customer/customer-form.tsx +70 -0
  22. package/src/customer/customer-order-history.tsx +34 -0
  23. package/src/customer/customer-select.tsx +63 -24
  24. package/src/customer/index.ts +2 -0
  25. package/src/index.ts +112 -1
  26. package/src/input/filter-chip.tsx +85 -27
  27. package/src/input/index.ts +1 -0
  28. package/src/input/quantity-stepper.tsx +64 -0
  29. package/src/input/search-input.tsx +30 -7
  30. package/src/layout/checkout-layout.tsx +46 -0
  31. package/src/layout/index.ts +5 -0
  32. package/src/layout/live-tab-screen.tsx +54 -0
  33. package/src/layout/pos-layout.tsx +64 -0
  34. package/src/layout/settings-layout.tsx +58 -0
  35. package/src/layout/store-settings-choice-screen.tsx +142 -0
  36. package/src/order/index.ts +4 -0
  37. package/src/order/order-card.tsx +48 -0
  38. package/src/order/order-detail.tsx +82 -0
  39. package/src/order/order-list.tsx +39 -0
  40. package/src/order/order-status-badge.tsx +32 -0
  41. package/src/product/category-nav.tsx +70 -0
  42. package/src/product/index.ts +3 -0
  43. package/src/product/product-card.tsx +36 -8
  44. package/src/product/product-grid.tsx +25 -6
  45. package/src/product/product-image.tsx +28 -7
  46. package/src/product/product-list.tsx +44 -0
  47. package/src/product/product-price.tsx +77 -22
  48. package/src/product/product-sku.tsx +5 -5
  49. package/src/product/product-stock-badge.tsx +41 -28
  50. package/src/product/product-title.tsx +2 -3
  51. package/src/product/product-variant-picker.tsx +61 -0
  52. package/src/register/cash-count-input.tsx +85 -0
  53. package/src/register/closure-sheet.tsx +155 -0
  54. package/src/register/index.ts +12 -0
  55. package/src/register/movement-sheet.tsx +118 -0
  56. package/src/register/open-register-card.tsx +109 -0
  57. package/src/register/register-bar.helpers.ts +57 -0
  58. package/src/register/register-bar.tsx +85 -0
  59. package/src/register/register-column.tsx +117 -0
  60. package/src/register/register-count.tsx +330 -0
  61. package/src/register/register-open-close.tsx +52 -0
  62. package/src/register/register-panel.tsx +151 -0
  63. package/src/register/register-picker.tsx +60 -0
  64. package/src/register/register-summary.tsx +72 -0
  65. package/src/sale/cart-bar.tsx +18 -0
  66. package/src/sale/cart.tsx +66 -0
  67. package/src/sale/catalogue.tsx +131 -0
  68. package/src/sale/discount-form.tsx +85 -0
  69. package/src/sale/index.ts +9 -0
  70. package/src/sale/orders-list.tsx +66 -0
  71. package/src/sale/print-style.ts +11 -0
  72. package/src/sale/receipt.tsx +61 -0
  73. package/src/sale/sync-status.tsx +20 -0
  74. package/src/sale/tender.tsx +35 -0
  75. package/src/settings/connector-status.tsx +65 -0
  76. package/src/settings/index.ts +3 -0
  77. package/src/settings/settings-group.tsx +23 -0
  78. package/src/settings/settings-row.tsx +23 -0
  79. package/src/ui/accordion/index.tsx +59 -0
  80. package/src/ui/alert-dialog/index.tsx +91 -0
  81. package/src/ui/avatar/index.tsx +50 -0
  82. package/src/ui/badge/index.tsx +60 -0
  83. package/src/ui/button/index.tsx +90 -0
  84. package/src/ui/card/index.tsx +60 -0
  85. package/src/ui/checkbox/index.tsx +29 -0
  86. package/src/ui/collapsible/index.tsx +24 -0
  87. package/src/ui/combobox/index.tsx +98 -0
  88. package/src/ui/context-menu/index.tsx +159 -0
  89. package/src/ui/dialog/index.tsx +92 -0
  90. package/src/ui/dropdown-menu/index.tsx +161 -0
  91. package/src/ui/hover-card/index.tsx +31 -0
  92. package/src/ui/hstack/index.tsx +43 -0
  93. package/src/ui/icon/index.tsx +44 -0
  94. package/src/ui/icon/search-icon.tsx +9 -0
  95. package/src/ui/icon/svg-icon.tsx +36 -0
  96. package/src/ui/icon-button/index.tsx +58 -0
  97. package/src/ui/index.ts +99 -0
  98. package/src/ui/input/index.tsx +100 -0
  99. package/src/ui/label/index.tsx +26 -0
  100. package/src/ui/loader/index.tsx +37 -0
  101. package/src/ui/popover/index.tsx +31 -0
  102. package/src/ui/progress/index.tsx +28 -0
  103. package/src/ui/radio-group/index.tsx +38 -0
  104. package/src/ui/select/index.tsx +96 -0
  105. package/src/ui/separator/index.tsx +29 -0
  106. package/src/ui/slider/index.tsx +28 -0
  107. package/src/ui/switch/index.tsx +31 -0
  108. package/src/ui/table/index.tsx +59 -0
  109. package/src/ui/tabs/index.tsx +53 -0
  110. package/src/ui/text/index.tsx +36 -0
  111. package/src/ui/textarea/index.tsx +29 -0
  112. package/src/ui/toast/index.tsx +52 -0
  113. package/src/ui/toggle/index.tsx +69 -0
  114. package/src/ui/toggle-group/index.tsx +66 -0
  115. package/src/ui/tooltip/index.tsx +30 -0
  116. package/src/ui/vstack/index.tsx +43 -0
  117. package/src/uniwind-env.d.ts +9 -1
  118. package/src/__tests__/cart-line.test.tsx +0 -53
  119. package/src/__tests__/cart-panel.test.tsx +0 -55
  120. package/src/__tests__/cart-total.test.tsx +0 -60
  121. package/src/__tests__/customer-card.test.tsx +0 -47
  122. package/src/__tests__/customer-select.test.tsx +0 -33
  123. package/src/__tests__/filter-chip.test.tsx +0 -40
  124. package/src/__tests__/helpers.ts +0 -111
  125. package/src/__tests__/order-summary-line.test.tsx +0 -18
  126. package/src/__tests__/order-summary.test.tsx +0 -32
  127. package/src/__tests__/payment-method-card.test.tsx +0 -24
  128. package/src/__tests__/payment-selector.test.tsx +0 -23
  129. package/src/__tests__/product-card.test.tsx +0 -31
  130. package/src/__tests__/product-grid.test.tsx +0 -37
  131. package/src/__tests__/product-image.test.tsx +0 -54
  132. package/src/__tests__/product-price.test.tsx +0 -68
  133. package/src/__tests__/product-sku.test.tsx +0 -47
  134. package/src/__tests__/product-stock-badge.test.tsx +0 -58
  135. package/src/__tests__/product-title.test.tsx +0 -38
  136. package/src/__tests__/search-input.test.tsx +0 -22
@@ -0,0 +1,48 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ import { OrderStatusBadge, type OrderStatus } from './order-status-badge';
6
+
7
+ export interface OrderCardProps extends Omit<ViewProps, 'children'> {
8
+ orderNumber: string;
9
+ date: string;
10
+ total: string;
11
+ status: OrderStatus;
12
+ customerName?: string;
13
+ onPress?: () => void;
14
+ className?: string;
15
+ }
16
+
17
+ export function OrderCard({
18
+ orderNumber,
19
+ date,
20
+ total,
21
+ status,
22
+ customerName,
23
+ onPress,
24
+ className,
25
+ ...viewProps
26
+ }: OrderCardProps) {
27
+ const Wrapper = onPress ? Pressable : View;
28
+
29
+ return (
30
+ <Wrapper
31
+ onPress={onPress}
32
+ className={cn('gap-2 rounded-lg border border-border bg-card p-3', className)}
33
+ {...viewProps}
34
+ >
35
+ <View className="flex-row items-center justify-between">
36
+ <Text className="text-sm font-semibold text-foreground">{orderNumber}</Text>
37
+ <OrderStatusBadge status={status} />
38
+ </View>
39
+ <View className="flex-row items-center justify-between">
40
+ <View className="gap-0.5">
41
+ <Text className="text-xs text-muted-foreground">{date}</Text>
42
+ {customerName && <Text className="text-xs text-muted-foreground">{customerName}</Text>}
43
+ </View>
44
+ <Text className="text-sm font-semibold text-foreground">{total}</Text>
45
+ </View>
46
+ </Wrapper>
47
+ );
48
+ }
@@ -0,0 +1,82 @@
1
+ import type { ReactNode } from 'react';
2
+ import { ScrollView, Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ import { OrderStatusBadge, type OrderStatus } from './order-status-badge';
7
+
8
+ export interface OrderDetailLineItem {
9
+ name: string;
10
+ quantity: number;
11
+ total: string;
12
+ }
13
+
14
+ export interface OrderDetailProps extends Omit<ViewProps, 'children'> {
15
+ orderNumber: string;
16
+ status: OrderStatus;
17
+ lineItems: OrderDetailLineItem[];
18
+ subtotal: string;
19
+ tax: string;
20
+ total: string;
21
+ customerSlot?: ReactNode;
22
+ paymentSlot?: ReactNode;
23
+ footerSlot?: ReactNode;
24
+ className?: string;
25
+ }
26
+
27
+ export function OrderDetail({
28
+ orderNumber,
29
+ status,
30
+ lineItems,
31
+ subtotal,
32
+ tax,
33
+ total,
34
+ customerSlot,
35
+ paymentSlot,
36
+ footerSlot,
37
+ className,
38
+ ...viewProps
39
+ }: OrderDetailProps) {
40
+ return (
41
+ <ScrollView className={cn('flex-1', className)} {...viewProps}>
42
+ <View className="gap-4 p-4">
43
+ <View className="flex-row items-center justify-between">
44
+ <Text className="text-lg font-bold text-foreground">{orderNumber}</Text>
45
+ <OrderStatusBadge status={status} />
46
+ </View>
47
+
48
+ {customerSlot && <View className="border-b border-border pb-3">{customerSlot}</View>}
49
+
50
+ <View className="gap-1">
51
+ <Text className="text-xs font-semibold text-muted-foreground">Items</Text>
52
+ {lineItems.map((item, i) => (
53
+ <View key={i} className="flex-row items-center justify-between py-1">
54
+ <Text className="flex-1 text-sm text-foreground">
55
+ {item.name} x{item.quantity}
56
+ </Text>
57
+ <Text className="text-sm text-foreground">{item.total}</Text>
58
+ </View>
59
+ ))}
60
+ </View>
61
+
62
+ <View className="gap-1 border-t border-border pt-3">
63
+ <View className="flex-row justify-between">
64
+ <Text className="text-sm text-muted-foreground">Subtotal</Text>
65
+ <Text className="text-sm text-foreground">{subtotal}</Text>
66
+ </View>
67
+ <View className="flex-row justify-between">
68
+ <Text className="text-sm text-muted-foreground">Tax</Text>
69
+ <Text className="text-sm text-foreground">{tax}</Text>
70
+ </View>
71
+ <View className="flex-row justify-between pt-1">
72
+ <Text className="text-sm font-bold text-foreground">Total</Text>
73
+ <Text className="text-sm font-bold text-foreground">{total}</Text>
74
+ </View>
75
+ </View>
76
+
77
+ {paymentSlot && <View className="border-t border-border pt-3">{paymentSlot}</View>}
78
+ {footerSlot && <View className="border-t border-border pt-3">{footerSlot}</View>}
79
+ </View>
80
+ </ScrollView>
81
+ );
82
+ }
@@ -0,0 +1,39 @@
1
+ import type { ReactNode } from 'react';
2
+ import { ScrollView, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface OrderListProps extends Omit<ViewProps, 'children'> {
7
+ items: any[];
8
+ renderItem: (item: any, index: number) => ReactNode;
9
+ headerSlot?: ReactNode;
10
+ emptyState?: ReactNode;
11
+ className?: string;
12
+ }
13
+
14
+ export function OrderList({
15
+ items,
16
+ renderItem,
17
+ headerSlot,
18
+ emptyState,
19
+ className,
20
+ ...viewProps
21
+ }: OrderListProps) {
22
+ const hasItems = items.length > 0;
23
+
24
+ return (
25
+ <View className={cn('flex-1', className)} {...viewProps}>
26
+ {headerSlot && <View className="px-3 py-2">{headerSlot}</View>}
27
+
28
+ {hasItems ? (
29
+ <ScrollView contentContainerClassName="gap-2 p-3">
30
+ {items.map((item, index) => (
31
+ <View key={item.id ?? index}>{renderItem(item, index)}</View>
32
+ ))}
33
+ </ScrollView>
34
+ ) : (
35
+ emptyState ?? null
36
+ )}
37
+ </View>
38
+ );
39
+ }
@@ -0,0 +1,32 @@
1
+ import { Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export type OrderStatus = 'pending' | 'processing' | 'completed' | 'refunded' | 'cancelled';
6
+
7
+ export interface OrderStatusBadgeProps extends Omit<ViewProps, 'children'> {
8
+ status: OrderStatus;
9
+ className?: string;
10
+ }
11
+
12
+ const STATUS_CONFIG: Record<OrderStatus, { label: string; badge: string; dot: string; text: string }> = {
13
+ pending: { label: 'Pending', badge: 'bg-warning/15', dot: 'bg-warning', text: 'text-foreground' },
14
+ processing: { label: 'Processing', badge: 'bg-info/15', dot: 'bg-info', text: 'text-foreground' },
15
+ completed: { label: 'Completed', badge: 'bg-success/15', dot: 'bg-success', text: 'text-foreground' },
16
+ refunded: { label: 'Refunded', badge: 'bg-destructive/15', dot: 'bg-destructive', text: 'text-foreground' },
17
+ cancelled: { label: 'Cancelled', badge: 'bg-muted/15', dot: 'bg-muted', text: 'text-muted-foreground' },
18
+ };
19
+
20
+ export function OrderStatusBadge({ status, className, ...viewProps }: OrderStatusBadgeProps) {
21
+ const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
22
+
23
+ return (
24
+ <View
25
+ className={cn('flex-row items-center self-start gap-1.5 rounded-xl px-2 py-1', config.badge, className)}
26
+ {...viewProps}
27
+ >
28
+ <View className={cn('h-1.5 w-1.5 rounded-full', config.dot)} />
29
+ <Text className={cn('text-xs font-semibold', config.text)}>{config.label}</Text>
30
+ </View>
31
+ );
32
+ }
@@ -0,0 +1,70 @@
1
+ import { Pressable, ScrollView, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export interface CategoryItem {
6
+ id: string;
7
+ name: string;
8
+ }
9
+
10
+ export interface CategoryNavProps extends Omit<ViewProps, 'children'> {
11
+ categories: CategoryItem[];
12
+ selectedId: string;
13
+ onSelect: (id: string) => void;
14
+ orientation?: 'horizontal' | 'vertical';
15
+ className?: string;
16
+ }
17
+
18
+ export function CategoryNav({
19
+ categories,
20
+ selectedId,
21
+ onSelect,
22
+ orientation = 'horizontal',
23
+ className,
24
+ ...viewProps
25
+ }: CategoryNavProps) {
26
+ const isHorizontal = orientation === 'horizontal';
27
+
28
+ const content = categories.map((cat) => {
29
+ const active = cat.id === selectedId;
30
+ return (
31
+ <Pressable
32
+ key={cat.id}
33
+ onPress={() => onSelect(cat.id)}
34
+ className={cn(
35
+ 'rounded-full px-3 py-1.5',
36
+ active ? 'bg-primary' : 'bg-muted',
37
+ )}
38
+ >
39
+ <Text
40
+ className={cn(
41
+ 'text-sm font-medium',
42
+ active ? 'text-primary-foreground' : 'text-foreground',
43
+ )}
44
+ >
45
+ {cat.name}
46
+ </Text>
47
+ </Pressable>
48
+ );
49
+ });
50
+
51
+ if (isHorizontal) {
52
+ return (
53
+ <ScrollView
54
+ horizontal
55
+ showsHorizontalScrollIndicator={false}
56
+ contentContainerClassName="flex-row gap-2 px-3 py-2"
57
+ className={className}
58
+ {...viewProps}
59
+ >
60
+ {content}
61
+ </ScrollView>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <View className={cn('gap-1 p-2', className)} {...viewProps}>
67
+ {content}
68
+ </View>
69
+ );
70
+ }
@@ -5,3 +5,6 @@ export { ProductSku, type ProductSkuProps } from './product-sku';
5
5
  export { ProductStockBadge, type ProductStockBadgeProps } from './product-stock-badge';
6
6
  export { ProductCard, type ProductCardProps } from './product-card';
7
7
  export { ProductGrid, type ProductGridProps } from './product-grid';
8
+ export { ProductList, type ProductListProps } from './product-list';
9
+ export { CategoryNav, type CategoryNavProps, type CategoryItem } from './category-nav';
10
+ export { ProductVariantPicker, type ProductVariantPickerProps, type VariantOption } from './product-variant-picker';
@@ -1,26 +1,54 @@
1
- import { Pressable, View, type ViewProps } from 'react-native';
1
+ import { Pressable } from 'react-native';
2
2
  import { cn } from '@tallyui/theme';
3
+ import { useProductTraits } from '@tallyui/core';
4
+ import { VStack, Text, type VStackProps } from '../ui';
3
5
  import { ProductImage } from './product-image';
4
6
  import { ProductTitle } from './product-title';
5
7
  import { ProductPrice } from './product-price';
6
8
 
7
- export interface ProductCardProps extends Omit<ViewProps, 'children'> {
9
+ export interface ProductCardProps extends Omit<VStackProps, 'children'> {
8
10
  doc: any;
9
11
  onPress?: () => void;
10
12
  imageSize?: number;
11
13
  currencySymbol?: string;
14
+ /** Shown in place of the price when the channel doesn't sell this product. */
15
+ notSoldLabel?: string;
12
16
  className?: string;
13
17
  }
14
18
 
15
- export function ProductCard({ doc, onPress, imageSize = 80, currencySymbol, className, ...viewProps }: ProductCardProps) {
19
+ export function ProductCard({ doc, onPress, imageSize = 80, currencySymbol, notSoldLabel = 'Not sold here', className, ...props }: ProductCardProps) {
20
+ // No <ConnectorProvider> above: traits are unavailable, so the card
21
+ // renders as an ordinary, sellable product.
22
+ let traits: ReturnType<typeof useProductTraits> | undefined;
23
+ try {
24
+ traits = useProductTraits();
25
+ } catch {
26
+ traits = undefined;
27
+ }
28
+ const sellable = !traits || traits.isSellable(doc);
29
+
16
30
  const content = (
17
- <View className={cn('items-center gap-2 rounded-lg bg-surface p-3', className)} {...viewProps}>
18
- <ProductImage doc={doc} size={imageSize} className="rounded-md" />
19
- <ProductTitle doc={doc} className="text-sm" numberOfLines={2} />
20
- <ProductPrice doc={doc} currencySymbol={currencySymbol} />
21
- </View>
31
+ <VStack
32
+ space="sm"
33
+ className={cn('items-center rounded-lg border border-border bg-card p-3', className)}
34
+ {...props}
35
+ >
36
+ {/* Unsellable: the card keeps its opaque surface and only its contents dim, so
37
+ it reads as lower contrast than its neighbours on any backdrop, in both themes. */}
38
+ <ProductImage doc={doc} size={imageSize} className={cn('rounded-md', !sellable && 'opacity-50')} />
39
+ <ProductTitle doc={doc} className={cn('text-sm', !sellable && 'opacity-50')} numberOfLines={2} />
40
+ {sellable ? (
41
+ <ProductPrice doc={doc} currencySymbol={currencySymbol} />
42
+ ) : (
43
+ <Text className="text-sm text-muted-foreground">{notSoldLabel}</Text>
44
+ )}
45
+ </VStack>
22
46
  );
23
47
 
48
+ // Not sold in this channel: shown for awareness only (opt-in or the demo), never tappable.
49
+ if (!sellable) {
50
+ return <Pressable disabled aria-disabled onPress={undefined}>{content}</Pressable>;
51
+ }
24
52
  if (onPress) {
25
53
  return <Pressable onPress={onPress}>{content}</Pressable>;
26
54
  }
@@ -1,8 +1,10 @@
1
1
  import type { ReactNode } from 'react';
2
- import { ScrollView, View, type ViewProps } from 'react-native';
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<ViewProps, 'children'> {
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
- const hasItems = items.length > 0;
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
- <View className={cn('flex-1', className)} {...viewProps}>
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
- {items.map((item, index) => (
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
- </View>
83
+ </VStack>
65
84
  );
66
85
  }
@@ -1,15 +1,22 @@
1
- import { Image, type ImageProps, type ImageStyle, type StyleProp } from 'react-native';
1
+ import { View, type ImageStyle, type StyleProp } from 'react-native';
2
+ import { Image } from '@tallyui/primitives';
2
3
 
3
4
  import { useProductTraits } from '@tallyui/core';
4
5
  import { cn } from '@tallyui/theme';
6
+ import { Text } from '../ui';
5
7
 
6
- export interface ProductImageProps extends Omit<ImageProps, 'source'> {
8
+ export interface ProductImageProps extends Omit<React.ComponentPropsWithoutRef<typeof Image.Root>, 'source'> {
7
9
  /** The raw RxDB product document */
8
10
  doc: any;
9
11
  /** Image dimensions */
10
12
  size?: number;
11
13
  /** Style overrides (for dynamic sizing) */
12
14
  style?: StyleProp<ImageStyle>;
15
+ /**
16
+ * Render a same-size tile with the product's initial when there is no
17
+ * image, so rows and grids stay aligned. Defaults to false (render nothing).
18
+ */
19
+ showPlaceholder?: boolean;
13
20
  className?: string;
14
21
  }
15
22
 
@@ -22,18 +29,32 @@ export interface ProductImageProps extends Omit<ImageProps, 'source'> {
22
29
  * <ProductImage doc={productDocument} size={80} className="rounded-lg" />
23
30
  * ```
24
31
  */
25
- export function ProductImage({ doc, size = 60, style, className, ...imageProps }: ProductImageProps) {
26
- const { getImageUrl } = useProductTraits();
32
+ export function ProductImage({ doc, size = 60, style, showPlaceholder = false, className, ...imageProps }: ProductImageProps) {
33
+ const { getImageUrl, getName } = useProductTraits();
27
34
  const imageUrl = getImageUrl(doc);
28
35
 
29
36
  if (!imageUrl) {
30
- return null; // TODO: placeholder image
37
+ if (!showPlaceholder) return null;
38
+ const name = getName(doc);
39
+ return (
40
+ <View
41
+ role="img"
42
+ aria-label={name}
43
+ className={cn('items-center justify-center rounded border border-border bg-primary/10', className)}
44
+ style={{ width: size, height: size }}
45
+ >
46
+ <Text className="font-semibold text-primary" style={{ fontSize: size * 0.4 }}>
47
+ {name.trim().charAt(0).toUpperCase() || '?'}
48
+ </Text>
49
+ </View>
50
+ );
31
51
  }
32
52
 
33
53
  return (
34
- <Image
54
+ <Image.Root
35
55
  source={{ uri: imageUrl }}
36
- className={cn('rounded', className)}
56
+ alt={getName(doc)}
57
+ className={cn('rounded border border-border bg-card', className)}
37
58
  style={[{ width: size, height: size }, style]}
38
59
  {...imageProps}
39
60
  />
@@ -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 { Text, type TextProps } from 'react-native';
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
- /** Currency symbol to prepend (defaults to '$') */
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
- * Works with any connector — extracts price via traits regardless
18
- * of whether it's a string field (WooCommerce) or derived from
19
- * variant pricing (Medusa).
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} currencySymbol="$" />
46
+ * <ProductPrice doc={productDocument} currency="EUR" />
23
47
  * ```
24
48
  */
25
- export function ProductPrice({ doc, currencySymbol = '$', className, ...textProps }: ProductPriceProps) {
26
- const { getPrice, isOnSale, getSalePrice, getRegularPrice } = useProductTraits();
27
- const price = getPrice(doc);
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 (!price) {
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 (isOnSale(doc)) {
38
- const salePrice = getSalePrice(doc);
39
- const regularPrice = getRegularPrice(doc);
86
+ if (resolved.was) {
40
87
  return (
41
- <Text className={cn('text-sm font-medium text-sale', className)} {...textProps}>
42
- {currencySymbol}{salePrice ?? price}
43
- {regularPrice ? ` (was ${currencySymbol}${regularPrice})` : ''}
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 className={cn('text-sm font-medium text-price', className)} {...textProps}>
50
- {currencySymbol}{price}
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
  }