@tallyui/components 1.0.0 → 3.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1113 -83
  3. package/dist/index.js +4936 -353
  4. package/package.json +27 -5
  5. package/src/cart/cart-line-actions.tsx +40 -0
  6. package/src/cart/cart-line.tsx +18 -32
  7. package/src/cart/cart-note-input.tsx +46 -0
  8. package/src/cart/cart-panel.tsx +39 -26
  9. package/src/cart/cart-total.tsx +43 -47
  10. package/src/cart/discount-badge.tsx +19 -0
  11. package/src/cart/index.ts +4 -1
  12. package/src/checkout/cash-tendered.tsx +101 -0
  13. package/src/checkout/change-display.tsx +19 -0
  14. package/src/checkout/index.ts +3 -0
  15. package/src/checkout/order-summary-line.tsx +7 -8
  16. package/src/checkout/order-summary.tsx +8 -7
  17. package/src/checkout/payment-method-card.tsx +10 -9
  18. package/src/checkout/payment-selector.tsx +5 -5
  19. package/src/checkout/receipt-preview.tsx +78 -0
  20. package/src/customer/customer-card.tsx +16 -12
  21. package/src/customer/customer-form.tsx +72 -0
  22. package/src/customer/customer-order-history.tsx +34 -0
  23. package/src/customer/customer-picker.tsx +124 -0
  24. package/src/customer/customer-select.tsx +80 -31
  25. package/src/customer/index.ts +3 -0
  26. package/src/index.ts +114 -1
  27. package/src/input/filter-chip.tsx +85 -27
  28. package/src/input/index.ts +1 -0
  29. package/src/input/quantity-stepper.tsx +64 -0
  30. package/src/input/search-input.tsx +30 -7
  31. package/src/layout/checkout-layout.tsx +46 -0
  32. package/src/layout/index.ts +5 -0
  33. package/src/layout/live-tab-screen.tsx +54 -0
  34. package/src/layout/pos-layout.tsx +64 -0
  35. package/src/layout/settings-layout.tsx +58 -0
  36. package/src/layout/store-settings-choice-screen.tsx +142 -0
  37. package/src/order/index.ts +4 -0
  38. package/src/order/order-card.tsx +48 -0
  39. package/src/order/order-detail.tsx +82 -0
  40. package/src/order/order-list.tsx +39 -0
  41. package/src/order/order-status-badge.tsx +32 -0
  42. package/src/product/category-nav.tsx +70 -0
  43. package/src/product/index.ts +3 -0
  44. package/src/product/product-card.tsx +36 -8
  45. package/src/product/product-grid.tsx +46 -13
  46. package/src/product/product-image.tsx +28 -7
  47. package/src/product/product-list.tsx +44 -0
  48. package/src/product/product-price.tsx +77 -22
  49. package/src/product/product-sku.tsx +5 -5
  50. package/src/product/product-stock-badge.tsx +41 -28
  51. package/src/product/product-title.tsx +2 -3
  52. package/src/product/product-variant-picker.tsx +61 -0
  53. package/src/register/cash-count-input.tsx +85 -0
  54. package/src/register/closure-sheet.tsx +161 -0
  55. package/src/register/index.ts +12 -0
  56. package/src/register/movement-sheet.tsx +118 -0
  57. package/src/register/open-register-card.tsx +109 -0
  58. package/src/register/register-bar.helpers.ts +57 -0
  59. package/src/register/register-bar.tsx +85 -0
  60. package/src/register/register-column.tsx +117 -0
  61. package/src/register/register-count.tsx +330 -0
  62. package/src/register/register-open-close.tsx +52 -0
  63. package/src/register/register-panel.tsx +151 -0
  64. package/src/register/register-picker.tsx +60 -0
  65. package/src/register/register-summary.tsx +72 -0
  66. package/src/sale/cart-bar.tsx +18 -0
  67. package/src/sale/cart.tsx +66 -0
  68. package/src/sale/catalogue.tsx +139 -0
  69. package/src/sale/discount-form.tsx +85 -0
  70. package/src/sale/index.ts +9 -0
  71. package/src/sale/orders-list.tsx +108 -0
  72. package/src/sale/print-style.ts +11 -0
  73. package/src/sale/receipt.tsx +62 -0
  74. package/src/sale/sync-status.tsx +141 -0
  75. package/src/sale/tender.tsx +38 -0
  76. package/src/settings/connector-status.tsx +65 -0
  77. package/src/settings/index.ts +3 -0
  78. package/src/settings/settings-group.tsx +23 -0
  79. package/src/settings/settings-row.tsx +23 -0
  80. package/src/ui/accordion/index.tsx +59 -0
  81. package/src/ui/alert-dialog/index.tsx +91 -0
  82. package/src/ui/avatar/index.tsx +50 -0
  83. package/src/ui/badge/index.tsx +60 -0
  84. package/src/ui/button/index.tsx +90 -0
  85. package/src/ui/card/index.tsx +60 -0
  86. package/src/ui/checkbox/index.tsx +29 -0
  87. package/src/ui/collapsible/index.tsx +24 -0
  88. package/src/ui/combobox/index.tsx +98 -0
  89. package/src/ui/context-menu/index.tsx +159 -0
  90. package/src/ui/dialog/index.tsx +92 -0
  91. package/src/ui/dropdown-menu/index.tsx +161 -0
  92. package/src/ui/hover-card/index.tsx +31 -0
  93. package/src/ui/hstack/index.tsx +43 -0
  94. package/src/ui/icon/index.tsx +44 -0
  95. package/src/ui/icon/search-icon.tsx +9 -0
  96. package/src/ui/icon/svg-icon.tsx +36 -0
  97. package/src/ui/icon-button/index.tsx +58 -0
  98. package/src/ui/index.ts +99 -0
  99. package/src/ui/input/index.tsx +100 -0
  100. package/src/ui/label/index.tsx +26 -0
  101. package/src/ui/loader/index.tsx +37 -0
  102. package/src/ui/popover/index.tsx +31 -0
  103. package/src/ui/progress/index.tsx +28 -0
  104. package/src/ui/radio-group/index.tsx +38 -0
  105. package/src/ui/select/index.tsx +96 -0
  106. package/src/ui/separator/index.tsx +29 -0
  107. package/src/ui/slider/index.tsx +28 -0
  108. package/src/ui/switch/index.tsx +31 -0
  109. package/src/ui/table/index.tsx +59 -0
  110. package/src/ui/tabs/index.tsx +53 -0
  111. package/src/ui/text/index.tsx +36 -0
  112. package/src/ui/textarea/index.tsx +29 -0
  113. package/src/ui/toast/index.tsx +52 -0
  114. package/src/ui/toggle/index.tsx +69 -0
  115. package/src/ui/toggle-group/index.tsx +66 -0
  116. package/src/ui/tooltip/index.tsx +30 -0
  117. package/src/ui/vstack/index.tsx +43 -0
  118. package/src/uniwind-env.d.ts +9 -1
  119. package/src/__tests__/cart-line.test.tsx +0 -53
  120. package/src/__tests__/cart-panel.test.tsx +0 -55
  121. package/src/__tests__/cart-total.test.tsx +0 -60
  122. package/src/__tests__/customer-card.test.tsx +0 -47
  123. package/src/__tests__/customer-select.test.tsx +0 -33
  124. package/src/__tests__/filter-chip.test.tsx +0 -40
  125. package/src/__tests__/helpers.ts +0 -111
  126. package/src/__tests__/order-summary-line.test.tsx +0 -18
  127. package/src/__tests__/order-summary.test.tsx +0 -32
  128. package/src/__tests__/payment-method-card.test.tsx +0 -24
  129. package/src/__tests__/payment-selector.test.tsx +0 -23
  130. package/src/__tests__/product-card.test.tsx +0 -31
  131. package/src/__tests__/product-grid.test.tsx +0 -37
  132. package/src/__tests__/product-image.test.tsx +0 -54
  133. package/src/__tests__/product-price.test.tsx +0 -68
  134. package/src/__tests__/product-sku.test.tsx +0 -47
  135. package/src/__tests__/product-stock-badge.test.tsx +0 -58
  136. package/src/__tests__/product-title.test.tsx +0 -38
  137. package/src/__tests__/search-input.test.tsx +0 -22
@@ -0,0 +1,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
  }
@@ -1,7 +1,6 @@
1
- import { Text, type TextProps } from 'react-native';
2
-
3
1
  import { useProductTraits } from '@tallyui/core';
4
2
  import { cn } from '@tallyui/theme';
3
+ import { Text, type TextProps } from '../ui';
5
4
 
6
5
  export interface ProductSkuProps extends Omit<TextProps, 'children'> {
7
6
  /** The raw RxDB product document (connector-specific shape) */
@@ -22,12 +21,13 @@ export interface ProductSkuProps extends Omit<TextProps, 'children'> {
22
21
  * ```
23
22
  */
24
23
  export function ProductSku({ doc, fallback = '—', className, ...textProps }: ProductSkuProps) {
25
- const { getSku } = useProductTraits();
24
+ const { getSku, getVariantCount } = useProductTraits();
25
+ const count = getVariantCount(doc);
26
26
  const sku = getSku(doc);
27
27
 
28
28
  return (
29
- <Text className={cn('text-xs text-muted', className)} {...textProps}>
30
- {sku ?? fallback}
29
+ <Text className={cn('text-xs text-muted-foreground', className)} {...textProps}>
30
+ {count > 1 ? `${count} variants` : sku ?? fallback}
31
31
  </Text>
32
32
  );
33
33
  }
@@ -1,73 +1,86 @@
1
- import { Text, View, type ViewProps } from 'react-native';
1
+ import { View } from 'react-native';
2
2
 
3
- import { useProductTraits } from '@tallyui/core';
3
+ import { useProductStock } from '@tallyui/core';
4
+ import type { StockStatus } from '@tallyui/core';
4
5
  import { cn } from '@tallyui/theme';
6
+ import { Text, HStack, type HStackProps } from '../ui';
5
7
 
6
- export interface ProductStockBadgeProps extends Omit<ViewProps, 'children'> {
8
+ export interface ProductStockBadgeProps extends Omit<HStackProps, 'children'> {
7
9
  /** The raw RxDB product document (connector-specific shape) */
8
10
  doc: any;
9
11
  /** Whether to show the quantity alongside the status */
10
12
  showQuantity?: boolean;
13
+ /** Append when reconciled stock was last confirmed, if the provider gives it (default true) */
14
+ showAsOf?: boolean;
11
15
  className?: string;
12
16
  }
13
17
 
14
- const statusLabels: Record<string, string> = {
15
- instock: 'In Stock',
16
- outofstock: 'Out of Stock',
17
- onbackorder: 'On Backorder',
18
+ const statusLabels: Record<StockStatus, string> = {
19
+ in_stock: 'In Stock',
20
+ out_of_stock: 'Out of Stock',
21
+ backorder: 'On Backorder',
18
22
  unknown: 'Unknown',
19
23
  };
20
24
 
21
- const STATUS_STYLES: Record<string, { badge: string; dot: string; text: string }> = {
22
- instock: {
25
+ const STATUS_STYLES: Record<StockStatus, { badge: string; dot: string; text: string }> = {
26
+ in_stock: {
23
27
  badge: 'bg-success/15',
24
28
  dot: 'bg-success',
25
- text: 'text-success',
29
+ text: 'text-foreground',
26
30
  },
27
- outofstock: {
28
- badge: 'bg-danger/15',
29
- dot: 'bg-danger',
30
- text: 'text-danger',
31
+ out_of_stock: {
32
+ badge: 'bg-destructive/15',
33
+ dot: 'bg-destructive',
34
+ text: 'text-foreground',
31
35
  },
32
- onbackorder: {
36
+ backorder: {
33
37
  badge: 'bg-warning/15',
34
38
  dot: 'bg-warning',
35
- text: 'text-warning',
39
+ text: 'text-foreground',
36
40
  },
37
41
  unknown: {
38
42
  badge: 'bg-muted/15',
39
43
  dot: 'bg-muted',
40
- text: 'text-muted',
44
+ text: 'text-muted-foreground',
41
45
  },
42
46
  };
43
47
 
48
+ /** Local HH:MM when `iso` is today, otherwise a short local date. */
49
+ function formatAsOf(iso: string): string {
50
+ const date = new Date(iso);
51
+ return date.toDateString() === new Date().toDateString()
52
+ ? date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
53
+ : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
54
+ }
55
+
44
56
  /**
45
57
  * Displays a visual stock status badge for a product.
46
58
  *
47
- * Shows a colored label (In Stock / Out of Stock / On Backorder)
48
- * with an optional quantity count.
59
+ * Shows a label (In Stock / Out of Stock / On Backorder) with a dot carrying the colour
60
+ * with an optional quantity count. When the ConnectorProvider is given a
61
+ * reconciled stock overlay, it shows that stock and when it was confirmed.
49
62
  *
50
63
  * ```tsx
51
64
  * <ProductStockBadge doc={productDocument} showQuantity />
52
65
  * ```
53
66
  */
54
- export function ProductStockBadge({ doc, showQuantity = false, className, ...viewProps }: ProductStockBadgeProps) {
55
- const { getStockStatus, getStockQuantity } = useProductTraits();
56
- const status = getStockStatus(doc);
57
- const quantity = getStockQuantity(doc);
67
+ export function ProductStockBadge({ doc, showQuantity = false, showAsOf = true, className, ...props }: ProductStockBadgeProps) {
68
+ const { status, quantity, asOf } = useProductStock(doc);
58
69
  const label = statusLabels[status] ?? statusLabels.unknown;
59
70
  const styles = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
60
71
 
61
72
  return (
62
- <View
63
- className={cn('flex-row items-center self-start gap-1.5 rounded-xl px-2 py-1', styles.badge, className)}
64
- {...viewProps}
73
+ <HStack
74
+ space="none"
75
+ className={cn('gap-1.5 self-start rounded-xl px-2 py-1', styles.badge, className)}
76
+ {...props}
65
77
  >
66
78
  <View className={cn('h-1.5 w-1.5 rounded-full', styles.dot)} />
67
79
  <Text className={cn('text-xs font-semibold', styles.text)}>
68
80
  {label}
69
- {showQuantity && quantity != null ? ` (${quantity})` : ''}
81
+ {status !== 'out_of_stock' && showQuantity && quantity != null ? ` (${quantity})` : ''}
82
+ {asOf && showAsOf ? <Text className="text-xs font-normal text-muted-foreground">{` · as of ${formatAsOf(asOf)}`}</Text> : null}
70
83
  </Text>
71
- </View>
84
+ </HStack>
72
85
  );
73
86
  }
@@ -1,7 +1,6 @@
1
- import { Text, type TextProps } from 'react-native';
2
-
3
1
  import { useProductTraits } from '@tallyui/core';
4
2
  import { cn } from '@tallyui/theme';
3
+ import { Text, type TextProps } from '../ui';
5
4
 
6
5
  export interface ProductTitleProps extends Omit<TextProps, 'children'> {
7
6
  /** The raw RxDB product document (connector-specific shape) */
@@ -23,7 +22,7 @@ export interface ProductTitleProps extends Omit<TextProps, 'children'> {
23
22
  export function ProductTitle({ doc, className, ...textProps }: ProductTitleProps) {
24
23
  const { getName } = useProductTraits();
25
24
  return (
26
- <Text className={cn('text-base font-semibold text-foreground', className)} {...textProps}>
25
+ <Text className={cn('font-semibold', className)} {...textProps}>
27
26
  {getName(doc)}
28
27
  </Text>
29
28
  );
@@ -0,0 +1,61 @@
1
+ import { Pressable, Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export interface VariantOption {
6
+ name: string;
7
+ values: string[];
8
+ }
9
+
10
+ export interface ProductVariantPickerProps extends Omit<ViewProps, 'children'> {
11
+ options: VariantOption[];
12
+ selected?: Record<string, string>;
13
+ onSelect?: (selection: Record<string, string>) => void;
14
+ className?: string;
15
+ }
16
+
17
+ export function ProductVariantPicker({
18
+ options,
19
+ selected = {},
20
+ onSelect,
21
+ className,
22
+ ...viewProps
23
+ }: ProductVariantPickerProps) {
24
+ const handleSelect = (optionName: string, value: string) => {
25
+ onSelect?.({ ...selected, [optionName]: value });
26
+ };
27
+
28
+ return (
29
+ <View className={cn('gap-3', className)} {...viewProps}>
30
+ {options.map((option) => (
31
+ <View key={option.name} className="gap-1.5">
32
+ <Text className="text-xs font-semibold text-muted-foreground">{option.name}</Text>
33
+ <View className="flex-row flex-wrap gap-2">
34
+ {option.values.map((value) => {
35
+ const active = selected[option.name] === value;
36
+ return (
37
+ <Pressable
38
+ key={value}
39
+ onPress={() => handleSelect(option.name, value)}
40
+ className={cn(
41
+ 'rounded-lg border px-3 py-1.5',
42
+ active ? 'border-primary bg-primary/10' : 'border-border bg-card',
43
+ )}
44
+ >
45
+ <Text
46
+ className={cn(
47
+ 'text-sm font-medium',
48
+ active ? 'text-primary' : 'text-foreground',
49
+ )}
50
+ >
51
+ {value}
52
+ </Text>
53
+ </Pressable>
54
+ );
55
+ })}
56
+ </View>
57
+ </View>
58
+ ))}
59
+ </View>
60
+ );
61
+ }
@@ -0,0 +1,85 @@
1
+ import { useState } from 'react';
2
+ import { Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ import { QuantityStepper } from '../input/quantity-stepper';
7
+
8
+ export interface Denomination {
9
+ label: string;
10
+ value: number;
11
+ }
12
+
13
+ const DEFAULT_DENOMINATIONS: Denomination[] = [
14
+ { label: '$100', value: 100 },
15
+ { label: '$50', value: 50 },
16
+ { label: '$20', value: 20 },
17
+ { label: '$10', value: 10 },
18
+ { label: '$5', value: 5 },
19
+ { label: '$1', value: 1 },
20
+ { label: '25\u00a2', value: 0.25 },
21
+ { label: '10\u00a2', value: 0.10 },
22
+ { label: '5\u00a2', value: 0.05 },
23
+ { label: '1\u00a2', value: 0.01 },
24
+ ];
25
+
26
+ export interface CashCountInputProps extends Omit<ViewProps, 'children'> {
27
+ denominations?: Denomination[];
28
+ defaultCounts?: Record<number, number>;
29
+ counts?: Record<number, number>;
30
+ onChangeCounts?: (counts: Record<number, number>) => void;
31
+ onChangeTotal?: (total: number) => void;
32
+ className?: string;
33
+ }
34
+
35
+ export function CashCountInput({
36
+ denominations = DEFAULT_DENOMINATIONS,
37
+ defaultCounts,
38
+ counts: controlledCounts,
39
+ onChangeCounts,
40
+ onChangeTotal,
41
+ className,
42
+ ...viewProps
43
+ }: CashCountInputProps) {
44
+ const [internalCounts, setInternalCounts] = useState<Record<number, number>>(defaultCounts ?? {});
45
+ const counts = controlledCounts ?? internalCounts;
46
+
47
+ const total = denominations.reduce(
48
+ (sum, d) => sum + d.value * (counts[d.value] ?? 0),
49
+ 0,
50
+ );
51
+
52
+ const handleCountChange = (denomination: number, quantity: number) => {
53
+ const next = { ...counts, [denomination]: quantity };
54
+ if (controlledCounts === undefined) setInternalCounts(next);
55
+ onChangeCounts?.(next);
56
+ const newTotal = denominations.reduce(
57
+ (sum, d) => sum + d.value * (next[d.value] ?? 0),
58
+ 0,
59
+ );
60
+ onChangeTotal?.(newTotal);
61
+ };
62
+
63
+ return (
64
+ <View className={cn('gap-2', className)} {...viewProps}>
65
+ {denominations.map((d) => (
66
+ <View key={d.value} className="flex-row items-center justify-between px-3 py-1">
67
+ <Text className="w-16 text-sm font-medium text-foreground">{d.label}</Text>
68
+ <QuantityStepper
69
+ quantity={counts[d.value] ?? 0}
70
+ min={0}
71
+ onChangeQuantity={(qty) => handleCountChange(d.value, qty)}
72
+ />
73
+ <Text className="w-20 text-right text-sm text-muted-foreground">
74
+ {(d.value * (counts[d.value] ?? 0)).toFixed(2)}
75
+ </Text>
76
+ </View>
77
+ ))}
78
+ <View className="border-t border-border px-3 pt-2">
79
+ <Text className="text-right text-base font-bold text-foreground">
80
+ Total: {total.toFixed(2)}
81
+ </Text>
82
+ </View>
83
+ </View>
84
+ );
85
+ }
@@ -0,0 +1,161 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Animated, View } from 'react-native';
3
+ import type { useRegisterSession } from '@tallyui/pos';
4
+ import { countVariance, TAX_ROUNDING_MIXED_NOTE, varianceText } from '@tallyui/pos';
5
+
6
+ import { formatMoney, minorUnitDigits } from '@tallyui/core';
7
+ import { Button } from '../ui/button';
8
+ import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
9
+ import { Text } from '../ui/text';
10
+ import { tenderLabel, varianceWord } from './register-count';
11
+
12
+ /**
13
+ * The completion checkmark's entrance: fades and scales in over 180ms unless the platform
14
+ * reports reduced motion (`AccessibilityInfo.isReduceMotionEnabled`; on web, react-native-web
15
+ * resolves it from `matchMedia('(prefers-reduced-motion: reduce)')`), in which case it appears
16
+ * at once. Plain `Animated`, not `react-native-reanimated`: no new dependency.
17
+ */
18
+ function useCompletionMotion() {
19
+ const opacity = useRef(new Animated.Value(0)).current;
20
+ const scale = useRef(new Animated.Value(0.6)).current;
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ AccessibilityInfo.isReduceMotionEnabled().then((reduced) => {
24
+ if (cancelled) return;
25
+ if (reduced) {
26
+ opacity.setValue(1);
27
+ scale.setValue(1);
28
+ return;
29
+ }
30
+ Animated.parallel([
31
+ Animated.timing(opacity, { toValue: 1, duration: 180, useNativeDriver: false }),
32
+ Animated.timing(scale, { toValue: 1, duration: 180, useNativeDriver: false }),
33
+ ]).start();
34
+ });
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ }, [opacity, scale]);
39
+ return { opacity, scale };
40
+ }
41
+
42
+ export interface ClosureSheetProps {
43
+ /** `useRegisterSession`'s return value: reads `lastClosure` and `blind`. */
44
+ register: ReturnType<typeof useRegisterSession>;
45
+ /** ISO 4217 code, for the figures. */
46
+ currency: string;
47
+ /** Renders no print button when absent — printing is a later job (registers c2). */
48
+ onPrint?: () => void | Promise<void>;
49
+ onDone: () => void;
50
+ }
51
+
52
+ /**
53
+ * Confirms a closed session from `register.lastClosure`: the local closure number always, and
54
+ * the counted figures and variance per tender unless `blind`. Port provenance (ADR-032
55
+ * amendment 1): WCPOS `next` `3b5331b5c` `closure-sheet.tsx` (LEDGER 54, 62). Leaves out the
56
+ * Z-report preview, print/reprint and the server-number adoption (registers c2) — `onPrint` is
57
+ * an optional hook for a host that later wires up printing; without it, no button renders.
58
+ * Unlike WCPOS (which still shows counted cash to a blind cashier), every figure here is hidden
59
+ * while blind, per the register-screens-b design, since TallyUI's closure lists every tender.
60
+ */
61
+ export function ClosureSheet({ register, currency, onPrint, onDone }: ClosureSheetProps) {
62
+ const { lastClosure: closure, blind } = register;
63
+ const digits = minorUnitDigits(currency);
64
+ const { opacity, scale } = useCompletionMotion();
65
+ const [busy, setBusy] = useState(false);
66
+ const [error, setError] = useState('');
67
+ if (!closure) return null;
68
+ const methods = Object.keys(closure.counted);
69
+ const approvedByName = closure.breakdowns.approved_by_name;
70
+ const approvedById = closure.breakdowns.approved_by;
71
+ const approvedBy =
72
+ typeof approvedByName === 'string' && approvedByName
73
+ ? approvedByName
74
+ : typeof approvedById === 'string' && approvedById
75
+ ? approvedById
76
+ : '';
77
+ const print = async () => {
78
+ if (!onPrint) return;
79
+ setBusy(true);
80
+ setError('');
81
+ try {
82
+ await onPrint();
83
+ } catch (e) {
84
+ setError(e instanceof Error ? e.message : String(e));
85
+ } finally {
86
+ setBusy(false);
87
+ }
88
+ };
89
+ return (
90
+ <Dialog
91
+ open
92
+ onOpenChange={(open) => {
93
+ if (!open) onDone();
94
+ }}
95
+ >
96
+ <DialogContent testID="closure-sheet">
97
+ <Animated.View
98
+ testID="closure-check"
99
+ style={{ opacity, transform: [{ scale }] }}
100
+ className="bg-success/10 h-10 w-10 items-center justify-center rounded-full"
101
+ >
102
+ <Text className="text-success text-lg font-bold" accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
103
+ {'✓'}
104
+ </Text>
105
+ </Animated.View>
106
+ <DialogTitle testID="closure-title">Register closed</DialogTitle>
107
+ {/* The number stays visible, blind or not: the Z report references it (the Front desk, 2026-09-28). */}
108
+ <Text testID="closure-number" className="text-muted-foreground min-h-11">{`Closure #${closure.number}`}</Text>
109
+ {!blind && (
110
+ <View testID="closure-figures" className="gap-3">
111
+ {methods.map((method) => {
112
+ const countedMinor = closure.counted[method] ?? 0;
113
+ const expectedMinor = closure.expected[method] ?? 0;
114
+ const varianceMinor = closure.variance[method] ?? countVariance(countedMinor, expectedMinor);
115
+ return (
116
+ <View key={method} className="gap-1">
117
+ <Text className="text-muted-foreground text-xs font-semibold">{tenderLabel(method)}</Text>
118
+ <Text testID={`closure-counted-${method}`} className="min-h-11 tabular-nums">
119
+ {`Counted ${formatMoney({ amount: countedMinor, currency })}`}
120
+ </Text>
121
+ <Text testID={`closure-expected-${method}`} className="min-h-11 tabular-nums">
122
+ {`Expected ${formatMoney({ amount: expectedMinor, currency })}`}
123
+ </Text>
124
+ <Text testID={`closure-variance-${method}`} className="min-h-11 tabular-nums">
125
+ {varianceText(
126
+ varianceMinor,
127
+ digits,
128
+ (major) => formatMoney({ amount: Math.round(major * 10 ** digits), currency }) ?? '',
129
+ varianceWord,
130
+ )}
131
+ </Text>
132
+ </View>
133
+ );
134
+ })}
135
+ </View>
136
+ )}
137
+ {/* Not a figure: shows who approved the close even while blind. */}
138
+ {!!approvedBy && <Text testID="closure-approved-by">Approved by {approvedBy}</Text>}
139
+ {/* Not a figure either (#287). The wording is approved by the Front desk, 2026-09-30. */}
140
+ {closure.breakdowns.tax_rounding_mixed === true && (
141
+ <Text testID="closure-tax-rounding-mixed" className="text-muted-foreground">
142
+ {TAX_ROUNDING_MIXED_NOTE}
143
+ </Text>
144
+ )}
145
+ {!!error && (
146
+ <Text testID="closure-print-error" className="text-destructive">
147
+ {error}
148
+ </Text>
149
+ )}
150
+ {!blind && !!onPrint && (
151
+ <Button testID="closure-print" className="min-h-14" disabled={busy} onPress={print}>
152
+ <Text>{busy ? 'Printing…' : 'Print the Z report'}</Text>
153
+ </Button>
154
+ )}
155
+ <Button testID="closure-done" variant="outline" className="min-h-14" onPress={onDone}>
156
+ <Text>Done</Text>
157
+ </Button>
158
+ </DialogContent>
159
+ </Dialog>
160
+ );
161
+ }
@@ -0,0 +1,12 @@
1
+ export { CashCountInput, type CashCountInputProps, type Denomination } from './cash-count-input';
2
+ export { RegisterSummary, type RegisterSummaryProps, type TransactionSummary } from './register-summary';
3
+ export { RegisterOpenClose, type RegisterOpenCloseProps } from './register-open-close';
4
+ export { RegisterPicker, type RegisterPickerProps, type RegisterPickerRegister } from './register-picker';
5
+ export { OpenRegisterCard, type OpenRegisterCardProps } from './open-register-card';
6
+ export { RegisterBar, type RegisterBarProps } from './register-bar';
7
+ export { describeRegisterBarPill, type RegisterBarPill } from './register-bar.helpers';
8
+ export { MovementSheet, type MovementSheetProps } from './movement-sheet';
9
+ export { RegisterPanel, type RegisterPanelProps } from './register-panel';
10
+ export { RegisterColumn, type RegisterColumnProps } from './register-column';
11
+ export { RegisterCount, APPROVAL_REQUIRED_TEXT, type RegisterCountProps } from './register-count';
12
+ export { ClosureSheet, type ClosureSheetProps } from './closure-sheet';