diff --git a/e2e/accounts.spec.ts b/e2e/accounts.spec.ts index b429b96..2364d2e 100644 --- a/e2e/accounts.spec.ts +++ b/e2e/accounts.spec.ts @@ -20,7 +20,7 @@ test('lands on the ledger-grouped accounts list with a mocked, authenticated ses await expect( page .getByRole('navigation', { name: 'Navegación principal' }) - .getByText('Cuentas'), + .getByText('Movimientos'), ).toBeVisible(); }); diff --git a/src/components/account/account-carousel.test.tsx b/src/components/account/account-carousel.test.tsx index 3622343..183d379 100644 --- a/src/components/account/account-carousel.test.tsx +++ b/src/components/account/account-carousel.test.tsx @@ -117,6 +117,6 @@ describe('AccountCarousel', () => { />, ); - expect(screen.getByText('1500.50 COP')).toBeInTheDocument(); + expect(screen.getByText('$ 1.501')).toBeInTheDocument(); }); }); diff --git a/src/components/account/account-carousel.tsx b/src/components/account/account-carousel.tsx index d799fba..bd1979c 100644 --- a/src/components/account/account-carousel.tsx +++ b/src/components/account/account-carousel.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { Label } from '~/components/ui/label'; import type { Account } from '~/domain/accounts/types'; +import { type Asset, formatAmount } from '~/lib/formatters'; import { cn } from '~/lib/utils'; import { getProductKindIcon } from './product-presentation'; @@ -13,11 +14,12 @@ function formatAccountBalance( account: Account, asset: string, precision: number, + unit: Asset, ): string { const entry = account.balances.find((balance) => balance.asset === asset); const parsed = entry ? Number.parseInt(entry.current, 10) : Number.NaN; const amount = Number.isNaN(parsed) ? 0 : parsed / 10 ** precision; - return amount.toFixed(2); + return formatAmount(unit, amount); } function AccountCard({ @@ -31,7 +33,7 @@ function AccountCard({ account: Account; asset: string; precision: number; - unit: string; + unit: Asset; isActive: boolean; onClick: () => void; }) { @@ -40,7 +42,7 @@ function AccountCard({ (product) => product.urn === account.primaryUrn, ); const Icon = getProductKindIcon(primary?.kind ?? 'other'); - const balanceLabel = `${formatAccountBalance(account, asset, precision)} ${unit}`; + const balanceLabel = formatAccountBalance(account, asset, precision, unit); return (

{t('accounts.detail.title')}

@@ -632,7 +657,11 @@ function RouteComponent() { id="transfer-amount" inputMode="decimal" value={transferAmount} - onChange={(e) => setTransferAmount(e.target.value)} + onChange={(e) => + setTransferAmount( + sanitizeTransferAmountInput(e.target.value, selectedAsset), + ) + } placeholder="0" disabled={transferMutation.isPending} className="h-12 rounded-xl" diff --git a/src/routes/_authed/accounts/index.tsx b/src/routes/_authed/accounts/index.tsx index 4ca8643..83a01f5 100644 --- a/src/routes/_authed/accounts/index.tsx +++ b/src/routes/_authed/accounts/index.tsx @@ -12,6 +12,7 @@ import type { Account } from '~/domain/accounts/types'; import { useAccounts } from '~/hooks/accounts/use-accounts'; import i18n from '~/i18n/config'; import { formatCOP, formatUSD, sortBalancesForDisplay } from '~/lib/formatters'; +import { goBackOrFallback } from '~/lib/navigation'; export const Route = createFileRoute('/_authed/accounts/')({ component: RouteComponent, @@ -72,13 +73,18 @@ function RouteComponent() { return (
- + goBackOrFallback(() => { + void navigate({ to: '/profile' }); + }) + } className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground" > {t('common.back')} - +

{t('accounts.title')}

diff --git a/src/routes/_authed/breb-keys/pay-transfer/index.tsx b/src/routes/_authed/breb-keys/pay-transfer/index.tsx index 9e6a988..8004416 100644 --- a/src/routes/_authed/breb-keys/pay-transfer/index.tsx +++ b/src/routes/_authed/breb-keys/pay-transfer/index.tsx @@ -369,7 +369,7 @@ function RouteComponent() { />
- {fundedAccounts.length > 1 ? ( + {fundedAccounts.length > 0 ? ( (null); + + useEffect(() => { + if (sourceAccounts.length === 1 && sourceAccounts[0]) { + setSourceLedgerId(sourceAccounts[0].ledgerId); + return; + } + setSourceLedgerId((current) => + current && sourceAccounts.some((account) => account.ledgerId === current) + ? current + : null, + ); + }, [sourceAccounts]); + + const sourceAccount = + sourceAccounts.find((account) => account.ledgerId === sourceLedgerId) ?? + null; + const sourceAccountUrn = sourceAccount?.primaryUrn ?? ''; const parsedAmount = Number.parseInt(amount.replace(/\D/g, ''), 10) || 0; const amountSrc = useMemo(() => { @@ -287,6 +305,18 @@ function RouteComponent() { })}
+ {step === 'amount' && sourceAccounts.length > 0 && ( + + )} + {step === 'amount' && ( ; search?: Record; + group?: 'colombia' | 'us'; + fee?: string; } & ({ to: string; onClick?: never } | { to?: never; onClick: () => void }); function RouteComponent() { const { t } = useTranslation(); const options: SendOption[] = [ + { + title: t('send.options.bloqueFriends.title'), + description: t('send.options.bloqueFriends.description'), + to: '/send/bloque-friends', + icon: Users, + fee: t('send.noFee'), + }, { title: t('send.options.brebKeys.title'), description: t('send.options.brebKeys.description'), to: '/breb-keys/pay-transfer', search: { from: '/send' }, icon: KeyRound, + group: 'colombia', + fee: `${formatCOP(500)} + 0.4%`, }, { title: t('send.options.colombianBanks.title'), description: t('send.options.colombianBanks.description'), to: '/send/colombian-banks', icon: Building2, - }, - { - title: t('send.options.bloqueFriends.title'), - description: t('send.options.bloqueFriends.description'), - to: '/send/bloque-friends', - icon: Users, + group: 'colombia', + fee: `${formatCOP(5500)} + 0.2%`, }, { title: t('send.options.usBanks.title'), description: t('send.options.usBanks.description'), to: '/send/us-banks', icon: Building2, + group: 'us', + fee: `${formatUSD(1)} + 1%`, }, { title: t('send.options.blockchain.title'), @@ -52,6 +62,12 @@ function RouteComponent() { }, ]; + const ungrouped = options.filter((option) => !option.group); + const colombiaOptions = options.filter( + (option) => option.group === 'colombia', + ); + const usOptions = options.filter((option) => option.group === 'us'); + return (
@@ -69,50 +85,67 @@ function RouteComponent() {
- {options.map((option) => { - const Icon = option.icon; - const isDisabled = !option.to; - const content = ( -
-
- -
-
-

- {option.title} -

-

- {option.description} -

-
-
- ); + {ungrouped.map((option) => renderSendOption(option))} +
- if (option.to) { - return ( - - {content} - - ); - } +
+

+ {t('send.groups.colombia')} +

+ {colombiaOptions.map((option) => renderSendOption(option))} +
- return ( - - ); - })} +
+

+ {t('send.groups.us')} +

+ {usOptions.map((option) => renderSendOption(option))}
); } + +function renderSendOption(option: SendOption) { + const Icon = option.icon; + const isDisabled = !option.to; + const content = ( +
+
+ +
+
+

{option.title}

+

{option.description}

+
+ {option.fee && ( + + {option.fee} + + )} +
+ ); + + if (option.to) { + return ( + + {content} + + ); + } + + return ( + + ); +} diff --git a/src/routes/_authed/send/us-banks/index.tsx b/src/routes/_authed/send/us-banks/index.tsx index 8111deb..ed25fea 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 { AccountCarousel } from '~/components/account/account-carousel'; import { useAuth } from '~/contexts/auth/auth-context'; import type { ExecutionOutcome } from '~/domain/payments/types'; import { useAccountPicker } from '~/hooks/accounts/use-account-picker'; @@ -67,7 +68,24 @@ function RouteComponent() { const [autoRetry, setAutoRetry] = useState(false); const { accounts: sourceAccounts, isLoading: isLoadingAccounts } = useAccountPicker({ asset: FROM_ASSET }); - const sourceAccountUrn = sourceAccounts[0]?.primaryUrn ?? ''; + const [sourceLedgerId, setSourceLedgerId] = useState(null); + + useEffect(() => { + if (sourceAccounts.length === 1 && sourceAccounts[0]) { + setSourceLedgerId(sourceAccounts[0].ledgerId); + return; + } + setSourceLedgerId((current) => + current && sourceAccounts.some((account) => account.ledgerId === current) + ? current + : null, + ); + }, [sourceAccounts]); + + const sourceAccount = + sourceAccounts.find((account) => account.ledgerId === sourceLedgerId) ?? + null; + const sourceAccountUrn = sourceAccount?.primaryUrn ?? ''; const parsedAmount = Number.parseFloat(amount) || 0; const amountSrc = useMemo(() => { @@ -272,6 +290,18 @@ function RouteComponent() { })} + {step === 'amount' && sourceAccounts.length > 0 && ( + + )} + {step === 'amount' && ( -
- - {feeLabel ?? t('topup.amountStep.pseFee')} - - - {formatCOP(fee)} - -
-
-
- - {t('topup.amountStep.totalToDebit')} - - - {formatCOP(parsed + fee)} - -
+ {fee > 0 && ( + <> +
+ + {feeLabel ?? t('topup.amountStep.pseFee')} + + + {formatCOP(fee)} + +
+
+
+ + {t('topup.amountStep.totalToDebit')} + + + {formatCOP(parsed + fee)} + +
+ + )} {arrivalLabel !== null && (

{arrivalLabel ?? t('topup.amountStep.estimatedArrival')} diff --git a/src/routes/_authed/topup/index.tsx b/src/routes/_authed/topup/index.tsx index 64fef4e..3c24980 100644 --- a/src/routes/_authed/topup/index.tsx +++ b/src/routes/_authed/topup/index.tsx @@ -3,6 +3,7 @@ import { Building2, CreditCard, KeyRound, Wallet } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { AccountCarousel } from '~/components/account/account-carousel'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; import { Label } from '~/components/ui/label'; @@ -18,7 +19,7 @@ import type { ExecutionOutcome } from '~/domain/payments/types'; import { useAccountPicker } from '~/hooks/accounts/use-account-picker'; import { useCreatePseOrder, usePseBanks } from '~/hooks/payments/use-pse-topup'; import { useRates } from '~/hooks/payments/use-rates'; -import { formatAmount, formatCOP } from '~/lib/formatters'; +import { formatAmount, formatCOP, formatUSD } from '~/lib/formatters'; import { cn } from '~/lib/utils'; import { TopUpErrorStep } from './-components/error-step'; import { ExecutionOutcomeStep } from './-components/execution-outcome-step'; @@ -116,7 +117,28 @@ function RouteComponent() { const { accounts: destinationAccounts, isLoading: isLoadingAccounts } = useAccountPicker(); - const destinationAccountUrn = destinationAccounts[0]?.primaryUrn; + const [destinationLedgerId, setDestinationLedgerId] = useState( + null, + ); + + useEffect(() => { + if (destinationAccounts.length === 1 && destinationAccounts[0]) { + setDestinationLedgerId(destinationAccounts[0].ledgerId); + return; + } + setDestinationLedgerId((current) => + current && + destinationAccounts.some((account) => account.ledgerId === current) + ? current + : null, + ); + }, [destinationAccounts]); + + const destinationAccount = + destinationAccounts.find( + (account) => account.ledgerId === destinationLedgerId, + ) ?? null; + const destinationAccountUrn = destinationAccount?.primaryUrn; const banksQuery = usePseBanks(); @@ -314,15 +336,25 @@ function RouteComponent() {

)} - {step === 'method' && ( -
- {[ + {step === 'method' && + (() => { + const methods: Array<{ + title: string; + subtitle: string; + icon: typeof Building2; + enabled: boolean; + onClick: () => void; + group?: 'colombia' | 'us'; + fee?: string; + }> = [ { title: t('topup.methods.colombianBanks.title'), subtitle: t('topup.methods.colombianBanks.subtitle'), icon: Building2, enabled: true, onClick: () => setStep('amount'), + group: 'colombia', + fee: `${formatCOP(2500)} + 1%`, }, { title: t('topup.methods.brebKeys.title'), @@ -334,6 +366,8 @@ function RouteComponent() { to: '/breb-keys/deposit', search: { from: '/topup' }, }), + group: 'colombia', + fee: `${formatCOP(500)} + 0.2%`, }, { title: t('topup.methods.usBanks.title'), @@ -341,6 +375,8 @@ function RouteComponent() { icon: Building2, enabled: true, onClick: () => navigate({ to: '/topup/us-banks' }), + group: 'us', + fee: `${formatUSD(0.25)} + 1%`, }, { title: t('topup.methods.blockchain.title'), @@ -357,7 +393,9 @@ function RouteComponent() { enabled: false, onClick: () => toast.info(t('topup.methods.card.comingSoon')), }, - ].map((option) => { + ]; + + const renderMethod = (option: (typeof methods)[number]) => { const Icon = option.icon; return (
-
+

{option.title}

@@ -380,15 +418,57 @@ function RouteComponent() { {option.subtitle}

+ {option.fee && ( + + {option.fee} + + )} ); - })} - - )} + }; + + const colombiaMethods = methods.filter((m) => m.group === 'colombia'); + const usMethods = methods.filter((m) => m.group === 'us'); + const otherMethods = methods.filter((m) => !m.group); + + return ( + <> +
+

+ {t('topup.groups.colombia')} +

+ {colombiaMethods.map(renderMethod)} +
+ +
+

+ {t('topup.groups.us')} +

+ {usMethods.map(renderMethod)} +
+ +
+ {otherMethods.map(renderMethod)} +
+ + ); + })()} {step === 'amount' && (
+ {destinationAccounts.length > 0 && ( + + )} +