Pular para o conteúdo principal

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;
Tabela ainda não existe no banco

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:

ColunaTipoNotas
iduuid ou textChave primária
nometextNome da concessionária
cidadetextNome da cidade
uftextSigla do estado (2 chars)
marcastext[]Array de marcas
logo_urltextNullable
latfloat8Latitude WGS-84
lngfloat8Longitude WGS-84
ativabooleanApenas 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

viewModeComportamento
'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:

  1. Define selectedConcessionaria com o item clicado.
  2. Faz scroll suave até a seção do mapa (mapSectionRef.current?.scrollIntoView).
  3. O mapa detecta a mudança de selectedMarker e executa flyTo na coordenada, abrindo o popup.

Filtros e busca

Entradas do hero

O ConcessionariasHero expõe três controles de filtro:

ControleEstadoComportamento
Input de textosearchQueryBusca por cidade, UF ou nome do estado (acentos ignorados)
Select de estadoselectedStateFiltra por UF; ao mudar, limpa selectedCity e reseta page para 1
Select de cidadeselectedCityHabilitado 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:

  1. Descarta itens cuja uf não bata com selectedState (se preenchido).
  2. Descarta itens cuja cidade não bata com selectedCity (se preenchido).
  3. Se searchQuery não estiver vazio, normaliza a string (remove acentos, lowercase via NFD) e testa contra cidade, uf e 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çãoModoO que é exibido
userLocation definidaPinsnearest5 + pin azul do usuário; animação flyToBounds na primeira localização
focusedMarkers.length > 0 (filtros sem localização)PinsPins de todos os resultados filtrados, sem pin de usuário
Sem localização e sem filtrosCoropléticoEstados 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:

IntervaloCor
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):

ÍconeVariávelUso
Dealer pin cyanDEALER_PINPins normais (28×38 px)
Selected pin com anel brancoSELECTED_PINConcessionária selecionada via card click (40×52 px)
Círculo azul pulsanteUSER_PINLocalização do usuário (22×22 px)

Seleção via card

Quando selectedMarker muda, o useEffect dedicado:

  1. Remove o marcador selecionado anterior.
  2. Cria um novo marcador com SELECTED_PIN e zIndexOffset: 2000.
  3. Abre o popup com nome, cidade/UF e distância (se localização disponível).
  4. Executa map.flyTo com duração de 0,8 s e zoom nível 13.
Scroll e flyTo são independentes

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.
Dropdowns sempre abrem abaixo do input

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, focusedMarkers ou userLocation mudam.
  • Effect de seleção: reage a selectedMarker para criar/remover o pin selecionado e executar flyTo.

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:

PropVarianteUso
compact={false} (padrão)Layout horizontal com logo 48×48, nome, cidade/UF, badges de marcas e distânciaConcessionariasList
compact={true}Layout compacto 2×2 grid com logo 32×32 e badge de distânciaPainel 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:

CampoDescrição
filteredTodas as concessionárias que passam nos filtros ativos
nearest5As 5 primeiras de filtered ordenadas por distância ao userLocation (vazio se sem localização)
paginatedFatia de filtered correspondente à página atual (12 itens)
totalPagesNúmero de páginas para o total filtrado
totalQuantidade 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

Dados em mock — aguardando TEC-53

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

  1. Criar a migration com o schema descrito na seção Schema esperado da tabela.
  2. Remover o cast 'concessionarias' as never da query e gerar os tipos com pnpm supabase:web:typegen.
  3. Deletar _lib/concessionarias.mock.ts.
  4. Remover o TODO: remover MOCK_CONCESSIONARIAS e os dois blocos de fallback em concessionarias.queries.ts.
  5. Ajustar os limiares de cor do coroplético (CHORO_STEPS em concessionarias-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
];