Pular para o conteúdo principal

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

CamadaArquivoResponsabilidade
Config brand-editávelapp/config/layout/composition.tsEscolhe shell, estrutura, slots e variantes de componente
Defaults + helperapp/layouts/layout.defaults.tsValores default do base + defineLayoutConfig() (deep merge)
Shellsapp/layouts/shells/Macro-arquitetura do viewport: HeaderTopShell, SplitShell, MainContent, registry.ts, shell-props.ts
Orchestratorapp/layouts/DefaultLayout.tsxProviders, initializers, dispatch do shell, modais
Catálogo centralapp/layouts/layout-registry.tsMapa string key → componente (para os slots eager)
Variantesapp/layouts/variants/<slot>/Componentes de variante reutilizáveis
Tiposapp/types/layout.tsLayoutConfig, 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.

shellComponenteDescrição
"header-top" (default)HeaderTopShellHeader full-width no topo; sidebar e main abaixo
"split-shell"SplitShellSidebar 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 em max-w-content mx-auto w-full (limite vindo de ~/config/theme/sizes.ts). Só tem efeito no header-top; o split-shell ignora.
  • footerFullWidth?: booleantrue renderiza 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

ChaveComponenteEmbute input de busca?
header-defaultHeaderDefault.tsxNão
header-stackedHeaderStacked.tsxSim

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

ChaveComponente
header-secondary-nav-buttonsHeaderSecondaryNavButtons.tsx
ChaveComponente
sidebar-narrowSidebarNarrow.tsx
sidebar-accordionSidebarAccordion.tsx
sidebar-tabbedSidebarTabbed.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.

ChaveComponente
footer-defaultFooterDefault.tsx
footer-stackedFooterStacked.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

ChaveComponente
mobile-nav-flatMobileBottomNavFlat.tsx
mobile-nav-fab-logoMobileBottomNavFabLogo.tsx

Os itens são configurados separadamente em ~/config/layout/bottomnav (brand-overridable).

SlotSituação
bannerBannerVariantKey = "banner-medium", mas bannerRegistry está vazio e o default é null — o base nunca indexa o registry
rightPanelRightPanelVariantKey = 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

ChaveValores
gameCardcard-default
bannerSlideslide-default
sectionTitlesection-title-default, section-title-gradient
ctaButtoncta-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:

InitializerCarregamentoFunção
AuthInitializereagerReidrata auth 100% client-side (/api/auth/profile), sincroniza validações e favoritos
AnalyticsInitializereagerBootstrap de analytics
AnaLayerInitializereagerBootstrap do subsistema AnaLayer
WalletInitializerlazySó monta para usuário autenticado
SmarticoInitializerlazy~600 linhas fora do chunk do _layout. O hash vem de useSmarticoHashStore (cookie gm_id), não de loaderData
LiveSupportInitializerlazyWidget 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.

ComponenteCarregamentoTrigger
AuthModalseager (SSR)Login, registro, recuperação
LazyValidationBlockerOverlaylazyUsuário sem validação completa tenta ação protegida
LazyValidationStepsModallazySteps de validação (e-mail, SMS, docs, endereço, nome)
LazyPasswordValidationModallazyConfirmação de senha para ações sensíveis
LazyKycModal + LazyKycSdkOrchestratorlazyKYC (iframe ou SDK por operador)
LazyPaymentModalslazyDepósito, saque, PIX
LazyCampaignWidget / LazyStrategyOverlaylazyCampanhas e estratégias
LazyPreGameDrawerlazyGateado por featuresConfig.preGameDrawer
LazyUserPanel / LazySideSheetRoot / LazyGameAssistantlazyPainel do usuário, side-sheets, assistente in-game
LazyBackToTop / LazyBottomNotification / LazyLastGameFloatingWidgetlazyWidgets flutuantes (ordenados pelo floatingStack — ver State Management)
SearchKeyboardShortcuteagerCtrl/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.

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 mapa INTENT_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 de image/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:

  1. Criar o componente em app/layouts/variants/<slot>/<NomeVariante>.tsx
  2. Adicionar a chave ao union em app/types/layout.ts
  3. Registrar: no layout-registry.ts (header, header-secondary, sidebar) ou no mapa de lazy loader correspondente (footer, mobile-bottom-nav)
  4. Usar a chave nova no composition.ts da 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. :::