diff --git a/src/components/account/accounts-carousel.tsx b/src/components/account/accounts-carousel.tsx index 220875f..3944baf 100644 --- a/src/components/account/accounts-carousel.tsx +++ b/src/components/account/accounts-carousel.tsx @@ -2,6 +2,7 @@ import { Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { Account } from '~/domain/accounts/types'; import { formatCOP, formatUSD, sortBalancesForDisplay } from '~/lib/formatters'; +import { cn } from '~/lib/utils'; import { getProductKindIcon } from './product-presentation'; function formatAccountBalanceChip(asset: string, current: string): string { @@ -61,10 +62,18 @@ export function AccountsCarousel({ accounts, onSelectAccount, onAddAccount, + highlightAdd = false, }: { accounts: Account[]; onSelectAccount: (urn: string) => void; onAddAccount: () => void; + /** + * Highlights the "+ new account" card to guide a genuinely accountless + * user there. Callers must gate this on their accounts query having + * settled — `accounts` reads as `[]` while it's still loading too, and + * that must not read as "confirmed empty" (see BQE-2653). + */ + highlightAdd?: boolean; }) { const { t } = useTranslation(); return ( @@ -83,13 +92,34 @@ export function AccountsCarousel({ diff --git a/src/domain/accounts/ports.ts b/src/domain/accounts/ports.ts index b0960a6..5291664 100644 --- a/src/domain/accounts/ports.ts +++ b/src/domain/accounts/ports.ts @@ -62,7 +62,7 @@ export type CreateBrebKeyInput = { export type CreatePolygonAccountInput = { name?: string; ledgerId?: string }; -export type CreateVirtualAccountInput = { name?: string }; +export type CreateVirtualAccountInput = { name?: string; ledgerId?: string }; export type CreateExternalUsBankAccountInput = { /** URL the hosted Plaid Link page redirects to once linking finishes. */ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3afe651..4ae8ee8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -77,14 +77,17 @@ "brebKeyLabel": "BRE-B key", "newCard": "New card", "polygonAccount": "Polygon account", + "newVirtualAccount": "New account", "cardNameLabel": "Card name", "optionalNameLabel": "Name (optional)", "cardNamePlaceholder": "Personal", "polygonNamePlaceholder": "Main", + "virtualAccountNamePlaceholder": "Personal", "sharesBalanceOf": "It will share the balance of {{label}}.", "defaultCardName": "Card", "cardCreatedToast": "Card created successfully", "polygonCreatedToast": "Polygon account created", + "virtualAccountCreatedToast": "Account created successfully", "createProductErrorToast": "Couldn't create the product. Please try again.", "transferToOwnAccount": "Transfer to another account", "transferDrawerTitle": "Transfer funds", @@ -328,7 +331,8 @@ "quickActions": { "title": "Quick actions", "topup": "Top up", - "send": "Send" + "send": "Send", + "needsAccount": "Create an account first to use this." } }, "send": { @@ -396,7 +400,8 @@ "noRateToContinue": "No rate available to continue.", "amountStep": { "label": "Amount to send (COP)", - "minAmount": "Minimum amount: $10,000 COP" + "minAmount": "Minimum amount: $10,000 COP", + "feeLabel": "Fee" } }, "usBanks": { @@ -405,6 +410,7 @@ "noRateForOrder": "No rate selected to create the order.", "invalidAmountForOrder": "Invalid amount to create the order.", "noSourceAccount": "No source account available.", + "kycRejectedError": "Your identity verification wasn't approved, so we can't complete this operation. Retry verification from your profile.", "minAmountToast": "The minimum amount is $10 USD.", "noRateToContinue": "No rate available to continue.", "transferSentToast": "RTP transfer started successfully.", @@ -514,7 +520,7 @@ "documentNumberPlaceholder": "E.g.: 123456789" }, "confirmStep": { - "summary": "Top-up summary", + "summary": "Summary", "sending": "Sending...", "sendWithdrawal": "Send withdrawal", "verifyDisclaimer": "Verify that the bank details are correct before sending." @@ -567,6 +573,7 @@ "invalidAmountForOrder": "Invalid amount to create the order.", "noSourceAccount": "We couldn't find a linked bank account to pull from.", "noDestinationAccount": "We couldn't find an available destination account.", + "kycRejectedError": "Your identity verification wasn't approved, so we can't complete this operation. Retry verification from your profile.", "minAmountToast": "The minimum amount is $10 USD.", "noRateToContinue": "No rate available to continue.", "topupStartedToast": "ACH top-up started successfully.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 683a17a..1d6b0c1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -77,14 +77,17 @@ "brebKeyLabel": "Llave BRE-B", "newCard": "Nueva tarjeta", "polygonAccount": "Cuenta Polygon", + "newVirtualAccount": "Nueva cuenta", "cardNameLabel": "Nombre de la tarjeta", "optionalNameLabel": "Nombre (opcional)", "cardNamePlaceholder": "Personal", "polygonNamePlaceholder": "Principal", + "virtualAccountNamePlaceholder": "Personal", "sharesBalanceOf": "Compartirá el saldo de {{label}}.", "defaultCardName": "Tarjeta", "cardCreatedToast": "Tarjeta creada exitosamente", "polygonCreatedToast": "Cuenta Polygon creada", + "virtualAccountCreatedToast": "Cuenta creada exitosamente", "createProductErrorToast": "No se pudo crear el producto. Intenta de nuevo.", "transferToOwnAccount": "Transferir a otra cuenta", "transferDrawerTitle": "Transferir fondos", @@ -328,7 +331,8 @@ "quickActions": { "title": "Acciones rápidas", "topup": "Recargar", - "send": "Enviar" + "send": "Enviar", + "needsAccount": "Crea una cuenta primero para poder usar esta opción." } }, "send": { @@ -396,7 +400,8 @@ "noRateToContinue": "No hay tasa disponible para continuar.", "amountStep": { "label": "Monto a enviar (COP)", - "minAmount": "Monto mínimo: $10,000 COP" + "minAmount": "Monto mínimo: $10,000 COP", + "feeLabel": "Comisión" } }, "usBanks": { @@ -405,6 +410,7 @@ "noRateForOrder": "No hay tasa seleccionada para crear la orden.", "invalidAmountForOrder": "Monto inválido para crear la orden.", "noSourceAccount": "No hay cuenta origen disponible.", + "kycRejectedError": "Tu verificación de identidad no fue aprobada, por eso no podemos completar esta operación. Vuelve a intentar la verificación desde tu perfil.", "minAmountToast": "El monto mínimo es $10 USD.", "noRateToContinue": "No hay tasa disponible para continuar.", "transferSentToast": "Transferencia RTP iniciada correctamente.", @@ -514,7 +520,7 @@ "documentNumberPlaceholder": "Ej: 123456789" }, "confirmStep": { - "summary": "Resumen de recarga", + "summary": "Resumen", "sending": "Enviando...", "sendWithdrawal": "Enviar retiro", "verifyDisclaimer": "Verifica que los datos bancarios sean correctos antes de enviar." @@ -567,6 +573,7 @@ "invalidAmountForOrder": "Monto inválido para crear la orden.", "noSourceAccount": "No encontramos una cuenta bancaria vinculada desde donde recargar.", "noDestinationAccount": "No encontramos una cuenta destino disponible.", + "kycRejectedError": "Tu verificación de identidad no fue aprobada, por eso no podemos completar esta operación. Vuelve a intentar la verificación desde tu perfil.", "minAmountToast": "El monto mínimo es $10 USD.", "noRateToContinue": "No hay tasa disponible para continuar.", "topupStartedToast": "Recarga ACH iniciada correctamente.", diff --git a/src/infra/bloque/accounts-repository.ts b/src/infra/bloque/accounts-repository.ts index 4151b7a..fe7d6ab 100644 --- a/src/infra/bloque/accounts-repository.ts +++ b/src/infra/bloque/accounts-repository.ts @@ -468,9 +468,10 @@ async function transfer(input: TransferInput): Promise { async function createVirtualAccount( input: CreateVirtualAccountInput, ): Promise { - const account = await bloque.accounts.virtual.create( - input.name ? { name: input.name } : {}, - ); + const account = await bloque.accounts.virtual.create({ + ...(input.name ? { name: input.name } : {}), + ...(input.ledgerId ? { ledgerId: input.ledgerId } : {}), + }); return mapToProduct(account as ListedAccount); } diff --git a/src/routes/_authed/(home)/-components/quick-actions.tsx b/src/routes/_authed/(home)/-components/quick-actions.tsx index 77c7571..3c91001 100644 --- a/src/routes/_authed/(home)/-components/quick-actions.tsx +++ b/src/routes/_authed/(home)/-components/quick-actions.tsx @@ -3,9 +3,16 @@ import { Link } from '@tanstack/react-router'; import { KeyRound, Landmark, Send } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; import { cn } from '~/lib/utils'; -export function QuickActions() { +/** + * `topup`/`send`/BRE-B all fail deep inside their own flow with a confusing + * "no destination account" error when the user has no account at all yet + * (see BQE-2653) — disabling them up front, before that flow is ever + * reached, is cheaper to understand than any error message once inside it. + */ +export function QuickActions({ hasAccount }: { hasAccount: boolean }) { const { t } = useTranslation(); const actions = [ { @@ -33,21 +40,39 @@ export function QuickActions() {
{actions.map((action) => { const Icon = action.icon; + const content = ( +
+
+ +
+ + {action.label} + +
+ ); + + if (!hasAccount) { + return ( + + ); + } + return ( -
-
- -
- - {action.label} - -
+ {content} ); })} diff --git a/src/routes/_authed/(home)/index.tsx b/src/routes/_authed/(home)/index.tsx index 7bcb3c2..7c933c8 100644 --- a/src/routes/_authed/(home)/index.tsx +++ b/src/routes/_authed/(home)/index.tsx @@ -80,6 +80,10 @@ function RouteComponent() { const [showCreateAccount, setShowCreateAccount] = useState(false); const accounts = accountsQuery.data ?? []; + // While the accounts query is still resolving, `accounts` is `[]` — that + // must not read as "no accounts" and disable the quick actions for a user + // who actually has one; only a *settled* empty list means that. + const hasAccount = accountsQuery.isLoading || accounts.length > 0; const assets: Asset[] = ['USD', 'COP']; const selectedBalance = parsedBalances[selectedAsset] ?? 0; @@ -130,7 +134,7 @@ function RouteComponent() {
- +
@@ -152,6 +156,7 @@ function RouteComponent() { navigate({ to: '/accounts/$urn', params: { urn } }) } onAddAccount={() => setShowCreateAccount(true)} + highlightAdd={!accountsQuery.isLoading && accounts.length === 0} /> diff --git a/src/routes/_authed/accounts/$urn.tsx b/src/routes/_authed/accounts/$urn.tsx index 7662b72..a2bf9ff 100644 --- a/src/routes/_authed/accounts/$urn.tsx +++ b/src/routes/_authed/accounts/$urn.tsx @@ -8,6 +8,7 @@ import { KeyRound, Plus, Wallet, + WalletCards, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -35,6 +36,7 @@ import { useAccount } from '~/hooks/accounts/use-accounts'; import { useCreateCard } from '~/hooks/accounts/use-cards'; import { useCreatePolygonAccount } from '~/hooks/accounts/use-polygon-account'; import { useTransfer } from '~/hooks/accounts/use-transfer'; +import { useCreateVirtualAccount } from '~/hooks/accounts/use-virtual-account'; import type { Asset, Movement } from '~/lib/formatters'; import { formatCOP, formatUSD, sortBalancesForDisplay } from '~/lib/formatters'; import { cn } from '~/lib/utils'; @@ -51,7 +53,7 @@ const ASSET_LABELS: Record = { KSM: 'KSM', }; -type AddProductStep = 'closed' | 'pick' | 'card' | 'polygon'; +type AddProductStep = 'closed' | 'pick' | 'card' | 'polygon' | 'virtual'; function parseAmount(rawAmount: string, rawAsset: string) { const [, precisionStr] = rawAsset.split('/'); @@ -117,6 +119,7 @@ function RouteComponent() { const createCardMutation = useCreateCard(); const createPolygonMutation = useCreatePolygonAccount(); + const createVirtualMutation = useCreateVirtualAccount(); const transferMutation = useTransfer(); const { accounts: ownAccounts } = useAccountPicker(); const transferDestinations = ownAccounts.filter( @@ -149,7 +152,9 @@ function RouteComponent() { []; const Icon = getProductKindIcon(primaryProduct?.kind ?? 'other'); - const handlePickProductKind = (kind: 'card' | 'breb' | 'polygon') => { + const handlePickProductKind = ( + kind: 'card' | 'breb' | 'polygon' | 'virtual', + ) => { if (!account) return; if (kind === 'breb') { setAddProductStep('closed'); @@ -178,6 +183,12 @@ function RouteComponent() { ledgerId: account.ledgerId, }); toast.success(t('accounts.detail.polygonCreatedToast')); + } else if (addProductStep === 'virtual') { + await createVirtualMutation.mutateAsync({ + name: productName.trim() || undefined, + ledgerId: account.ledgerId, + }); + toast.success(t('accounts.detail.virtualAccountCreatedToast')); } setAddProductStep('closed'); } catch { @@ -186,7 +197,9 @@ function RouteComponent() { }; const isCreatingProduct = - createCardMutation.isPending || createPolygonMutation.isPending; + createCardMutation.isPending || + createPolygonMutation.isPending || + createVirtualMutation.isPending; const transferAssetBalance = balances.find( (balance) => balance.asset === selectedAsset, @@ -474,6 +487,11 @@ function RouteComponent() {
{[ + { + kind: 'virtual' as const, + label: t('accounts.productKind.pocket'), + icon: WalletCards, + }, { kind: 'card' as const, label: t('accounts.productKind.card'), @@ -512,7 +530,9 @@ function RouteComponent() { {addProductStep === 'card' ? t('accounts.detail.newCard') - : t('accounts.detail.polygonAccount')} + : addProductStep === 'virtual' + ? t('accounts.detail.newVirtualAccount') + : t('accounts.detail.polygonAccount')}
@@ -532,7 +552,9 @@ function RouteComponent() { placeholder={ addProductStep === 'card' ? t('accounts.detail.cardNamePlaceholder') - : t('accounts.detail.polygonNamePlaceholder') + : addProductStep === 'virtual' + ? t('accounts.detail.virtualAccountNamePlaceholder') + : t('accounts.detail.polygonNamePlaceholder') } maxLength={40} disabled={isCreatingProduct} diff --git a/src/routes/_authed/send/colombian-banks/index.tsx b/src/routes/_authed/send/colombian-banks/index.tsx index cfe5a04..88f8009 100644 --- a/src/routes/_authed/send/colombian-banks/index.tsx +++ b/src/routes/_authed/send/colombian-banks/index.tsx @@ -299,6 +299,9 @@ function RouteComponent() { label={t('send.colombianBanks.amountStep.label')} minAmount={MIN_TRANSFER_AMOUNT} minAmountLabel={t('send.colombianBanks.amountStep.minAmount')} + feeLabel={t('send.colombianBanks.amountStep.feeLabel')} + disclaimer={null} + arrivalLabel={null} /> )} diff --git a/src/routes/_authed/send/us-banks/index.tsx b/src/routes/_authed/send/us-banks/index.tsx index 05f11be..8111deb 100644 --- a/src/routes/_authed/send/us-banks/index.tsx +++ b/src/routes/_authed/send/us-banks/index.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { useAuth } from '~/contexts/auth/auth-context'; import type { ExecutionOutcome } from '~/domain/payments/types'; import { useAccountPicker } from '~/hooks/accounts/use-account-picker'; import { useRates } from '~/hooks/payments/use-rates'; @@ -53,6 +54,8 @@ export const Route = createFileRoute('/_authed/send/us-banks/')({ function RouteComponent() { const { t } = useTranslation(); + const { user } = useAuth(); + const kycStatus = user.kycStatus; const [step, setStep] = useState('amount'); const [amount, setAmount] = useState(''); const [bankForm, setBankForm] = @@ -106,7 +109,12 @@ function RouteComponent() { const rateError = useMemo(() => { if (parsedAmount < MIN_TRANSFER_AMOUNT_USD) return null; if (!sourceAccountUrn && !isLoadingAccounts) { - return t('send.usBanks.noSourceAccount'); + // A rejected KYC blocks account provisioning entirely, which is what + // actually surfaces as "no source account" here — say so instead of + // the generic error so the user knows to fix verification. + return kycStatus === 'rejected' + ? t('send.usBanks.kycRejectedError') + : t('send.usBanks.noSourceAccount'); } if (ratesQuery.isError) { return t('convert.rateFetchError'); @@ -119,6 +127,7 @@ function RouteComponent() { parsedAmount, sourceAccountUrn, isLoadingAccounts, + kycStatus, ratesQuery.isError, ratesQuery.isSuccess, selectedRate, diff --git a/src/routes/_authed/topup/-components/amount-step.tsx b/src/routes/_authed/topup/-components/amount-step.tsx index 07b34bf..af1d028 100644 --- a/src/routes/_authed/topup/-components/amount-step.tsx +++ b/src/routes/_authed/topup/-components/amount-step.tsx @@ -20,6 +20,11 @@ interface AmountStepProps { label?: string; minAmount?: number; minAmountLabel?: string; + feeLabel?: string; + /** The PSE-specific default only applies to PSE deposits — pass `null` to hide it for other rails (e.g. a bank payout). */ + disclaimer?: string | null; + /** Same as `disclaimer`: the "5-15 min" default is PSE-specific — pass `null` to hide it for other rails. */ + arrivalLabel?: string | null; } const quickAmounts = [50_000, 100_000, 200_000, 500_000]; @@ -36,6 +41,9 @@ export function TopUpAmountStep({ label, minAmount = DEFAULT_MIN_AMOUNT, minAmountLabel, + feeLabel, + disclaimer, + arrivalLabel, }: AmountStepProps) { const { t } = useTranslation(); const parsed = Number.parseInt(amount.replace(/\D/g, ''), 10) || 0; @@ -95,7 +103,7 @@ export function TopUpAmountStep({
- {t('topup.amountStep.pseFee')} + {feeLabel ?? t('topup.amountStep.pseFee')} {formatCOP(fee)} @@ -110,9 +118,11 @@ export function TopUpAmountStep({ {formatCOP(parsed + fee)}
-

- {t('topup.amountStep.estimatedArrival')} -

+ {arrivalLabel !== null && ( +

+ {arrivalLabel ?? t('topup.amountStep.estimatedArrival')} +

+ )}
)} @@ -165,9 +175,11 @@ export function TopUpAmountStep({ {isLoadingRate ? t('convert.queryingRate') : t('common.continue')} -

- {t('topup.amountStep.pseDisclaimer')} -

+ {disclaimer !== null && ( +

+ {disclaimer ?? t('topup.amountStep.pseDisclaimer')} +

+ )} ); } diff --git a/src/routes/_authed/topup/-components/us-amount-step.tsx b/src/routes/_authed/topup/-components/us-amount-step.tsx index b004a36..93f3482 100644 --- a/src/routes/_authed/topup/-components/us-amount-step.tsx +++ b/src/routes/_authed/topup/-components/us-amount-step.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ArrowLeft } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; @@ -18,6 +19,7 @@ interface UsAmountStepProps { ratioLabel: string; } | null; onAmountChange: (v: string) => void; + onBack?: () => void; onNext: () => void; } @@ -31,6 +33,7 @@ export function UsAmountStep({ rateError, rateSummary, onAmountChange, + onBack, onNext, }: UsAmountStepProps) { const { t } = useTranslation(); @@ -39,6 +42,17 @@ export function UsAmountStep({ return (
+ {onBack && ( + + )} +