{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "onboarding", "type": "registry:block", "title": "Onboarding block", "description": "Company details in, an application status out: creates the organisation and its account through the real operations, renders the verification status verbatim with a humane waiting state, decline-with-review, and a restricted-mode banner.", "dependencies": [ "@venlyfinance/react@^0.2.0", "@venlyfinance/sdk@^0.3.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/status-pill", "@venlyfinance/timeline", "@venlyfinance/field-list" ], "files": [ { "path": "registry/blocks/onboarding.tsx", "type": "registry:component", "target": "~/components/venly/blocks/onboarding.tsx", "content": "import { useMemo, useState, type CSSProperties, type ReactElement } from \"react\";\nimport type { Party, Account } from \"@venlyfinance/sdk\";\nimport { useAccount, useCreateAccount, useCreateParty, useParty } from \"@venlyfinance/react\";\nimport { StatusPill, type StatusIntent } from \"../components/status-pill.js\";\nimport { Timeline, type TimelineStep } from \"../components/timeline.js\";\nimport { FieldList } from \"../components/field-list.js\";\n\n/**\n * Onboarding block – company details in, an application status out.\n *\n * What the API actually carries, and therefore what this block renders:\n * creating the organisation (`POST /parties`) and its account\n * (`POST /accounts`) starts verification; from then on the API reports one\n * application-level status per record (`Party.kybStatus`,\n * `Account.kycStatus`). There is no endpoint to submit documents, advance\n * verification, or read per-requirement progress – so this block renders\n * the literal status humanely and never invents a step it can't observe.\n *\n * Design contract encoded by this block:\n * - Statuses come from the API's fields verbatim; the UI label map is\n * applied once, here. There is no path to a \"Verified\" badge that the\n * API didn't report.\n * - The waiting state answers the operator's four questions: must I act,\n * who has it, how long, and what still works. Where the API publishes no\n * review window, the copy says so instead of inventing an SLA.\n * - A decline is humane: it names the organisation, offers a review as the\n * primary action, and invents no reasons – the API carries none.\n * - Re-verification on a live account is a banner naming the consequence\n * and what keeps working, never a lockout.\n */\n\n// ── Status label maps (API enums verbatim → UI labels, mapped once) ────\n\nconst KYB_PILL: Record = {\n PENDING: { label: \"In review\", intent: \"pending\" },\n VERIFIED: { label: \"Verified\", intent: \"positive\" },\n DENIED: { label: \"Declined\", intent: \"negative\" },\n};\n\nconst KYC_PILL: Record = {\n VERIFICATION_PENDING: { label: \"In review\", intent: \"pending\" },\n VERIFIED: { label: \"Verified\", intent: \"positive\" },\n REJECTED: { label: \"Declined\", intent: \"negative\" },\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 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\nconst sectionHeading: CSSProperties = {\n margin: 0,\n fontSize: \"var(--font-size-label)\",\n fontWeight: 600,\n color: \"var(--text-secondary)\",\n textTransform: \"uppercase\",\n letterSpacing: \"0.04em\",\n};\n\n// ── Company form ───────────────────────────────────────────────────────\n\nexport interface CompanyFormValues {\n name: string;\n vatNumber: string;\n addressLine1: string;\n city: string;\n postalCode: string;\n country: string;\n}\n\nexport interface CompanyFormProps {\n /** Pre-fill from sign-up (the session already knows the company name). */\n initialName?: string;\n /** Chain the auto-provisioned wallet lives on. */\n chain?: \"AVALANCHE\" | \"BASE\" | \"POLYGON\";\n /** Your identifier for the account; defaults to one derived from the name. */\n accountExternalId?: string;\n onCreated: (ids: { partyId: string; accountId: string }) => void;\n style?: CSSProperties;\n className?: string;\n}\n\n/**\n * Single page, two sections, review before submit. Submit creates the\n * ORGANISATION party, then the account (which auto-provisions the\n * custodial wallet) – verification starts as a side effect.\n */\nexport function CompanyForm({\n initialName,\n chain = \"BASE\",\n accountExternalId,\n onCreated,\n style,\n className,\n}: CompanyFormProps): ReactElement {\n const [values, setValues] = useState({\n name: initialName ?? \"\",\n vatNumber: \"\",\n addressLine1: \"\",\n city: \"\",\n postalCode: \"\",\n country: \"\",\n });\n const [reviewing, setReviewing] = useState(false);\n const [error, setError] = useState(null);\n const createParty = useCreateParty();\n const createAccount = useCreateAccount();\n const submitting = createParty.isPending || createAccount.isPending;\n\n const set = (key: keyof CompanyFormValues) => (v: string) =>\n setValues((prev) => ({ ...prev, [key]: v }));\n\n const hasAddress = values.addressLine1 || values.city || values.postalCode || values.country;\n\n const submit = (): void => {\n setError(null);\n const externalId =\n accountExternalId ??\n `acct-${values.name.toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/(^-|-$)/g, \"\")}`;\n createParty.mutate(\n {\n partyType: \"ORGANISATION\",\n name: values.name,\n vatNumber: values.vatNumber || undefined,\n address: hasAddress\n ? {\n addressLine1: values.addressLine1 || undefined,\n city: values.city || undefined,\n postalCode: values.postalCode || undefined,\n country: values.country || undefined,\n }\n : undefined,\n },\n {\n onError: (e) => setError(e.message),\n onSuccess: (party) => {\n createAccount.mutate(\n {\n externalId,\n chain,\n name: `${values.name} – Main`,\n partyId: party.id,\n },\n {\n onError: (e) => setError(e.message),\n onSuccess: (account) =>\n onCreated({ partyId: party.id as string, accountId: account.id as string }),\n },\n );\n },\n },\n );\n };\n\n if (reviewing) {\n return (\n \n

\n Review your details\n

\n \n \n \n {error ? (\n

\n {error}\n

\n ) : null}\n
\n \n \n
\n \n );\n }\n\n return (\n {\n e.preventDefault();\n setReviewing(true);\n }}\n >\n

\n Tell us about your company\n

\n

Company

\n
\n \n set(\"name\")(e.target.value)}\n />\n
\n
\n \n set(\"vatNumber\")(e.target.value)}\n />\n
\n

Registered address (optional)

\n
\n \n set(\"addressLine1\")(e.target.value)}\n />\n
\n
\n
\n \n set(\"postalCode\")(e.target.value)}\n />\n
\n
\n \n set(\"city\")(e.target.value)}\n />\n
\n
\n
\n \n set(\"country\")(e.target.value.toUpperCase())}\n />\n
\n \n \n );\n}\n\n// ── Verification status home ───────────────────────────────────────────\n\nexport type VerificationOutcome = \"in-review\" | \"verified\" | \"declined\";\n\n/** The gate derivation the shell shares: both records report VERIFIED. */\nexport function verificationOutcome(party?: Party, account?: Account): VerificationOutcome {\n if (party?.kybStatus === \"DENIED\" || account?.kycStatus === \"REJECTED\") return \"declined\";\n if (party?.kybStatus === \"VERIFIED\" && account?.kycStatus === \"VERIFIED\") return \"verified\";\n return \"in-review\";\n}\n\nexport interface VerificationStatusViewProps {\n party?: Party;\n account?: Account;\n /** Where status-change notice goes – echoed in the waiting copy. */\n email: string;\n /** Primary action on the declined state; opens your support channel. */\n onAskForReview?: () => void;\n /** The verified state's forward action (\"Go to your account\"). */\n onContinue?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\n/** Presentational half: the status page over already-loaded records. */\nexport function VerificationStatusView({\n party,\n account,\n email,\n onAskForReview,\n onContinue,\n style,\n className,\n}: VerificationStatusViewProps): ReactElement {\n const outcome = verificationOutcome(party, account);\n\n const steps: TimelineStep[] = useMemo(() => {\n if (outcome === \"verified\") {\n return [\n { key: \"submitted\", label: \"Submitted\", state: \"completed\" },\n { key: \"review\", label: \"In review\", state: \"completed\" },\n { key: \"ready\", label: \"Ready\", state: \"completed\" },\n ];\n }\n if (outcome === \"declined\") {\n return [\n { key: \"submitted\", label: \"Submitted\", state: \"completed\" },\n { key: \"review\", label: \"In review\", state: \"completed\" },\n { key: \"ready\", label: \"Declined\", state: \"failed\" },\n ];\n }\n return [\n { key: \"submitted\", label: \"Submitted\", state: \"completed\" },\n { key: \"review\", label: \"In review\", state: \"current\" },\n { key: \"ready\", label: \"Ready\", state: \"future\" },\n ];\n }, [outcome]);\n\n const kybPill = party?.kybStatus ? KYB_PILL[party.kybStatus] : undefined;\n const kycPill = account?.kycStatus ? KYC_PILL[account.kycStatus] : undefined;\n const companyName = party?.name ?? \"your company\";\n\n return (\n \n \n\n {outcome === \"declined\" ? (\n \n

\n We couldn't verify {companyName} based on the information provided.\n

\n

\n Ask for a review and we'll take another look.\n

\n {onAskForReview ? (\n \n ) : null}\n \n ) : outcome === \"verified\" ? (\n \n

\n {companyName} is verified.\n

\n {onContinue ? (\n \n ) : null}\n \n ) : (\n \n

\n Your application is in review.\n

\n

\n Nothing is needed from you right now. We don't have a fixed review window to share\n yet – we'll email {email} the moment your status changes.\n

\n

\n While you wait: explore the app · prepare your first recipients · your account\n details for receiving arrive once you're verified.\n

\n \n )}\n\n \n

Application status

\n
\n \n Business verification\n \n {kybPill ? : }\n
\n
\n \n Account verification\n \n {kycPill ? : }\n
\n \n \n );\n}\n\nexport interface VerificationStatusHomeProps\n extends Omit {\n partyId: string;\n accountId: string;\n /** Status poll interval; the page watches for the decision. */\n pollIntervalMs?: number;\n}\n\n/** Connected block: literal party + account statuses, polled for changes. */\nexport function VerificationStatusHome({\n partyId,\n accountId,\n pollIntervalMs = 2000,\n ...viewProps\n}: VerificationStatusHomeProps): ReactElement {\n const { data: party } = useParty(partyId, { refetchInterval: pollIntervalMs });\n const { data: account } = useAccount(accountId, { refetchInterval: pollIntervalMs });\n return ;\n}\n\n// ── Restricted banner ──────────────────────────────────────────────────\n\nexport interface RestrictedBannerProps {\n companyName: string;\n /**\n * \"unverified\": first-time verification still in review.\n * \"reverification\": a live, previously verified account flipped back to\n * in-review – the banner names the consequence and what keeps working.\n */\n variant: \"unverified\" | \"reverification\";\n /** Link back to the status page. */\n onViewStatus?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function RestrictedBanner({\n companyName,\n variant,\n onViewStatus,\n style,\n className,\n}: RestrictedBannerProps): ReactElement {\n return (\n \n \n {variant === \"reverification\"\n ? `We need updated details for ${companyName}. Money movement pauses until this is done – everything else keeps working.`\n : `${companyName}'s application is in review. Money movement and your account details for receiving unlock once you're verified – everything else keeps working.`}\n \n {onViewStatus ? (\n \n View status\n \n ) : null}\n \n );\n}\n" } ] }