Layout Composition System
O front-web-base usa um sistema de composição de layout que permite a cada brand escolher granularmente quais componentes de slot usar (header, sidebar, footer, coluna direita) — sem duplicar o DefaultLayout.tsx e sem criar forks de código para cada variação visual.
Problema resolvido
Antes do sistema de composição, cada brand que precisasse de um layout diferente teria que:
- Copiar o
DefaultLayout.tsxinteiro e modificá-lo - Manter essa cópia sincronizada com atualizações do template base
- Repetir o processo para cada slot que quisesse customizar
Com o sistema de composição, um brand configura apenas o que muda, e o DefaultLayout.tsx orquestra automaticamente com os componentes corretos.
Arquitetura
Quatro camadas independentes
A composição visual do layout existe em 4 camadas ortogonais — trocar uma não invalida as outras:
- Shell (
layoutConfig.shell) — macro-arrangement do viewport. Define se o header sticky fica full-width acima de tudo, ou se a sidebar fica full-height à esquerda com header+main empilhados na direita. Hoje:"header-top"(default),"split-shell"(replica do7k.bet.brprod). - Structure (
layoutConfig.structure) — colunas internas (main-only/left-main/left-main-right/main-right) + flags on/off (header,footer,topbarNotification,mobileBottomNav). - Slots (
layoutConfig.slots) — qual variant de cada peça (header, headerSecondary, sidebar, footer, rightPanel, banner) está ativa. - Component variants (
layoutConfig.componentVariants) — variants globais de primitivas compartilhadas (ex:sectionTitle).
Brand Fork
└── app/config/layout/composition.ts ← o que o brand define (muda por fork)
↓ defineLayoutConfig() deep merge
app/layouts/layout.defaults.ts ← defaults do base (nunca overrideable)
↓ shell + slots + componentVariants resolvem chaves
app/layouts/shells/registry.ts ← shell macro-arrangement
app/layouts/layout-registry.ts ← catálogo: string key → componente de slot
app/components/section-title/registry.ts ← variants globais de primitivas
↓ renderiza
app/layouts/shells/<Shell>.tsx ← macro layout (HeaderTopShell, SplitShell)
app/layouts/variants/<slot>/<Nome>.tsx ← componentes concretos de slot
Configuração por brand
O defineLayoutConfig(overrides) recebe um objeto parcial e faz deep merge com os defaults. O brand só precisa especificar o que diferencia da configuração base.
// app/layouts/layout.defaults.ts
export const layoutDefaults: LayoutConfig = {
shell: "header-top",
structure: { columns: "left-main" },
slots: {
header: "header-default",
headerSecondary: null,
sidebar: "sidebar-narrow",
footer: "footer-default",
},
componentVariants: {
sectionTitle: "section-title-default",
},
sidebar: {
widths: { collapsed: "70px", expanded: "280px" },
},
};
export function defineLayoutConfig(overrides: DeepPartial<LayoutConfig>): LayoutConfig {
return deepMerge(layoutDefaults, overrides);
}
Shell architecture (macro-layout)
Shells vivem em app/layouts/shells/. Cada um implementa o contrato ShellProps (topbar, header, sidebar, rightPanel, footer, refs, mobile state, sports flags) e é resolvido via app/layouts/shells/registry.ts.
app/layouts/shells/
├── shell-props.ts — interface ShellProps (contrato compartilhado)
├── registry.ts — Partial<Record<ShellArchitecture, ShellComponent>>
├── HeaderTopShell.tsx — shell default (header sticky no topo, sidebar+main embaixo)
├── SplitShell.tsx — shell 7k (sidebar full-height, header+main à direita)
└── MainContent.tsx — helper compartilhado (sports flags + footer placement)
Como adicionar shell novo: drop <NewShell>.tsx em app/layouts/shells/ implementando ShellProps, registra em registry.ts, estende ShellArchitecture em app/types/layout.ts. Brand opt-in via shell: "<key>". Slots, columns, e variants continuam funcionando dentro do novo shell.
Mobile é universal: independente do shell, sidebar vira drawer overlay e header sticky no topo. Shell só altera comportamento em lg+.
Sidebar variants são shell-agnósticas. Consomem CSS custom properties publicadas pelo shell ativo:
| Var | header-top | split-shell |
|---|---|---|
--sidebar-top | var(--header-h) | 0 |
--sidebar-h | calc(100dvh - var(--header-h) - var(--topbar-visible-h)) | 100dvh |
--sidebar-w-collapsed / --sidebar-w-expanded | brand-level (de layoutConfig.sidebar.widths) | idem |
Variant não conhece o shell ativo nem os widths brand — só lê os vars. Permite criar shells novos sem refactorar sidebar.
Auxiliary widgets escapam o shell via position: fixed: BackToTop, BottomNotification, LastGameFloatingWidget, UpdateBanner, MobileBottomNav, modais (auth/deposit/kyc), UserPanel slide-out, fullscreen game iframe. Independem do shell ativo.
HeaderSecondary slot — barra contextual abaixo do header sticky
Quando a brand precisa de "barra principal sticky + faixa de navegação logo abaixo, em fluxo normal", a navegação não deve ser empurrada pra baixo dentro do mesmo componente de header. Se for, a faixa secundária vira sticky junto porque o shell mede a zona inteira em data-layout-header.
Convenção:
slots.headercontinua sendo a barra sticky principal.slots.headerSecondaryé a faixa logo abaixo do header, renderizada fora da zona sticky.- A navegação compartilhada vive em
app/components/layout/HeaderNav.tsxcomappearance: "pill" | "tab" | "button". - Variants ficam em
app/layouts/variants/header-secondary/(ex:HeaderSecondaryNavButtons.tsx). - Conteúdo contextual (Slots/Live/Crash Games em rota cassino, esportes em rota sports, etc.) é resolvido por
app/layouts/variants/header-secondary/resolve.tslendoapp/config/layout/header-secondary-nav.ts. - Config aceita 3 fontes:
static(link declarado direto),casino-sidebar(reusa item decasinoItemspori18nKey),sports-sidebar(reusa item desportsMainItems/topSportsItemsporslug).
Combinações comuns:
header-default+headerSecondary: null→ comportamento históricoheader-stacked+header-secondary-nav-buttons→ padrão 7k legado
HeaderStacked.tsx é uma casca genérica em cima do HeaderDefault: sem nav inline e sem logo desktop, mas mantém logo mobile.
Component variants — primitivas compartilhadas brand-globais
Algumas primitivas (hoje só SectionTitle) têm variants escolhidos globalmente por brand, não por consumer individual. Vivem em layoutConfig.componentVariants:
componentVariants: {
sectionTitle: "section-title-default" | "section-title-gradient" | "section-title-panel"
}
A primitive SectionTitle em app/components/section-title/ concentra shell, ícone, heading semântico (h2/h3), setas de carousel e CTA de "ver todos". Headers de rows/widgets que seguem essa família visual delegam pra SectionTitle em vez de criar markup bespoke. Exceções por seção ficam em app/config/widgets/section-titles.ts (defaults + byId por row.slug ou id sintético).
Naming: variants usam nome de padrão visual, não de brand (ex: SectionTitleGradient / section-title-gradient, NÃO SectionTitle7K). Reusável por qualquer brand futura.
Registry central
O registry mapeia cada string key para o componente React correspondente. Ao adicionar uma nova variante, basta registrá-la aqui.
// app/layouts/layout-registry.ts
export const layoutRegistry = {
header: {
"header-default": HeaderDefault,
"header-vera": HeaderVera,
},
sidebar: {
"sidebar-narrow": SidebarNarrow,
"sidebar-wide": SidebarWide,
},
footer: {
"footer-default": FooterDefault,
},
rightPanel: {
"right-panel-winners": RightPanelWinners,
},
} satisfies LayoutRegistry;
DefaultLayout como orchestrator
O DefaultLayout.tsx lê o layoutConfig do context e resolve os componentes via registry:
// app/layouts/DefaultLayout.tsx (simplificado)
const { slots, structure } = useLayoutConfig();
const Header = layoutRegistry.header[slots.header];
const Sidebar = layoutRegistry.sidebar[slots.sidebar];
const Footer = layoutRegistry.footer[slots.footer];
const RightPanel = slots.rightPanel ? layoutRegistry.rightPanel[slots.rightPanel] : null;
Estrutura de pastas
app/
├── config/
│ └── layout.config.ts ← brand-editável (overrideable)
├── layouts/
│ ├── DefaultLayout.tsx ← orchestrator (não overrideable)
│ ├── layout.defaults.ts ← defaults + defineLayoutConfig()
│ ├── layout-registry.ts ← catálogo de variantes
│ ├── topbar/
│ │ └── handlers.ts ← lógica de handlers de topbar notifications
│ └── variants/
│ ├── header/
│ │ ├── HeaderDefault.tsx
│ │ └── HeaderVera.tsx ← placeholder vera-bet
│ ├── sidebar/
│ │ ├── SidebarNarrow.tsx
│ │ └── SidebarWide.tsx
│ ├── footer/
│ │ └── FooterDefault.tsx
│ └── right-panel/
│ └── RightPanelWinners.tsx
└── types/
└── layout.ts ← LayoutConfig, ColumnLayout, *VariantKey
Regra importante: app/layouts/ e app/types/layout.ts são maquinário estrutural — nunca fazem parte da whitelist de overrides. Brands customizam apenas app/config/layout/composition.ts.
Como adicionar uma nova variante
1. Criar o componente
// app/layouts/variants/header/HeaderCustom.tsx
export function HeaderCustom() {
// implementação da nova variante de header
return <header>...</header>;
}
2. Adicionar a chave de tipo
// app/types/layout.ts
export type HeaderVariantKey =
| "header-default"
| "header-vera"
| "header-custom"; // ← nova chave
3. Registrar no registry
// app/layouts/layout-registry.ts
import { HeaderCustom } from "./variants/header/HeaderCustom";
export const layoutRegistry = {
header: {
"header-default": HeaderDefault,
"header-vera": HeaderVera,
"header-custom": HeaderCustom, // ← novo registro
},
// ...
};
4. Usar no brand
// overrides/<brand-key>/app/config/layout/composition.ts
import { defineLayoutConfig } from "~/layouts/layout.defaults";
export const layoutConfig = defineLayoutConfig({
slots: {
header: "header-custom",
},
});
Como configurar o layout de um brand
O brand precisa criar um arquivo de override em:
overrides/<brand-key>/app/config/layout/composition.ts
O arquivo deve usar defineLayoutConfig e especificar apenas o que diferencia da configuração padrão:
import { defineLayoutConfig } from "~/layouts/layout.defaults";
// Exemplo: vera-bet usa layout com três colunas e header customizado
export const layoutConfig = defineLayoutConfig({
structure: { columns: "left-main-right" },
slots: {
header: "header-vera",
sidebar: "sidebar-wide",
rightPanel: "right-panel-winners",
},
});
O Vite plugin de overrides redireciona automaticamente ~/config/layout.config para o arquivo da brand quando ORIGIN_DOMAIN bate com o <brand-key>.
Exemplo: Vera Bet
O brand vera-bet usa um layout com três colunas (sidebar esquerda + main + painel de vencedores à direita):
┌─────────────┬───────────────────────────┬──────────────┐
│ │ Header Vera │ │
├─────────────┤ │ │
│ │ main (flex-1) │ Right Panel │
│ Sidebar │ │ (Winners) │
│ Wide │ {children} │ │
│ │ │ │
└─────────────┴───────────────────────────┴──────────────┘
Config: structure.columns = "left-main-right", slots.rightPanel = "right-panel-winners"
Widgets — UI opcional embebida em slots
Além dos slots estruturais (header, sidebar, footer, banner, right-panel), o template tem um conceito paralelo de widgets: blocos de UI que a brand pode ligar/desligar e parametrizar via app/config/widgets/<nome>.ts. Cada widget tem seu próprio variant (visual) + items[] (conteúdo) + opções específicas.
A diferença com os slots:
- Slot (
composition.ts) decide o esqueleto da página — header sempre existe, sidebar sempre existe, etc. - Widget (
widgets/<nome>.ts) é opcional — brand definevariant: nullpra desligar inteiro, ou injeta items/cores.
Widgets atuais
| Widget | Config | Componente | Função |
|---|---|---|---|
| Sidebar Buttons | app/config/widgets/sidebar-buttons.ts | app/widgets/sidebar-buttons/ | Pilha de CTAs no rail da sidebar (referral, tournaments, missions, etc.) |
| Top Games | app/config/widgets/top-games.ts | app/widgets/top-games/ | Row "Pagando Muito" — cards numerados de jogos. Variants: outline-sideways, showcase, corner-diagonal (legado 7k) |
| Home Banner | app/config/widgets/home-banner.ts | app/widgets/home-banner/ | Hero da home. Variants: single-fade (Slideshow), multi-scroll (Carousel rail) |
| Topbar Notifications | app/config/widgets/topbar-notifications.ts | app/widgets/topbar-notifications/ | Barra rotativa no topo (download app, telegram, etc.) |
| Bottom Notifications | app/config/widgets/bottom-notifications.ts | app/widgets/bottom-notifications/ | CTA flutuante mobile no fundo |
| Campaign Widget | app/config/widgets/campaign-widget.ts | app/widgets/campaign-widget/ | Presentinho flutuante de campanha |
| Home Leagues | app/config/widgets/home-leagues.ts | app/widgets/home-leagues/ | Row de ligas esportivas na home. Variants: default (round avatars), square (104×144 cards do legado 7k) |
Sidebar Buttons widget — pattern canônico
O sidebar-buttons é o widget mais elaborado e serve de referência pro pattern de widgets configuráveis no projeto. Tem 3 variants visuais e um catálogo typed de "intents":
// app/types/sidebar-buttons.ts
export type SidebarButtonsVariantKey = "colored" | "gradient" | "grid";
export type SidebarButtonIntent =
| "casino" | "sports" | "tournaments" | "missions"
| "referral" | "rewards" | "mini-games" | "promotions";
export interface SidebarButtonsConfig {
variant: SidebarButtonsVariantKey | null; // null = widget desliga
items: SidebarButtonItem[];
decoration?: string; // SVG watermark (só usado pelo variant `grid`)
}
Visual variants:
| Variant | Look | Quando usar |
|---|---|---|
colored | Pilha vertical de pills full-width com gradient diagonal + sublabel opcional ("Participe dos / Torneios") | Default do base — destaque alto, mostra bônus dinâmico no referral |
gradient | Pills compactas (rounded-lg, p-2) com gradient horizontal accent → muted, ícone Lucide + chevron | Estilo 7K — clean, integrado com o resto da nav |
grid | 3-col grid de tiles com 3D illustrations + decoration SVG | Estilo Vera — visual rico, requer assets PNG |
Items podem ser:
- Catalog (
{ intent: "tournaments" }) — typed enum, rota canônica protegida, label/icon defaults do core - Custom (
{ custom: true, label, href, ... }) — escape hatch pra CTAs únicos da brand - Special (
{ type: "sponsor-cta" }) — componente dedicado pra dados que não cabem em config estático (ex: sponsor com srcSet)
Brand controla cores per-item:
// overrides/<brand>/app/config/widgets/sidebar-buttons.ts
{
intent: "referral",
iconType: "lucide",
icon: Users,
gradient: ["#FF4606", "#13051c"], // accent → sidebar bg
}
Tres tiers de prioridade (do mais específico pro mais genérico):
gradient: [from, to]— full override de ambos stopscolor: "#hex"— só accent, end usa fallback do variant- Nenhum — variant usa o default brand-aware via Tailwind theme tokens (
from-sidebar-button-bg to-sidebar-bgno gradient variant;from-bg-secondary to-bg-primaryno grid)
Por que matiz constante no fade-to-near-black: o pattern [medium-tone, near-black-same-hue] (ex: ["#761821", "#280505"] red wine) cria depth sem virar uma transição cor-pra-cor dissonante. Mantém só luminosidade caindo (~45% → ~7%), preservando identidade.
Brand-aware via theme tokens (default): se o brand não passa gradient/color, o variant usa Tailwind classes que puxam do tema do brand (bg-sidebar-button-bg, bg-sidebar-bg). Cada brand vê o widget pintado nas suas próprias cores automaticamente — sem precisar override per-item.
⚠️ Gotcha de Tailwind
important: true: este projeto usaimportant: truenotailwind.config.js, que adiciona!importantem toda utility class. Isso inverte a precedência normal entre inline style e class. Os variants resolvem isso emitindo a classe Tailwind do default somente quandodata.gradient/data.colornão foram setados — quando há override, a classe é omitida e o inline style ganha por padrão. VejathemeBgClassemSidebarButtonsGradient.tsx/SidebarButtonsGrid.tsx.
Estrutura app/widgets/
Paralelo a app/layouts/, mas pra widgets opcionais:
app/widgets/
├── sidebar-buttons/ ← NOVO widget pattern
│ ├── SidebarButtons.tsx ← dispatcher (lê config + delega)
│ ├── SidebarButtonsColored.tsx ← variant component
│ ├── SidebarButtonsGradient.tsx ← variant component
│ ├── SidebarButtonsGrid.tsx ← variant component
│ ├── intents.ts ← INTENT_DEFAULTS (catalog)
│ ├── resolve.ts ← helper resolve item → flat shape
│ ├── registry.ts ← variant key → component
│ ├── referral-helpers.ts ← hooks compartilhados
│ ├── specials/ ← componentes dedicados (sponsor-cta)
│ └── __tests__/ ← unit tests
├── topbar-notifications/ ← migrado de app/components/layout/
├── bottom-notifications/ ← idem
├── campaign-widget/ ← migrado de app/components/campaign/
└── home-leagues/ ← migrado de app/components/sports/
Domain primitives — variants in-place, config compartilhado
Componentes que são primitivas de domínio (usados em N superfícies do produto, ex: GameCard, ProviderCard) podem ganhar variant system + config sem sair do folder de domínio. Reserve app/widgets/ pra widgets de página opcionais (sidebar-buttons, top-games, topbar-notifications). Pra primitivas de domínio:
- Componente fica onde está (ex:
app/components/games/GameCard.tsx). - Variants ao lado do dispatcher (
<Variant>.tsxno mesmo folder) +<nome>-registry.ts. - Tipos centralizados em
app/types/<nome>.ts. - Config canônica em
app/config/widgets/<nome>.ts— mesmo que o componente não seja "widget", o folderwidgets/é o pattern do projeto pra "componente UI configurável". - Brand override em
overrides/<brand>/app/config/widgets/<nome>.ts.
Isso preserva imports existentes em consumidores múltiplos (mover quebraria N arquivos sem ganho). Exemplos shipped:
| Primitive | Folder | Variants | Config |
|---|---|---|---|
GameCard | app/components/games/ | classic (default), stacked (legado 7k pill) | app/config/widgets/game-card.ts |
ProviderCard | app/components/home/ | classic (default), logo-only (legado 7k card wider-than-tall) | app/config/widgets/provider-card.ts |
GameStats / GameWinners | app/components/games/ | sem variants — só campo decoration por brand | app/config/widgets/game-stats-card.ts + game-winner-card.ts |
Asset configurável > CSS-only pra decoração brandizada
Quando o stakeholder espera elemento decorativo brand-specific (logo recortado, watermark, ilustração), o componente expõe campo decoration no config widget:
type WidgetDecoration =
| { kind: "none" }
| { kind: "asset"; src: string; alt?: string };
Variant renderiza <img> quando kind === "asset". Cada brand cria asset em overrides/<brand>/public/assets/widgets/<nome>/decoration.<ext>. Default = none.
Não usar CSS-only (clip-path, linear-gradient em pseudo-element) pra reproduzir decorações que o stakeholder espera ver brandizadas — limita reuse cross-brand. Pattern canônico em app/types/top-games.ts (TopGamesDecoration); replicado em game-stats-card, game-winner-card.
Lógica compartilhada entre variants → util
Quando duas variants de uma primitiva compartilham resolução não-trivial (ex: chain de fallback de stats no GameCard), extrai pra um util ao lado: <componente>-balloon.ts, <componente>-resolve.ts, etc. Util fica no mesmo folder do componente. Evita duplicação e drift entre variants.
File-replacement — regra de replicação em overrides
O brandOverridesPlugin do Vite faz file-replacement, não deep-merge — quando overrides/<brand>/app/config/<dir>/X.ts existe, ele substitui inteiro o arquivo do base. Não há merge campo-a-campo.
Consequência prática: sempre que uma refac mudar o shape de uma config overridable (adicionar/remover/renomear campo, trocar tipo, mudar enum), aplicar a mudança em todos os overrides que a brand tiver do arquivo em questão. Senão, brands com override antigo:
- Vão quebrar em runtime (campo agora obrigatório, override não tem)
- Ou ficar com default silencioso (campo opcional, override não tem →
undefinedem runtime)
Checklist antes de fechar uma refac de config:
# 1. Encontrar overrides que mencionam o campo antigo/novo
grep -rn "nomeDoCampo" overrides/ app/config/
# 2. Garantir que nenhum ficou desalinhado
pnpm typecheck:ci && pnpm quality
Se a mudança é deletar um campo e o valor era igual ao default em todas as brands, pode-se propor remover o override inteiro (brand herda default) — mas é decisão do dev, não unilateral.
Regra simples: quando criar campo novo num config com overrides, copia o default pra todos os overrides mesmo quando o valor bate com o default. Mantém explícito e evita surpresa quando o default mudar no futuro.
Separação de responsabilidades
| Pasta | Overrideable | Propósito |
|---|---|---|
app/config/ | ✅ Sim | Brand config — o que varia por fork. Subdividida em 14 subpastas semânticas: theme/, layout/, widgets/, sections/, catalog/, routes/, analytics/, features/, scripts/, legal/, seo/, payments/, gamification/, sports/, referral/, content/ |
app/layouts/ | ❌ Não | Maquinário estrutural dos slots (header, sidebar, footer, banner, right-panel) |
app/widgets/ | ❌ Não | Componentes dos widgets opcionais embebidos. Brand controla via app/config/widgets/<nome>.ts |
app/router/ | ❌ Não | Registro de rotas (brands customizam via app/config/routes/paths.ts) |
app/types/ | ❌ Não | Tipos compartilhados — configs e overrides importam daqui |
Sidebar variants disponíveis
| Key | Look | Quando usar |
|---|---|---|
sidebar-narrow | Default. Sections "flat" (label + chevron + items abaixo, dividers entre sections) | Maioria das brands |
sidebar-wide | Delega pra narrow mas com widths maiores | Brands com sidebar protagonista (ex: Vera) |
sidebar-accordion | Sections como accordions (header clicável + chevron rotacional + body smooth-height via grid-template-rows: 0fr → 1fr). Items em pill gradient (bg-gradient-to-r from-sidebar-button-bg to-sidebar-bg). Comportamento route-aware: rota cassino abre casino + fecha sports; rota sports faz o inverso; home preserva escolha manual | Brands com look 7k legado (cl-bet7k-com, 7k-bet-br) |
Lógica de data layer (sections, sportsNavItems, popularItems, topSportsNavItems, restrictedAccountSection) é compartilhada via hook useSidebarSections (app/layouts/variants/sidebar/hooks/useSidebarSections.ts). Items abaixo das sections (promotions, support, blog, FAQ, install-app, telegram, affiliates, responsible-gaming, debug) são compartilhados via componente <SidebarBottomItems style="default" | "pill" />.
Documentação relacionada
- Architecture — Icon system —
unplugin-icons+ Iconify (lucide / simple-icons / mdi) + custom SVGs - Architecture — Performance PSI — checklist de LCP / FCP / CLS / TBT pra mudanças above-the-fold
- Template — Layout — comportamento dos componentes de variante (header, sidebar, footer)
- Pontos de Customização Rápida — como configurar o
composition.tsem um fork - Arquitetura — app/config — o que pode e não pode entrar em
app/config/