{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "receive", "type": "registry:block", "title": "Receive block", "description": "Bank details a payer's finance team actually reads: mandatory payment-reference enforcement, warning above the fields, copy that names the field.", "dependencies": [ "@venlyfinance/react@^0.2.0", "@venlyfinance/sdk@^0.3.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/money", "@venlyfinance/field-list" ], "files": [ { "path": "registry/blocks/receive.tsx", "type": "registry:component", "target": "~/components/venly/blocks/receive.tsx", "content": "import { ReactElement, useMemo, useState, useCallback } from \"react\";\nimport type {\n Account,\n VirtualBankAccount,\n} from \"@venlyfinance/sdk\";\nimport {\n useAccount,\n useVirtualBankAccounts,\n useCreateVirtualBankAccount,\n} from \"@venlyfinance/react\";\nimport { FieldList } from \"../components/field-list.js\";\nimport { StatusPill } from \"../components/status-pill.js\";\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst REFERENCE_WARNING =\n \"Enter the payment reference exactly as shown. If it is missing or changed, the payment cannot be matched automatically and may require manual reconciliation. If your bank cannot accept the reference exactly as shown, stop and contact the recipient before sending.\";\n\nconst FRAUD_CHECK_ADVISORY =\n \"If these details differ from instructions you used before, confirm the change with the recipient through a trusted channel before sending.\";\n\nconst CURRENCY_DISPLAY: Record = {\n EUR_SEPA: \"SEPA\",\n};\n\n// ─── Serializer ──────────────────────────────────────────────────────────────\n\nexport interface ReceiveDetails {\n name: string;\n iban: string;\n bic: string;\n bankName: string;\n beneficiaryName: string;\n referenceCode: string;\n currency: string;\n bankAccountType: string;\n targetCryptocurrency?: string;\n}\n\nexport function serializeReceiveDetails(details: ReceiveDetails): string {\n const lines: string[] = [];\n const now = new Date();\n const tzName = now.toLocaleString(\"en-GB\", { timeZoneName: \"short\" }).split(\", \").pop() ?? \"\";\n lines.push(\"Bank transfer instructions\");\n lines.push(\n `Generated: ${now.toLocaleDateString(\"en-GB\", {\n day: \"2-digit\",\n month: \"2-digit\",\n year: \"numeric\",\n })} ${now.toLocaleTimeString(\"en-GB\", {\n hour: \"2-digit\",\n minute: \"2-digit\",\n hour12: false,\n })} ${tzName}`\n );\n lines.push(\"\");\n lines.push(\"Important\");\n lines.push(REFERENCE_WARNING);\n lines.push(\"\");\n lines.push(\"Payment reference (required)\");\n lines.push(details.referenceCode);\n lines.push(details.beneficiaryName);\n lines.push(details.iban);\n lines.push(details.bic);\n lines.push(details.bankName);\n lines.push(`Currency to send: ${details.currency}`);\n lines.push(\n `Transfer type: ${\n CURRENCY_DISPLAY[details.bankAccountType] ?? \"SEPA\"\n } bank transfer`\n );\n lines.push(\"\");\n lines.push(\"Currency conversion\");\n lines.push(\n details.targetCryptocurrency\n ? `Send ${details.currency}, not ${details.targetCryptocurrency}. The recipient account converts incoming ${details.currency} to ${details.targetCryptocurrency}.`\n : `Send ${details.currency}. (No conversion takes place.)`\n );\n lines.push(\"\");\n lines.push(\"Fraud check\");\n lines.push(FRAUD_CHECK_ADVISORY);\n return lines.join(\"\\n\");\n}\n\n// ─── Clipboard helper ───────────────────────────────────────────────────────\n\nexport async function copyText(value: string): Promise {\n if (typeof navigator === \"undefined\" || !navigator.clipboard) return false;\n try {\n await navigator.clipboard.writeText(value);\n return true;\n } catch {\n return false;\n }\n}\n\n// ─── PDF adapter (dependency-light) ──────────────────────────────────────────\n\nfunction sanitizeFileName(base: string): string {\n return base.replace(/[^\\w]/g, \"_\").slice(0, 40).toLowerCase();\n}\n\nexport function downloadPDF(\n details: ReceiveDetails,\n name: string,\n generateClock: () => string = () => new Date().toLocaleString()\n): void {\n const now = generateClock();\n const dateStr =\n name && name.trim().length > 0\n ? `${sanitizeFileName(name)}-${now.split(\",\")[0].replace(/\\//g, \"-\").replace(/ /g, \"-\")}`\n : now.split(\",\")[0].replace(/\\//g, \"-\").replace(/ /g, \"-\");\n void dateStr;\n const html = `\nBank transfer instructions\n\n

Bank transfer instructions

\n

Generated: ${now}

\n

Important

\n

${REFERENCE_WARNING}

\n\n\n\n\n\n\n\n\n
Payment reference${details.referenceCode}
(required)
Beneficiary name${details.beneficiaryName}
IBAN${details.iban}
BIC / SWIFT code${details.bic}
Bank name${details.bankName}
Currency to send${details.currency}
Transfer type${CURRENCY_DISPLAY[details.bankAccountType] ?? \"SEPA\"} bank transfer
\n

Currency conversion

\n

${details.targetCryptocurrency ? `Send ${details.currency}, not ${details.targetCryptocurrency}. The recipient account converts incoming ${details.currency} to ${details.targetCryptocurrency}.` : `Send ${details.currency}. (No conversion takes place.)`}

\n

Fraud check

\n

${FRAUD_CHECK_ADVISORY}

\n`;\n\n const win = window.open(\"\", \"_blank\");\n if (!win) return;\n win.document.write(html);\n win.document.close();\n win.focus();\n setTimeout(() => {\n win.print();\n setTimeout(() => win.close(), 2000);\n }, 300);\n}\n\n// ─── Completeness helper ────────────────────────────────────────────────────\n\nconst REQUIRED_FIELDS: (keyof VirtualBankAccount)[] = [\n \"referenceCode\",\n \"beneficiaryName\",\n \"iban\",\n \"bic\",\n \"bankName\",\n \"currency\",\n \"bankAccountType\",\n \"targetCryptocurrency\",\n];\n\nexport function isComplete(vba: VirtualBankAccount): boolean {\n return REQUIRED_FIELDS.every((k) => {\n const v = vba[k];\n return v !== undefined && v !== null && v !== \"\";\n });\n}\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\ninterface VbaListResponse {\n items: (VirtualBankAccount | null)[];\n pagination?: {\n pageNumber?: number;\n numberOfElements?: number;\n numberOfPages?: number;\n hasNextPage?: boolean;\n hasPreviousPage?: boolean;\n };\n}\n\n// ─── Connected component ────────────────────────────────────────────────────\n\n/**\n * Data-bound entry point for /receive.\n *\n * Flow:\n * 1. Load account (kycStatus), load VBA list (paginated).\n * 2. Route to checklist / provision form / detail / picker.\n */\nexport function ReceiveBlock({\n accountId,\n}: {\n accountId: string;\n}): ReactElement | null {\n // ── Account ─────────────────────────────────────────────────────────────\n const {\n data: accountData,\n isLoading: accountLoading,\n } = useAccount(accountId);\n\n // React-query-v6 shape: { result, status }\n const account = useMemo(() => {\n if (!accountData || typeof accountData !== \"object\") return null;\n return \"result\" in accountData\n ? (accountData.result as Account | undefined)\n : (accountData as unknown as Account);\n }, [accountData]);\n\n // ── VBA list ────────────────────────────────────────────────────────────\n const vbaQuery = useVirtualBankAccounts(accountId);\n const vbaRaw = useMemo(() => {\n if (!vbaQuery.data) return { items: [], pagination: null };\n const d = vbaQuery.data as VbaListResponse;\n return {\n items: d.items.filter((i): i is VirtualBankAccount => i != null),\n pagination: d.pagination ?? null,\n };\n }, [vbaQuery.data]);\n\n // ── Compute derived state ───────────────────────────────────────────────\n const validItems = useMemo(\n () => vbaRaw.items.filter((v) => v.id && v.status === \"ACTIVE\"),\n [vbaRaw.items]\n );\n\n /** CLOSED details still exist and must be shown as closed, never hidden. */\n const closedItems = useMemo(\n () => vbaRaw.items.filter((v) => v.id && v.status === \"CLOSED\"),\n [vbaRaw.items]\n );\n\n\n const autoSelectSingle =\n vbaRaw.pagination &&\n vbaRaw.pagination.numberOfPages === 1 &&\n vbaRaw.pagination.numberOfElements === 1 &&\n validItems.length === 1;\n\n // ── Provision mutation ──────────────────────────────────────────────────\n const createMutation = useCreateVirtualBankAccount();\n\n // ── Derive page ─────────────────────────────────────────────────────────\n\n // Loading\n if (accountLoading || vbaQuery.isLoading) {\n return (\n
\n

\n Loading bank details...\n

\n
\n );\n }\n\n // Sparse account: no kycStatus at all\n if (!account?.kycStatus) {\n return (\n
\n

\n Account status unavailable\n

\n

\n Bank details can't be created or shared until the account status is\n available.\n

\n
\n );\n }\n\n // ── VERIFICATION_PENDING or REJECTED ────────────────────────────────────\n if (\n account.kycStatus === \"VERIFICATION_PENDING\" ||\n account.kycStatus === \"REJECTED\"\n ) {\n return (\n \n );\n }\n\n // ── VERIFIED ────────────────────────────────────────────────────────────\n // No ACTIVE VBA, but a CLOSED one exists → the closed surface, never the\n // provision form. Offering \"set up bank details\" to an account that holds\n // closed details hides the do-not-use warning the contract requires.\n if (validItems.length === 0 && closedItems.length > 0) {\n return ;\n }\n\n // No VBA at all → provisioning\n if (validItems.length === 0) {\n return (\n {}}\n />\n );\n }\n\n // Auto-select single → single detail\n if (autoSelectSingle) {\n return (\n \n );\n }\n\n // Multiple valid → picker\n return (\n \n );\n}\n\n// ─── Single-detail page (VERIFIED + active VBA) ─────────────────────────────\n\ninterface DetailPageProps {\n vba: VirtualBankAccount;\n account: Account;\n}\n\nfunction DetailPage({ vba, account }: DetailPageProps): ReactElement {\n const [copiedField, setCopiedField] = useState(null);\n const [copyError, setCopyError] = useState(null);\n\n const complete = isComplete(vba);\n\n // Per-field copy\n const handleFieldCopy = useCallback(\n async (label: string, value: string): Promise => {\n if (!complete) {\n setCopyError(\n `${label} wasn't copied. Select and copy the value manually, or try again.`\n );\n return;\n }\n const ok = await copyText(value);\n if (ok) {\n setCopiedField(`${label} copied.`);\n setTimeout(() => setCopiedField(null), 1500);\n }\n },\n [complete]\n );\n\n // Whole-set text copy\n const handleCopyAll = useCallback(async (): Promise => {\n if (!complete) {\n setCopyError(\n \"Bank transfer details weren't copied. Try again or download the PDF.\"\n );\n return;\n }\n const details: ReceiveDetails = {\n name: account.name ?? \"Unnamed bank details\",\n iban: vba.iban ?? \"\",\n bic: vba.bic ?? \"\",\n bankName: vba.bankName ?? \"\",\n beneficiaryName: vba.beneficiaryName ?? \"\",\n referenceCode: vba.referenceCode ?? \"\",\n currency: vba.currency ?? \"\",\n bankAccountType: vba.bankAccountType ?? \"\",\n targetCryptocurrency: vba.targetCryptocurrency ?? undefined,\n };\n const ok = await copyText(serializeReceiveDetails(details));\n if (ok) setCopiedField(\"Bank transfer details copied.\");\n else\n setCopyError(\n \"Bank transfer details weren't copied. Try again or download the PDF.\"\n );\n setTimeout(() => setCopiedField(null), 2000);\n }, [complete, account, vba]);\n\n // PDF download\n const handleDownloadPDF = useCallback((): void => {\n if (!complete) {\n setCopyError(\n \"The PDF wasn't created. Try again or copy the details as text.\"\n );\n return;\n }\n const details: ReceiveDetails = {\n name: account.name ?? \"Unnamed bank details\",\n iban: vba.iban ?? \"\",\n bic: vba.bic ?? \"\",\n bankName: vba.bankName ?? \"\",\n beneficiaryName: vba.beneficiaryName ?? \"\",\n referenceCode: vba.referenceCode ?? \"\",\n currency: vba.currency ?? \"\",\n bankAccountType: vba.bankAccountType ?? \"\",\n targetCryptocurrency: vba.targetCryptocurrency ?? undefined,\n };\n downloadPDF(details, account.name ?? \"Unnamed bank details\");\n }, [complete, account, vba]);\n\n // ── VBA has status CLOSED ───────────────────────────────────────────────\n if (vba.status === \"CLOSED\") {\n return (\n \n );\n }\n\n // ── VBA ACTIVE and complete: the happy path ───────────────────────────────\n if (complete) {\n return (\n \n \n {vba.targetCryptocurrency ? (\n
\n \n Send {vba.currency}, not {vba.targetCryptocurrency}. The\n recipient account converts incoming {vba.currency} to{\" \"}\n {vba.targetCryptocurrency}.\n

\n
\n ) : null}\n \n );\n }\n\n // ── VBA ACTIVE but incomplete ────────────────────────────────────────────\n return (\n \n {/*\n * The completeness gate: when ANY serializer input is absent, EVERY copy\n * path is closed - per-field included. A partially-copied instruction set\n * is the failure mode this journey exists to prevent, so no row is\n * copyable here and no onCopy handler is wired.\n */}\n \n
\n window.location.reload()}\n style={{\n border: \"none\",\n background: \"none\",\n color: \"var(--text-secondary)\",\n cursor: \"pointer\",\n fontSize: \"var(--font-size-label)\",\n textDecoration: \"underline\",\n }}\n >\n Reload bank details\n \n
\n \n );\n}\n\n// ─── AccountInfoPage: verification-pending / rejected ─────────────────────────\n\nfunction AccountInfoPage({\n account,\n vbaList,\n createMutation,\n}: {\n account: Account;\n vbaList: (VirtualBankAccount | null)[];\n createMutation: ReturnType;\n}): ReactElement {\n void vbaList;\n void createMutation;\n const isPending = account.kycStatus === \"VERIFICATION_PENDING\";\n const isRejected = account.kycStatus === \"REJECTED\";\n\n return (\n
\n {isPending ? (\n <>\n \n Bank details aren't available yet.\n \n

\n Account verification is in review. No completion estimate is\n available here. Open{\" \"}\n \n Verification status\n {\" \"}\n to see whether any action is required. Bank details can be created\n after verification is complete. You can continue to view this\n account; other sections show their own availability.\n

\n \n \n \n \n Account selected\n \n \n Complete\n \n \n \n \n Account verification\n \n \n In review\n \n \n \n \n Bank details\n \n \n Not available\n \n \n \n \n \n ) : isRejected ? (\n <>\n \n Bank details are unavailable.\n \n

\n Account verification was declined, so bank details cannot be\n created.\n

\n \n \n \n \n Account selected\n \n \n Complete\n \n \n \n \n Account verification\n \n \n Declined\n \n \n \n \n Bank details\n \n \n Not available\n \n \n \n \n

\n \n View verification status\n \n

\n \n ) : null}\n
\n );\n}\n\n// ─── Provision form (VERIFIED, no VBA) ───────────────────────────────────────\n\nfunction ProvisionForm({\n accountId,\n createMutation,\n onError,\n}: {\n accountId: string;\n createMutation: ReturnType;\n onError?: (msg: string) => void;\n}): ReactElement {\n const [name, setName] = useState(\"\");\n const [crypto, setCrypto] = useState(\"USDC\");\n const [creating, setCreating] = useState(false);\n const [error, setError] = useState(null);\n\n const handleCreate = async (): Promise => {\n if (!name.trim()) {\n const msg = \"Check the highlighted fields.\";\n setError(msg);\n onError?.(msg);\n return;\n }\n setCreating(true);\n setError(null);\n try {\n const idempotencyKey = `venly-provision-${accountId}-${name.trim()}-${Date.now()}`;\n await createMutation.mutateAsync({\n accountId,\n body: {\n name: name.trim(),\n inCurrency: \"EUR\",\n targetCryptocurrency: crypto,\n idempotencyKey,\n },\n });\n // On success the SDK refetches; the parent component will re-render with the VBA.\n window.location.reload();\n } catch (_err) {\n const msg = \"Bank details weren't created. Try again.\";\n setError(msg);\n onError?.(msg);\n } finally {\n setCreating(false);\n }\n };\n\n return (\n
\n \n Set up bank details\n \n

\n Create EUR SEPA bank-transfer details for this account.\n

\n\n
\n \n Bank details name\n \n setName(e.target.value)}\n style={{\n width: \"100%\",\n padding: \"var(--space-sm)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n border:\n \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n outline: \"none\",\n }}\n placeholder=\"My EUR Deposit Account\"\n />\n
\n\n
\n

\n Payer sends: EUR via SEPA\n

\n
\n\n
\n \n Convert incoming EUR to\n \n setCrypto(e.target.value)}\n style={{\n width: \"100%\",\n padding: \"var(--space-sm)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n border:\n \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n }}\n >\n \n \n \n \n \n
\n\n
\n \n {creating ? \"Creating...\" : \"Create bank details\"}\n \n
\n\n {error && (\n \n {error}\n

\n )}\n
\n );\n}\n\n// ─── Picker page (multiple active VBAs) ──────────────────────────────────────\n\nfunction PickerPage({\n account: _account,\n vbaList,\n}: {\n account: Account;\n vbaList: (VirtualBankAccount | null)[];\n}): ReactElement {\n const [selectedId, setSelectedId] = useState(null);\n\n /** Returned by the API but unusable: no id, or no status. */\n const unselectable = useMemo(\n () => vbaList.filter((v): v is VirtualBankAccount => v != null && (!v.id || !v.status)),\n [vbaList]\n );\n\n // Sort: ACTIVE first, then CLOSED; by name (then id)\n const sorted = useMemo(() => {\n const items = vbaList.filter(\n (v): v is VirtualBankAccount => v != null && !!v.id && !!v.status\n );\n return items.sort((a, b) => {\n if (a.status === \"ACTIVE\" && b.status !== \"ACTIVE\") return -1;\n if (a.status !== \"ACTIVE\" && b.status === \"ACTIVE\") return 1;\n return (a.name ?? \"\").localeCompare(b.name ?? \"\");\n });\n }, [vbaList]);\n\n const selected = useMemo(\n () => sorted.find((v) => v.id === selectedId) ?? null,\n [sorted, selectedId]\n );\n\n const activeItems = sorted.filter((v) => v.status === \"ACTIVE\");\n const closedItems = sorted.filter((v) => v.status === \"CLOSED\");\n\n // Derive which to show\n const showItems =\n activeItems.length > 0\n ? activeItems\n : closedItems.length > 0\n ? closedItems\n : [];\n\n return (\n
\n \n Choose bank details\n \n

\n Select the bank details you want to share.\n

\n\n {showItems.length === 0 && sorted.length > 0 && (\n

\n No bank details are available.\n

\n )}\n\n
\n {\n setSelectedId(e.target.value);\n }}\n style={{\n width: \"100%\",\n padding: \"var(--space-sm)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n border:\n \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n }}\n >\n \n {showItems.map((v) => (\n \n ))}\n {unselectable.map((_v, i) => (\n \n ))}\n \n
\n\n {unselectable.length > 0 && (\n \n {unselectable.length === 1 ? \"One entry is\" : `${unselectable.length} entries are`}{\" \"}\n missing identity or status information and can't be shared.\n

\n )}\n\n {selected && }\n
\n );\n}\n\n// ─── ShareSection (complete, active) ─────────────────────────────────────────\n\nfunction ShareSection({\n accountName,\n onCopyAll,\n onDownloadPDF,\n copiedField,\n copyError,\n children,\n}: {\n accountName: string;\n onCopyAll: () => void;\n onDownloadPDF: () => void;\n copiedField: string | null;\n copyError: string | null;\n children: React.ReactNode;\n}): ReactElement {\n return (\n
\n \n \n {accountName}\n \n \n\n \n\n \n \n {copiedField?.includes(\"Bank transfer details\")\n ? \"Copied!\"\n : \"Copy details\"}\n \n \n Download PDF\n \n \n\n \n Payer sends: EUR via SEPA\n

\n\n
\n \n {REFERENCE_WARNING}\n

\n
\n\n {children}\n\n {copiedField && (\n \n {copiedField}\n \n )}\n\n {copyError && (\n \n {copyError}\n \n )}\n
\n );\n}\n\n// ─── BlockedSection (ACTIVE but incomplete) ───────────────────────────────────\n\nfunction BlockedSection({\n copyError: _copyError,\n setCopyError: _setCopyError,\n children,\n}: {\n copyError: string | null;\n setCopyError: (msg: string | null) => void;\n children: React.ReactNode;\n}): ReactElement {\n return (\n
\n \n \n Bank transfer instructions\n \n \n\n \n\n \n Bank transfer instructions are incomplete.\n

\n One or more required fields are unavailable. Copying and download\n are disabled so incomplete instructions cannot be sent.\n

\n \n\n \n \n Copy details\n \n \n Download PDF\n \n \n\n {children}\n
\n );\n}\n\n// ─── Closed page ─────────────────────────────────────────────────────────────\n\nfunction ClosedPage({\n account,\n}: {\n account: Account;\n}): ReactElement {\n void account;\n return (\n
\n \n \n {account.name ?? \"Unnamed bank details\"}\n \n \n\n \n\n \n Closed\n

\n

\n These bank details are closed. Do not share or use them for a new\n transfer.\n

\n
\n );\n}\n" } ] }