{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "balances", "type": "registry:block", "title": "Balances block", "description": "The home surface on the real wallet balance source: available/reserved hero per asset, segmented bar behind the two-bucket threshold, per-asset table, and masking that covers every figure including the chrome miniature.", "dependencies": [ "@venlyfinance/react@^0.2.0", "@venlyfinance/sdk@^0.3.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/money", "@venlyfinance/balance-card", "@venlyfinance/data-table" ], "files": [ { "path": "registry/blocks/balances.tsx", "type": "registry:component", "target": "~/components/venly/blocks/balances.tsx", "content": "import type { CSSProperties, ReactElement } from \"react\";\nimport type { WalletBalance } from \"@venlyfinance/sdk\";\nimport { useWallets } from \"@venlyfinance/react\";\nimport { Money, MASK } from \"../lib/money.js\";\nimport { BalanceCard } from \"../components/balance-card.js\";\nimport { DataTable, RowText, type DataTableColumn } from \"../components/data-table.js\";\n\n/**\n * Balances block – the home surface, wired to the wallet balance source.\n *\n * Design contract encoded by this block:\n * - The figures come from the API's wallet balances (total / available /\n * reserved per asset), never from literals. The hero is `available` –\n * the figure the Send button spends; `total` is demoted below the rule.\n * - The segmented bar renders only at two or more non-zero buckets: a\n * 100%-wide single band implies a split that isn't there.\n * - Masking covers EVERYTHING – hero, buckets, and every table row. A\n * masked hero beside visible per-asset rows leaks what masking hides.\n * - Reserved rows render an em-dash at zero: `0.00` in every cell buries\n * the one real reservation.\n * - The reserved bucket drills through to the records causing it.\n * - The available figure echoes into the chrome via BalanceMiniature –\n * the persistent miniature is part of this component's contract.\n */\n\nexport interface AssetBalanceRow {\n asset: string;\n /** Chains this asset sits on, for the secondary row line. */\n chains: string[];\n total: number;\n available: number;\n reserved: number;\n}\n\n/**\n * Aggregates balance rows per asset, sorted by available descending so\n * magnitudes stack. Contract 1.3.0: listWallets returns per-asset balance\n * rows (amounts as numbers) and no longer names the wallet's chain, so the\n * chain chips render only when the API someday says which chain a balance\n * lives on - a labelled gap, never an invented value.\n */\nexport function assetBalanceRows(wallets: WalletBalance[]): AssetBalanceRow[] {\n const byAsset = new Map();\n for (const balance of wallets) {\n if (!balance.asset) continue;\n const row = byAsset.get(balance.asset) ?? {\n asset: balance.asset,\n chains: [],\n total: 0,\n available: 0,\n reserved: 0,\n };\n row.total += Number(balance.amount?.total ?? 0);\n row.available += Number(balance.amount?.available ?? 0);\n row.reserved += Number(balance.amount?.reserved ?? 0);\n byAsset.set(balance.asset, row);\n }\n return [...byAsset.values()].sort((a, b) => b.available - a.available);\n}\n\n/**\n * Rows whose own figures don't reconcile (total ≠ available + reserved,\n * beyond float noise on 6-decimal amounts). The surface SHOWS the API's\n * numbers unchanged and says they don't add up – it never \"corrects\" money.\n */\nexport function arithmeticMismatches(rows: AssetBalanceRow[]): string[] {\n return rows\n .filter((r) => Math.abs(r.total - (r.available + r.reserved)) > 0.000001)\n .map((r) => r.asset);\n}\n\n/** The bar renders only when a split actually exists. */\nexport function segmentedBarBuckets(row: AssetBalanceRow): { label: string; amount: number }[] {\n const buckets = [\n { label: \"Available\", amount: row.available },\n { label: \"Reserved\", amount: row.reserved },\n ].filter((b) => b.amount > 0);\n return buckets.length >= 2 ? buckets : [];\n}\n\nfunction SegmentedBar({ row, masked }: { row: AssetBalanceRow; masked: boolean }): ReactElement | null {\n const buckets = segmentedBarBuckets(row);\n if (buckets.length === 0) return null;\n const sum = buckets.reduce((acc, b) => acc + b.amount, 0);\n return (\n `${b.label} ${b.amount.toFixed(2)}`).join(\", \")\n }\n style={{\n display: \"flex\",\n gap: \"var(--space-3xs)\",\n height: \"var(--bar-height)\",\n borderRadius: \"var(--radius-pill)\",\n overflow: \"hidden\",\n marginTop: \"var(--space-md)\",\n maxWidth: \"var(--card-max-width)\",\n }}\n >\n {buckets.map((b) => (\n \n ))}\n \n );\n}\n\nexport interface BalancesViewProps {\n rows: AssetBalanceRow[];\n /** Asset whose composition leads. Defaults to the largest available. */\n primaryAsset?: string;\n /** Qualifier line under the hero, e.g. the account name. */\n qualifier?: string;\n masked?: boolean;\n onToggleMasked?: () => void;\n /** Drill-through from the reserved bucket to the causing records. */\n onReservedDrill?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\n/** Presentational half: everything below the data fetch. */\nexport function BalancesView({\n rows,\n primaryAsset,\n qualifier,\n masked = false,\n onToggleMasked,\n onReservedDrill,\n style,\n className,\n}: BalancesViewProps): ReactElement {\n const primary = rows.find((r) => r.asset === primaryAsset) ?? rows[0];\n\n if (!primary) {\n return (\n
\n

\n No balances yet. Funds arriving on your account details will appear here.\n

\n
\n );\n }\n\n const columns: DataTableColumn[] = [\n {\n key: \"asset\",\n header: \"Asset\",\n cell: (r) => ,\n },\n {\n key: \"total\",\n header: \"Total\",\n money: true,\n cell: (r) => (\n \n ),\n },\n {\n key: \"reserved\",\n header: \"Reserved\",\n money: true,\n // Zero reserves render the em-dash: an empty column of 0.00 buries\n // the one row that actually has money locked up.\n cell: (r) => 0 ? r.reserved : null} masked={masked} style={{ fontWeight: 400 }} />,\n },\n {\n key: \"available\",\n header: \"Available\",\n money: true,\n cell: (r) => ,\n },\n ];\n\n return (\n
\n 0 ? onReservedDrill : undefined,\n },\n ]}\n />\n \n {primary.reserved > 0 ? (\n // Architecture honesty: a reservation is not money gone. Say so.\n \n Reserved funds are still yours – they're held for transfers in flight and release\n when those settle or fail.\n

\n ) : null}\n {arithmeticMismatches(rows).length > 0 ? (\n \n The figures for {arithmeticMismatches(rows).join(\", \")} don't add up (total ≠\n available + reserved). Showing the numbers as reported, unchanged.\n

\n ) : null}\n {rows.length > 0 ? (\n \n r.asset} />\n \n ) : null}\n
\n );\n}\n\n/** Connected block: wallet balances for the account, live from the client. */\nexport function BalancesBlock({\n accountId,\n primaryAsset,\n qualifier,\n masked,\n onToggleMasked,\n onReservedDrill,\n style,\n className,\n}: Omit & { accountId: string }): ReactElement {\n const { data, isPending, isError, refetch } = useWallets(accountId);\n\n if (isPending) {\n return (\n
\n

Loading balances…

\n
\n );\n }\n\n if (isError) {\n // Local degrade: this surface reports its own failure and offers a\n // retry; it never takes the rest of the app down with it.\n return (\n
\n

\n Balances couldn't load. The rest of the app still works – your money is unaffected\n by a display error.\n

\n void refetch()}\n style={{\n marginTop: \"var(--space-md)\",\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n background: \"var(--surface-raised)\",\n color: \"var(--text-primary)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n fontSize: \"var(--font-size-label)\",\n fontFamily: \"var(--font-family)\",\n cursor: \"pointer\",\n }}\n >\n Try again\n \n
\n );\n }\n\n return (\n \n );\n}\n\n/**\n * Persistent miniature – the primary available figure echoed in the chrome\n * (sidebar or top bar). Shares the surface's masked state: a masked hero\n * beside a visible miniature would leak the number the user just hid.\n */\nexport function BalanceMiniature({\n accountId,\n primaryAsset,\n masked = false,\n style,\n className,\n}: {\n accountId: string;\n primaryAsset?: string;\n masked?: boolean;\n style?: CSSProperties;\n className?: string;\n}): ReactElement | null {\n const { data } = useWallets(accountId);\n const rows = assetBalanceRows(data?.items ?? []);\n const primary = rows.find((r) => r.asset === primaryAsset) ?? rows[0];\n if (!primary) return null;\n\n return (\n \n Available\n \n {masked ? MASK : primary.available.toLocaleString(\"en-US\", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{\" \"}\n {primary.asset}\n \n \n );\n}\n" } ] }