User Preferences
Sistema de preferências do usuário persistidas via PATCH /bff/users/update-user-info-metadata. A primeira preferência implementada é landingPage (para onde levar o usuário após o login), mas a feature foi desenhada para ser estendida com novas preferências sem mexer na infraestrutura — basta adicionar uma entrada no whitelist do core e um campo no formulário do base.
Este documento serve tanto como referência da feature atual quanto como playbook passo-a-passo para adicionar uma nova preferência.
Visao geral
Usuario abre /user/config
│
▼
PreferencesSection (gated por featuresConfig.userPreferences)
│
▼
Le userInfo.preferencies do useAccountsStore
│
▼
Usuario seleciona valor + clica "Salvar"
│
▼
useProfile().updateUserPreferences({ preferencies: { ... } })
│
▼
defaultUpdateUserPreferences (HTTP adapter do core)
│
▼
POST /api/user/update-preferences (rota interna do base)
│
▼
createUserFromClient(client).updateUserPreferences(body)
│
▼ (whitelist guard — throws em chave/valor invalidos)
│
PATCH /bff/users/update-user-info-metadata
│
▼
Optimistic update no useAccountsStore (sem refetch)
Ao login subsequente, o LoginModal lê userInfo.preferencies.landingPage da resposta do /api/auth/login e redireciona para a rota correspondente — uma única vez, no momento do login, e apenas quando o usuário está na home (logins mid-flow não disparam redirect).
Por que a logica vive no core
O endpoint do BFF (/bff/users/update-user-info-metadata) aceita qualquer JSON dentro do campo preferencies (typo intencional — vem do contrato do backend, não corrigir). Isso significa que o front é o único responsável por restringir o que pode ou não ser persistido. A escolha foi colocar essa responsabilidade inteiramente no core (@cactus-agents/accounts):
- O base nunca conhece o path
/bff/users/update-user-info-metadata. - O base nunca conhece a lista de chaves/valores aceitos — ele importa
LANDING_PAGE_OPTIONS(e quaisquer constantes futuras) do core. - O service
updateUserPreferencesitera o payload contraALLOWED_PREFERENCESe dáthrowantes de chamar a API se houver chave desconhecida ou valor fora da lista. Isso vale tanto pra chamadas via base quanto pra qualquer outro consumidor do SDK no futuro.
Esse desenho é o exemplo canônico do princípio "o base monta o layout, o core fornece as regras, dados e ferramentas" (ver Core vs Base). Forks que herdam o base não podem expandir o whitelist sem republicar o core.
Arquivos principais
Core (@cactus-agents/accounts)
| Arquivo | Tipo | Descrição |
|---|---|---|
src/types/user.ts | Types + constantes | LANDING_PAGE_OPTIONS, ALLOWED_PREFERENCES, UserPreferences, UpdateUserPreferencesPayload |
src/types/auth.ts | Type | AuthUserInfo.preferencies?: UserPreferences |
src/user-service.ts | Service | updateUserPreferences(data) com whitelist guard |
src/react/profile-http.ts | HTTP adapter | defaultUpdateUserPreferences(data) → POST /api/user/update-preferences |
src/react/config.ts | Config | AccountsConfig.updateUserPreferences? (override opcional) |
src/react/profile-hooks.ts | Hook | useProfile().updateUserPreferences + optimistic update no store |
Base (front-web-base)
| Arquivo | Tipo | Descrição |
|---|---|---|
app/types/feature-flags.ts | Type | AppFeatureFlags.userPreferences?: boolean |
app/config/features/features.ts | Config | Default userPreferences: false (+ override true em betpontobet-bet-br) |
app/components/ui/RadioField.tsx | Component | Radio reutilizável (sr-only input + custom indicator) |
app/components/user/account/PreferencesSection.tsx | Component | UI da seção, lê do store + chama updateUserPreferences |
app/routes/api/user/update-preferences.ts | API Route | Action server-side, chama createUserFromClient(client).updateUserPreferences(body) |
app/routes/user/config.tsx | Route | Renderiza PreferencesSection antes de ContractsSection quando flag liga |
app/utils/landing-preference.ts | Helper | landingPreferenceHref(pref) — mapeia valor da preferência → href |
app/components/auth/LoginModal.tsx | Component | Dispara o redirect pós-login na home |
Whitelist (core)
A constante ALLOWED_PREFERENCES em packages/accounts/src/types/user.ts é a única fonte de verdade para o que pode ser persistido. Cada chave mapeia para o array de valores aceitos:
export const LANDING_PAGE_OPTIONS = [
"home",
"casino",
"casino_live",
"sports",
"sports_live",
] as const;
export const ALLOWED_PREFERENCES = {
landingPage: LANDING_PAGE_OPTIONS,
} as const satisfies Record<string, readonly string[]>;
E o guard no service:
async updateUserPreferences(data) {
const input = { ...(data?.preferencies ?? {}) };
for (const [key, value] of Object.entries(input)) {
const allowed = ALLOWED_PREFERENCES[key];
if (!allowed) throw new Error(`Unknown preference key: ${key}`);
if (!allowed.includes(value)) throw new Error(`Invalid value for ${key}: ${value}`);
}
return fetcher.patch("/bff/users/update-user-info-metadata", { preferencies: input });
}
Comportamento:
- Chave fora do mapa →
throw "Unknown preference key: X"(sem network call). - Valor fora da lista da chave →
throw "Invalid value for X: Y"(sem network call). - Payload vazio (
{ preferencies: {} }) → vai pro BFF como no-op.
Cobertura de testes: packages/accounts/src/__tests__/user-service.test.ts cobre os 4 casos (valor válido, valor inválido, chave desconhecida, payload vazio + uma combinação chave válida + chave extra).
Feature flag
AppFeatureFlags.userPreferences?: boolean controla dois pontos:
- Visibilidade da seção em
/user/config. - Redirect pós-login no
LoginModal.
Quando false (default em todas as brands exceto betpontobet-bet-br):
- A seção "Preferências" não renderiza.
- O redirect pós-login não roda → comportamento histórico preservado (user fica onde estava após login).
Regra crítica: o brandOverridesPlugin faz substituição de arquivo inteiro (não deep-merge). Adicionar userPreferences: false em todos os 13 arquivos overrides/<brand>/app/config/features/features.ts é obrigatório, mesmo que o valor seja igual ao default. Sem isso, a brand recebe undefined (que conta como falsy aqui, mas pode quebrar em outros consumidores). Ver Forking — Override Files.
Redirect pos-login
Disparado em app/components/auth/LoginModal.tsx em ambos os caminhos de sucesso (login normal + 2FA), depois de revalidator.revalidate() e antes de openAuthModal(null):
if (
featuresConfig.userPreferences &&
location.pathname === routeHref("home")
) {
navigate(landingPreferenceHref(response.userInfo?.preferencies?.landingPage));
}
Duas guardas:
-
featuresConfig.userPreferences— brands sem a feature mantêm o comportamento original (sem redirect implícito). Necessário porque, sem essa guarda, brands sem o opt-in passariam a redirecionar todo mundo pra/(já quelandingPageéundefined→ fallback "home"). -
location.pathname === routeHref("home")— só logins na home disparam o redirect. Um usuário que loga estando numa página de jogo, sportsbook ou promoção fica onde estava (claramente está ali por algum motivo).
Lê de response.userInfo direto da resposta do /api/auth/login, não do store hidratado depois — assim o redirect fica vinculado ao evento de login, não a refetches do profile (que rodam em mount, navegação, etc.).
Mapeamento valor → href
app/utils/landing-preference.ts:
export function landingPreferenceHref(preference) {
switch (preference) {
case "casino": return routeHref("casino");
case "casino_live": return routeHref("casino.live");
case "sports": return routeHref("sports");
case "sports_live": return sportPath("/live"); // sem route key dedicada
default: return routeHref("home"); // home + undefined
}
}
sports_live usa sportPath("/live") porque não há entrada dedicada em routes.paths.ts — a sportsbook lida com sub-rotas via sports.catchAll. O helper respeita o prefixo da brand (/sports, /deportes, etc.).
Optimistic update no store
useProfile().updateUserPreferences faz update local imediato após o sucesso da chamada — sem refetch:
const state = useAccountsStore.getState();
if (state.user && state.userInfo) {
state.setAuthUser(state.user, {
...state.userInfo,
preferencies: { ...state.userInfo.preferencies, ...data.preferencies },
});
}
A PreferencesSection re-renderiza com o novo valor sem precisar esperar uma round-trip nova ao BFF. O userInfo.preferencies na próxima visita à página vem do /api/auth/profile normalmente.
Playbook — adicionar uma nova preferência
Suponha que vamos adicionar colorMode: "light" | "dark" | "system" (controle de tema escolhido pelo usuário). Os passos:
1. Core — declarar a constante e o tipo
packages/accounts/src/types/user.ts:
export const COLOR_MODE_OPTIONS = ["light", "dark", "system"] as const;
export type ColorModePreference = (typeof COLOR_MODE_OPTIONS)[number];
export const ALLOWED_PREFERENCES = {
landingPage: LANDING_PAGE_OPTIONS,
colorMode: COLOR_MODE_OPTIONS, // ← entrada nova
} as const satisfies Record<string, readonly string[]>;
export interface UserPreferences {
landingPage?: LandingPagePreference;
colorMode?: ColorModePreference; // ← campo novo
}
Não precisa mexer no user-service.ts — o guard itera ALLOWED_PREFERENCES automaticamente. Teste novo: caso de valor válido + caso de valor inválido pra colorMode.
2. Core — re-exports
packages/accounts/src/types/index.ts:
export type { ColorModePreference, /* ... */ } from "./user";
export { COLOR_MODE_OPTIONS, /* ... */ } from "./user"; // se quiser exportar o array
packages/accounts/src/index.ts segue o mesmo padrão. Confira que o LANDING_PAGE_OPTIONS aparece no barrel — espelhe para o novo.
3. Core — i18n
packages/i18n/locales/<lang>/user.json, dentro de preferences:
"color_mode": {
"label": "Tema da interface",
"description": "Como você prefere ver o site.",
"light": "Claro",
"dark": "Escuro",
"system": "Seguir o sistema"
}
Adicione em todos os locales ativos (pt-br, pt, es, en).
4. Core — changeset + bump
pnpm changeset na raiz do core. Tipo: minor se for mudança aditiva (default). Mensagem padronizada:
feat(accounts): add colorMode preference to ALLOWED_PREFERENCES
Patch também o @cactus-agents/i18n (campo de tradução). Push direto na main; o bot da release abre o PR de version-bump.
5. Base — bump de versões
pnpm install no front-web-base após o release sair. Confirma que @cactus-agents/accounts e @cactus-agents/i18n foram bumpadas no package.json.
6. Base — UI
Espelhe PreferencesSection. Para uma preferência simples adicione um novo <fieldset> dentro do mesmo componente (mesmo padrão de RadioField em grid). Para preferências mais complexas (ex: configuração de notificações com múltiplas sub-opções) considere quebrar em sub-componentes.
import { COLOR_MODE_OPTIONS, type ColorModePreference } from "@cactus-agents/accounts";
const COLOR_MODE_LABEL: Record<ColorModePreference, string> = {
light: "user:preferences.color_mode.light",
dark: "user:preferences.color_mode.dark",
system: "user:preferences.color_mode.system",
};
// no submit:
await updateUserPreferences({
preferencies: {
landingPage: selectedLanding, // se mudou
colorMode: selectedColor, // se mudou
},
});
Nada precisa mudar no resto da arquitetura — a rota /api/user/update-preferences, o hook, o store, o redirect pós-login (se aplicável a esta nova preferência) já existem.
7. Base — consumir a preferência
A parte específica de "fazer a preferência funcionar" depende do que ela controla. Por exemplo, colorMode provavelmente seria lido pelo provider de tema no _layout ou no root.tsx, lendo userInfo.preferencies?.colorMode do useAccountsStore e aplicando dinamicamente. Use landingPage (consumida pelo LoginModal) como referência do padrão "ler do store no momento certo".
Importante: se a preferência precisa de comportamento "uma vez por login" (igual ao redirect), siga o padrão do LoginModal — leia de response.userInfo e dispare o efeito ali. Se é estado contínuo (igual colorMode), leia do store em todo render.
8. Brand overrides
A flag userPreferences já é o gate da feature inteira. Não há necessidade de adicionar uma sub-flag por preferência a menos que diferentes brands queiram expor preferências diferentes. Se for o caso, considere modelar como userPreferences: { landingPage: true, colorMode: false } (objeto em vez de boolean) e propagar pra todos os 13 overrides — mas isso é mudança de contrato e deve passar por discussão de produto antes.
Verificacao
pnpm qualityno base (lint + biome + typecheck:ci + vitest).- Pré-push gate (
.husky/pre-push) forçaCACTUS_FORCE_REGISTRY=1no test e typecheck:ci → pega divergência entre source local e versão publicada. - Manual:
feca dev --betpontobet-bet-br, login, salvar preferência, recarregar, validar persistência. Logout, login da home, validar redirect. Logout, ir pra/games, login, validar que não redireciona. - Whitelist: tentar
fetch('/api/user/update-preferences', { method:'POST', body: JSON.stringify({ preferencies: { foo: 'bar' } }) })no console → deve retornar400comerror: "update_preferences_failed".
Pontos de atencao
preferenciesé digitado errado de propósito (com "i") — é o contrato do BFF. Não "consertar" em lugar nenhum: type, payload, store, JSON. Comentários no código já anotam isso.- O redirect só roda em logins originados da home, e só pra brands com a flag ligada. Mudar isso afeta a UX de todos os usuários da brand.
- A whitelist é estritamente positiva: chave nova ou valor novo só funciona depois de bumpar o core. Não é possível "experimentar" no base sozinho.
- O optimistic update assume que o BFF persiste com sucesso — em erro, o store não é revertido. Se for crítico para alguma preferência, faça refetch do profile (
useAccountsStore.getState().refreshAuthProfile()) no catch.