Página Concessionárias — Site Público ABRACAF
Página pública da ABRACAF em /abracaf/concessionarias que permite aos visitantes localizar concessionárias Fiat associadas em todo o Brasil. A página combina um mapa Leaflet interativo com dois modos de visualização — proximidade (5 mais próximas via geolocalização) e todas (lista paginada) — e filtros por estado, cidade e texto livre.
Estrutura de arquivos
apps/web/app/[locale]/(extranet_public)/abracaf/concessionarias/
├── page.tsx # RSC — metadata + fetch + entrada
└── _components/
│ ├── concessionarias-page.tsx # 'use client' — orquestrador central
│ ├── concessionarias-hero.tsx # Hero (bg-abc-primary-branding) + ScrollDownButton
│ ├── concessionarias-search-bar.tsx # Busca + selects estado/cidade (flutua sobre o hero)
│ ├── concessionarias-map-modal.tsx # Modal full-screen do mapa (mobile)
│ ├── concessionarias-map-section.tsx # Seção mapa + controles de modo
│ ├── concessionarias-map-wrapper.tsx # next/dynamic ssr:false para o Leaflet
│ ├── concessionarias-map.tsx # 'use client' — mapa Leaflet puro
│ ├── concessionarias-list.tsx # Lista paginada de cards
│ └── concessionarias-card.tsx # Card individual (normal e compact)
└── _hooks/
│ ├── use-concessionarias-filter.ts # Filtro + paginação + nearest5
│ └── use-geolocation.ts # Wrapper de navigator.geolocation
└── _lib/
├── concessionarias.types.ts # Interfaces TypeScript
├── concessionarias.queries.ts # fetch via Supabase (com fallback mock)
├── concessionarias.utils.ts # haversine, filter, sort, estados
└── concessionarias.mock.ts # Dados mock (remover após TEC-53)
Tipos e camada de dados
Tipos principais
// _lib/concessionarias.types.ts
interface Concessionaria {
id: string;
nome: string;
cidade: string;
uf: string; // sigla do estado, ex: "SP"
marcas: string[]; // ex: ["Fiat", "RAM"]
logo_url: string | null;
lat: number;
lng: number;
}
interface UserLocation {
lat: number;
lng: number;
}
type ViewMode = 'proximity' | 'all';
Fetch de dados
fetchConcessionarias() em _lib/concessionarias.queries.ts é chamada no Server Component page.tsx. Ela consulta a tabela concessionarias filtrando ativa = true e ordena por nome. Em caso de erro ou de resultado vazio, retorna MOCK_CONCESSIONARIAS como fallback.
// _lib/concessionarias.queries.ts
const { data, error } = await supabase
.from('concessionarias')
.select('id, nome, cidade, uf, marcas, logo_url, lat, lng')
.eq('ativa', true)
.order('nome');
// Fallback automático se error ou data vazio:
return result.length > 0 ? result : MOCK_CONCESSIONARIAS;
A query usa 'concessionarias' as never para silenciar o TypeScript — a tabela real ainda não foi criada. O retorno sempre cai no mock até que a migration do TEC-53 seja aplicada e o seed real da ABRACAF seja inserido.
Schema esperado da tabela
Quando a tabela for criada (TEC-53), ela deve ter ao menos as seguintes colunas para que a query funcione sem alterações:
| Coluna | Tipo | Notas |
|---|---|---|
id | uuid ou text | Chave primária |
nome | text | Nome da concessionária |
cidade | text | Nome da cidade |
uf | text | Sigla do estado (2 chars) |
marcas | text[] | Array de marcas |
logo_url | text | Nullable |
lat | float8 | Latitude WGS-84 |
lng | float8 | Longitude WGS-84 |
ativa | boolean | Apenas true são retornadas |
Modo de visualização e lógica de estado
ConcessionariasPage é o componente orquestrador. Todo o estado vive nele e é passado por props para os filhos.
Dois modos
viewMode | Comportamento |
|---|---|
'proximity' | Mapa em destaque. Se o usuário concede localização, exibe 5 cards das mais próximas ao lado do mapa. Se não há localização mas há filtros, a lista aparece abaixo do mapa. |
'all' | Lista paginada aparece acima do mapa. O mapa continua visível abaixo. |
Lógica de markers no mapa
mapFocusedMarkers =
userLocation → nearest5 (5 mais próximas)
hasFilters → filtered (todos os resultados do filtro)
default → [] (mapa usa modo coroplético)
Quando mapFocusedMarkers está vazio e não há localização, o mapa renderiza um coroplético com a densidade de concessionárias por estado.
Interação card → mapa
Ao clicar em um card (tanto na lista quanto nos cards de proximidade), handleCardClick é chamado:
- Define
selectedConcessionariacom o item clicado. - Faz scroll suave até a seção do mapa (
mapSectionRef.current?.scrollIntoView). - O mapa detecta a mudança de
selectedMarkere executaflyTona coordenada, abrindo o popup.
Filtros e busca
Entradas do hero
O ConcessionariasHero expõe três controles de filtro:
| Controle | Estado | Comportamento |
|---|---|---|
| Input de texto | searchQuery | Busca por cidade, UF ou nome do estado (acentos ignorados) |
| Select de estado | selectedState | Filtra por UF; ao mudar, limpa selectedCity e reseta page para 1 |
| Select de cidade | selectedCity | Habilitado apenas quando um estado está selecionado; opções derivadas de getCitiesForState |
O select de cidade é desabilitado enquanto selectedState estiver vazio.
filterConcessionarias — normalização
A função em _lib/concessionarias.utils.ts aplica os filtros sequencialmente:
- Descarta itens cuja
ufnão bata comselectedState(se preenchido). - Descarta itens cuja
cidadenão bata comselectedCity(se preenchido). - Se
searchQuerynão estiver vazio, normaliza a string (remove acentos, lowercase viaNFD) e testa contracidade,ufe o nome completo do estado.
Paginação
- Tamanho de página: 12 cards por página.
totalPages = Math.ceil(filtered.length / PAGE_SIZE).- A página é zerada para 1 sempre que qualquer filtro muda (
handleStateChange,handleSearchChange,handleCityChange). - A paginação usa
safePage = Math.min(page, totalPages)para evitar estado inválido quando filtros reduzem o total.
Mapa interativo
O mapa usa Leaflet carregado com ssr: false via next/dynamic em ConcessionariasMapWrapper. Isso evita erros de SSR causados pelo acesso direto ao DOM que o Leaflet requer.
Tiles
O mapa usa os tiles do CARTO (light_all) via https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png.
Três modos de renderização
| Condição | Modo | O que é exibido |
|---|---|---|
userLocation definida | Pins | nearest5 + pin azul do usuário; animação flyToBounds na primeira localização |
focusedMarkers.length > 0 (filtros sem localização) | Pins | Pins de todos os resultados filtrados, sem pin de usuário |
| Sem localização e sem filtros | Coroplético | Estados coloridos por densidade de concessionárias |
Coroplético
O GeoJSON dos estados brasileiros é servido como arquivo estático em apps/web/public/brazil-states.geojson (98 KB, baixado uma vez do IBGE e versionado no repositório). O fetch aponta para /brazil-states.geojson, sem dependência de API externa em runtime:
const res = await fetch('/brazil-states.geojson');
O resultado é cacheado em memória (cachedStatesGeoJson) e reutilizado sem nova requisição enquanto a instância do servidor estiver ativa.
Por que arquivo estático e não API do IBGE em runtime? Os limites geográficos dos estados brasileiros não mudam. Servir o arquivo localmente elimina dependência de API externa, garante disponibilidade independente do IBGE e reduz latência. Se o arquivo precisar ser atualizado (improvável), basta rodar o curl novamente e commitar.
A coloração usa limiares fixos absolutos:
| Intervalo | Cor |
|---|---|
| 81 ou mais | #0b2650 (navy) |
| 31 a 80 | #005b8e (deep teal) |
| 11 a 30 | #00c0ea (abc-cyan) |
| 1 a 10 | #b3e5f5 (light cyan) |
| 0 | #dce5ed (cinza neutro) |
Pins SVG
Três ícones inline são usados (sem dependência de arquivo externo):
| Ícone | Variável | Uso |
|---|---|---|
| Dealer pin cyan | DEALER_PIN | Pins normais (28×38 px) |
| Selected pin com anel branco | SELECTED_PIN | Concessionária selecionada via card click (40×52 px) |
| Círculo azul pulsante | USER_PIN | Localização do usuário (22×22 px) |
Seleção via card
Quando selectedMarker muda, o useEffect dedicado:
- Remove o marcador selecionado anterior.
- Cria um novo marcador com
SELECTED_PINezIndexOffset: 2000. - Abre o popup com nome, cidade/UF e distância (se localização disponível).
- Executa
map.flyTocom duração de 0,8 s e zoom nível 13.
O scroll suave (scrollIntoView) é gerenciado pelo ConcessionariasPage e o flyTo pelo ConcessionariasMap. Eles ocorrem em paralelo — o mapSectionRef garante que o mapa já esteja visível antes que a animação do Leaflet termine.
Componentes
ConcessionariasPage
Componente 'use client' que centraliza todo o estado da feature. Carrega ConcessionariasMapSection e ConcessionariasList com next/dynamic para reduzir o bundle inicial. Ambos têm skeletons de loading definidos inline no dynamic().
Props recebidas: concessionarias: Concessionaria[] (passados pelo RSC page.tsx).
ConcessionariasHero
Hero com fundo bg-abc-primary-branding e gradiente radial decorativo no canto superior direito — mesmo padrão dos demais heroes do site (mercado, parceiros, etc.). Usa text-abc-typo-light-00 no título, text-abc-typo-light-50 no subtítulo e text-abc-secondary-branding no eyebrow. Inclui o ScrollDownButton apontando para #concessionarias-busca.
A busca e os filtros (input de texto, select de estado e select de cidade) ficam na ConcessionariasSearchBar, que flutua sobre o limite do hero.
ConcessionariasSearchBar
Barra de busca com:
- Input de texto com ícone de lupa.
- Select de estado com todas as 27 UFs brasileiras (incluindo DF).
- Select de cidade, desabilitado até que um estado seja selecionado.
Os SelectContent de estado e cidade usam collisionAvoidance={{ side: 'none' }}. Como a barra flutua sobre o hero, o Base UI tende a inverter a direção do dropdown (abrir para cima/ao lado) por falta de espaço — side: 'none' desativa esse flip e força a abertura sempre abaixo. Não use a prop do Radix avoidCollisions aqui: o projeto usa Base UI e ela dispara erro de DOM.
ConcessionariasMapSection
Seção que contém o mapa e os cards de proximidade. É responsável por:
- Exibir o CTA "Usar minha localização" flutuando sobre o mapa quando
viewMode === 'proximity'e não há localização. - Exibir erro de geolocalização com botão de fallback "Ver todas".
- Exibir 5 cards compactos à esquerda do mapa (desktop) / abaixo do mapa (mobile) quando há localização.
Os botões Mais próximas / Ver todas vivem em ConcessionariasPage, acima da lista e do mapa, para que fiquem visíveis tanto no modo lista quanto no modo mapa sem depender da posição de scroll.
ConcessionariasMapWrapper
Camada fina que envolve ConcessionariasMap com next/dynamic({ ssr: false }). Necessário porque o Leaflet acessa window e document diretamente e quebraria no SSR. O wrapper define isolate e overflow-hidden para encapsular o stacking context do mapa.
ConcessionariasMap
Componente 'use client' que gerencia o ciclo de vida do Leaflet via useRef e useEffect. A instância do mapa é criada uma única vez na montagem e atualizada reativamente por dois effects:
- Effect de markers: re-renderiza pins ou coroplético quando
allMarkers,focusedMarkersouuserLocationmudam. - Effect de seleção: reage a
selectedMarkerpara criar/remover o pin selecionado e executarflyTo.
A importação do Leaflet é dinâmica (await import('leaflet')) para garantir que só rode no browser.
ConcessionariasList
Lista em grid responsivo (1 coluna mobile, 2 colunas tablet, 3 colunas desktop). Inclui paginação com ellipsis inteligente via getPageNumbers. Exibe mensagem vazia quando total === 0.
ConcessionariasCard
Card individual com duas variantes:
| Prop | Variante | Uso |
|---|---|---|
compact={false} (padrão) | Layout horizontal com logo 48×48, nome, cidade/UF, badges de marcas e distância | ConcessionariasList |
compact={true} | Layout compacto 2×2 grid com logo 32×32 e badge de distância | Painel de proximidade no ConcessionariasMapSection |
Quando onClick é passado, o card recebe role="button", tabIndex={0} e handler de Enter para acessibilidade de teclado.
A distância é calculada via haversineKm e exibida como badge: "< 1 km" quando abaixo de 1 km, ou "X km" arredondado.
O logo usa AvatarImage com fallback de iniciais geradas por getInitials, que filtra preposições (de, da, do, dos, das, e) e pega as duas primeiras letras.
Hooks
useConcessionariasFilter
const { filtered, nearest5, paginated, totalPages, total } =
useConcessionariasFilter({
concessionarias,
searchQuery,
selectedState,
selectedCity,
userLocation,
page,
});
Retorna:
| Campo | Descrição |
|---|---|
filtered | Todas as concessionárias que passam nos filtros ativos |
nearest5 | As 5 primeiras de filtered ordenadas por distância ao userLocation (vazio se sem localização) |
paginated | Fatia de filtered correspondente à página atual (12 itens) |
totalPages | Número de páginas para o total filtrado |
total | Quantidade total de itens filtrados |
Todos os valores são memoizados com useMemo.
useGeolocation
const { coords, loading, error, requestLocation } = useGeolocation();
Wrapper sobre navigator.geolocation.getCurrentPosition com:
timeout: 10 segundos.maximumAge: 60 segundos (reusa posição cacheada pelo browser).- Mensagens de erro localizadas em português para os três códigos de erro do W3C (
PERMISSION_DENIED,POSITION_UNAVAILABLE,TIMEOUT).
requestLocation é estável (useCallback sem deps) e pode ser passado para filhos sem risco de re-render desnecessário.
Estado atual e débitos técnicos
A tabela concessionarias ainda não existe no banco. A query em concessionarias.queries.ts sempre cai no fallback MOCK_CONCESSIONARIAS. Quando a tabela for criada e populada com os dados reais da ABRACAF, remover a importação de MOCK_CONCESSIONARIAS e o fallback da query.
O que muda quando a tabela real estiver pronta
- Criar a migration com o schema descrito na seção Schema esperado da tabela.
- Remover o cast
'concessionarias' as neverda query e gerar os tipos compnpm supabase:web:typegen. - Deletar
_lib/concessionarias.mock.ts. - Remover o
TODO: remover MOCK_CONCESSIONARIASe os dois blocos de fallback emconcessionarias.queries.ts. - Ajustar os limiares de cor do coroplético (
CHORO_STEPSemconcessionarias-map.tsx) conforme o volume real de dados.
Limiares do coroplético
Os breakpoints de cor (CHORO_STEPS) estão calibrados para uma estimativa. Com os dados reais, revisar os valores de min para refletir a distribuição real de concessionárias por estado:
// concessionarias-map.tsx
const CHORO_STEPS: { min: number; color: string }[] = [
{ min: 81, color: '#0b2650' }, // navy — 81+
{ min: 31, color: '#005b8e' }, // deep teal — 31–80
{ min: 11, color: '#00c0ea' }, // abc-cyan — 11–30
{ min: 1, color: '#b3e5f5' }, // light cyan — 1–10
];