{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "bank-accounts", "type": "registry:block", "title": "Bank accounts block", "description": "The whitelisting surface withdrawals depend on: your company's own accounts (never third-party payees), seven account-type variants asking exactly the fields each requires, identifier re-entry against transcription slips, and verification status rendered verbatim.", "dependencies": [ "@venlyfinance/react@^0.3.0", "@venlyfinance/sdk@^0.4.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/data-table", "@venlyfinance/status-pill" ], "files": [ { "path": "registry/blocks/bank-accounts.tsx", "type": "registry:component", "target": "~/components/venly/blocks/bank-accounts.tsx", "content": "import { useMemo, useState, type CSSProperties, type ReactElement } from \"react\";\nimport type { FundflowComponents } from \"@venlyfinance/sdk\";\nimport {\n useBankAccountConfig,\n useCompanyBankAccounts,\n useCreateCompanyBankAccount,\n} from \"@venlyfinance/react\";\nimport { DataTable, RowText, type DataTableColumn } from \"../components/data-table.js\";\nimport { StatusPill, type StatusIntent } from \"../components/status-pill.js\";\n\n/**\n * Bank accounts block – the whitelisting surface withdrawals depend on.\n *\n * What the API models, and therefore what this block renders: a company\n * whitelists its OWN bank accounts (the account holder's legal name, never\n * a third-party payee), each account is verified out-of-band before it can\n * receive withdrawals, and seven account-type variants carry different\n * identifier fields. The list schema carries no account identifier at all –\n * rows are identified by the label you give them; the full identifier lives\n * on the detail record only.\n *\n * Design contract encoded by this block:\n * - Verification status is word + glyph, rendered from the field verbatim:\n * In review / Verified / Declined.\n * - The add form asks exactly the fields the chosen variant requires –\n * nothing generic, nothing missing – and confirms the account identifier\n * by re-entry (transcription risk is real; the API won't catch a typo'd\n * IBAN that happens to be valid).\n * - The own-name constraint is stated at the field, not discovered at\n * submit.\n */\n\ntype fundflow = FundflowComponents[\"schemas\"];\nexport type CompanyBankAccountListItem = fundflow[\"CompanyBankAccountListItem\"];\nexport type BankAccountType = NonNullable;\n\n// ── Status + label maps (field values verbatim → UI labels, once) ──────\n\nexport const BANK_ACCOUNT_STATUS_PILL: Record = {\n PENDING: { label: \"In review\", intent: \"pending\" },\n VERIFIED: { label: \"Verified\", intent: \"positive\" },\n DENIED: { label: \"Declined\", intent: \"negative\" },\n};\n\nconst RAIL_LABEL: Record = {\n ON_RAMP: \"Adding money\",\n OFF_RAMP: \"Withdrawals\",\n ON_AND_OFF_RAMP: \"Adding money + withdrawals\",\n};\n\nconst TYPE_LABEL: Record = {\n EUR_SEPA: \"EUR · SEPA\",\n GBP_FPS: \"GBP · Faster Payments\",\n GBP_CHAPS: \"GBP · CHAPS\",\n USD_ACH: \"USD · ACH\",\n USD_WIRE: \"USD · Wire\",\n USD_SWIFT: \"USD · SWIFT\",\n OTHER_SWIFT: \"SWIFT (other currency)\",\n};\n\n// ── Shared styling ─────────────────────────────────────────────────────\n\nconst inputStyle: CSSProperties = {\n width: \"100%\",\n boxSizing: \"border-box\",\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-md)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n};\n\nconst labelStyle: CSSProperties = {\n display: \"block\",\n fontSize: \"var(--font-size-label)\",\n color: \"var(--text-secondary)\",\n marginBottom: \"var(--space-2xs)\",\n};\n\nconst helperStyle: CSSProperties = {\n margin: \"0 0 var(--space-2xs)\",\n fontSize: \"var(--font-size-micro)\",\n color: \"var(--text-tertiary)\",\n};\n\nconst primaryButton: CSSProperties = {\n border: \"none\",\n background: \"var(--accent)\",\n color: \"var(--accent-fg)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-lg)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n fontWeight: 500,\n cursor: \"pointer\",\n};\n\nconst quietButton: CSSProperties = {\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-sm) var(--space-lg)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n cursor: \"pointer\",\n};\n\nfunction Field({\n id,\n label,\n helper,\n value,\n onChange,\n required = true,\n}: {\n id: string;\n label: string;\n helper?: string;\n value: string;\n onChange: (v: string) => void;\n required?: boolean;\n}): ReactElement {\n return (\n
\n \n {helper ?

{helper}

: null}\n onChange(e.target.value)}\n />\n
\n );\n}\n\n// ── Per-variant field model (spec-exact) ───────────────────────────────\n\n/** The identifier field per variant – the one worth re-entering. */\nconst IDENTIFIER_FIELD: Record = {\n EUR_SEPA: { key: \"iban\", label: \"IBAN\" },\n GBP_FPS: { key: \"accountNumber\", label: \"Account number\" },\n GBP_CHAPS: { key: \"accountNumber\", label: \"Account number\" },\n USD_ACH: { key: \"accountNumber\", label: \"Account number\" },\n USD_WIRE: { key: \"accountNumber\", label: \"Account number\" },\n USD_SWIFT: { key: \"accountNumber\", label: \"Account number\" },\n // OTHER_SWIFT resolves dynamically: whichever of accountNumber/iban is filled.\n};\n\ninterface VariantField {\n key: string;\n label: string;\n required: boolean;\n helper?: string;\n}\n\n/** Variant-specific fields, exactly the ones the create schema declares. */\nconst VARIANT_FIELDS: Record = {\n EUR_SEPA: [\n { key: \"iban\", label: \"IBAN\", required: true },\n { key: \"bic\", label: \"BIC\", required: false },\n ],\n GBP_FPS: [\n { key: \"accountNumber\", label: \"Account number\", required: true },\n { key: \"sortCode\", label: \"Sort code\", required: true },\n ],\n GBP_CHAPS: [\n { key: \"accountNumber\", label: \"Account number\", required: true },\n { key: \"sortCode\", label: \"Sort code\", required: true },\n ],\n USD_ACH: [\n { key: \"accountNumber\", label: \"Account number\", required: true },\n { key: \"routingNumber\", label: \"Routing number\", required: true },\n { key: \"email\", label: \"Beneficiary email\", required: true },\n { key: \"beneficiaryState\", label: \"State\", required: true },\n ],\n USD_WIRE: [\n { key: \"accountNumber\", label: \"Account number\", required: true },\n { key: \"routingNumber\", label: \"Routing number\", required: true },\n { key: \"email\", label: \"Beneficiary email\", required: true },\n { key: \"beneficiaryState\", label: \"State\", required: true },\n ],\n USD_SWIFT: [\n { key: \"bic\", label: \"BIC\", required: true },\n { key: \"accountNumber\", label: \"Account number\", required: true },\n { key: \"bankStreetAddress\", label: \"Bank street address\", required: true },\n { key: \"bankCity\", label: \"Bank city\", required: true },\n { key: \"bankPostalCode\", label: \"Bank postal code\", required: true },\n { key: \"beneficiaryState\", label: \"State\", required: true },\n ],\n OTHER_SWIFT: [\n { key: \"currency\", label: \"Currency (three-letter code)\", required: true },\n { key: \"bic\", label: \"BIC\", required: true },\n {\n key: \"accountNumber\",\n label: \"Account number\",\n required: false,\n helper: \"Provide the account number or the IBAN below – one of the two is required.\",\n },\n {\n key: \"iban\",\n label: \"IBAN\",\n required: false,\n helper: \"Provide the IBAN or the account number above – one of the two is required.\",\n },\n ],\n};\n\n// ── Add form ───────────────────────────────────────────────────────────\n\nexport interface AddBankAccountFormProps {\n onCreated: (account: { id?: string; name?: string }) => void;\n onCancel?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function AddBankAccountForm({\n onCreated,\n onCancel,\n style,\n className,\n}: AddBankAccountFormProps): ReactElement {\n const { data: config } = useBankAccountConfig();\n const create = useCreateCompanyBankAccount();\n const [accountType, setAccountType] = useState(\"\");\n const [values, setValues] = useState>({});\n const [confirmIdentifier, setConfirmIdentifier] = useState(\"\");\n const [error, setError] = useState(null);\n\n const enabledTypes = useMemo(\n () => (config?.enabledAccountTypes ?? []).filter((t) => t.type),\n [config],\n );\n const effectiveType = accountType || enabledTypes[0]?.type || \"\";\n const identifier =\n effectiveType === \"OTHER_SWIFT\"\n ? values.iban && !values.accountNumber\n ? { key: \"iban\", label: \"IBAN\" }\n : values.accountNumber\n ? { key: \"accountNumber\", label: \"Account number\" }\n : undefined\n : IDENTIFIER_FIELD[effectiveType];\n const set = (key: string) => (v: string) => setValues((prev) => ({ ...prev, [key]: v }));\n\n const submit = (): void => {\n setError(null);\n if (effectiveType === \"OTHER_SWIFT\" && !values.accountNumber && !values.iban) {\n setError(\"Provide the account number or the IBAN – one of the two is required.\");\n return;\n }\n if (identifier && (values[identifier.key] ?? \"\") !== confirmIdentifier) {\n setError(\"These account numbers don't match.\");\n return;\n }\n const body = {\n bankAccountType: effectiveType,\n name: values.name,\n bankName: values.bankName,\n companyName: values.companyName,\n bankCountry: values.bankCountry,\n beneficiaryAddressLine1: values.beneficiaryAddressLine1,\n beneficiaryAddressLine2: values.beneficiaryAddressLine2 || undefined,\n beneficiaryCity: values.beneficiaryCity,\n beneficiaryPostalCode: values.beneficiaryPostalCode,\n beneficiaryCountry: values.beneficiaryCountry,\n supportedRampType: values.supportedRampType || \"OFF_RAMP\",\n ...Object.fromEntries(\n (VARIANT_FIELDS[effectiveType] ?? [])\n .map((f) => [f.key, values[f.key]])\n .filter(([, v]) => v !== undefined && v !== \"\"),\n ),\n };\n create.mutate(body as Parameters[0], {\n onError: (e) => setError(e.message),\n onSuccess: (account) => onCreated(account as { id?: string; name?: string }),\n });\n };\n\n return (\n {\n e.preventDefault();\n submit();\n }}\n >\n

\n Add a bank account\n

\n\n
\n \n {\n setAccountType(e.target.value);\n setConfirmIdentifier(\"\");\n }}\n >\n {enabledTypes.map((t) => (\n \n ))}\n \n
\n\n \n \n \n \n\n {(VARIANT_FIELDS[effectiveType] ?? []).map((f) => (\n \n ))}\n {identifier ? (\n \n ) : null}\n\n

\n Beneficiary address\n

\n \n
\n
\n \n
\n
\n \n
\n
\n \n\n
\n \n set(\"supportedRampType\")(e.target.value)}\n >\n {Object.entries(RAIL_LABEL).map(([value, label]) => (\n \n ))}\n \n
\n\n {error ? (\n

\n {error}\n

\n ) : null}\n

\n New accounts are verified before they can receive withdrawals.\n

\n
\n \n {onCancel ? (\n \n ) : null}\n
\n \n );\n}\n\n// ── List ───────────────────────────────────────────────────────────────\n\nexport interface BankAccountsViewProps {\n accounts: CompanyBankAccountListItem[];\n onAdd?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\n/** Presentational half: the whitelist over already-loaded rows. */\nexport function BankAccountsView({ accounts, onAdd, style, className }: BankAccountsViewProps): ReactElement {\n const columns: DataTableColumn[] = [\n {\n key: \"account\",\n header: \"Account\",\n cell: (a) => ,\n },\n {\n key: \"type\",\n header: \"Type\",\n cell: (a) => (\n \n {TYPE_LABEL[a.bankAccountType ?? \"\"] ?? a.bankAccountType ?? \"—\"}\n \n ),\n },\n {\n key: \"country\",\n header: \"Country\",\n cell: (a) => (\n \n {a.bankCountry ?? \"—\"}\n \n ),\n },\n {\n key: \"rail\",\n header: \"Used for\",\n cell: (a) => (\n \n {RAIL_LABEL[a.supportedRampType ?? \"\"] ?? a.supportedRampType ?? \"—\"}\n \n ),\n },\n {\n key: \"status\",\n header: \"Status\",\n cell: (a) => {\n const pill = BANK_ACCOUNT_STATUS_PILL[a.verificationStatus ?? \"\"];\n return pill ? : ;\n },\n },\n ];\n\n return (\n
\n
\n

\n Bank accounts\n

\n {onAdd ? (\n \n ) : null}\n
\n {accounts.length === 0 ? (\n

\n No bank accounts yet. Add one to withdraw funds.\n

\n ) : (\n \n a.id ?? a.name ?? \"\"} />\n \n )}\n
\n );\n}\n\n/** Connected block: list + add-form toggle over the live whitelist. */\nexport function BankAccountsBlock({ style, className }: { style?: CSSProperties; className?: string }): ReactElement {\n const { data, isPending } = useCompanyBankAccounts();\n const [adding, setAdding] = useState(false);\n const [announce, setAnnounce] = useState(null);\n\n if (isPending) {\n return (\n
\n

Loading bank accounts…

\n
\n );\n }\n\n if (adding) {\n return (\n setAdding(false)}\n onCreated={(account) => {\n setAdding(false);\n setAnnounce(`${account.name ?? \"Bank account\"} added – verification is in review.`);\n }}\n />\n );\n }\n\n return (\n
\n {announce ? (\n

\n {announce}\n

\n ) : null}\n setAdding(true)} />\n
\n );\n}\n" } ] }