Favorite Games
Feature de "Jogos Favoritos" do template: o usuário marca um jogo com 💗 e o slug é persistido no BFF Cactus (/bff/favorite-games). A home renderiza uma row "Jogos Favoritos" e o coração aparece em todos os GameCards via o componente único FavoriteButton.
Gated por brand via featuresConfig.favoriteGames. Não requer env vars dedicadas — usa o ApiClient padrão (API_BASE_URL/ORIGIN_DOMAIN/CF_WORKER_KEY), igual ao resto das chamadas BFF.
:::info Histórico
A feature foi originalmente implementada contra uma API externa (fav-games.d7k.io, com header x-api-key e as envs FAV_GAMES_API_URL/FAV_GAMES_API_KEY). Em 2026-05-02 foi migrada para o BFF padrão e essas envs foram removidas. Se encontrar referências a fav-games.d7k.io ou FAV_GAMES_*, são resíduos a limpar.
:::
Visao geral
A hidratação é 100% client-side e sob demanda. Nenhum loader busca favoritos.
Leitura (on-demand, client) Escrita (toggle)
│ │
▼ ▼
FavoritesRow / FavoritesSheet / FavoriteButton click
/favorites / /search │
│ ▼
▼ useFavorites().toggle(slug)
GET /api/favorites │ (otimista no store)
│ ▼
▼ POST/DELETE /api/favorites { slug }
FavoritesCacheService.getSlugs() │
│ ▼
▼ FavoritesCacheService.toggle()
platform-cache (resource `userFavorites`, ├──► PUT /bff/favorite-games/toggle
chave por userId) └──► engine.purge("userFavorites", userId)
│
▼
GET /bff/favorite-games
- Leitura: o store é hidratado quando (e só quando) algum consumidor precisa — ver "Pontos de hidratação" abaixo.
- Escrita: toggle otimista no client +
PUT /bff/favorite-games/toggleserver-side, com rollback em erro.
:::danger O loader do _layout NÃO busca favoritos
loadFavoriteSlugsForRequest não existe mais — o nome não aparece em nenhum lugar de app/.
Desde o refactor de 2026-05 (refactor(home): favoritos/recentes 100% client-side e
refactor(favorites): página client-side), nem _layout.tsx nem _index.tsx conhecem o usuário
logado; a spec user-data-out-of-ssr (2026-07) fechou esse caminho de vez. Ver
State Management.
:::
Backend — BFF
Servido pelo BFF da brand via ApiClient de @cactus-agents/api-client. A identidade do usuário vem do JWT (não há userId/brand no payload); a brand é resolvida pelo header tenant (derivado do ORIGIN_DOMAIN).
| Método | Endpoint | Descrição |
|---|---|---|
GET | /bff/favorite-games | Lista jogos favoritos do usuário (com game data) |
PUT | /bff/favorite-games/toggle | Toggle idempotente, body { slug_url } → retorna { message, is_favorite } |
O toggle é idempotente do ponto de vista de input: o mesmo slug adiciona ou remove, e o backend devolve is_favorite com o estado final. As rotas antigas por id (/bff/favorite-games/<id>) e por slug no path foram removidas — não há fallback. Um 404 aqui é regressão de contrato com o BFF.
Arquivos principais
| Camada | Arquivo (front-web-base) | Responsabilidade |
|---|---|---|
| Service | app/services/favorites.server.ts | createFavoritesService(client) — wrapper do BFF sobre o ApiClient (list, toggle) + transforms snake_case→camelCase |
| Cache | app/services/favorites.cache.server.ts | FavoritesCacheService + createFavoritesCacheService (platform-cache, resource userFavorites, chave por userId) |
| API route | app/routes/api/favorites.ts | Proxy interno autenticado: GET devolve { favorites: string[] }, POST/DELETE fazem toggle |
| API helper | app/routes/api/games/by-slugs.ts | Resolve slugs em batch (cap interno de 50 por request) |
| Store | app/store/games.ts (slice favorites) | favoriteSlugs: Set<string>, _isFavoritesHydrated, pendingFavoriteWrites, hydrateFavorites, clearFavorites |
| Hook (estado) | app/hooks/useFavorites.ts | useFavorites().toggle, useFavorites().load, useIsFavorite(slug), cache em localStorage |
| Hook (bundle) | app/hooks/useFavoritesRecentsBundle.ts | Monta favoritos + recentes + sugestões no client (chunking de 50 slugs contra o by-slugs) |
| Hook (limpeza) | app/hooks/useFavoritesAuthSync.ts | Limpa estado escopado por auth em logout, troca de usuário e na transição anon→login |
| Botão | app/components/games/FavoriteButton.tsx | Coração (variant overlay/inline); abre auth modal se deslogado |
| Row | app/components/home/FavoritesRow.tsx | Carrossel + animações (enter/exit + FLIP reflow) + empty state |
| Sheet | app/components/sheets/FavoritesSheet.tsx | Side-sheet de favoritos (dispara load() ao abrir) |
| Página | app/routes/favorites.tsx | Grid da rota favorites (auth-gated, client-side) |
| Feature flag | app/config/features/features.ts (override por brand) | Habilita por brand |
Camada de cache
FavoritesCacheService espelha o GamesCacheService: envolve o FavoritesService com o engine de @cactus-agents/platform-cache, com chave por userId (resource userFavorites). Mutações chamam engine.purge(RESOURCE, userId) para invalidar a entrada do usuário, então a próxima leitura vai à origem.
TTL/SWR são configurados via PLATFORM_CACHE_POLICY_JSON (gerenciado pelo front-ops). Sem entrada, o engine cai em bypass — funciona, só não cacheia.
createFavoritesCacheService retorna null quando a request não tem token, deixando os callers degradarem graciosamente (loader/action não quebram).
Pontos de hidratação
O store não é pré-hidratado por request. A hidratação acontece sob demanda, a partir de quatro contextos:
| Contexto | Como dispara |
|---|---|
| Row de favoritos na home | FavoritesRow chama useFavoritesRecentsBundle({ wantFavorites: true, wantRecents: false }) |
| Row de recém-jogados | RecentlyPlayedRow usa o mesmo bundle com wantRecents: true |
| Side-sheet de favoritos | FavoritesSheet chama useFavorites().load() ao abrir |
Página de busca e página /favorites | useEffect no mount |
Não existe auto-load dentro do useFavorites(): todo <FavoriteButton> chama o hook, então um fetch
automático competiria com os toggles otimistas. load() é o entrypoint manual — quem chama decide
quando.
Há um cache em localStorage por usuário (brand:favorites:<userId>) que serve como aquecimento
otimista. Falha de quota/desabilitado é ignorada — é otimização, não requisito.
Feature flag
AppFeatureFlags.favoriteGames?: boolean (default false). Quando ligada, habilita:
- O
useFavoritesRecentsBundlea efetivamente buscar (caso contrário devolve o bundle vazio). - A row "Jogos Favoritos" na home (desde que também registrada nas home rows).
- O
FavoriteButtonnos game cards.
:::caution Brand overrides
O brandOverridesPlugin faz substituição de arquivo inteiro (não deep-merge). Ao habilitar/desabilitar a flag, garanta que favoriteGames está presente em todos os overrides/<brand>/app/config/features/features.ts — senão a brand recebe undefined. Ver Forking — Override Files.
:::
Habilitando para um brand novo
-
Feature flag em
overrides/<brand>/app/config/features/features.ts:favoriteGames: true, -
Row do carrossel em
overrides/<brand>/app/config/sections/home-rows.legacy.ts:{slug: "favorites-row",type: "widget",i18nKey: "casino:favorites.title",icon: "⭐",},Sem essa entrada, o
WidgetRenderernão monta a row mesmo com a flag ligada. -
Sanity check: reload da home logado → a row aparece (ou o empty state).
Não há env vars a configurar — a feature usa o BFF padrão (
API_BASE_URL).
Como debugar
| Sintoma | Onde olhar |
|---|---|
| "Favorito aparece e some" | Network → /api/favorites (status do POST/DELETE). 200 + card some = store reescrito por rehidratação stale (_isFavoritesHydrated). |
| "Slug favoritado mas card não aparece na row" | Network → /api/games/by-slugs. {"games":[]} = BFF não resolveu o slug (catálogo dessincronizado ou regressão de slug_url). |
| "POST falha com 401" | JWT cookie expirou/inválido → relogar. |
| "POST falha com 404 no toggle" | Regressão de contrato no BFF (/bff/favorite-games/toggle removido/alterado). |
Testar o BFF direto (com vars do .dev.vars):
curl -H 'tenant: <ORIGIN_DOMAIN>' \
-H 'cf-worker-key: <CF_WORKER_KEY>' \
'<API_BASE_URL>/casino-games?slug=pgsoft/fortune-snake'
Pontos de atencao
- Gotcha
slug_url:GET /casino-games?slug=...retorna o slug emslug_url(nãoslug). Normalizado emgames.cache.server.ts(getDetail) e emapi/games/by-slugs.ts. Se usargetDetailpor slug em outro lugar, replique a normalização. - O coração pode nascer vazio e "piscar": antes da hidratação,
useIsFavoriteretornafalse— o fallback SSR foi removido junto com o request global. É o trade-off aceito do modelo on-demand; um jogo favoritado aparece sem preenchimento até o fetch terminar. useIsFavoriteusa_isFavoritesHydrated(flag dedicada), não_isHomeHydrated— senão toggles fora da home (game detail, cassino) seriam ignorados.clearFavorites()também reseta_isFavoritesHydrated. Sem isso (bug corrigido em 2026-07), um seed com array vazio para visitante anônimo deixava a flag emtruee transformava o gate de fetch (if (isHydrated) return) num no-op permanente pelo resto da vida da aba — inclusive depois do login. É por isso queuseFavoritesAuthSyncroda também na transição anon→login, não só em logout/troca de usuário.- Documentação detalhada da UI/animações (FLIP, enter/exit, empty state) vive em
front-web-base/docs/features/favorites/overview.md.