@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.
| Grupo | Callbacks | Rota default |
|---|---|---|
| Wallet | refetchWallet, fetchTransactions, transferBonus, transferCashback, fetchReceipt | /api/wallet/* |
| Auth | login, logout, register, fetchProfile, validateDocument | /api/auth/* |
| Recovery | recoveryGetOptions, recoverySendEmail, recoverySendSms, recoveryValidateCode, recoverySaveKycId, recoveryConfirmReset | /api/auth/* |
| Perfil | updateAddress, 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ção | Janela | Constante | Bypass |
|---|---|---|---|
refetchWallet() | 2s | WALLET_FRESH_TTL_MS | { force: true } |
refreshAuthProfile() | 10s | PROFILE_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
| Hook | Retorna |
|---|---|
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
| Hook | Retorna |
|---|---|
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 deBRAND_LANGUAGE/BRAND_CURRENCY); displayDecimalDigits, que vem deCountryConfig.displayDecimalDigits— hoje0para NGA e CHL,2para os demais países do registry (fonte:packages/country-config/src/countries/*.ts). É por isso que o campo é obrigatório: sem ele, oIntldefault 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
activeCoinIdna 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;
}
| Hook | Descriçã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:
store.coinBalances[coinId]— saldos reais, alimentados pelo consumidor viauseSetCoinBalances(). Semântica nullish: se a chave existe, ela vence mesmo valendo0.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:
| Arquivo | Conteúdo |
|---|---|
src/react/config.ts | AccountsConfig, initAccounts |
src/react/init.ts | useAccountsInit |
src/react/store.ts | useAccountsStore, AccountsState, hydrateAccounts, useAccountsHydration |
src/react/auth-hooks.ts | useCurrentUser, useLogin, useLogout, useRegister, useRecovery, useValidateDocument, useRefreshProfile |
src/react/profile-hooks.ts | useProfile |
src/react/hooks.ts | Hooks de carteira/transações + useFormatMoney |
src/react/coins.ts | Camada dual-currency |
src/react/format.ts | formatMoney, formatCoin, Money |
src/react/{http,auth-http,profile-http}.ts | Adapters HTTP default (/api/wallet/*, /api/auth/*, /api/user/*, /api/address/*) |