State Management
O estado client-side do template vem de duas fontes, com donos diferentes:
| Fonte | Dono | O que guarda |
|---|---|---|
useAccountsStore (@cactus-agents/accounts/react) | Core | Auth (user, userInfo, isAuthenticated, authHydrated), wallet (wallet, transações), coins |
Stores Zustand em app/store/ | Base | Estado de UI, de sessão e de feature — 28 módulos hoje |
Dados de server (brand, clientEnv) chegam por loader + React Context, não por Zustand.
:::danger @cactus-agents/auth, /user e /wallet não existem mais
Foram unificados em @cactus-agents/accounts. As stores app/store/{auth,brand,wallet,user,accountMenu}.ts
também foram removidas. Qualquer código ou doc que use useAuthStore(), useWalletStore() ou
useAccountMenuStore() está desatualizado.
:::
Estado de auth/wallet — useAccountsStore
import { useAccountsStore } from "@cactus-agents/accounts/react";
const isAuthenticated = useAccountsStore((s) => s.isAuthenticated);
const user = useAccountsStore((s) => s.user);
const wallet = useAccountsStore((s) => s.wallet);
const refetchWallet = useAccountsStore((s) => s.refetchWallet);
Campos e ações que importam no dia a dia do base:
| Slice | Campos | Ações |
|---|---|---|
| Auth | user, userInfo, isAuthenticated, authHydrated, lastProfileFreshAt | setAuthUser, clearAuth, setAuthHydrated, refreshAuthProfile |
| Wallet | wallet, isLoading, error, lastWalletFreshAt | refetchWallet({ force }), transferBonus, transferCashback |
| Transações | transactions, transactionsRaw, transactionsMeta, transactionsLoading, transactionsFilter | fetchTransactions(filter) |
| Coins (dual-currency) | activeCoinId, coinBalances | setActiveCoin, setCoinBalances |
Dois pontos operacionais:
authHydratedficatruequando o primeiro fetch de perfil termina — logado ou não. É o sinal de "já sei o estado real", não de "está logado".refetchWallettem memoize por TTL de 2s; mutações que mexem no saldo (depósito, saque, aposta, transferência de bônus, resgate de cashback) precisam passar{ force: true }.
Auth reactivity
:::danger NUNCA leia auth de useRouteLoaderData("routes/_layout")
O campo auth não existe mais nesse loader (spec user-data-out-of-ssr, 2026-07): o documento
SSR é auth-agnóstico. Ler o loader do layout para decidir visibilidade é um snapshot morto — não
atualiza em login/logout via modal.
:::
O gate canônico é useAuthGate() (app/hooks/useAuthGate.ts):
import { useAuthGate } from "~/hooks/useAuthGate";
const isLogged = useAuthGate();
Como ele se comporta, em ordem cronológica:
- SSR e primeiro render client →
false. Toda página nasce com a casca de guest. - Pre-paint → um script inline síncrono em
root.tsxlê o cookie não-HttpOnlyis_authenticated=1e marcadata-auth-flagno<html>antes do primeiro paint. Isso segura o flash de header guest → logado via CSS, sem esperar JS de aplicação. - Pós-mount → o hook lê
hasAuthFlagCookie()(o mesmo cookie) como sinal otimista. - Pós-
authHydrated→ a store reativa assume:storeAuthenticated || cookieAuth. O||cobre a janela do fetch de perfil no caminho de casca SPA.
O hook também escuta o evento AUTH_FLAG_CLEARED_EVENT (~/utils/cookie.client) para reagir na hora
quando o cookie de flag é limpo.
Para código que precisa do usuário em si (não só do booleano), leia direto de useAccountsStore — é
reativo pelos mesmos motivos.
Stores do base (app/store/)
28 módulos hoje. Sem dumps de interface aqui de propósito: elas mudam toda semana e a fonte de verdade é o arquivo.
Sessão, layout e overlays
| Store | Hook | Papel |
|---|---|---|
layout.ts | useLayoutStore | Navegação estrutural: mobileMenuOpen, sidebarOpen, userPanelOpen, hideBottomNav, sectionToggles (seeded do config da brand — uma seção começa aberta a menos que declare defaultCollapsed: true) e syncSectionsForRoute |
side-sheets.ts | useSideSheetsStore | Qual side-sheet está aberto (favorites | recents | notifications). Só um por vez — abrir um substitui o anterior sem estado fechado intermediário |
floatingStack.ts | useFloatingStackStore | Ordenação vertical da coluna flutuante no canto inferior direito |
topbar.ts | useTopbarStore | Topbar de notificações: fila da brand, avaliação de shouldShow, dismiss com persistência em cookie/sessionStorage |
bottomNotification.ts | useBottomNotificationStore | Mesma mecânica do topbar, para a notificação flutuante do rodapé (inclui rotação e revalidate) |
installTooltip.ts | useInstallTooltipStore | Tooltip de instalação de app (variantes por plataforma, ex. ios_safari) |
sportsNav.ts | useSportsNavStore | Path atual dentro do SDK do sportsbook (o SDK navega por history.pushState, que o React Router não enxerga) + controles do SDK |
Contrato de ordenação de overlays
floatingStack.ts orquestra a pilha flutuante para que nenhum widget precise de cálculo de pixel
manual. A ordem é declarada em FLOATING_LAYERS (índice 0 = mais embaixo):
installTooltip → updateBanner → bottomNotification → lastGame → campaign → backToTop
Adicionar uma camada nova exige quatro passos: a chave em FLOATING_LAYERS na posição vertical
correta, a altura em LAYER_HEIGHTS, useRegisterFloating(layer, isVisible) no widget e
useFloatingBottom(layer) para aplicar o offset. Um widget que pode encolher numa aba de borda
(FloatingEdgeTab) permanece na mesma coluna: re-registra com altura menor e região "right".
side-sheets.ts coordena com useLayoutStore.userPanelOpen dentro do SideSheetRoot — abrir um
sheet fecha o painel do usuário e vice-versa. A dependência é unidirecional de propósito: a store
de sheets conhece layout.ts, nunca o contrário.
Catálogo, jogos e busca
| Store | Hook | Papel |
|---|---|---|
games.ts | useGamesStore | Store central do cassino: homeRows, categories, providers, topWins, lastWins, hubChips, caches (gameStatsCache, gamesBySlug) e o slice de favoritos (favoriteSlugs, _isFavoritesHydrated, pendingFavoriteWrites) |
search.ts | useSearchStore | Busca compartilhada entre o input do header e o da página. Debounce de 300ms, abort do fetch anterior, lastResolvedTerm (política "a URL espelha o resultado, não o buffer de digitação") e persistência em sessionStorage |
pre-game.ts | usePreGameStore | Jogo do bottom-sheet de pre-game (aberto por tap no card, mobile) |
gameAssistant.ts | useGameAssistantStore | Assistente flutuante in-game: expanded + qual sheet está aberto |
Auth, validação e KYC (UI)
| Store | Hook | Papel |
|---|---|---|
authModal.ts | useAuthModalStore | Qual modal de auth está aberto + authModalReason (hoje só session_expired, produzido por handleUnauthorized após um 401) |
recovery.ts | useRecoveryStore | Fluxo do modal de recuperação de senha (steps, token temporário, e-mail/telefone mascarados, disponibilidade de KYC). Dados sensíveis transitam só server-side via cookie HttpOnly |
validationSteps.ts | useValidationStepsStore | Modal de validações contextuais (deposit, withdraw, …). Tem um contador de abertura usado como key para forçar remontagem quando o modal reabre no mesmo tick |
validationRuntime.ts | useValidationRuntimeStore | Resultado global de fetchAllValidations() |
passwordValidation.ts | usePasswordValidationStore | Modal standalone de confirmação de senha |
kyc.ts | useKycStore | Fluxo completo de KYC. O mode discrimina iframe (BFF devolveu url) de sdk (devolveu token, painel por operador — Unico, Sumsub). Ver KYC |
onboarding.ts | useOnboardingStore | Sinal efêmero "acabou de registrar", consumido por useAfterRegisterFlow para abrir o modal de depósito (esperando o LimitsStep quando o caminho regulatório está ativo) |
protection.ts | useProtectionStore | Coordenação de accordions da página /user/protection (um painel pode forçar a abertura de vizinhos) |
Financeiro e recompensas
| Store | Hook | Papel |
|---|---|---|
payments.ts | usePaymentsStore | Modal de depósito/saque, providers, método selecionado, cupom, valor inicial e os flags de auto-open/auto-submit |
walletModal.ts | useWalletModalStore | Abre/fecha o modal de carteira (o dado da carteira vive em useAccountsStore) |
rewards.ts | useRewardsStore | Página /user/rewards: abas, filtros, paginação de disponíveis/finalizados, modal de resgate |
rewardsCount.ts | useRewardsCountStore | Contador app-wide de prêmios resgatáveis (a rota de prêmios reseta a store ao desmontar, então o número precisa de casa própria) |
ftdCheckin.ts | useFtdCheckinStore | Estado do check-in pós-FTD (missões, dia, ftdDate, task) |
Gamificação, campanhas e cache de sessão
| Store | Hook | Papel |
|---|---|---|
gamification.ts | useGamificationStore | Estado do SDK Smartico: ready, sdkIdentified, visitorMode, perfil, missões, badges, torneios, loja, mini-games, níveis, bônus, jackpots, sorteios, inbox e traduções |
smartico.ts | useSmarticoHashStore | O smarticoHash resolvido no client. Não vem mais do loader (doc SSR auth-agnóstico) — é alimentado pelo cookie gm_id |
strategy.ts | useStrategyStore | Overlay de campanha/estratégia (iframe + dedup por sessão: uma estratégia com repeat=false não reabre no resto da aba) |
userConfigCache.ts | useUserConfigCacheStore | Cache de sessão da área do usuário (contas sociais, anos do informe de rendimentos) para as abas não re-buscarem com skeleton a cada mount. null = nunca buscado nesta sessão |
Dados de server (loaders)
Brand e clientEnv chegam pelo loader do _layout.tsx e são distribuídos por Context, não por
Zustand:
// app/routes/_layout.tsx — shape real
interface LayoutLoaderData {
brand: BrandConfig | null;
brandError: BrandError | null;
clientEnv: ClientEnv;
serverIsMobile: boolean;
legalTerms: Promise<Pick<LegalTerm, "route" | "title">[]>; // deferred
orgCountry: { alpha2: string; name: string } | null;
}
Não há auth nem smarticoHash aqui. Existe um teste leak-canary
(app/routes/__tests__/no-user-data-in-ssr.test.ts) que falha se qualquer campo derivado do usuário
logado for reintroduzido. Ver Routing.
Padrão de acesso
| Precisa de | Use |
|---|---|
| "Está logado?" para UI | useAuthGate() |
| Usuário / perfil / carteira | useAccountsStore((s) => …) |
| Brand config | useBrand() (Context) |
| Env do client | useClientEnv() / useCassinoMode() |
| Navegação estrutural | useLayoutStore() |
| Catálogo / favoritos | useGamesStore(), useFavorites(), useIsFavorite(slug) |
| Modais | a store dedicada de cada um (qualquer componente pode abrir/fechar) |
| Widget flutuante novo | useRegisterFloating() + useFloatingBottom() do floatingStack |