{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "activity", "type": "registry:block", "title": "Activity block", "description": "One feed over both money rails: the account's transfers interleaved with the company's withdrawals and add-money requests, a labelled Scope column, three bands (In progress / Completed / Didn't complete), live-synced detail panels, unified CSV export, and arrow-key row stepping. Mount UnifiedActivityBlock for the full feed, or ActivityBlock for a transfers-only ledger.", "dependencies": [ "@venlyfinance/react@^0.2.0", "@venlyfinance/sdk@^0.3.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/money", "@venlyfinance/data-table", "@venlyfinance/status-pill", "@venlyfinance/side-panel", "@venlyfinance/timeline", "@venlyfinance/field-list", "@venlyfinance/withdraw" ], "files": [ { "path": "registry/blocks/activity.tsx", "type": "registry:component", "target": "~/components/venly/blocks/activity.tsx", "content": "import { useEffect, useMemo, useState, type CSSProperties, type ReactElement } from \"react\";\nimport type { FundflowComponents, Transfer } from \"@venlyfinance/sdk\";\nimport { describeRampStatus, useRampRequests, useTransfers } from \"@venlyfinance/react\";\nimport { Money, formatAmount } from \"../lib/money.js\";\nimport { DataTable, RowText, type DataTableColumn, type DataTableGroup } from \"../components/data-table.js\";\nimport { StatusPill, type StatusIntent } from \"../components/status-pill.js\";\nimport { SidePanel } from \"../components/side-panel.js\";\nimport { Timeline, type TimelineStep } from \"../components/timeline.js\";\nimport { FieldList } from \"../components/field-list.js\";\nimport { WITHDRAW_STATUS_PILL } from \"./withdraw.js\";\n\n/**\n * Activity block – the ledger plus its detail panel.\n *\n * Design contract encoded by this block:\n * - Clicking a row opens the side panel; it never navigates. The source\n * row stays tinted while the panel is open.\n * - Pending sits in its own section ABOVE settled; the empty pending\n * section is still drawn, collapsed – \"Pending: 0\" is a state.\n * - The summary strip is one inline stat row (~a table row tall), its\n * figures recompute on every filter, and each figure is a selectable\n * scope switch – the summary IS the primary filter.\n * - Export declares its scope in prose BEFORE offering a format, and the\n * trigger reads \"Export filtered\" while a filter is active.\n * - Amounts are signed relative to the account: money leaving is negative\n * with a true minus, but the level stays tonally neutral – debits are\n * not red; red is reserved for failure.\n * - Status renders as an inline pill after the primary label – zero column\n * width, only on rows that need it (PENDING/FAILED; COMPLETED rows stay\n * quiet because success is the default, not news).\n * - The failure explanation lives where the status is: the panel timeline's\n * terminal node carries the error message.\n * - ↑/↓ step the panel through the visible rows without closing it; Esc\n * closes. The footer chips advertise exactly the keys that work.\n */\n\nexport type ActivityScope = \"all\" | \"pending\" | \"failed\";\n\nexport function transferStatusIntent(status: Transfer[\"status\"]): {\n intent: StatusIntent;\n label: string;\n} | null {\n switch (status) {\n case \"PENDING\":\n return { intent: \"pending\", label: \"Pending\" };\n case \"FAILED\":\n return { intent: \"negative\", label: \"Failed\" };\n default:\n return null; // COMPLETED rows stay quiet: colour is a budget\n }\n}\n\nexport function transferTimeline(transfer: Transfer): TimelineStep[] {\n return [\n {\n key: \"created\",\n label: \"Created\",\n meta: transfer.createdAt,\n state: \"completed\",\n },\n {\n key: \"settled\",\n label:\n transfer.status === \"FAILED\"\n ? (transfer.errorMessage ?? \"Transfer failed\")\n : \"Settled\",\n meta: transfer.status === \"COMPLETED\" ? transfer.updatedAt : undefined,\n state:\n transfer.status === \"COMPLETED\"\n ? \"completed\"\n : transfer.status === \"FAILED\"\n ? \"failed\"\n : \"current\",\n },\n ];\n}\n\n/** Direction relative to the account viewing the ledger. */\nexport function transferDirection(transfer: Transfer, accountId: string): \"in\" | \"out\" {\n return transfer.senderAccountId === accountId ? \"out\" : \"in\";\n}\n\n/** Outgoing money is negative; the LEVEL stays tonally neutral either way. */\nexport function signedTransferAmount(\n transfer: Transfer,\n accountId?: string,\n): number | undefined {\n if (transfer.amount === undefined) return undefined;\n if (accountId && transferDirection(transfer, accountId) === \"out\") return -transfer.amount;\n return transfer.amount;\n}\n\n/** The scope switch the summary strip drives. */\nexport function scopeTransfers(transfers: Transfer[], scope: ActivityScope): Transfer[] {\n switch (scope) {\n case \"pending\":\n return transfers.filter((t) => t.status === \"PENDING\");\n case \"failed\":\n return transfers.filter((t) => t.status === \"FAILED\");\n default:\n return transfers;\n }\n}\n\n/** Recomputed on every filter change – aggregates must never go stale. */\nexport function activitySummary(transfers: Transfer[]): {\n transfers: number;\n pending: number;\n failed: number;\n} {\n return {\n transfers: transfers.length,\n pending: transfers.filter((t) => t.status === \"PENDING\").length,\n failed: transfers.filter((t) => t.status === \"FAILED\").length,\n };\n}\n\n/** Scope in prose BEFORE the format choice – the reader must know what\n * leaves the app before choosing how it leaves. */\nexport function exportScopeSentence(count: number, total: number): string {\n return count === total\n ? `Exports all ${total} transfer${total === 1 ? \"\" : \"s\"} on this account.`\n : `Exports the ${count} transfer${count === 1 ? \"\" : \"s\"} matching your current filters, out of ${total}.`;\n}\n\nfunction csvField(value: unknown): string {\n const s = value === undefined || value === null ? \"\" : String(value);\n return /[\",\\n]/.test(s) ? `\"${s.replaceAll('\"', '\"\"')}\"` : s;\n}\n\nexport function transfersToCsv(transfers: Transfer[], accountId?: string): string {\n const header = \"id,createdAt,direction,asset,chain,amount,status,description,reference\";\n const lines = transfers.map((t) =>\n [\n t.id,\n t.createdAt,\n accountId ? transferDirection(t, accountId) : \"\",\n t.asset,\n t.chain,\n signedTransferAmount(t, accountId),\n t.status,\n t.description,\n t.merchantReference,\n ]\n .map(csvField)\n .join(\",\"),\n );\n return [header, ...lines].join(\"\\n\");\n}\n\n/** The activity table's group for a transfer: pending above settled. */\nexport function transferGroupKey(transfer: Transfer): \"pending\" | \"settled\" {\n return transfer.status === \"PENDING\" ? \"pending\" : \"settled\";\n}\n\n/**\n * The ids the keyboard stepper may visit: exactly the rows the grouped\n * table renders. A collapsed group renders no rows, so its ids are\n * excluded – the stepper must never select a row with no
\n Transaction {transfer.transactionHash}\n
\n ) : null}\n\n {descriptor.explanation}\n
\n ) : null}\nLoading activity…
\n\n Exports the {visible.length} rows matching your current filters – this\n account’s transfers plus the company’s withdrawals and Add money\n rows – out of {all.length}.\n
\n\n No activity yet. Transfers and withdrawals appear here as soon as they’re created.\n
\n ) : visible.length === 0 ? (\n\n Nothing matches these filters.{\" \"}\n \n
\n ) : (\nLoading activity…
\n ) : (\n <>\n {/* Summary strip: one inline stat row, no containers, under a\n table row tall. Each figure is the scope switch. */}\n\n {exportScopeSentence(visible.length, all.length)}\n
\n