Brand Loading (Server-Side)
Visão geral
A configuração de marca (brand) é carregada no server-side via React Router loader, com cache e tratamento de erro robusto, e passada aos componentes via cadeia de Providers.
Fluxo de dados
_layout.tsx loader (server-side)
├── Monta clientEnv (9 campos — ver abaixo)
├── getCountryByAlpha3(BRAND_COUNTRY) → orgCountry (pro JSON-LD)
├── fetchLegalTerms(...) → Promise NÃO aguardada (streamed)
├── await loadBrandConfigForRequest(request, { cloudflareEnv, waitUntil, context })
│ └── loadBrandConfig → cacheEngine.fetch("brandConfig", …) → BrandResult
│ ├── createBrandFromClient(client, { country, language }) → BrandConfig
│ │ ├── GET /appearance → transformAppearance
│ │ ├── GET /bff/features → transformFeatures
│ │ └── POST /bookmaker-settings → transformSettings
│ └── Erro → { ok: false, error: { message, status, detail? } }
└── return { brand, brandError, clientEnv, serverIsMobile, legalTerms, orgCountry }
:::danger O loader NÃO retorna auth
Desde a spec user-data-out-of-ssr (2026-07-24) nenhum dado do usuário logado
atravessa o SSR. loadAuthProfile e smarticoHash não existem mais no loader
(grep loadAuthProfile app/ → zero resultados). Há um teste leak-canary que falha
se alguém re-adicionar: app/routes/__tests__/no-user-data-in-ssr.test.ts.
Auth hidrata 100% client-side — ver Fluxo de autenticação. :::
brand é o único await crítico que resta no caminho do TTFB. legalTerms
saiu do caminho crítico (app-shell streaming, 2026-07): volta como Promise
não-aguardada e o RR7 faz stream via <Await>/<Suspense> no footer, que degrada
graciosamente pra links config-only com [].
DefaultLayout (client-side) — cadeia de 9 providers
└── <EnvProvider env={clientEnv}>
└── <DeviceProvider serverIsMobile={serverIsMobile}>
└── <BrandProvider brand={brandWithLinkOverrides}>
└── <CountryProvider country={clientEnv.BRAND_COUNTRY}>
└── <TranslationProvider>
└── <ComponentVariantsProvider value={layoutConfig.componentVariants}>
└── <AnaLayerProvider>
└── <FtdOfferProvider>
└── <FtdCheckinAnnouncementProvider>
└── Componentes filhos
BrandResult — Discriminated Union
O loadBrandConfig retorna um discriminated union para tratamento explícito de erro:
type BrandResult =
| { ok: true; brand: BrandConfig; fromCache: boolean }
| { ok: false; error: BrandError }
type BrandError = {
message: string;
status: number;
detail?: string;
}
No loader, o resultado é verificado:
const result = await loadBrandConfig(context.cloudflare?.env);
if (!result.ok) {
// Renderiza BrandErrorPage com informações do erro
return { brandError: result.error };
}
const { brand } = result;
Na _layout.tsx, quando brandError está presente, o layout renderiza o componente BrandErrorPage ao invés do conteúdo normal.
Cache de Brand
O brand config é cacheado para evitar 3 requests a cada page load. Não existe
getBrandCache nem BRAND_CACHE_TTL — o cache é delegado ao engine do
@cactus-agents/platform-cache:
// app/services/brand.server.ts
cacheEngine.fetch(
"brandConfig",
() => createBrandFromClient(client, { country, language }),
{ waitUntil },
);
Os TTLs vêm do YAML em front-ops/config/cache/defaults.yml, resource
brandConfig:
| Parâmetro | Valor |
|---|---|
ttlSeconds | 60 (1 min) |
staleWhileRevalidateSeconds | 300 (5 min) |
staleIfErrorSeconds | 21600 (6h) |
O TTL é deliberadamente baixo pra mudança de backoffice aparecer em ≤1min; o custo extra no BFF é absorvido pelo single-flight coalescing do engine. Racional completo em Cache architecture.
loadBrandConfigForRequest
O loader não chama loadBrandConfig direto: usa
loadBrandConfigForRequest(request, …), um wrapper de dedup por request — se
mais de um ponto no mesmo request pedir o brand, só uma resolução acontece.
Três requests em paralelo
O @cactus-agents/brand faz 3 requests simultâneos:
GET /appearance
Retorna dados visuais: logo, banners, social links, sponsorships, CSS customizado, cores do tema.
GET /bff/features
Feature flags por país: social auth, carousels, maintenance mode, auth config, módulos ativos, validação, contatos.
POST /bookmaker-settings
Configurações de negócio: nome, SEO, limites de depósito/saque/apostas, bônus, rollover, registro, analytics.
Transformações
O @cactus-agents/brand transforma os dados raw da API em tipos limpos:
- Banners:
{"1":{...}}objects OU[]→Banner[]sorted by order - Links: dedup (último LINK_TO_X entry vence)
- JSON string fields: parsed via
safeParseJson - Flags
0|1→boolean - Country filtering:
selectByCountrycom fallback chain
BrandConfig resultante
type BrandConfig = {
appearance: BrandAppearance // logo, banners, social, links, sponsorships, colors
features: BrandFeatures // feature flags, auth config, modules, contacts
settings: BrandSettings // name, SEO, limits, bonus, registration, analytics
}
Cadeia de Providers
O DefaultLayout monta 9 providers aninhados (app/layouts/DefaultLayout.tsx):
- EnvProvider —
clientEnv(9 campos) viauseClientEnv() - DeviceProvider — recebe o hint
serverIsMobile(derivado do UA no loader) como fallback de SSR; expõeuseIsLowEnd()(ver Performance) - BrandProvider —
BrandConfigviauseBrand(). RecebebrandWithLinkOverrides, não obrandcru: os links deappearancesão mesclados com os overrides de link da marca antes de entrar no context - CountryProvider — configuração por país (moeda, locale, validações) via
useCountry() - TranslationProvider — setup i18next com namespace e idioma da marca via
useTranslation() - ComponentVariantsProvider — variantes de componente do
layoutConfig - AnaLayerProvider — camada de analytics
- FtdOfferProvider — oferta de primeiro depósito
- FtdCheckinAnnouncementProvider — anúncio de check-in FTD
Acesso nos componentes
import { useBrand } from '~/context/brand';
import { useClientEnv } from '~/context/env';
function MyComponent() {
const brand = useBrand();
const env = useClientEnv();
// brand.appearance.logo
// brand.features.maintenanceMode
// brand.settings.name
// env.ORIGIN_DOMAIN — NÃO existe env.API_BASE_URL
}
clientEnv
O loader monta clientEnv com as variáveis seguras de expor ao browser
(app/context/env.tsx) — 9 campos:
interface ClientEnv {
ORIGIN_DOMAIN: string;
/** Domínio público canônico quando difere do ORIGIN_DOMAIN. Só SEO consome. */
PUBLIC_DOMAIN?: string;
BRAND_LANGUAGE: string;
BRAND_COUNTRY: string; // alpha-3
BRAND_CURRENCY: string;
TURNSTILE_SITE_KEY?: string;
CASSINO_MODE?: "legacy" | "api_new";
FORCE_SPORTBOOK?: SportsProvider;
RUT_VALIDATION?: boolean; // kill switch do fluxo de identidade Chile
}
:::danger Nunca adicione API_BASE_URL ao ClientEnv
O host do BFF não pode chegar ao browser. O client só fala com rotas
same-origin /api/*. API_BASE_URL vive apenas server-side
(~/services/api.server lê cf.API_BASE_URL). O docblock de
app/context/env.tsx registra a proibição.
:::
Nota: BRAND_TIMEZONE também é server-only e não faz parte da ClientEnv.
getClientEnvSnapshot()
Para código client que roda fora da árvore React — hoje, os clientLoaders,
que não têm acesso a hooks/context — existe um snapshot módulo-level:
import { getClientEnvSnapshot } from "~/context/env";
const env = getClientEnvSnapshot(); // null antes da hidratação
Populado num effect do EnvProvider, ou seja: só no browser (nunca durante SSR —
sem estado cross-request no worker). Consumidores devem cair num fallback
conservador quando vier null.
Meta function — SEO
A _layout.tsx exporta uma meta function que gera tags SEO abrangentes a partir do BrandConfig:
<title>,<meta name="description">— dados debrand.settings- Open Graph tags (og:title, og:description, og:image, og:url)
- Twitter Card tags
- Favicon e theme-color
- Canonical URL
A fronteira tipada do loader
O tipo de retorno do loader é a fronteira que garante o SSR auth-agnóstico — nenhum campo derivado do usuário logado pode existir aqui:
interface LayoutLoaderData {
brand: BrandConfig | null;
brandError: BrandError | null;
clientEnv: ClientEnv;
serverIsMobile: boolean;
/** Promise NÃO aguardada — streamed via <Await>/<Suspense> no footer */
legalTerms: Promise<Pick<LegalTerm, "route" | "title">[]>;
/** País da brand pro JSON-LD de Organization */
orgCountry: { alpha2: string; name: string } | null;
}
orgCountry é resolvido no loader de propósito: getCountryByAlpha3 puxa o world
catalog (~70KB) do country-config, e chamá-lo no meta() (que roda também no
client) colocaria isso no bundle de toda página só por dois campos.
shouldRevalidate
O _layout exporta um shouldRevalidate que retorna false em navegação SPA
cross-page. A motivação original foi remover um round-trip transatlântico: o
loader antes buscava auth (chamada per-user, não cacheável) em paralelo com
brand, na cauda de toda navegação de usuário logado.
Isso é seguro porque brand / legalTerms / clientEnv / serverIsMobile são
estáticos pra sessão inteira (mesmo host, mesmo UA), e porque auth não vem mais do
loader — a reatividade de auth passa 100% por useAuthGate().
Arquivos relevantes
| Arquivo | Papel |
|---|---|
app/services/brand.server.ts | loadBrandConfig() + loadBrandConfigForRequest() |
app/services/api.server.ts | createClient() — ApiClient SSR |
app/routes/_layout.tsx | Loader (brand + clientEnv + legalTerms) + meta + shouldRevalidate |
app/routes/__tests__/no-user-data-in-ssr.test.ts | Leak canary — falha se auth voltar ao loader |
app/context/brand.tsx | BrandProvider + useBrand() |
app/context/env.tsx | EnvProvider + useClientEnv() + getClientEnvSnapshot() |
app/context/device.tsx | DeviceProvider + useIsLowEnd() |
app/context/component-variants.tsx | ComponentVariantsProvider |
app/context/country.tsx | CountryProvider + useCountry() |
app/context/i18n.tsx | TranslationProvider |
app/layouts/DefaultLayout.tsx | Monta cadeia de providers |
app/components/common/BrandErrorPage.tsx | Página de erro quando brand falha |
Docs relacionadas
- Fluxo de autenticação — por que
authsaiu do loader - Cache architecture — TTLs do
brandConfig - Multi-tenancy e países —
BRAND_COUNTRYalpha-3 - Trunks de marca