Pular para o conteúdo principal

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âmetroValor
ttlSeconds60 (1 min)
staleWhileRevalidateSeconds300 (5 min)
staleIfErrorSeconds21600 (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|1boolean
  • Country filtering: selectByCountry com 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):

  1. EnvProviderclientEnv (9 campos) via useClientEnv()
  2. DeviceProvider — recebe o hint serverIsMobile (derivado do UA no loader) como fallback de SSR; expõe useIsLowEnd() (ver Performance)
  3. BrandProviderBrandConfig via useBrand(). Recebe brandWithLinkOverrides, não o brand cru: os links de appearance são mesclados com os overrides de link da marca antes de entrar no context
  4. CountryProvider — configuração por país (moeda, locale, validações) via useCountry()
  5. TranslationProvider — setup i18next com namespace e idioma da marca via useTranslation()
  6. ComponentVariantsProvider — variantes de componente do layoutConfig
  7. AnaLayerProvider — camada de analytics
  8. FtdOfferProvider — oferta de primeiro depósito
  9. 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.servercf.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 de brand.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

ArquivoPapel
app/services/brand.server.tsloadBrandConfig() + loadBrandConfigForRequest()
app/services/api.server.tscreateClient() — ApiClient SSR
app/routes/_layout.tsxLoader (brand + clientEnv + legalTerms) + meta + shouldRevalidate
app/routes/__tests__/no-user-data-in-ssr.test.tsLeak canary — falha se auth voltar ao loader
app/context/brand.tsxBrandProvider + useBrand()
app/context/env.tsxEnvProvider + useClientEnv() + getClientEnvSnapshot()
app/context/device.tsxDeviceProvider + useIsLowEnd()
app/context/component-variants.tsxComponentVariantsProvider
app/context/country.tsxCountryProvider + useCountry()
app/context/i18n.tsxTranslationProvider
app/layouts/DefaultLayout.tsxMonta cadeia de providers
app/components/common/BrandErrorPage.tsxPágina de erro quando brand falha

Docs relacionadas