@tallyui/components 3.0.0 → 3.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/components",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "type": "module",
5
5
  "description": "Universal POS UI components for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -27,22 +27,22 @@
27
27
  "directory": "packages/components"
28
28
  },
29
29
  "peerDependencies": {
30
- "@tallyui/core": "^3.0.0",
31
- "@tallyui/primitives": "^3.0.0",
32
- "@tallyui/theme": "^3.0.0",
30
+ "@tallyui/core": "^3.0.2",
31
+ "@tallyui/primitives": "^3.0.2",
32
+ "@tallyui/theme": "^3.0.2",
33
33
  "react": ">=18",
34
34
  "react-native": ">=0.74",
35
35
  "react-native-svg": ">=15"
36
36
  },
37
37
  "dependencies": {
38
38
  "class-variance-authority": "^0.7.1",
39
- "@tallyui/pos": "3.0.0"
39
+ "@tallyui/pos": "3.0.2"
40
40
  },
41
41
  "devDependencies": {
42
- "@tallyui/connector-medusa": "3.0.0",
43
- "@tallyui/connector-vendure": "3.0.0",
44
- "@tallyui/connector-woocommerce": "3.0.0",
45
- "@tallyui/mock-api": "3.0.0",
42
+ "@tallyui/connector-medusa": "3.0.2",
43
+ "@tallyui/connector-vendure": "3.0.2",
44
+ "@tallyui/connector-woocommerce": "3.0.2",
45
+ "@tallyui/mock-api": "3.0.2",
46
46
  "@types/react": ">=18",
47
47
  "react": "^19.2.4",
48
48
  "react-native-svg": "15.12.1",
@@ -4,7 +4,9 @@ import type { MovementType, useRegisterSession } from '@tallyui/pos';
4
4
 
5
5
  import { formatMoney } from '@tallyui/core';
6
6
  import { Button } from '../ui/button';
7
- import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
7
+ import { Dialog, DialogClose, DialogContent, DialogTitle } from '../ui/dialog';
8
+ import { IconButton } from '../ui/icon-button';
9
+ import { XIcon } from '../ui/icon/x-icon';
8
10
  import { Text } from '../ui/text';
9
11
  import { MovementSheet } from './movement-sheet';
10
12
 
@@ -64,7 +66,14 @@ export function RegisterPanel({ register, currency, registerName, open, onOpenCh
64
66
  >
65
67
  {/* The heading stays visible; a phone-height viewport must never push it under the page
66
68
  header (the Front desk review, 2026-09-28) — only the body below it scrolls. */}
67
- <DialogTitle testID="register-panel-amount">{registerName ?? 'Register'}</DialogTitle>
69
+ <View className="flex-row items-center justify-between gap-2">
70
+ <DialogTitle testID="register-panel-amount">{registerName ?? 'Register'}</DialogTitle>
71
+ <DialogClose asChild>
72
+ <IconButton variant="ghost" size="sm" testID="register-panel-dismiss" accessibilityLabel="Close panel">
73
+ <XIcon size={16} />
74
+ </IconButton>
75
+ </DialogClose>
76
+ </View>
68
77
  <Text testID="register-panel-sales-count">{salesCount} sale{salesCount === 1 ? '' : 's'} this session</Text>
69
78
  <ScrollView testID="register-panel-body" className="flex-1" contentContainerClassName="gap-3">
70
79
  <View className="flex-row gap-2">
package/src/sale/cart.tsx CHANGED
@@ -41,7 +41,7 @@ export function Cart({ sale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10
41
41
  {discountForm(line.id, `Discount on ${line.name}`)}
42
42
  </>;
43
43
  return <CartPanel dataSet={{ print: 'hide' }} items={order.lineItems} renderItem={renderItem}
44
- emptyState={<Text className="text-muted-foreground">Scan or tap a product to start a sale.</Text>}
44
+ emptyState={<Text testID="cart-empty" className="px-3 py-2 text-muted-foreground">Scan or tap a product to start a sale.</Text>}
45
45
  afterItems={order.lineItems.length ? <View className="gap-3 px-3 py-2">
46
46
  <DiscountChips discounts={order.discounts} amounts={orderAmounts} currency={order.currency} onRemove={sale.removeDiscount}
47
47
  prefix="Order discount" />
@@ -32,7 +32,7 @@ function laterOf(a: Date | null | undefined, b: Date | null | undefined): Date |
32
32
  return a.getTime() >= b.getTime() ? a : b;
33
33
  }
34
34
 
35
- export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, lastStockCheckAt, hour12, minCodeLength }: {
35
+ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusText, statusAccessory, lastSyncedAt, loading, lastStockCheckAt, hour12, minCodeLength }: {
36
36
  products: Doc[];
37
37
  traits: ProductTraits<Doc>;
38
38
  currency: string;
@@ -42,6 +42,9 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
42
42
  /** Rendered at the end of the status line, e.g. a register control at phone width; the row renders even without `statusText`. */
43
43
  statusAccessory?: ReactNode;
44
44
  lastSyncedAt: Date | null;
45
+ /** The catalogue's first pull is still running, so an empty grid says "Loading products…" rather than "No products yet.". Defaults to lastSyncedAt === null; pass false
46
+ * when a null lastSyncedAt doesn't mean a pull is running (e.g. offline on first start). */
47
+ loading?: boolean;
45
48
  /** The last completed stock reconcile pass, persisted across restarts. The "stock as of" time shown is the
46
49
  * latest of this, the provider's stockOverlayAsOf (when valid) and lastSyncedAt; null and omitted alike. */
47
50
  lastStockCheckAt?: Date | null;
@@ -53,6 +56,7 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
53
56
  * in its unfocused wedge listener — not this prop's job. */
54
57
  minCodeLength?: number;
55
58
  }) {
59
+ const isLoading = loading ?? lastSyncedAt === null;
56
60
  // Idempotent (the adapter's overlay returns just the fields the stock map sets), so an app that already
57
61
  // merges the overlay itself keeps working, and can drop its own merge.
58
62
  const shown = useStockOverlaid(products) as Doc[];
@@ -132,7 +136,7 @@ export function Catalogue<Doc>({ products, traits, currency, onSelect, statusTex
132
136
  </Pressable>
133
137
  )}
134
138
  emptyState={<Text className="mt-10 text-center text-sm text-muted-foreground">
135
- {query.trim() ? `No products match "${query.trim()}".` : 'No products yet.'}
139
+ {query.trim() ? `No products match "${query.trim()}".` : isLoading ? 'Loading products…' : 'No products yet.'}
136
140
  </Text>} />
137
141
  </View>
138
142
  );
package/src/sale/index.ts CHANGED
@@ -4,6 +4,7 @@ export { Tender } from './tender';
4
4
  export { DiscountForm, DiscountChips, parseDiscount, discountLabel } from './discount-form';
5
5
  export { Catalogue, formatStockSyncTime } from './catalogue';
6
6
  export { Receipt } from './receipt';
7
+ export { orderReference } from './order-reference';
7
8
  export { injectPrintStyle } from './print-style';
8
9
  export { SyncStatus } from './sync-status';
9
10
  export { OrdersList } from './orders-list';
@@ -0,0 +1,6 @@
1
+ /** The order reference a visitor sees on the receipt and in Orders (front desk ruling, 2026-10-06): the finalized
2
+ * PosOrder id's last 8 characters, then the backend's display id once the store has one. */
3
+ export function orderReference(order: { id: string; serverRefs?: { displayId?: string } }): string {
4
+ const local = order.id.slice(-8);
5
+ return order.serverRefs?.displayId ? `${local} · #${order.serverRefs.displayId}` : local;
6
+ }
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { Pressable, ScrollView, Text, View } from 'react-native';
3
3
  import { formatMoney, knownWarnings } from '@tallyui/core';
4
4
  import { needsAttention, type OutboxState, type PosOrder } from '@tallyui/pos';
5
+ import { orderReference } from './order-reference';
5
6
  import { sinceText } from './since-text';
6
7
 
7
8
  const STATUS_LABEL = { pending: 'Waiting to sync', applied: 'Synced', rejected: 'Not accepted' };
@@ -42,6 +43,7 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
42
43
  }) {
43
44
  const retrying = useRef(new Set<string>());
44
45
  const [retryingIds, setRetryingIds] = useState(new Set<string>());
46
+ const [expanded, setExpanded] = useState(new Set<string>());
45
47
  useEffect(() => {
46
48
  for (const id of retrying.current) {
47
49
  if (!orders.some((order) => order.id === id && order.syncStatus === 'rejected')) retrying.current.delete(id);
@@ -52,14 +54,31 @@ export function OrdersList({ orders, onRetry, formatDate = defaultFormatDate, fo
52
54
  {[{ title: 'Needs attention', orders: needsAttention(orders, { stuckCommandIds: stuck?.commandIds }) }, { title: 'Recent', orders }].filter((section) => section.title === 'Recent' || section.orders.length > 0).map((section) => (
53
55
  <View key={section.title} className="mb-6 gap-3">
54
56
  <Text accessibilityRole="header" className="text-xl font-semibold text-foreground">{section.title}</Text>
57
+ {section.title === 'Recent' && orders.length === 0 ? <Text testID="orders-empty" className="text-muted-foreground">No sales yet. Completed sales appear here.</Text> : null}
55
58
  {section.orders.map((order) => {
56
59
  const count = order.lines.reduce((sum, line) => sum + line.quantity, 0);
60
+ const key = `${section.title}:${order.id}`;
61
+ const isExpanded = expanded.has(key);
57
62
  // The order's own stuck entry: its clock and latest reason, not the earliest across all stuck orders.
58
63
  const stuckEntry = order.syncStatus === 'pending' ? stuck?.orders.find((entry) => entry.commandId === order.commandId) : undefined;
59
64
  return (
60
65
  <View key={order.id} className="gap-1 rounded-md border border-border bg-card p-3">
61
- <Text className="text-foreground">{order.serverRefs?.displayId ? `Order #${order.serverRefs.displayId} · ` : ''}{count} {count === 1 ? 'item' : 'items'}</Text>
62
- <Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
66
+ <Pressable accessibilityRole="button" aria-expanded={isExpanded}
67
+ accessibilityHint="Shows the items and payments of this sale" testID={`order-row-${order.id}`} className="gap-1"
68
+ onPress={() => setExpanded((current) => {
69
+ const next = new Set(current);
70
+ if (next.has(key)) next.delete(key); else next.add(key);
71
+ return next;
72
+ })}>
73
+ <Text className="text-foreground">{`Order ${orderReference(order)} · ${count} ${count === 1 ? 'item' : 'items'}`}</Text>
74
+ <Text className="text-muted-foreground">{formatDate(order.createdAt)} · {formatMoney({ amount: order.totalMinor, currency: order.currency })} · {STATUS_LABEL[order.syncStatus]}</Text>
75
+ </Pressable>
76
+ {isExpanded ? <View testID={`order-detail-${order.id}`} className="mt-1 gap-1 border-t border-border pt-2">
77
+ {order.lines.map((line) => <Text key={line.id} className="text-foreground">{`${line.quantity} × ${line.name}`}</Text>)}
78
+ {order.payments.map((payment) => <Text key={payment.id} className="text-muted-foreground">
79
+ {`${payment.method === 'cash' ? 'Cash' : 'Card terminal'} ${formatMoney({ amount: payment.amountMinor, currency: order.currency })}${payment.reference ? ` · ${payment.reference}` : ''}`}
80
+ </Text>)}
81
+ </View> : null}
63
82
  {stuckEntry ? <Text className="text-destructive">
64
83
  {/* The same words for any reason, with no reason code; the hour as the status line has it (numeric, #245). */}
65
84
  {`Hasn't reached the online store since ${sinceText(stuckEntry)}.`}
@@ -2,16 +2,21 @@ import { useEffect } from 'react';
2
2
  import { Pressable, Text, View } from 'react-native';
3
3
  import { formatMoney } from '@tallyui/core';
4
4
  import { buildReceiptData, type SentOrder } from '@tallyui/pos';
5
+ import type { PosOrder } from '@tallyui/pos';
5
6
  import { injectPrintStyle } from './print-style';
6
7
  import { discountLabel } from './discount-form';
8
+ import { orderReference } from './order-reference';
7
9
 
8
10
  const defaultFormatDate = (iso: string) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
9
11
 
10
12
  /** `store`/`taxLabel`/`topInset`/`formatDate` replace the app's Medusa settings, hard-coded VAT (default matches
11
13
  * TV6a's `Cart`), outbox strip and date util, so `Receipt` stays platform-neutral (ADR-052). */
12
- export function Receipt({ order, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
14
+ export function Receipt({ order, posOrder, store, cashier, registerId, newSale, taxLabel = (ratePpm: number) => `Tax ${ratePpm / 10000}%`,
13
15
  topInset = 0, formatDate = defaultFormatDate }: {
14
16
  order: SentOrder; store: { name: string; address?: string }; cashier: string; registerId: string; newSale: () => void;
17
+ /** The finalized order (useSale's receipt stage has it). With it the receipt prints its reference and time, the
18
+ * same as Orders; without it (a preview before finalize) the sale id is marked as a draft. */
19
+ posOrder?: PosOrder;
15
20
  taxLabel?: (ratePpm: number) => string; topInset?: number; formatDate?: (iso: string) => string;
16
21
  }) {
17
22
  useEffect(injectPrintStyle, []);
@@ -24,6 +29,7 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
24
29
  <Text className={`text-right text-foreground ${bold ? 'font-semibold' : ''}`}>{amount}</Text>
25
30
  </View>;
26
31
  const { totals } = receipt;
32
+ const lineDiscountMinor = totals.discountMinor - receipt.orderDiscountMinor;
27
33
  const taxRows = totals.taxLines.map((line, index) =>
28
34
  row(`${totals.taxInclusive ? 'incl. ' : ''}${taxLabel(line.ratePpm)}`, money(line.amountMinor), false, index));
29
35
  return <>
@@ -31,8 +37,8 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
31
37
  <View className="w-full max-w-md self-center gap-3 p-4 bg-card">
32
38
  <Text className="text-lg font-semibold text-foreground">{receipt.header.storeName}</Text>
33
39
  {receipt.header.storeAddress ? <Text className="text-muted-foreground">{receipt.header.storeAddress}</Text> : null}
34
- <Text className="text-foreground">Order {receipt.header.orderNumber.slice(-8)}</Text>
35
- <Text className="text-muted-foreground">{formatDate(receipt.header.date)}</Text>
40
+ <Text testID="receipt-order" className="text-foreground">{posOrder ? `Order ${orderReference(posOrder)}` : `Order ${receipt.header.orderNumber.slice(-8)} (draft)`}</Text>
41
+ <Text className="text-muted-foreground">{formatDate(posOrder?.createdAt ?? receipt.header.date)}</Text>
36
42
  <Text className="text-muted-foreground">Cashier: {receipt.header.cashier}</Text>
37
43
  {receipt.header.customer && <Text className="text-muted-foreground">Customer: {receipt.header.customer}</Text>}
38
44
  {receipt.lineItems.map((line, index) => <View key={index}>
@@ -42,11 +48,11 @@ export function Receipt({ order, store, cashier, registerId, newSale, taxLabel =
42
48
  {line.displayDiscounts.map((discount, i) => <View key={i} className="pl-4">{row(`${discountLabel(
43
49
  order.lineItems[index].discounts[i], receipt.currency)} off`, `−${money(discount.amountMinor)}`)}</View>)}
44
50
  </View>)}
45
- {receipt.orderDiscountMinor > 0 ? row('Order discount', `−${money(receipt.orderDiscountMinor)}`) : null}
46
- {/* order.display's figures (ADR-063; medusapos ADR 0008), in the cart's order. Exclusive: subtotal − discount + tax
47
- = total; inclusive: subtotal − discount = total, and the tax is "incl.", not added. */}
51
+ {/* Each discount appears once in the totals: line discounts are itemised under their lines and summed once here;
52
+ the order discount appears once. order.display's figures (ADR-063; medusapos ADR 0008). */}
48
53
  {row('Subtotal', money(totals.subtotalMinor))}
49
- {totals.discountMinor > 0 ? row('Discount', `−${money(totals.discountMinor)}`) : null}
54
+ {lineDiscountMinor > 0 ? row('Line discounts', `−${money(lineDiscountMinor)}`) : null}
55
+ {receipt.orderDiscountMinor > 0 ? row('Order discount', `−${money(receipt.orderDiscountMinor)}`) : null}
50
56
  {taxRows}
51
57
  {row('Total', money(totals.totalMinor), true)}
52
58
  {receipt.payments.map((payment, index) => row(payment.method === 'cash' ? 'Cash tendered' : 'Card terminal',
@@ -0,0 +1,9 @@
1
+ import { Line } from 'react-native-svg';
2
+ import { createSvgIcon } from './svg-icon';
3
+
4
+ export const XIcon = createSvgIcon('XIcon', () => (
5
+ <>
6
+ <Line x1={18} y1={6} x2={6} y2={18} />
7
+ <Line x1={6} y1={6} x2={18} y2={18} />
8
+ </>
9
+ ));
package/src/ui/index.ts CHANGED
@@ -7,6 +7,7 @@ export { VStack, vstackVariants, type VStackProps } from './vstack';
7
7
  export { Icon, iconVariants, type IconProps } from './icon';
8
8
  export { createSvgIcon, type SvgIconProps } from './icon/svg-icon';
9
9
  export { SearchIcon } from './icon/search-icon';
10
+ export { XIcon } from './icon/x-icon';
10
11
  export { Loader, loaderVariants, type LoaderProps } from './loader';
11
12
  export { Badge, badgeVariants, badgeTextVariants, type BadgeProps } from './badge';
12
13
  export { Avatar, avatarVariants, type AvatarProps } from './avatar';