Pular para o conteúdo principal

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:

  1. Copiar o DefaultLayout.tsx inteiro e modificá-lo
  2. Manter essa cópia sincronizada com atualizações do template base
  3. 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:

  1. 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 do 7k.bet.br prod).
  2. Structure (layoutConfig.structure) — colunas internas (main-only / left-main / left-main-right / main-right) + flags on/off (header, footer, topbarNotification, mobileBottomNav).
  3. Slots (layoutConfig.slots) — qual variant de cada peça (header, headerSecondary, sidebar, footer, rightPanel, banner) está ativa.
  4. 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:

Varheader-topsplit-shell
--sidebar-topvar(--header-h)0
--sidebar-hcalc(100dvh - var(--header-h) - var(--topbar-visible-h))100dvh
--sidebar-w-collapsed / --sidebar-w-expandedbrand-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.header continua 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.tsx com appearance: "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.ts lendo app/config/layout/header-secondary-nav.ts.
  • Config aceita 3 fontes: static (link declarado direto), casino-sidebar (reusa item de casinoItems por i18nKey), sports-sidebar (reusa item de sportsMainItems / topSportsItems por slug).

Combinações comuns:

  • header-default + headerSecondary: null → comportamento histórico
  • header-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 define variant: null pra desligar inteiro, ou injeta items/cores.

Widgets atuais

WidgetConfigComponenteFunção
Sidebar Buttonsapp/config/widgets/sidebar-buttons.tsapp/widgets/sidebar-buttons/Pilha de CTAs no rail da sidebar (referral, tournaments, missions, etc.)
Top Gamesapp/config/widgets/top-games.tsapp/widgets/top-games/Row "Pagando Muito" — cards numerados de jogos. Variants: outline-sideways, showcase, corner-diagonal (legado 7k)
Home Bannerapp/config/widgets/home-banner.tsapp/widgets/home-banner/Hero da home. Variants: single-fade (Slideshow), multi-scroll (Carousel rail)
Topbar Notificationsapp/config/widgets/topbar-notifications.tsapp/widgets/topbar-notifications/Barra rotativa no topo (download app, telegram, etc.)
Bottom Notificationsapp/config/widgets/bottom-notifications.tsapp/widgets/bottom-notifications/CTA flutuante mobile no fundo
Campaign Widgetapp/config/widgets/campaign-widget.tsapp/widgets/campaign-widget/Presentinho flutuante de campanha
Home Leaguesapp/config/widgets/home-leagues.tsapp/widgets/home-leagues/Row de ligas esportivas na home. Variants: default (round avatars), square (104×144 cards do legado 7k)

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:

VariantLookQuando usar
coloredPilha 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
gradientPills compactas (rounded-lg, p-2) com gradient horizontal accent → muted, ícone Lucide + chevronEstilo 7K — clean, integrado com o resto da nav
grid3-col grid de tiles com 3D illustrations + decoration SVGEstilo 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):

  1. gradient: [from, to] — full override de ambos stops
  2. color: "#hex" — só accent, end usa fallback do variant
  3. Nenhum — variant usa o default brand-aware via Tailwind theme tokens (from-sidebar-button-bg to-sidebar-bg no gradient variant; from-bg-secondary to-bg-primary no 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 usa important: true no tailwind.config.js, que adiciona !important em 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 quando data.gradient/data.color não foram setados — quando há override, a classe é omitida e o inline style ganha por padrão. Veja themeBgClass em SidebarButtonsGradient.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>.tsx no mesmo folder) + <nome>-registry.ts.
  • Tipos centralizados em app/types/<nome>.ts.
  • Config canônica em app/config/widgets/<nome>.tsmesmo que o componente não seja "widget", o folder widgets/ é 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:

PrimitiveFolderVariantsConfig
GameCardapp/components/games/classic (default), stacked (legado 7k pill)app/config/widgets/game-card.ts
ProviderCardapp/components/home/classic (default), logo-only (legado 7k card wider-than-tall)app/config/widgets/provider-card.ts
GameStats / GameWinnersapp/components/games/sem variants — só campo decoration por brandapp/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 → undefined em 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

PastaOverrideablePropósito
app/config/✅ SimBrand 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ãoMaquinário estrutural dos slots (header, sidebar, footer, banner, right-panel)
app/widgets/❌ NãoComponentes dos widgets opcionais embebidos. Brand controla via app/config/widgets/<nome>.ts
app/router/❌ NãoRegistro de rotas (brands customizam via app/config/routes/paths.ts)
app/types/❌ NãoTipos compartilhados — configs e overrides importam daqui
KeyLookQuando usar
sidebar-narrowDefault. Sections "flat" (label + chevron + items abaixo, dividers entre sections)Maioria das brands
sidebar-wideDelega pra narrow mas com widths maioresBrands com sidebar protagonista (ex: Vera)
sidebar-accordionSections 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 manualBrands 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