@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,141 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Platform, Text, View } from 'react-native';
3
+ import type { SyncNotice } from '@tallyui/core';
4
+ import type { OutboxState } from '@tallyui/pos';
5
+
6
+ /**
7
+ * What the cashier reads when the product pull stops, keyed by the notice's code; the code itself is never shown.
8
+ * Every detail starts "You can keep selling." `software`, `minVersion` and `fix` come from the notice, so the
9
+ * component never names a backend or a version itself.
10
+ */
11
+ type PullNoticeText = { line: (pluginName: string) => string; detail: (notice: SyncNotice, pluginName: string) => string };
12
+ const PULL_NOTICE_TEXT: Record<string, PullNoticeText> = {
13
+ unauthorized: {
14
+ line: () => "Products aren't updating: this till needs to sign in to the online store again.",
15
+ detail: () => 'You can keep selling. Products, prices and stock stay as they were until someone signs in again.',
16
+ },
17
+ till_update_required: {
18
+ line: () => "Products aren't updating: this till needs updating.",
19
+ detail: () => 'You can keep selling. Products, prices and stock stay as they were until this till is updated.',
20
+ },
21
+ unsupported_store: {
22
+ line: () => "Products aren't updating: the online store needs a software update.",
23
+ detail: ({ software, minVersion }) => software && minVersion
24
+ ? `You can keep selling. Ask the store owner to update ${software} to version ${minVersion} or later.`
25
+ : "You can keep selling. Ask the store owner to update the store's software.",
26
+ },
27
+ missing_plugin: {
28
+ line: (pluginName) => `Products aren't updating: the online store is missing ${pluginName}.`,
29
+ detail: (_notice, pluginName) => `You can keep selling. This till couldn't find ${pluginName} on the online store. `
30
+ + "Ask the store owner to check that it is installed and switched on, and that the store address in this till's settings is right.",
31
+ },
32
+ store_misconfigured: {
33
+ line: () => "Products aren't updating: a setting on the online store needs changing.",
34
+ detail: ({ fix }) => fix ? `You can keep selling. Ask the store owner to ${fix}.`
35
+ : "You can keep selling. Ask the store owner to check the store's settings.",
36
+ },
37
+ };
38
+ const PULL_NOTICE_FALLBACK: PullNoticeText = {
39
+ line: () => "Products aren't updating.",
40
+ detail: () => 'You can keep selling. Products, prices and stock stay as they were. '
41
+ + 'Restart the app; if it keeps happening, tell the store owner.',
42
+ };
43
+
44
+ // The line the cashier reads while the store keeps answering 404 (OutboxState.backendMissing) and no order is stuck.
45
+ const BACKEND_MISSING = "Sales aren't reaching the online store. Keep selling: they're saved on this till and will send by themselves.";
46
+ // The same line once an order is stuck, with the store missing or not; {time} becomes stuck.since.
47
+ const BACKEND_MISSING_SINCE = "Sales haven't reached the online store since {time}. Keep selling: they're saved on this till and will send by themselves.";
48
+ // The line instead when no sale waits but till updates do, and its "since" variant once registerState.stuck is set.
49
+ const TILL_UPDATES_MISSING = "Till updates aren't reaching the online store. Keep selling: they're saved on this till and will send by themselves.";
50
+ const TILL_UPDATES_MISSING_SINCE = "Till updates haven't reached the online store since {time}. Keep selling: they're saved on this till and will send by themselves.";
51
+ // The detail below it; {pluginName} becomes the pluginName prop. With nothing waiting (store missing or not) the line is only
52
+ // "Sales are up to date.", and here {lastTime} is set.
53
+ const BACKEND_MISSING_DETAIL = "This till couldn't find {pluginName} on the online store{lastTime}. Ask the store owner to check that it is installed "
54
+ + "and switched on, and that the store address in this till's settings is right.";
55
+
56
+ // The whole status line while the store has refused sales (OutboxState.rejected), in place of every other outbox line (#269).
57
+ // `waiting` is empty, or ", " and what else waits, so the line never hides waiting sales.
58
+ const REFUSED = (n: number, waiting: string) => n === 1
59
+ ? `1 sale needs attention${waiting} · The online store refused it. Ask the store owner to look at the till's sync log.`
60
+ : `${n} sales need attention${waiting} · The online store refused them. Ask the store owner to look at the till's sync log.`;
61
+ // The first detail below it, in the Front desk's ruling (#269). Not every refusal can be retried (idempotency_mismatch can't).
62
+ const REFUSED_DETAIL = 'Refused sales stay on this till under Needs attention, each with what to do next.';
63
+ // The sentence after the waiting count once the store refused a whole batch (OutboxState.refused) from the outbox shown, when
64
+ // no command carries a code. Neither outbox sets a timer then: a new pending command flushes its outbox. For sales,
65
+ // useOrderOutbox also calls start() when the store opens, which flushes; no app starts the register outbox at launch today,
66
+ // so the till-updates sentence names only the next till update (#269).
67
+ const SALES_REFUSED = 'The online store refused the last send. This till will try again with the next sale, or when the app is reopened.';
68
+ const TILL_UPDATES_REFUSED = 'The online store refused the last send. This till will try again with the next till update.';
69
+
70
+ const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`;
71
+ // iOS has no live region (accessibilityLiveRegion is Android-only), so there each text is announced when it changes: never on
72
+ // the first mount, nor on a re-render with the same text. Texts that change in the same render go in ONE call, joined by a space.
73
+ function useAnnounceOnIos(...texts: (string | undefined)[]) {
74
+ const announced = useRef(texts);
75
+ useEffect(() => {
76
+ const changed = texts.filter((text, index) => text && text !== announced.current[index]);
77
+ announced.current = texts;
78
+ if (changed.length > 0 && Platform.OS === 'ios') AccessibilityInfo.announceForAccessibility(changed.join(' '));
79
+ }, texts);
80
+ }
81
+ /**
82
+ * `pluginName` names the store's plugin in the detail, as its owners know it; `registerState` counts till updates.
83
+ * `pullNotice` (a stopped product pull) shows as its own line and detail, above the outbox line.
84
+ */
85
+ export function SyncStatus({ state, registerState, pluginName = 'the POS plugin', pullNotice }:
86
+ { state: OutboxState; registerState?: OutboxState; pluginName?: string; pullNotice?: SyncNotice }) {
87
+ const updates = registerState?.pending ?? 0;
88
+ // The outbox whose sending and retrying text is shown (and whose countdown ticks): the register's when only till updates wait.
89
+ const outbox = state.pending === 0 && registerState?.pending ? registerState : state;
90
+ const [now, setNow] = useState(Date.now);
91
+ useEffect(() => {
92
+ setNow(Date.now());
93
+ if (!outbox.nextAttemptAt) return;
94
+ const timer = setInterval(() => setNow(Date.now()), 1000);
95
+ return () => clearInterval(timer);
96
+ }, [outbox.nextAttemptAt]);
97
+ const seconds = Math.max(0, Math.ceil(((outbox.nextAttemptAt ?? now) - now) / 1000));
98
+ const label = [state.pending && count(state.pending, 'sale'),
99
+ updates && count(updates, 'till update')].filter(Boolean).join(' and ') + ' waiting to sync';
100
+ const { stuck } = state;
101
+ const backendMissing = state.backendMissing ?? registerState?.backendMissing;
102
+ // The device's own 12/24-hour format, with no forced leading zero on the hour.
103
+ const at = (time: number) => new Date(time).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
104
+ const backendMissingText = state.pending === 0 && updates > 0 ? (registerState?.stuck
105
+ ? TILL_UPDATES_MISSING_SINCE.replace('{time}', () => at(registerState.stuck!.since)) : TILL_UPDATES_MISSING)
106
+ : stuck ? BACKEND_MISSING_SINCE.replace('{time}', () => at(stuck.since)) : BACKEND_MISSING;
107
+ const upToDate = state.pending === 0 && updates === 0;
108
+ // The sentence shows with the store missing, or once the waiting sales (with none waiting, the till updates) are stuck, then as
109
+ // its "since" variant: the same words for any reason, and no reason code.
110
+ // A refused batch from the outbox shown (the sales', whenever a sale waits) replaces that sentence: it is the store's latest
111
+ // answer, and sending waits for that outbox's next command or start.
112
+ const sentence = outbox.refused ? ` · ${outbox === state ? SALES_REFUSED : TILL_UPDATES_REFUSED}`
113
+ : backendMissing || (state.pending === 0 && updates > 0 ? registerState?.stuck : stuck) ? ` · ${backendMissingText}` : '';
114
+ // The live region holds only the substance (counts and the sentence), so it is announced when that changes and never as sending
115
+ // or retrying flips or the countdown ticks: those are a short line of their own below it, outside any live region.
116
+ // Refused sales are never up to date, and their line outranks the sentences and sending or retrying. It keeps what else waits:
117
+ // "5 waiting to sync" for sales alone, else the label, which names the till updates (and the sales, if any).
118
+ const refused = state.rejected ? REFUSED(state.rejected, upToDate ? '' : `, ${updates ? label : `${state.pending} waiting to sync`}`) : '';
119
+ const spoken = refused || (upToDate ? 'Sales are up to date.' : label + sentence);
120
+ const doing = refused || upToDate || backendMissing || outbox.refused ? '' : outbox.sending ? 'Sending…'
121
+ : outbox.lastRetryReason ? `Retrying in ${seconds} s.` : '';
122
+ const text = pullNotice
123
+ && (Object.hasOwn(PULL_NOTICE_TEXT, pullNotice.code) ? PULL_NOTICE_TEXT[pullNotice.code] : PULL_NOTICE_FALLBACK);
124
+ const notice = pullNotice && text
125
+ && { line: text.line(pluginName), detail: text.detail(pullNotice, pluginName) };
126
+ useAnnounceOnIos(notice?.line, spoken);
127
+ // Both notices name the missing plugin: both lines show, and the pull notice's detail once, below them, in place of the outbox's.
128
+ const oneDetail = pullNotice?.code === 'missing_plugin' && !!backendMissing;
129
+ // The notice lines carry no accessibility label, so a screen reader reads their text; the outbox line's label is its spoken text.
130
+ // The notice line and the outbox line are polite live regions (react-native-web renders accessibilityLiveRegion as aria-live);
131
+ // the details are not: each line says what changed, and its detail is there to read.
132
+ return <View>{notice ? <>
133
+ <Text accessibilityLiveRegion="polite" className="px-4 py-2 text-xs text-muted-foreground">{notice.line}</Text>
134
+ {oneDetail ? null : <Text className="px-4 pb-2 text-xs text-muted-foreground">{notice.detail}</Text>}
135
+ </> : null}<Text accessibilityLabel={spoken} accessibilityLiveRegion="polite" className="px-4 py-2 text-xs text-muted-foreground">{spoken}</Text>
136
+ {doing ? <Text className="px-4 pb-2 text-xs text-muted-foreground">{doing}</Text> : null}
137
+ {refused ? <Text className="px-4 pb-2 text-xs text-muted-foreground">{REFUSED_DETAIL}</Text> : null}
138
+ {oneDetail && notice ? <Text className="px-4 pb-2 text-xs text-muted-foreground">{notice.detail}</Text>
139
+ : backendMissing ? <Text className="px-4 pb-2 text-xs text-muted-foreground">
140
+ {BACKEND_MISSING_DETAIL.replace('{pluginName}', () => pluginName).replace('{lastTime}', () => upToDate ? ' the last time it checked' : '')}</Text> : null}</View>;
141
+ }
@@ -0,0 +1,38 @@
1
+ import { Pressable, Text, TextInput, View } from 'react-native';
2
+ import { formatMoney } from '@tallyui/core';
3
+ import type { useSale } from '@tallyui/pos';
4
+ import { CashTendered, ChangeDisplay } from '../checkout';
5
+
6
+ /** order.create's bound on `payments[].reference`, in UTF-16 units: a typed reference never goes past what finalize keeps. */
7
+ const REFERENCE_MAX = 255;
8
+
9
+ export function Tender({ sale }: { sale: ReturnType<typeof useSale> }) {
10
+ const { order, stage } = sale;
11
+ if (stage.kind !== 'tender') return null;
12
+ const money = (amount: number) => ({ amount, currency: order.currency });
13
+ return <View dataSet={{ print: 'hide' }} className="gap-4 p-4">
14
+ {stage.method === 'cash' ? <>
15
+ <CashTendered total={money(order.totalMinor)} amount={money(order.payments[0]?.amountMinor ?? 0)}
16
+ onChangeAmount={(amount) => sale.setTender({ method: 'cash', amountMinor: amount.amount })} />
17
+ <ChangeDisplay change={money(order.changeDueMinor)} />
18
+ {order.balanceDueMinor > 0 ? <Text className="text-foreground">Balance due: {formatMoney(money(order.balanceDueMinor))}</Text> : null}
19
+ <Pressable accessibilityRole="button" disabled={order.balanceDueMinor > 0} onPress={sale.complete} className={`rounded-md bg-primary px-4 py-3 ${order.balanceDueMinor > 0 ? 'opacity-50' : ''}`}>
20
+ <Text className="text-center font-semibold text-primary-foreground">Complete sale</Text>
21
+ </Pressable>
22
+ </> : <>
23
+ <Text className="text-foreground">Card terminal: {formatMoney(money(order.totalMinor))}</Text>
24
+ <TextInput accessibilityLabel="Terminal reference" placeholder="Terminal reference" maxLength={REFERENCE_MAX}
25
+ value={order.payments[0]?.reference ?? ''}
26
+ onChangeText={(reference) => sale.setTender({ method: 'external', amountMinor: order.totalMinor, reference })} className="rounded-md border border-border px-3 py-2 text-foreground" />
27
+ <Pressable accessibilityRole="button" onPress={sale.complete} className="rounded-md bg-primary px-4 py-3"><Text className="text-center font-semibold text-primary-foreground">Payment approved on terminal</Text></Pressable>
28
+ </>}
29
+ {sale.error ? <Text accessibilityRole="alert" className="text-destructive">{sale.error}</Text> : null}
30
+ {sale.canContinue ? <>
31
+ <Text className="text-foreground">This sale is stored and will be sent. Continue to the next sale.</Text>
32
+ <Pressable accessibilityRole="button" onPress={() => sale.continueSale()} className="rounded-md bg-primary px-4 py-3">
33
+ <Text className="text-center font-semibold text-primary-foreground">Continue</Text>
34
+ </Pressable>
35
+ </> : null}
36
+ <Pressable accessibilityRole="button" onPress={sale.cancelTender} className="rounded-md border border-border bg-card px-4 py-3"><Text className="text-center text-foreground">Back</Text></Pressable>
37
+ </View>;
38
+ }
@@ -0,0 +1,65 @@
1
+ import { Text, View, type ViewProps } from 'react-native';
2
+
3
+ import { cn } from '@tallyui/theme';
4
+
5
+ export type ConnectionStatus = 'connected' | 'disconnected' | 'syncing' | 'error';
6
+
7
+ export interface ConnectorStatusProps extends Omit<ViewProps, 'children'> {
8
+ name: string;
9
+ status: ConnectionStatus;
10
+ lastSync?: string;
11
+ error?: string;
12
+ className?: string;
13
+ /** Products the sales channel doesn't sell (the calculated-price runner's `unreported`). Hidden when undefined or 0. */
14
+ unsoldCount?: number;
15
+ /** True when that count is from an older pass than the last failure (`!isFingerprintResultCurrent(state)`). */
16
+ unsoldStale?: boolean;
17
+ /** The line's text; default: `${n} product(s) not sold in this channel`, plus ` (last check failed)` when stale. */
18
+ formatUnsold?: (count: number, stale: boolean) => string;
19
+ }
20
+
21
+ // App wiring:
22
+ // const s = useObservable(runner.state$);
23
+ // <ConnectorStatus … unsoldCount={s.lastResult?.unreported} unsoldStale={!isFingerprintResultCurrent(s)} />
24
+
25
+ function defaultFormatUnsold(count: number, stale: boolean): string {
26
+ return `${count} ${count === 1 ? 'product' : 'products'} not sold in this channel${stale ? ' (last check failed)' : ''}`;
27
+ }
28
+
29
+ const STATUS_CONFIG: Record<ConnectionStatus, { label: string; dot: string; text: string }> = {
30
+ connected: { label: 'Connected', dot: 'bg-success', text: 'text-success' },
31
+ disconnected: { label: 'Disconnected', dot: 'bg-muted', text: 'text-muted-foreground' },
32
+ syncing: { label: 'Syncing...', dot: 'bg-info', text: 'text-info' },
33
+ error: { label: 'Error', dot: 'bg-destructive', text: 'text-destructive' },
34
+ };
35
+
36
+ export function ConnectorStatus({
37
+ name,
38
+ status,
39
+ lastSync,
40
+ error,
41
+ className,
42
+ unsoldCount,
43
+ unsoldStale,
44
+ formatUnsold = defaultFormatUnsold,
45
+ ...viewProps
46
+ }: ConnectorStatusProps) {
47
+ const config = STATUS_CONFIG[status];
48
+
49
+ return (
50
+ <View className={cn('gap-2 rounded-lg border border-border bg-card p-3', className)} {...viewProps}>
51
+ <View className="flex-row items-center justify-between">
52
+ <Text className="text-sm font-semibold text-foreground">{name}</Text>
53
+ <View className="flex-row items-center gap-1.5">
54
+ <View className={cn('h-2 w-2 rounded-full', config.dot)} />
55
+ <Text className={cn('text-xs font-medium', config.text)}>{config.label}</Text>
56
+ </View>
57
+ </View>
58
+ {lastSync && <Text className="text-xs text-muted-foreground">Last sync: {lastSync}</Text>}
59
+ {error && <Text className="text-xs text-destructive">{error}</Text>}
60
+ {!!unsoldCount && (
61
+ <Text className={cn('text-xs', unsoldStale ? 'text-muted-foreground' : 'text-warning')}>{formatUnsold(unsoldCount, !!unsoldStale)}</Text>
62
+ )}
63
+ </View>
64
+ );
65
+ }
@@ -0,0 +1,3 @@
1
+ export { SettingsRow, type SettingsRowProps } from './settings-row';
2
+ export { SettingsGroup, type SettingsGroupProps } from './settings-group';
3
+ export { ConnectorStatus, type ConnectorStatusProps, type ConnectionStatus } from './connector-status';
@@ -0,0 +1,23 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface SettingsGroupProps extends Omit<ViewProps, 'children'> {
7
+ title: string;
8
+ description?: string;
9
+ children: ReactNode;
10
+ className?: string;
11
+ }
12
+
13
+ export function SettingsGroup({ title, description, children, className, ...viewProps }: SettingsGroupProps) {
14
+ return (
15
+ <View className={cn('gap-1', className)} {...viewProps}>
16
+ <View className="px-4 py-2">
17
+ <Text className="text-sm font-semibold text-foreground">{title}</Text>
18
+ {description && <Text className="text-xs text-muted-foreground">{description}</Text>}
19
+ </View>
20
+ <View className="rounded-lg border border-border bg-card">{children}</View>
21
+ </View>
22
+ );
23
+ }
@@ -0,0 +1,23 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Text, View, type ViewProps } from 'react-native';
3
+
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ export interface SettingsRowProps extends Omit<ViewProps, 'children'> {
7
+ label: string;
8
+ description?: string;
9
+ action?: ReactNode;
10
+ className?: string;
11
+ }
12
+
13
+ export function SettingsRow({ label, description, action, className, ...viewProps }: SettingsRowProps) {
14
+ return (
15
+ <View className={cn('flex-row items-center gap-3 px-4 py-3', className)} {...viewProps}>
16
+ <View className="flex-1 gap-0.5">
17
+ <Text className="text-sm font-medium text-foreground">{label}</Text>
18
+ {description && <Text className="text-xs text-muted-foreground">{description}</Text>}
19
+ </View>
20
+ {action && <View>{action}</View>}
21
+ </View>
22
+ );
23
+ }
@@ -0,0 +1,59 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import { Accordion as AccordionPrimitive, TextClassContext } from '@tallyui/primitives';
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ const Accordion = AccordionPrimitive.Root;
7
+
8
+ const AccordionItem = React.forwardRef<
9
+ React.ComponentRef<typeof AccordionPrimitive.Item>,
10
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
11
+ >(function AccordionItem({ className, ...props }, ref) {
12
+ return (
13
+ <AccordionPrimitive.Item ref={ref} className={cn('border-b border-border', className)} {...props} />
14
+ );
15
+ });
16
+ AccordionItem.displayName = 'AccordionItem';
17
+
18
+ const AccordionTrigger = React.forwardRef<
19
+ React.ComponentRef<typeof AccordionPrimitive.Trigger>,
20
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
21
+ >(function AccordionTrigger({ className, children, ...props }, ref) {
22
+ return (
23
+ <AccordionPrimitive.Header className="flex">
24
+ <TextClassContext.Provider value="text-sm font-medium">
25
+ <AccordionPrimitive.Trigger
26
+ ref={ref}
27
+ className={cn(
28
+ 'flex flex-1 flex-row items-center justify-between py-4 font-medium web:transition-all web:hover:underline',
29
+ className,
30
+ )}
31
+ {...props}
32
+ >
33
+ {children}
34
+ </AccordionPrimitive.Trigger>
35
+ </TextClassContext.Provider>
36
+ </AccordionPrimitive.Header>
37
+ );
38
+ });
39
+ AccordionTrigger.displayName = 'AccordionTrigger';
40
+
41
+ const AccordionContent = React.forwardRef<
42
+ React.ComponentRef<typeof AccordionPrimitive.Content>,
43
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
44
+ >(function AccordionContent({ className, children, ...props }, ref) {
45
+ return (
46
+ <TextClassContext.Provider value="text-sm">
47
+ <AccordionPrimitive.Content
48
+ ref={ref}
49
+ className={cn('overflow-hidden text-sm web:transition-all', className)}
50
+ {...props}
51
+ >
52
+ <View className="pb-4 pt-0">{children}</View>
53
+ </AccordionPrimitive.Content>
54
+ </TextClassContext.Provider>
55
+ );
56
+ });
57
+ AccordionContent.displayName = 'AccordionContent';
58
+
59
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,91 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import { AlertDialog as AlertDialogPrimitive, TextClassContext } from '@tallyui/primitives';
4
+ import { cn } from '@tallyui/theme';
5
+
6
+ const AlertDialog = AlertDialogPrimitive.Root;
7
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
8
+ const AlertDialogPortal = AlertDialogPrimitive.Portal;
9
+
10
+ const AlertDialogOverlay = React.forwardRef<
11
+ React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
12
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
13
+ >(function AlertDialogOverlay({ className, ...props }, ref) {
14
+ return (
15
+ <AlertDialogPrimitive.Overlay
16
+ ref={ref}
17
+ className={cn('fixed inset-0 z-50 bg-black/80', className)}
18
+ {...props}
19
+ />
20
+ );
21
+ });
22
+ AlertDialogOverlay.displayName = 'AlertDialogOverlay';
23
+
24
+ const AlertDialogContent = React.forwardRef<
25
+ React.ComponentRef<typeof AlertDialogPrimitive.Content>,
26
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
27
+ >(function AlertDialogContent({ className, children, ...props }, ref) {
28
+ return (
29
+ <AlertDialogPortal>
30
+ <AlertDialogOverlay />
31
+ <AlertDialogPrimitive.Content
32
+ ref={ref}
33
+ className={cn(
34
+ 'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-background p-6 shadow-lg',
35
+ className,
36
+ )}
37
+ {...props}
38
+ >
39
+ {children}
40
+ </AlertDialogPrimitive.Content>
41
+ </AlertDialogPortal>
42
+ );
43
+ });
44
+ AlertDialogContent.displayName = 'AlertDialogContent';
45
+
46
+ function AlertDialogHeader({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
47
+ return <View className={cn('flex flex-col gap-2', className)} {...props} />;
48
+ }
49
+
50
+ function AlertDialogFooter({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
51
+ return <View className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)} {...props} />;
52
+ }
53
+
54
+ const AlertDialogTitle = React.forwardRef<
55
+ React.ComponentRef<typeof AlertDialogPrimitive.Title>,
56
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
57
+ >(function AlertDialogTitle({ className, ...props }, ref) {
58
+ return (
59
+ <TextClassContext.Provider value="text-lg font-semibold text-foreground">
60
+ <AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} />
61
+ </TextClassContext.Provider>
62
+ );
63
+ });
64
+ AlertDialogTitle.displayName = 'AlertDialogTitle';
65
+
66
+ const AlertDialogDescription = React.forwardRef<
67
+ React.ComponentRef<typeof AlertDialogPrimitive.Description>,
68
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
69
+ >(function AlertDialogDescription({ className, ...props }, ref) {
70
+ return (
71
+ <AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
72
+ );
73
+ });
74
+ AlertDialogDescription.displayName = 'AlertDialogDescription';
75
+
76
+ const AlertDialogAction = AlertDialogPrimitive.Action;
77
+ const AlertDialogCancel = AlertDialogPrimitive.Cancel;
78
+
79
+ export {
80
+ AlertDialog,
81
+ AlertDialogTrigger,
82
+ AlertDialogPortal,
83
+ AlertDialogOverlay,
84
+ AlertDialogContent,
85
+ AlertDialogHeader,
86
+ AlertDialogFooter,
87
+ AlertDialogTitle,
88
+ AlertDialogDescription,
89
+ AlertDialogAction,
90
+ AlertDialogCancel,
91
+ };
@@ -0,0 +1,50 @@
1
+ import * as React from 'react';
2
+ import { Image, View, Text, type ViewProps } from 'react-native';
3
+ import { cn } from '@tallyui/theme';
4
+ import { cva, type VariantProps } from 'class-variance-authority';
5
+
6
+ const avatarVariants = cva('relative overflow-hidden rounded-full', {
7
+ variants: {
8
+ size: {
9
+ sm: 'h-8 w-8',
10
+ md: 'h-10 w-10',
11
+ lg: 'h-12 w-12',
12
+ xl: 'h-16 w-16',
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ size: 'md',
17
+ },
18
+ });
19
+
20
+ type AvatarProps = ViewProps & VariantProps<typeof avatarVariants> & {
21
+ src?: string;
22
+ alt?: string;
23
+ fallback?: string;
24
+ };
25
+
26
+ function Avatar({ className, size, src, alt, fallback, ...props }: AvatarProps) {
27
+ const [hasError, setHasError] = React.useState(false);
28
+
29
+ return (
30
+ <View className={cn(avatarVariants({ size }), className)} {...props}>
31
+ {src && !hasError ? (
32
+ <Image
33
+ source={{ uri: src }}
34
+ accessibilityLabel={alt}
35
+ onError={() => setHasError(true)}
36
+ className="h-full w-full"
37
+ />
38
+ ) : (
39
+ <View className="flex h-full w-full items-center justify-center bg-muted">
40
+ <Text className="text-sm font-medium text-muted-foreground">
41
+ {fallback ?? alt?.charAt(0)?.toUpperCase() ?? '?'}
42
+ </Text>
43
+ </View>
44
+ )}
45
+ </View>
46
+ );
47
+ }
48
+
49
+ export { Avatar, avatarVariants };
50
+ export type { AvatarProps };
@@ -0,0 +1,60 @@
1
+ import * as React from 'react';
2
+ import { Text, View, type ViewProps } from 'react-native';
3
+ import { cn } from '@tallyui/theme';
4
+ import { cva, type VariantProps } from 'class-variance-authority';
5
+
6
+ const badgeVariants = cva(
7
+ 'inline-flex items-center rounded-full border px-2.5 py-0.5',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'border-transparent bg-primary',
12
+ secondary: 'border-transparent bg-secondary',
13
+ destructive: 'border-transparent bg-destructive',
14
+ outline: 'border-border',
15
+ success: 'border-transparent bg-success',
16
+ warning: 'border-transparent bg-warning',
17
+ info: 'border-transparent bg-info',
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: 'default',
22
+ },
23
+ }
24
+ );
25
+
26
+ const badgeTextVariants = cva('text-xs font-semibold', {
27
+ variants: {
28
+ variant: {
29
+ default: 'text-primary-foreground',
30
+ secondary: 'text-secondary-foreground',
31
+ destructive: 'text-destructive-foreground',
32
+ outline: 'text-foreground',
33
+ success: 'text-success-foreground',
34
+ warning: 'text-warning-foreground',
35
+ info: 'text-info-foreground',
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ variant: 'default',
40
+ },
41
+ });
42
+
43
+ type BadgeProps = ViewProps & VariantProps<typeof badgeVariants> & {
44
+ label?: string;
45
+ };
46
+
47
+ function Badge({ className, variant, label, children, ...props }: BadgeProps) {
48
+ return (
49
+ <View className={cn(badgeVariants({ variant }), className)} {...props}>
50
+ {label ? (
51
+ <Text className={badgeTextVariants({ variant })}>{label}</Text>
52
+ ) : (
53
+ children
54
+ )}
55
+ </View>
56
+ );
57
+ }
58
+
59
+ export { Badge, badgeVariants, badgeTextVariants };
60
+ export type { BadgeProps };
@@ -0,0 +1,90 @@
1
+ import * as React from 'react';
2
+ import { Pressable, type PressableProps } from 'react-native';
3
+ import { TextClassContext, Slot } from '@tallyui/primitives';
4
+ import { cn } from '@tallyui/theme';
5
+ import { cva, type VariantProps } from 'class-variance-authority';
6
+
7
+ const buttonVariants = cva(
8
+ 'inline-flex flex-row items-center justify-center gap-2 rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary active:opacity-90',
13
+ destructive: 'bg-destructive active:opacity-90',
14
+ outline: 'border border-input bg-background active:bg-accent',
15
+ secondary: 'bg-secondary active:opacity-80',
16
+ ghost: 'active:bg-accent',
17
+ link: 'web:underline-offset-4 web:hover:underline',
18
+ },
19
+ size: {
20
+ default: 'h-10 px-4 py-2',
21
+ sm: 'h-9 rounded-md px-3',
22
+ lg: 'h-11 rounded-md px-8',
23
+ icon: 'h-10 w-10',
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: 'default',
28
+ size: 'default',
29
+ },
30
+ }
31
+ );
32
+
33
+ const buttonTextVariants = cva(
34
+ 'web:whitespace-nowrap text-sm font-medium',
35
+ {
36
+ variants: {
37
+ variant: {
38
+ default: 'text-primary-foreground',
39
+ destructive: 'text-destructive-foreground',
40
+ outline: 'text-foreground',
41
+ secondary: 'text-secondary-foreground',
42
+ ghost: 'text-foreground',
43
+ link: 'text-primary',
44
+ },
45
+ size: {
46
+ default: '',
47
+ sm: '',
48
+ lg: 'text-base',
49
+ icon: '',
50
+ },
51
+ },
52
+ defaultVariants: {
53
+ variant: 'default',
54
+ size: 'default',
55
+ },
56
+ }
57
+ );
58
+
59
+ type ButtonProps = PressableProps & VariantProps<typeof buttonVariants> & {
60
+ asChild?: boolean;
61
+ };
62
+
63
+ const Button = React.forwardRef<React.ComponentRef<typeof Pressable>, ButtonProps>(
64
+ function Button({ className, variant, size, asChild = false, disabled, ...props }, ref) {
65
+ const Component = asChild ? Slot : Pressable;
66
+
67
+ return (
68
+ <TextClassContext.Provider
69
+ value={buttonTextVariants({ variant, size })}
70
+ >
71
+ <Component
72
+ ref={ref}
73
+ role="button"
74
+ disabled={disabled}
75
+ className={cn(
76
+ buttonVariants({ variant, size }),
77
+ disabled && 'opacity-50 web:pointer-events-none',
78
+ className,
79
+ )}
80
+ {...props}
81
+ />
82
+ </TextClassContext.Provider>
83
+ );
84
+ }
85
+ );
86
+
87
+ Button.displayName = 'Button';
88
+
89
+ export { Button, buttonVariants, buttonTextVariants };
90
+ export type { ButtonProps };