sass-imobiliaria/specs/022-ux-audit-imoveis/ux-audit.md

26 KiB
Raw Blame History

UX/UI Audit — Página /imoveis

Análise realizada em 18/04/2026. Baseada no código-fonte atual da PropertiesPage, FilterSidebar, PropertyRowCard, ContactModal e componentes relacionados.


Índice

  1. Diagnóstico Geral
  2. Arquitetura de Informação
  3. Filtros e Busca
  4. Cards de Imóvel
  5. Layout e Visualização
  6. Paginação
  7. Estado Vazio e Erros
  8. Micro-interações e Animações
  9. Acessibilidade (A11y)
  10. Mobile Experience
  11. Performance Percebida
  12. Fluxo de Conversão
  13. Roadmap Priorizado

1. Diagnóstico Geral

Pontos positivos atuais

  • Filtros sincronizados com a URL — links são compartilháveis e o histórico do browser funciona corretamente.
  • Skeleton loading implementado — evita CLS (Cumulative Layout Shift).
  • Carrossel de fotos com lazy load por slide individual.
  • Sidebar sticky no desktop, drawer no mobile.
  • Favoritos e comparação com contexto global persistente.
  • Badge de contagem de filtros ativos no botão mobile.

Problemas críticos identificados

Severidade Quantidade Descrição resumida
🔴 Alta 5 Impactam diretamente conversão ou usabilidade fundamental
🟡 Média 9 Degradam a experiência sem bloquear o uso
🟢 Baixa 8 Refinamentos e polish

2. Arquitetura de Informação

2.1 Ausência de campo de busca textual 🔴

Problema: Não existe um input de busca livre (por endereço, bairro, título ou código do imóvel). O usuário só consegue filtrar via dropdowns/chips. Para alguém que já sabe o endereço ou código do imóvel, o fluxo é completamente ineficiente.

Referências: Todos os grandes portais imobiliários (Zap, VivaReal, OLX) colocam a busca textual como primeiro ponto de entrada.

Solução recomendada:

┌─────────────────────────────────────────────────────┐
│  🔍  Buscar por endereço, bairro ou código...        │
└─────────────────────────────────────────────────────┘
  • Barra de busca proeminente no topo da área de resultados (não na sidebar).
  • Debounce de 400ms para evitar requests excessivos.
  • Parâmetro q na URL: /imoveis?q=Barra+Funda.
  • Busca no backend via ILIKE em title, address, code, neighborhood.name.

2.2 Ausência de ordenação 🔴

Problema: Não há opção para ordenar os resultados. O usuário não controla se quer ver por menor preço, maior área, mais recente ou destaque.

Solução recomendada:

// Dropdown de ordenação ao lado do contador de resultados
<select name="sort">
  <option value="relevance">Relevância</option>
  <option value="price_asc">Menor preço</option>
  <option value="price_desc">Maior preço</option>
  <option value="area_desc">Maior área</option>
  <option value="newest">Mais recente</option>
</select>
  • Parâmetro sort na URL.
  • Persistir a preferência de ordenação na URL (já funciona com o sistema atual de filtersToParams).

2.3 Falta de chips/tags de filtros ativos 🟡

Problema: Quando o usuário aplica filtros no desktop, não há feedback visual na área de resultados mostrando quais filtros estão ativos. O contador "87 imóveis encontrados" não revela por quê esse número é aquele.

Solução recomendada:

┌─ Filtros ativos ──────────────────────────────────────┐
│  [Aluguel ×]  [São Paulo ×]  [2+ quartos ×]  [Limpar tudo]  │
└───────────────────────────────────────────────────────┘
  • Chips removíveis logo abaixo do header, acima do primeiro card.
  • Cada chip tem × para remover individualmente.
  • Botão "Limpar tudo" só aparece quando há ≥ 2 chips.

2.4 Sem breadcrumbs 🟢

Problema: Não há indicação de onde o usuário está na hierarquia do site. Especialmente útil quando vem de uma busca filtrada.

Solução: Breadcrumb minimalista no header: Início > Imóveis > Apartamentos em São Paulo.


3. Filtros e Busca

3.1 Sidebar muito estreita para inputs de range 🟡

Problema: A sidebar tem w-56 (224px). Os RangeInputs de preço e área ficam comprimidos, forçando o usuário a digitar em campos muito pequenos sem feedback visual do range selecionado.

Solução recomendada:

  • Aumentar para w-64 (256px) ou w-72 (288px) no desktop.
  • Adicionar slider visual (range input duplo) acima dos inputs numéricos para preço — visualmente mais intuitivo e mais rápido que digitar valores.
// Price range com slider + inputs
<div>
  <RangeSlider min={0} max={5_000_000} value={[priceMin, priceMax]} />
  <RangeInputs ... />
</div>

3.2 Drawer mobile abre à direita 🟡

Problema: O drawer de filtros mobile abre pela direita (absolute right-0). O padrão de mercado (Google, Apple HIG) é que filtros e navegação secundária abram pela esquerda. Abrir pela direita quebra a expectativa de usuários que associam o lado direito a ações e notificações.

Solução: Mudar para left-0 com largura de 85vw máx 360px. O botão de filtro pode ficar onde está.


3.3 Nenhum feedback de "aplicando filtros" 🟡

Problema: Ao mudar qualquer filtro no sidebar desktop, a lista já recarrega (debounced pelo useEffect). Porém o usuário não tem feedback imediato de que algo está acontecendo — o skeleton só aparece depois do delay de rede, criando uma janela de latência percebida.

Solução:

  • Mostrar um indicador sutil (spinner ou barra de progresso no topo da lista) imediatamente ao alterar filtros, antes do resultado da API.
  • Adicionar transição opacity: 0.5 na lista enquanto loading === true, mantendo os cards anteriores visíveis em vez de sumindo e mostrando skeletons.
// Ao invés de mostrar skeleton completo, apenas escurecer
<div className={`flex flex-col gap-3 transition-opacity ${loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}`}>
  {result?.items.map(p => <PropertyRowCard ... />)}
</div>

3.4 Checkbox "incluir condomínio" ambíguo 🟢

Problema: O checkbox "incluir condomínio" no filtro de preço não é óbvio. Um usuário leigo pode não entender se isso soma o valor do condomínio ao preço de aluguel para filtrar, ou se está filtrando imóveis que tenham condomínio.

Solução:

  • Tooltip (?) com explicação: "Quando ativo, o filtro de preço inclui a taxa de condomínio no total considerado."
  • Ou reescrever o label para: Preço total (com condomínio).

3.5 Comodidades sempre colapsadas 🟢

Problema: Os grupos de comodidades (Lazer, Segurança, etc.) têm defaultOpen = false. Em um sidebar já scrollável, isso exige que o usuário descubra que essas seções existem e clique para expandir.

Solução: Manter colapsado por padrão, mas mostrar os chips das comodidades selecionadas mesmo colapsado, para que o usuário veja que há filtros ativos ali.


4. Cards de Imóvel

4.1 Altura fixa quebra o layout em telas menores 🔴

Problema: h-[220px] fixo no card e w-[340px] fixo na imagem. Em viewports entre 768px e 1024px (tablets), o card fica comprimido mas mantém a altura rígida, fazendo o texto ser truncado prematuramente.

Impacto: Usuários em tablets iPad (768px) ficam no breakpoint errado — recebem o layout desktop comprimido, não o mobile drawer.

Solução recomendada:

// Card com altura adaptável
<article className="group bg-panel border border-borderSubtle rounded-2xl overflow-hidden
  hover:border-borderStandard transition-all duration-200
  flex flex-col sm:flex-row sm:h-[220px]">

  {/* Imagem: full-width em mobile, fixed em desktop */}
  <div className="relative flex-shrink-0 w-full h-48 sm:w-[280px] sm:h-full lg:w-[340px]">

Problema: O elemento <Link> envolve toda a seção de informações do card, incluindo os botões "Comparar" e "Entre em contato". Isso cria elementos interativos aninhados (<button> dentro de <a>), o que é inválido no HTML5 e pode causar comportamento inconsistente em diferentes browsers e leitores de tela.

Solução: Reestruturar o card para que o <Link> seja apenas um elemento de fundo clicável (via position: absolute) e os botões fiquem fora do DOM do Link:

<article className="relative group ...">
  {/* Imagem */}
  <div className="...carousel..."></div>

  {/* Info section — sem ser um Link diretamente */}
  <div className="flex flex-col flex-1 p-5 gap-2">
    <Link to={...} className="absolute inset-0" aria-label="Ver detalhes" />

    {/* Todo o conteúdo */}
    <h3>...</h3>

    {/* Botões ficam acima do Link absoluto com z-index */}
    <div className="mt-auto flex items-center gap-2 relative z-10">
      <button onClick={handleCompareClick}>Comparar</button>
      <button onClick={handleContactClick}>Entre em contato</button>
    </div>
  </div>
</article>

4.3 "Ver detalhes →" como <span> não interativo 🟡

Problema: O texto Ver detalhes → é um <span> dentro de um <Link>. Visualmente parece um link separado, mas não tem estados de hover/focus próprios. Usuários podem tentar clicar especificamente nele achando que é um CTA distinto.

Solução: Transformar em um botão visual real com seta animada no hover:

<span className="ml-auto text-xs font-medium text-accent-violet group-hover:translate-x-0.5 transition-transform inline-flex items-center gap-1">
  Ver detalhes <ArrowRightIcon />
</span>

4.4 Tipo de imóvel ausente no card 🟡

Problema: O card não exibe o tipo/subtipo do imóvel (Apartamento, Casa, Sala Comercial, etc.). O usuário precisa entrar na página de detalhes para descobrir o tipo, mesmo que já esteja filtrando por tipo.

Solução: Adicionar o subtipo como um chip pequeno abaixo do título:

{property.subtype && (
  <span className="text-[10px] text-textTertiary bg-surface rounded px-1.5 py-0.5 w-fit">
    {property.subtype.name}
  </span>
)}

4.5 Carrossel inacessível em mobile (hover-only) 🔴

Problema: Os botões prev/next do carrossel têm opacity-0 group-hover:opacity-100. Em dispositivos touch, não existe o estado :hover, tornando os botões de navegação completamente invisíveis e inacessíveis em mobile.

Solução:

// Tornar visível em mobile, apenas com hover no desktop
className="... opacity-100 sm:opacity-0 sm:group-hover:opacity-100 ..."

Ou usar swipe gesture nativo em mobile (touchstart/touchend).


4.6 Ausência de badge "Novo" e "Destaque" 🟢

Problema: Não há diferenciação visual entre imóveis recém adicionados, imóveis em destaque ou imóveis com preço reduzido. Isso reduz a urgência percebida e o valor editorial da listagem.

Solução:

{property.is_featured && (
  <span className="bg-amber-500/90 text-white ..."> Destaque</span>
)}
{property.is_new && (  // criado nos últimos 7 dias
  <span className="bg-emerald-500/90 text-white ...">Novo</span>
)}

4.7 Abreviações confusas nas stats 🟢

Problema: qts (quartos) e ban (banheiros) são abreviações pouco intuitivas, especialmente para usuários menos familiarizados. não tem o label "Área".

Solução:

3 quartos  ·  2 banheiros  ·  85 m²  ·  1 vaga

Em espaços comprimidos, usar tooltips:

<span title="Quartos"><BedIcon /> 3</span>

5. Layout e Visualização

5.1 Único modo de visualização (lista) 🟡

Problema: A página só oferece visualização em lista horizontal. Muitos usuários preferem visualização em grade (especialmente para imóveis com fotos bonitas) pois permite comparar mais imóveis visualmente de uma vez.

Solução: Toggle de visualização no header:

[≡ Lista]  [⊞ Grade]
  • Lista: layout atual PropertyRowCard (horizontal, 1 coluna).
  • Grade: cards verticais PropertyGridCard (2-3 colunas, foto em cima), com menos detalhes mas foto maior.
  • Persistir preferência em localStorage.
// Grade: 2-3 colunas responsivas
<div className={view === 'grid'
  ? 'grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4'
  : 'flex flex-col gap-3'}>

5.2 Per page fixo em 16 🟢

Problema: O per_page: 16 é hardcoded em dois lugares (handleFiltersChange e handleClear). O usuário não pode escolher ver mais ou menos resultados por página.

Solução: Seletor discreto no header: Exibir: [16] [32] [48].


6. Paginação

6.1 Sem indicador de posição 🟡

Problema: A paginação mostra apenas os números de página mas não informa quantos resultados estão sendo exibidos em relação ao total. "Página 3 de 12" não comunica que estamos vendo "imóveis 33-48 de 185".

Solução:

// Acima da paginação ou integrado ao header
<p className="text-xs text-textTertiary text-center mt-6">
  Exibindo {(page - 1) * perPage + 1}{Math.min(page * perPage, total)} de {total} imóveis
</p>

6.2 Scroll to top abrupto 🟡

Problema: window.scrollTo({ top: 0, behavior: 'smooth' }) faz o scroll mas não há indicação visual de que os resultados mudaram. O usuário pode não perceber que está vendo novos resultados.

Solução:

  • Adicionar uma transição sutil de opacity nos cards ao trocar de página.
  • Ou scroll para o topo do container de resultados, não da janela inteira (para não esconder o sidebar).
// Scroll para o topo da área de resultados
gridRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })

6.3 Paginação duplicada 🟢

Melhoria: Adicionar paginação também no topo da lista de resultados (acima do primeiro card). Útil para quando o usuário quer mudar de página sem scrollar até o fim.


7. Estado Vazio e Erros

7.1 Estado vazio sem sugestões 🟡

Problema atual:

Nenhum imóvel encontrado com esses filtros.
[Limpar filtros]

Esse estado desperdiça uma oportunidade de reter o usuário e ajudá-lo a encontrar algo relevante.

Solução — Empty State rico:

😕 Nenhum imóvel encontrado

Tente estas sugestões:
• [Remover filtro de bairro]   → 12 imóveis disponíveis
• [Ampliar faixa de preço]     → 8 imóveis disponíveis
• [Mudar para 1+ quartos]      → 5 imóveis disponíveis

[Ou limpar todos os filtros →]

Para calcular as sugestões, fazer requests paralelos com filtros relaxados.


7.2 Sem tratamento de erro de rede 🔴

Problema: O fetchProperties usa try/finally mas não guarda o erro. Se a API falhar, a lista simplesmente permanece em estado anterior ou vazia, sem nenhuma mensagem ao usuário.

Solução:

const [error, setError] = useState<string | null>(null)

// no fetchProperties:
} catch (err) {
  setError('Não foi possível carregar os imóveis. Tente novamente.')
} finally {
  setLoading(false)
}

// no render:
{error && (
  <div className="...">
    <p>{error}</p>
    <button onClick={() => fetchProperties(filters)}>Tentar novamente</button>
  </div>
)}

8. Micro-interações e Animações

8.1 Sem animação de entrada nos cards 🟢

Problema: Os cards aparecem abruptamente após o loading. Uma animação sutil de entrada melhora a percepção de qualidade.

Solução com Tailwind + delay staggered:

{result.items.map((property, i) => (
  <div
    key={property.id}
    className="animate-fade-in-up"
    style={{ animationDelay: `${i * 40}ms` }}
  >
    <PropertyRowCard property={property} />
  </div>
))}
/* Em index.css */
@keyframes fade-in-up {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
  animation: fade-in-up 0.25s ease both;
}

8.2 Sem botão "Voltar ao topo" 🟢

Problema: Em listas longas (16 cards), o usuário precisa scrollar muito para voltar ao topo e ajustar filtros no sidebar desktop ou clicar no botão de filtros mobile.

Solução: Botão flutuante que aparece após scrollar 400px:

{scrollY > 400 && (
  <button
    onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
    className="fixed bottom-24 right-6 z-40 w-10 h-10 rounded-full bg-panel border border-borderStandard shadow-lg ..."
  >
    
  </button>
)}

8.3 Barra de comparação conflita com ComparisonBar 🟢

Problema: A ComparisonBar fica fixa no bottom-0. Quando ativa, ela sobrepõe o rodapé e pode encobrir o botão "Voltar ao topo". Sem margem dinâmica no conteúdo principal, cards podem ficar atrás da barra.

Solução: Quando ComparisonBar está visível, adicionar pb-20 no container principal.


9. Acessibilidade (A11y)

9.1 Elementos interativos aninhados 🔴

(Ver seção 4.2 — <button> dentro de <a> é HTML inválido)


9.2 Carrossel sem suporte a teclado 🟡

Problema: Os botões prev/next do carrossel não têm tabIndex e estão ocultos visualmente (opacity-0). Um usuário que navega por teclado não consegue navegar pelas fotos.

Solução:

<button
  onClick={prev}
  onFocus={() => /* mostrar botões */}
  tabIndex={0}
  aria-label="Foto anterior"
  // Remover opacity-0 do tabIndex focus state
  className="... focus:opacity-100"
>

9.3 Dots do carrossel com área clicável pequena 🟡

Problema: Os dots têm w-1.5 h-1.5 (6px). A área mínima recomendada pela WCAG é 24×24px.

Solução:

// Área de toque maior com padding
<button
  className="p-2 -m-2" // área de toque 22px+padding
>
  <span className="block w-1.5 h-1.5 rounded-full ..." />
</button>

9.4 <main> sem id ou aria-label 🟢

Problema: O <main> existe, mas não tem aria-label ou id="main-content" para skip links.

Solução:

<main id="main-content" aria-label="Listagem de imóveis" className="pt-14">

9.5 Filtros sidebar sem role="search" ou aria-label 🟢

Problema: O sidebar de filtros não tem semântica ARIA adequada.

Solução:

<aside aria-label="Filtros de busca">
  <FilterSidebar ... />
</aside>

10. Mobile Experience

10.1 Layout horizontal comprimido em tablet 🔴

(Ver seção 4.1 — altura fixa do card)

O breakpoint lg:hidden para o sidebar significa que em tablets (768px1023px), o usuário recebe o layout desktop mas com espaço insuficiente.

Solução: Mudar o breakpoint do sidebar para md:block ou xl:block e ajustar o layout do card para ser vertical até xl.


10.2 Sem infinite scroll como alternativa 🟢

Problema: A paginação tradicional obriga o usuário a clicar e esperar. Em mobile, infinite scroll ou "Carregar mais" é mais natural.

Solução opcional: Botão "Ver mais imóveis" no final da lista (append, não replace) como alternativa à paginação:

{result.page < result.pages && (
  <button
    onClick={() => loadMore()}
    className="w-full mt-6 py-3 rounded-xl border border-borderStandard ..."
  >
    Carregar mais imóveis ({remaining} restantes)
  </button>
)}

10.3 Tipografia pequena demais em mobile 🟡

Problema: text-xs (12px) em múltiplos lugares (stats do card, labels de filtro). A WCAG recomenda mínimo 16px para corpo de texto em mobile.

Elementos afetados:

  • Stats do card (text-xs text-textSecondary)
  • Label do botão "Entre em contato" (text-xs font-semibold)
  • Paginação (text-xs)
  • Seção titles da sidebar (text-xs font-medium uppercase)

Solução: Escalar para text-sm (14px) em mobile, mantendo text-xs apenas em elementos secundários decorativos.


11. Performance Percebida

11.1 5 requests paralelos antes de renderizar qualquer coisa 🟡

Problema: O Promise.all na montagem carrega tipos, comodidades, cidades, bairros e imobiliárias antes de mostrar o sidebar. Enquanto isso, a sidebar fica vazia.

Solução:

  • Mostrar skeleton da sidebar enquanto os dados de catálogo carregam.
  • Priorizar o request de getProperties (o mais importante) e deixar o catálogo carregar em segundo plano.
// Separar o loading do catálogo do loading dos imóveis
const [catalogLoading, setCatalogLoading] = useState(true)

// Catalog carrega em background, não bloqueia os imóveis
useEffect(() => {
  Promise.all([...]).then(([...]) => {
    // set states
    setCatalogLoading(false)
  })
}, [])

11.2 Todas as fotos do carrossel são renderizadas no DOM 🟡

Problema: O PhotoCarousel renderiza todos os slides no DOM desde o início, apenas mudando a opacity. Um imóvel com 10 fotos renderiza 10 <img> tags, mesmo que o usuário veja apenas 1.

Solução: Renderizar apenas o slide atual e os adjacentes (virtualização simples):

// Renderizar apenas slide atual ± 1
{slides.map((photo, i) => (
  Math.abs(i - current) <= 1 && (
    <SlideImage key={i} src={photo.url} alt={...} />
  )
))}

12. Fluxo de Conversão

12.1 "Entre em contato" visualmente fraco 🟡

Problema: O botão verde bg-emerald-500 é a única ação de conversão no card, mas divide espaço igualmente com "Comparar" (border simples). O olho do usuário não é guiado para o CTA principal.

Hierarquia visual atual: [Comparar][Entre em contato] Ver detalhes →

Solução — hierarquia clara:

[Ver detalhes]            → ação primária (fundo brand)
[Entre em contato]        → ação secundária (outline brand)
[Comparar]                → ação terciária (ghost/minimal)

Ou: tornar "Entre em contato" o único botão com cor de fundo, ampliado:

<button className="rounded-lg px-4 py-2 text-xs font-semibold bg-brand text-white
  hover:bg-accentHover transition-colors shadow-sm">
  Falar com corretor
</button>

12.2 Comparação sem limite visual claro 🟢

Problema: Quando o usuário tenta adicionar um 4º imóvel à comparação, o botão simplesmente não funciona (por lógica no contexto), sem feedback visual do motivo.

Solução:

// Quando comparação está cheia e imóvel não está na lista
{!inComparison && comparisonFull && (
  <Tooltip content="Máximo de 3 imóveis para comparar. Remova um para adicionar este.">
    <button disabled className="opacity-40 cursor-not-allowed ...">Comparar</button>
  </Tooltip>
)}

12.3 WhatsApp como CTA não aparece no card 🟢

Problema: O número de WhatsApp existe no sistema e o ContactModal o utiliza, mas o card não oferece acesso direto. Muitos usuários preferem o WhatsApp a preencher um formulário.

Solução (opcional): Ícone de WhatsApp como botão terciário no card:

<a
  href={`https://wa.me/${whatsapp}?text=...`}
  target="_blank"
  rel="noopener noreferrer"
  className="..."
  onClick={e => e.stopPropagation()}
>
  <WhatsAppIcon />
</a>

13. Roadmap Priorizado

Sprint 1 — Crítico (impacto imediato na usabilidade)

# Item Esforço Impacto
1 Corrigir botões dentro de <Link> (semântica HTML) P 🔴
2 Carrossel visível em mobile (remover opacity-0 em touch) P 🔴
3 Tratamento de erro de rede no fetch de imóveis P 🔴
4 Layout do card responsivo (sem altura fixa) M 🔴
5 Campo de busca textual G 🔴

P = Pequeno (< 2h) · M = Médio (26h) · G = Grande (> 6h)


Sprint 2 — Alto valor (conversão e descoberta)

# Item Esforço Impacto
6 Ordenação de resultados (preço, área, data) M 🟡
7 Chips de filtros ativos com remoção individual M 🟡
8 Toggle lista/grade com PropertyGridCard G 🟡
9 Estado vazio com sugestões de filtros relaxados M 🟡
10 Hierarquia de CTAs no card (primário/secundário/terciário) P 🟡

Sprint 3 — Refinamentos (qualidade percebida)

# Item Esforço Impacto
11 Animação de entrada dos cards (stagger) P 🟢
12 Indicador de posição na paginação ("XY de Z imóveis") P 🟢
13 Botão "Voltar ao topo" flutuante P 🟢
14 Badge "Novo" e "Destaque" no card P 🟢
15 Suporte a teclado no carrossel M 🟢
16 Tipo de imóvel no card (subtipo) P 🟢
17 Virtualização de slides do carrossel M 🟢
18 Skeleton do sidebar enquanto catálogo carrega P 🟢
19 Slider visual para range de preço G 🟢
20 Paginação no topo da lista P 🟢

Referências e Benchmarks

Portal Funcionalidade de referência
VivaReal Chips de filtros ativos, toggle lista/mapa/grade, busca textual no topo
Zap Imóveis Ordenação proeminente, cards com subtipo e badges, infinite scroll mobile
Airbnb Slider de preço com histograma de distribuição, filtros modais ricos
Booking.com Estado vazio com sugestões de relaxamento de filtros
Rightmove (UK) Paginação com "XY de Z", salvar busca, alertas por email

Documento gerado para uso interno do projeto saas_imobiliaria. Revisão recomendada após implementação de cada sprint.