Pular para o conteúdo principal

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/toggle server-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étodoEndpointDescrição
GET/bff/favorite-gamesLista jogos favoritos do usuário (com game data)
PUT/bff/favorite-games/toggleToggle 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

CamadaArquivo (front-web-base)Responsabilidade
Serviceapp/services/favorites.server.tscreateFavoritesService(client) — wrapper do BFF sobre o ApiClient (list, toggle) + transforms snake_casecamelCase
Cacheapp/services/favorites.cache.server.tsFavoritesCacheService + createFavoritesCacheService (platform-cache, resource userFavorites, chave por userId)
API routeapp/routes/api/favorites.tsProxy interno autenticado: GET devolve { favorites: string[] }, POST/DELETE fazem toggle
API helperapp/routes/api/games/by-slugs.tsResolve slugs em batch (cap interno de 50 por request)
Storeapp/store/games.ts (slice favorites)favoriteSlugs: Set<string>, _isFavoritesHydrated, pendingFavoriteWrites, hydrateFavorites, clearFavorites
Hook (estado)app/hooks/useFavorites.tsuseFavorites().toggle, useFavorites().load, useIsFavorite(slug), cache em localStorage
Hook (bundle)app/hooks/useFavoritesRecentsBundle.tsMonta favoritos + recentes + sugestões no client (chunking de 50 slugs contra o by-slugs)
Hook (limpeza)app/hooks/useFavoritesAuthSync.tsLimpa estado escopado por auth em logout, troca de usuário e na transição anon→login
Botãoapp/components/games/FavoriteButton.tsxCoração (variant overlay/inline); abre auth modal se deslogado
Rowapp/components/home/FavoritesRow.tsxCarrossel + animações (enter/exit + FLIP reflow) + empty state
Sheetapp/components/sheets/FavoritesSheet.tsxSide-sheet de favoritos (dispara load() ao abrir)
Páginaapp/routes/favorites.tsxGrid da rota favorites (auth-gated, client-side)
Feature flagapp/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:

ContextoComo dispara
Row de favoritos na homeFavoritesRow chama useFavoritesRecentsBundle({ wantFavorites: true, wantRecents: false })
Row de recém-jogadosRecentlyPlayedRow usa o mesmo bundle com wantRecents: true
Side-sheet de favoritosFavoritesSheet chama useFavorites().load() ao abrir
Página de busca e página /favoritesuseEffect 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:

  1. O useFavoritesRecentsBundle a efetivamente buscar (caso contrário devolve o bundle vazio).
  2. A row "Jogos Favoritos" na home (desde que também registrada nas home rows).
  3. O FavoriteButton nos 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

  1. Feature flag em overrides/<brand>/app/config/features/features.ts:

    favoriteGames: true,
  2. 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 WidgetRenderer não monta a row mesmo com a flag ligada.

  3. 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

SintomaOnde 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 em slug_url (não slug). Normalizado em games.cache.server.ts (getDetail) e em api/games/by-slugs.ts. Se usar getDetail por slug em outro lugar, replique a normalização.
  • O coração pode nascer vazio e "piscar": antes da hidratação, useIsFavorite retorna false — 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.
  • useIsFavorite usa _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 em true e transformava o gate de fetch (if (isHydrated) return) num no-op permanente pelo resto da vida da aba — inclusive depois do login. É por isso que useFavoritesAuthSync roda 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.