Layout
O layout do front-web-base é um sistema de composição de variantes. Cada brand escolhe o shell,
a estrutura e quais componentes de slot (header, sidebar, footer, …) quer usar em
app/config/layout/composition.ts, sem reescrever o DefaultLayout.tsx.
Sistema de Composição
Camadas
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| Config brand-editável | app/config/layout/composition.ts | Escolhe shell, estrutura, slots e variantes de componente |
| Defaults + helper | app/layouts/layout.defaults.ts | Valores default do base + defineLayoutConfig() (deep merge) |
| Shells | app/layouts/shells/ | Macro-arquitetura do viewport: HeaderTopShell, SplitShell, MainContent, registry.ts, shell-props.ts |
| Orchestrator | app/layouts/DefaultLayout.tsx | Providers, initializers, dispatch do shell, modais |
| Catálogo central | app/layouts/layout-registry.ts | Mapa string key → componente (para os slots eager) |
| Variantes | app/layouts/variants/<slot>/ | Componentes de variante reutilizáveis |
| Tipos | app/types/layout.ts | LayoutConfig, ShellArchitecture, ColumnLayout, *VariantKey |
Shells
O shell decide como header, sidebar e main se empilham no nível do viewport. Decisões internas
(colunas, qual header, etc.) continuam em structure/slots.
shell | Componente | Descrição |
|---|---|---|
"header-top" (default) | HeaderTopShell | Header full-width no topo; sidebar e main abaixo |
"split-shell" | SplitShell | Sidebar full-height à esquerda do viewport; topbar + header + main + footer empilhados na coluna direita |
Mobile é unificado: independente do shell, a sidebar vira drawer overlay e o header fica no topo. O
shell só muda o layout em lg+. resolveShell() cai em header-top quando a chave não está
registrada — isso mantém o layout seguro durante rollouts parciais.
Estrutura do LayoutConfig
// app/types/layout.ts
export type ShellArchitecture = "header-top" | "split-shell";
export type ColumnLayout = "main-only" | "left-main" | "left-main-right" | "main-right";
export interface LayoutConfig {
shell?: ShellArchitecture; // default "header-top"
structure: LayoutStructureConfig;
slots: LayoutSlotsConfig;
componentVariants: ComponentVariantsConfig;
sidebar: SidebarLayoutConfig; // widths + scrollBehavior
}
export interface LayoutSlotsConfig {
header: HeaderVariantKey;
headerSecondary: HeaderSecondaryVariantKey | null;
sidebar: SidebarVariantKey | null;
rightPanel: RightPanelVariantKey | null; // hoje só pode ser null
footer: FooterVariantKey;
banner: BannerVariantKey | null;
mobileBottomNav: MobileBottomNavVariantKey | null; // null desliga a barra
}
export interface ComponentVariantsConfig {
gameCard: GameCardVariantKey;
bannerSlide: BannerSlideVariantKey;
sectionTitle: SectionTitleVariantKey;
ctaButton: CtaButtonVariantKey;
}
LayoutStructureConfig carrega columns, os booleanos header / footer /
topbarNotification, dois flags de comportamento do slot secundário em rotas de esportes
(headerSecondaryInsideHeaderOnSports, hideHeaderSecondaryOnSports) e dois de largura:
contentContainer?: "full" | "contained"—"full"(default) faz a linha sidebar+main+rightPanel ocupar o viewport inteiro;"contained"envelopa emmax-w-content mx-auto w-full(limite vindo de~/config/theme/sizes.ts). Só tem efeito noheader-top; osplit-shellignora.footerFullWidth?: boolean—truerenderiza o footer como irmão da linha (full-width no viewport) em vez de último filho do<main>.
SidebarLayoutConfig traz widths: { collapsed, expanded } — strings de CSS length publicadas pelo
shell como custom properties — e scrollBehavior?: "sticky" | "page".
Variantes disponíveis
Header
| Chave | Componente | Embute input de busca? |
|---|---|---|
header-default | HeaderDefault.tsx | Não |
header-stacked | HeaderStacked.tsx | Sim |
headerEmbedsSearchInput (em layout-registry.ts) é um Record<HeaderVariantKey, boolean>: quando
true, a rota /search pula o input próprio no desktop e deixa o HeaderSearchInput dirigir a
store de busca. Mobile sempre renderiza o input in-page. Por ser um Record completo, adicionar
uma variante de header é erro de compilação até a decisão ser registrada ali.
Header secondary
| Chave | Componente |
|---|---|
header-secondary-nav-buttons | HeaderSecondaryNavButtons.tsx |
Sidebar
| Chave | Componente |
|---|---|
sidebar-narrow | SidebarNarrow.tsx |
sidebar-accordion | SidebarAccordion.tsx |
sidebar-tabbed | SidebarTabbed.tsx |
app/layouts/variants/sidebar/ também tem partes compartilhadas (SidebarSection,
SidebarPillItem, SidebarIconTile, SidebarPromoBanner, SidebarBottomItems,
SidebarFloatingExtras, useSidebarPositioning) e um SidebarWide.tsx que não está registrado
em sidebarRegistry nem declarado em SidebarVariantKey — não é selecionável por config hoje.
Footer
| Chave | Componente |
|---|---|
footer-default | FooterDefault.tsx |
footer-stacked | FooterStacked.tsx |
:::info Footer e mobile-bottom-nav não estão no registry
São resolvidos por mapas de lazy loader por variante (footerLazyLoaders no DefaultLayout.tsx,
MobileBottomNavDispatcher.tsx) para que cada brand baixe só o chunk da variante ativa. Um registry
eager forçaria o Rollup a empacotar todas as variantes como dependência estática do _layout. A
segurança em tempo de compilação vem do próprio tipo (Record<FooterVariantKey, …>).
:::
Mobile bottom nav
| Chave | Componente |
|---|---|
mobile-nav-flat | MobileBottomNavFlat.tsx |
mobile-nav-fab-logo | MobileBottomNavFabLogo.tsx |
Os itens são configurados separadamente em ~/config/layout/bottomnav (brand-overridable).
Banner e right panel
| Slot | Situação |
|---|---|
banner | BannerVariantKey = "banner-medium", mas bannerRegistry está vazio e o default é null — o base nunca indexa o registry |
rightPanel | RightPanelVariantKey = never — o slot só aceita null. A variante legada right-panel-winners foi removida em 2026-05-03 e o caso de uso (coluna de maiores ganhos) migrou para dentro do fluxo da home, não mais como slot do shell. A pasta app/layouts/variants/right-panel/ não existe |
:::caution Invariante do registry
O DefaultLayout só lê de um registry quando o slot correspondente não é null. É por isso que
registries vazios são seguros.
:::
Variantes de componente
| Chave | Valores |
|---|---|
gameCard | card-default |
bannerSlide | slide-default |
sectionTitle | section-title-default, section-title-gradient |
ctaButton | cta-flat (default, brand-aware por tokens), cta-signature (gradiente diagonal + glow, cores derivadas de button-bg/button-text via color-mix()), cta-secondary |
cta-secondary não é uma variante global de brand: é aplicada por call-site com
<CtaButton variantOverride="cta-secondary">, para ações secundárias (Entrar ao lado de Cadastrar,
"Saiba mais" ao lado de "Jogar", cancelar). O visual é autocontido, então combina com qualquer
primário sólido.
Provider chain
Ordem real de aninhamento no DefaultLayout.tsx:
EnvProvider
└── DeviceProvider (serverIsMobile do loader → fallback SSR)
└── BrandProvider
└── CountryProvider
└── TranslationProvider
└── ComponentVariantsProvider
└── AnaLayerProvider
└── FtdOfferProvider
└── FtdCheckinAnnouncementProvider
Dentro do provider mais interno rodam os initializers:
| Initializer | Carregamento | Função |
|---|---|---|
AuthInitializer | eager | Reidrata auth 100% client-side (/api/auth/profile), sincroniza validações e favoritos |
AnalyticsInitializer | eager | Bootstrap de analytics |
AnaLayerInitializer | eager | Bootstrap do subsistema AnaLayer |
WalletInitializer | lazy | Só monta para usuário autenticado |
SmarticoInitializer | lazy | ~600 linhas fora do chunk do _layout. O hash vem de useSmarticoHashStore (cookie gm_id), não de loaderData |
LiveSupportInitializer | lazy | Widget de suporte |
Modais e overlays
AuthModals é eager e SSR-renderizado (abertura instantânea), e fica fora do Suspense dos
modais lazy de propósito: qualquer re-render do layout na janela de hidratação forçaria o boundary a
renderizar no client, e com os chunks lazy ainda baixando o fallback null destruiria o DOM SSR do
modal aberto — fechando, reabrindo vazio e perdendo o que o usuário digitou.
| Componente | Carregamento | Trigger |
|---|---|---|
AuthModals | eager (SSR) | Login, registro, recuperação |
LazyValidationBlockerOverlay | lazy | Usuário sem validação completa tenta ação protegida |
LazyValidationStepsModal | lazy | Steps de validação (e-mail, SMS, docs, endereço, nome) |
LazyPasswordValidationModal | lazy | Confirmação de senha para ações sensíveis |
LazyKycModal + LazyKycSdkOrchestrator | lazy | KYC (iframe ou SDK por operador) |
LazyPaymentModals | lazy | Depósito, saque, PIX |
LazyCampaignWidget / LazyStrategyOverlay | lazy | Campanhas e estratégias |
LazyPreGameDrawer | lazy | Gateado por featuresConfig.preGameDrawer |
LazyUserPanel / LazySideSheetRoot / LazyGameAssistant | lazy | Painel do usuário, side-sheets, assistente in-game |
LazyBackToTop / LazyBottomNotification / LazyLastGameFloatingWidget | lazy | Widgets flutuantes (ordenados pelo floatingStack — ver State Management) |
SearchKeyboardShortcut | eager | Ctrl/Cmd+K; não monta para usuário restrito |
Widgets
Além dos slots estruturais, o template tem widgets opcionais que cada brand liga/desliga via
app/config/widgets/<nome>.ts. São 37 arquivos de config hoje, e app/widgets/ tem 17 pastas
de widget + shared/ — o inventário fica em Widgets catalog.
Sidebar Buttons — o widget canônico
CTAs no rail da sidebar. Três variantes visuais (colored, gradient, icon-tiles), um catálogo
fechado de intents (casino, sports, tournaments, missions, referral, rewards,
mini-games, promotions, store) e customItems como escape hatch.
Regras que definem o desenho:
- O catálogo de intents é fechado — adicionar um intent novo é mudança de core (toca
app/types/sidebar-buttons.ts+ o mapaINTENT_DEFAULTS). - Cada intent tem rota canônica resolvida em render time por
routeHref(routeKey), o que respeita o path sobrescrito de cada brand. A brand não pode sobrescrever a rota — só o visual. iconTypeé opcional: o resolver infere pela presença deimage/emoji.
// overrides/<brand>/app/config/widgets/sidebar-buttons.ts
import type { SidebarButtonItem, SidebarButtonsConfig } from "~/types/sidebar-buttons";
import Trophy from "~icons/lucide/trophy";
import Users from "~icons/lucide/users";
const items: SidebarButtonItem[] = [
{
intent: "referral",
iconType: "lucide",
icon: Users,
borderGradient: ["#C2D713", "#1c1e34"],
},
{ intent: "tournaments", iconType: "lucide", icon: Trophy },
{
custom: true,
label: "Mini Games Diários",
iconType: "image",
image: "/assets/mini-games/mini-games-gift-icon.webp",
},
];
export const sidebarButtonsConfig: SidebarButtonsConfig = {
variant: "gradient",
items,
};
:::danger Ícones vêm do unplugin-icons, não do lucide-react
lucide-react não é dependência do projeto (removido em 2026-04-28) — um
import { Users } from "lucide-react" não resolve. Use ~icons/<dataset>/<nome>
(~icons/lucide/users, ~icons/mdi/card-multiple-outline) ou um SVG custom de
app/icons/custom/. O tipo do campo icon é IconComponent (~/types/icon).
:::
Outros campos úteis do item: label, sublabel (só a variante colored renderiza), badge,
color, gradient, borderGradient / accentBorderColor, iconRotation, iconPulse,
requiresAuth, visible, disabled, pendoId.
Detalhes do sistema em Layout Composition.
Override de brand
app/config/layout/composition.ts é o ponto brand-editável. defineLayoutConfig faz deep merge com
os defaults do base — a brand só declara o que muda.
// app/config/layout/composition.ts (base — layout padrão)
import { defineLayoutConfig } from "~/layouts/layout.defaults";
export const layoutConfig = defineLayoutConfig({});
// overrides/<brand-key>/app/config/layout/composition.ts
import { defineLayoutConfig } from "~/layouts/layout.defaults";
export const layoutConfig = defineLayoutConfig({
shell: "split-shell",
structure: {
columns: "left-main",
contentContainer: "contained",
footerFullWidth: true,
},
slots: {
header: "header-stacked",
sidebar: "sidebar-accordion",
footer: "footer-stacked",
mobileBottomNav: "mobile-nav-fab-logo",
},
componentVariants: {
ctaButton: "cta-signature",
sectionTitle: "section-title-gradient",
},
});
Todas as chaves acima existem em app/types/layout.ts — o exemplo compila.
Para criar uma variante nova:
- Criar o componente em
app/layouts/variants/<slot>/<NomeVariante>.tsx - Adicionar a chave ao union em
app/types/layout.ts - Registrar: no
layout-registry.ts(header, header-secondary, sidebar) ou no mapa de lazy loader correspondente (footer, mobile-bottom-nav) - Usar a chave nova no
composition.tsda brand
Para um shell novo: implementar ShellProps em app/layouts/shells/, registrar em
shells/registry.ts e estender ShellArchitecture.
:::danger defineLayoutConfig faz deep merge, mas o override do ARQUIVO não
O brandOverridesPlugin substitui o arquivo inteiro — o deep merge acontece dentro dele, entre o
objeto que a brand passa e os defaults do base. Ao adicionar um campo novo em LayoutConfig,
declare o default em layout.defaults.ts; assim nenhuma brand precisa ser tocada. Ver
Forking — Override Files.
:::