Pular para o conteúdo principal

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 LoginModaluserInfo.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 updateUserPreferences itera o payload contra ALLOWED_PREFERENCES e dá throw antes 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)

ArquivoTipoDescrição
src/types/user.tsTypes + constantesLANDING_PAGE_OPTIONS, ALLOWED_PREFERENCES, UserPreferences, UpdateUserPreferencesPayload
src/types/auth.tsTypeAuthUserInfo.preferencies?: UserPreferences
src/user-service.tsServiceupdateUserPreferences(data) com whitelist guard
src/react/profile-http.tsHTTP adapterdefaultUpdateUserPreferences(data)POST /api/user/update-preferences
src/react/config.tsConfigAccountsConfig.updateUserPreferences? (override opcional)
src/react/profile-hooks.tsHookuseProfile().updateUserPreferences + optimistic update no store

Base (front-web-base)

ArquivoTipoDescrição
app/types/feature-flags.tsTypeAppFeatureFlags.userPreferences?: boolean
app/config/features/features.tsConfigDefault userPreferences: false (+ override true em betpontobet-bet-br)
app/components/ui/RadioField.tsxComponentRadio reutilizável (sr-only input + custom indicator)
app/components/user/account/PreferencesSection.tsxComponentUI da seção, lê do store + chama updateUserPreferences
app/routes/api/user/update-preferences.tsAPI RouteAction server-side, chama createUserFromClient(client).updateUserPreferences(body)
app/routes/user/config.tsxRouteRenderiza PreferencesSection antes de ContractsSection quando flag liga
app/utils/landing-preference.tsHelperlandingPreferenceHref(pref) — mapeia valor da preferência → href
app/components/auth/LoginModal.tsxComponentDispara 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:

  1. Visibilidade da seção em /user/config.
  2. 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:

  1. 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á que landingPage é undefined → fallback "home").

  2. 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 quality no base (lint + biome + typecheck:ci + vitest).
  • Pré-push gate (.husky/pre-push) força CACTUS_FORCE_REGISTRY=1 no 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 retornar 400 com error: "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.