Pular para o conteúdo principal

@cactus-agents/accounts/react

Camada React de @cactus-agents/accounts@2.9.0: a store Zustand useAccountsStore, os hooks de auth/perfil/carteira, o formatador de dinheiro e a camada dual-currency (coins).

É o subpath mais importado do SDK no front-web-base — bem mais que o root. O root (services HTTP + regras puras) está em accounts.

import { useAccountsStore, useFormatMoney } from "@cactus-agents/accounts/react";

Peer deps: react >= 18, zustand >= 4.

Bootstrap — initAccounts / useAccountsInit

Toda a camada React lê de um singleton de config. Chamar hooks antes de inicializar joga um erro explícito (getAccountsConfig() throws "call initAccounts({ locale, currency, displayDecimalDigits }) before using hooks").

Use useAccountsInit(config) — wrapper idempotente de initAccounts, seguro para renderizar múltiplas vezes (só a primeira chamada vale). No front-web-base isso vive em app/components/AccountsBootstrap.tsx, montado perto da raiz:

import { useAccountsInit } from "@cactus-agents/accounts/react";

export function AccountsBootstrap() {
const env = useClientEnv();
const country = useCountry();
useAccountsInit({
locale: LOCALE_MAP[env.BRAND_LANGUAGE] ?? env.BRAND_LANGUAGE,
currency: env.BRAND_CURRENCY,
displayDecimalDigits: country.displayDecimalDigits,
fetchTransactions, // override opcional
});
return null;
}

Além de inicializar, useAccountsInit semeia activeCoinId na store com o defaultCoinId quando a camada de coins está habilitada — no-op para brands fiat.

AccountsConfig

Três campos obrigatórios, o resto são callbacks de injeção.

interface AccountsConfig {
locale: string;
currency: string;
/** Casas decimais usadas por formatMoney e por tudo que deriva dele
* (useFormatMoney, useTransactions, useRealBalance/Bonus/Cashback).
* Obrigatório — deve vir de CountryConfig.displayDecimalDigits. */
displayDecimalDigits: number;
/** Camada dual-currency opcional. Ausente ou enabled:false = fiat puro. */
coins?: CoinsConfig;
// ... callbacks (abaixo)
}

Os callbacks são o seam de injeção: cada um substitui o adapter HTTP default. Todos opcionais — omitindo, a lib usa a rota de proxy do Base.

GrupoCallbacksRota default
WalletrefetchWallet, fetchTransactions, transferBonus, transferCashback, fetchReceipt/api/wallet/*
Authlogin, logout, register, fetchProfile, validateDocument/api/auth/*
RecoveryrecoveryGetOptions, recoverySendEmail, recoverySendSms, recoveryValidateCode, recoverySaveKycId, recoveryConfirmReset/api/auth/*
PerfilupdateAddress, updatePhone, updateProfile, storeDocument, updateMarketing, updateUserPreferences, lookupPostalCode/api/user/*, /api/address/*

:::note Por que o token nunca aparece aqui Os defaults batem em rotas do próprio Base, não no BFF. O JWT fica em cookie HttpOnly e é lido server-side na rota de proxy — nenhum hook desta camada vê token. :::

useAccountsStore — fonte reativa canônica de auth

const isAuthenticated = useAccountsStore((s) => s.isAuthenticated);

:::danger Nunca leia auth de useRouteLoaderData Esta é uma regra fixada do workspace. useRouteLoaderData("routes/_layout") é um snapshot de SSR — não atualiza quando o usuário loga/desloga por modal, então qualquer visibilidade decidida a partir dele fica errada até um reload. Leia sempre de useAccountsStore, que é reativa.

No front-web-base o gate SSR-safe canônico é useAuthGate() (app/hooks/useAuthGate.ts): SSR sempre pinta guest, o cookie is_authenticated serve como sinal otimista pós-mount, e a store assume depois da hidratação. O loader do _layout não retorna auth. :::

AccountsState

interface AccountsState {
// Wallet
wallet: WalletData | null;
isLoading: boolean;
error: Error | null;
lastWalletFreshAt: number;

// Transactions
transactions: Transaction[];
transactionsRaw: TransactionResponseRaw | null;
transactionsMeta: TransactionsMeta | null; // { total, currentPage, perPage, lastPage }
transactionsLoading: boolean;
transactionsFilter: TransactionFilter;

// Auth
user: AuthUser | null;
userInfo: AuthUserInfo | null;
isAuthenticated: boolean;
/** true depois que o primeiro fetch de perfil assenta — logado ou não. */
authHydrated: boolean;
/** Date.now() da última vez que user/userInfo vieram de fonte confiável. 0 = nunca. */
lastProfileFreshAt: number;

// Coins (dual-currency)
activeCoinId: CoinId | null;
coinBalances: Record<CoinId, number> | null;

// Actions
setWallet; setLoading; setError;
refetchWallet(opts?: { force?: boolean }): Promise<void>;
fetchTransactions(filter?): Promise<void>;
transferBonus(); transferCashback();
setAuthUser(user, userInfo); clearAuth(); setAuthHydrated(value?);
refreshAuthProfile(opts?: { force?: boolean }): Promise<void>;
setActiveCoin(id); setCoinBalances(map);
}

clearAuth() limpa também wallet, transactions e coinBalances, e reseta activeCoinId para o default configurado — dado por usuário não vaza entre sessões.

Hidratação a partir do loader

import { hydrateAccounts, useAccountsHydration } from "@cactus-agents/accounts/react";

// Imperativo (fora de render):
hydrateAccounts(walletData);

// Em render, perto da raiz — sincroniza loader data na store sem flicker,
// pulando quando a referência não mudou:
useAccountsHydration(loaderData.walletData);

TTL memoize e dedup de in-flight

Duas ações da store têm cache de curta duração para matar rajadas de chamadas duplicadas:

AçãoJanelaConstanteBypass
refetchWallet()2sWALLET_FRESH_TTL_MS{ force: true }
refreshAuthProfile()10sPROFILE_FRESH_TTL_MS{ force: true }

refetchWallet também faz dedup de in-flight: se um fetch já está rodando, o segundo caller recebe a mesma promise em vez de disparar uma chamada paralela ao BFF.

Regra prática: passe { force: true } depois de mutações que mudaram o estado (depósito, saque, aposta, transferência de bônus/cashback, alteração de limites, 2FA, documento, endereço). Callers passivos (botão de reload, listener de evento, hook de mount) devem omitir a opção para colapsarem em uma chamada só.

:::note As constantes não são exportadas WALLET_FRESH_TTL_MS e PROFILE_FRESH_TTL_MS existem em src/react/store.ts mas não estão no barrel /react — não conte com importá-las. :::

Hooks de auth

HookRetorna
useCurrentUser(){ user, userInfo, isAuthenticated, hydrated }
useLogin(){ login(payload), isLoading, error, fieldErrors, reset }
useRegister(){ register(payload), isLoading, error, fieldErrors, reset }
useLogout(){ logout(), isLoading, error }
useRefreshProfile()(opts?: { force?: boolean }) => Promise<void>
useValidateDocument(){ validate(payload), isLoading, error, reset }
useRecovery(){ getOptions, sendEmail, sendSms, validateCode, saveKycId, confirmReset, isLoading, error, reset }

useLogin / useRegister hidratam a store automaticamente quando a resposta trouxer user + userInfo. useLogout chama clearAuth() mesmo quando a chamada ao servidor falha — o cookie expira e a intenção do usuário já foi expressa.

:::note Não há caminho de logout automático nesta camada useLogout usa o adapter logout (default POST /api/auth/logout). O AuthService do root tem um logoutAuto() pensado para logout forçado em 401/440, mas AccountsConfig não expõe um seam logoutAuto?, e o adapter defaultLogoutAuto do pacote não é exportado nem chamado. Detalhes e o que seria necessário para ligar em accounts. Trate logout forçado e voluntário como o mesmo caminho aqui. :::

fieldErrors (Record<string, string>) vem de AuthError.fieldErrors, populado pelas rotas de proxy do Base — é o que renderiza erro por campo no formulário.

Hook de perfil

useProfile() agrupa as mutações de edição de perfil com estado de loading/erro escopado ao componente (cada instância tem seu próprio useState, então duas seções salvando em paralelo não interferem):

const { updateAddress, updatePhone, updateProfile, storeDocument,
updateMarketing, updateUserPreferences, lookupPostalCode,
isLoading, error, fieldErrors, reset } = useProfile();

updateUserPreferences faz update otimista: depois do PATCH, faz merge das novas preferências no userInfo da store, então a UI reflete a mudança sem esperar um refetch de perfil.

Hooks de carteira

HookRetorna
useWallet(){ wallet, isLoading, error, refetch }
useRealBalance() / useBonusBalance() / useCashbackBalance(){ balance: Money, isLoading, error, refetch }
useWalletFlags(){ hasReal, hasBonus, hasCashback, hasFirstDeposit }
useRollover(){ rollover, accomplished, isLoading }
useTransactions(){ transactions, meta, isLoading, filter, fetch, raw }
useTransferBonus() / useTransferCashback(){ transfer, isLoading, error }
useWithdrawReceipt(withdrawId){ receipt, isLoading, error } — auto-fetch a cada mudança do id; null reseta
useRefreshWallet()(opts?: { force?: boolean }) => Promise<void>

Money é { raw: number; formatted: string; currency: string } — use raw só para lógica e renderize formatted.

useTransactions() devolve FormattedTransaction[] = Transaction & { amountFormatted: string }, com amountFormatted já no locale/currency configurados e em valor absoluto (o sinal é implícito em direction).

useFormatMoney() — formatador canônico de dinheiro

const formatMoney = useFormatMoney();
formatMoney(1234.5); // "R$ 1.234,50"

:::tip Use este hook, não Intl.NumberFormat à mão Regra fixada do workspace: para formatar dinheiro em componente React, use useFormatMoney() de @cactus-agents/accounts/react. Ele resolve locale + currency + displayDecimalDigits de uma vez, a partir do AccountsConfig. Montar Intl.NumberFormat({ style: "currency" }) no componente perde a regra de decimais e o locale do país. :::

O que ele resolve:

  • locale e currency do AccountsConfig (que no Base vêm de BRAND_LANGUAGE / BRAND_CURRENCY);
  • displayDecimalDigits, que vem de CountryConfig.displayDecimalDigits — hoje 0 para NGA e CHL, 2 para os demais países do registry (fonte: packages/country-config/src/countries/*.ts). É por isso que o campo é obrigatório: sem ele, o Intl default reintroduz os centavos em NGN/CLP;
  • símbolo via currencyDisplay: "narrowSymbol", com fallback "${currency} ${value.toFixed(digits)}" caso o locale seja inválido (nunca joga);
  • coin ativo: quando a camada de coins está habilitada, o hook assina activeCoinId na store e formata na precisão do coin ativo sem símbolo — um toggle de coin reflete em todos os call-sites sem mudança por arquivo. Com coins desabilitados a saída é byte-a-byte idêntica ao comportamento fiat.

Os formatadores puros também são exportados, para uso fora de React:

import { formatCoin, formatMoney } from "@cactus-agents/accounts/react";

formatMoney(10, { locale: "pt-BR", currency: "BRL", displayDecimalDigits: 2 });
// → { raw: 10, formatted: "R$ 10,00", currency: "BRL" }

formatCoin(1500, { locale: "en-US", decimals: 0 }); // → { raw: 1500, formatted: "1,500" }

Camada dual-currency (coins)

Camada aditiva, gated e default-OFF. Sem AccountsConfig.coins (ou com enabled: false), todo consumidor de accounts se comporta exatamente como fiat.

interface CoinDef {
id: CoinId; // string opaca, ex: "GC" | "SC"
label: string; // "Gold Coins"
shortLabel: string; // "GC"
decimals: number; // GC:0, SC:2
wallet: "real" | "bonus" | "cashback";
redeemable: boolean; // SC:true, GC:false
previewBalance?: number; // fallback de dev/preview
icon?: string; // path público do ícone
}

interface CoinsConfig {
enabled: boolean;
coins: CoinDef[];
defaultCoinId: CoinId;
}
HookDescrição
useCoinsConfig()CoinsConfig | null (não reativo — lê do singleton)
useCoinsEnabled()true só quando configurado e habilitado
useSweepstakesEnabled()Alias semântico de useCoinsEnabled() — byte-idêntico
useCoinDefs()CoinDef[], ou [] quando desabilitado
useActiveCoin(){ coin, activeCoinId, setActiveCoin }reativo
useCoinBalance(coinId){ raw, formatted, coin }
useActiveCoinBalance()Saldo do coin ativo
useFormatCoin(coinId?)Formatador do coin (ou do ativo, quando omitido)
useSetCoinBalances()Setter para o Base alimentar os saldos reais

Precedência de resolução de saldo em useCoinBalance — primeira fonte não-nula ganha:

  1. store.coinBalances[coinId] — saldos reais, alimentados pelo consumidor via useSetCoinBalances(). Semântica nullish: se a chave existe, ela vence mesmo valendo 0.
  2. coin.previewBalance ?? 0.

A carteira fiat não é fonte de saldo de coin: em brand sweepstakes os saldos vêm só do endpoint de balances, então o header mostra coins e nunca crédito fiat. O service que produz esses saldos é o sweepstakes — que não está adotado no trunk atual do base, então na prática coinBalances fica null e a camada inteira permanece inerte aqui.

Superfície exportada

src/react/index.ts é o barrel; os módulos são:

ArquivoConteúdo
src/react/config.tsAccountsConfig, initAccounts
src/react/init.tsuseAccountsInit
src/react/store.tsuseAccountsStore, AccountsState, hydrateAccounts, useAccountsHydration
src/react/auth-hooks.tsuseCurrentUser, useLogin, useLogout, useRegister, useRecovery, useValidateDocument, useRefreshProfile
src/react/profile-hooks.tsuseProfile
src/react/hooks.tsHooks de carteira/transações + useFormatMoney
src/react/coins.tsCamada dual-currency
src/react/format.tsformatMoney, formatCoin, Money
src/react/{http,auth-http,profile-http}.tsAdapters HTTP default (/api/wallet/*, /api/auth/*, /api/user/*, /api/address/*)