feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -0,0 +1,38 @@
# Specification Quality Checklist: Melhorias UX/UI — Listagem de Imóveis
**Purpose**: Validar completude e qualidade da especificação antes de prosseguir para o planejamento
**Created**: 2026-04-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Sem detalhes de implementação (linguagens, frameworks, APIs)
- [x] Focado em valor para o usuário e necessidades do negócio
- [x] Escrito para stakeholders não-técnicos
- [x] Todas as seções obrigatórias preenchidas
## Requirement Completeness
- [x] Sem marcadores [NEEDS CLARIFICATION] remanescentes
- [x] Requisitos são testáveis e não-ambíguos
- [x] Critérios de sucesso são mensuráveis
- [x] Critérios de sucesso são agnósticos de tecnologia
- [x] Todos os cenários de aceitação estão definidos
- [x] Edge cases estão identificados
- [x] Escopo claramente delimitado (3 sprints com prioridades)
- [x] Dependências e premissas identificadas
## Feature Readiness
- [x] Todos os requisitos funcionais têm critérios de aceitação claros
- [x] Cenários de usuário cobrem os fluxos primários
- [x] Feature atende os resultados mensuráveis definidos em Success Criteria
- [x] Sem detalhes de implementação vazando para a especificação
## Notes
- Spec cobre 20 itens de melhoria distribuídos em 3 sprints de prioridade
- Sprint 1 (P1): 5 itens críticos — todos com FR e cenários de aceitação
- Sprint 2 (P2): 5 itens de alto valor — todos com FR e cenários de aceitação
- Sprint 3 (P3): 10 refinamentos — agrupados em User Story 8 com cenários individuais
- Pronto para `/speckit.plan`

View file

@ -0,0 +1,214 @@
# API Contract — `GET /api/v1/properties`
**Feature**: 023-ux-melhorias-imoveis
**Versão**: extensão da rota existente — sem breaking changes
**Arquivo**: `backend/app/routes/properties.py`
---
## Descrição
Rota de listagem paginada de imóveis. Esta feature adiciona dois novos parâmetros query opcionais (`q` e `sort`) à rota existente. Todos os parâmetros existentes são preservados sem alteração.
---
## Request
```
GET /api/v1/properties
```
### Query Parameters
#### Parâmetros novos (adicionados nesta feature)
| Parâmetro | Tipo | Obrigatório | Valores | Default | Descrição |
|---|---|---|---|---|---|
| `q` | `string` | Não | qualquer string, máx 200 chars | — (sem filtro textual) | Busca case-insensitive em `title`, `address`, `code`, `neighborhood.name` |
| `sort` | `string` | Não | `relevance` · `price_asc` · `price_desc` · `area_desc` · `newest` | `relevance` | Critério de ordenação dos resultados |
#### Parâmetros existentes (preservados)
| Parâmetro | Tipo | Descrição |
|---|---|---|
| `listing_type` | `'venda' \| 'aluguel'` | Tipo de negócio |
| `subtype_id` | `integer` | ID do subtipo de imóvel |
| `city_id` | `integer` | ID da cidade |
| `neighborhood_id` | `integer` | ID do bairro |
| `imobiliaria_id` | `integer` | ID da imobiliária |
| `price_min` | `number` | Preço mínimo |
| `price_max` | `number` | Preço máximo |
| `include_condo` | `'true'` | Incluir condomínio no cálculo de preço |
| `bedrooms_min` | `integer` | Mínimo de quartos |
| `bedrooms_max` | `integer` | Máximo de quartos |
| `bathrooms_min` | `integer` | Mínimo de banheiros |
| `bathrooms_max` | `integer` | Máximo de banheiros |
| `parking_min` | `integer` | Mínimo de vagas |
| `parking_max` | `integer` | Máximo de vagas |
| `area_min` | `integer` | Área mínima (m²) |
| `area_max` | `integer` | Área máxima (m²) |
| `amenity_ids` | `string` (lista separada por vírgula) | IDs de comodidades (AND lógico) |
| `page` | `integer` | Página atual (default: 1) |
| `per_page` | `integer` | Resultados por página (default: 24, max: 48) |
---
## Response
### 200 OK
Sem alterações no schema de resposta existente.
```json
{
"items": [
{
"id": "uuid",
"title": "Apartamento Jardins 2 quartos",
"slug": "apartamento-jardins-2-quartos",
"address": "Rua Oscar Freire, 123",
"code": "AP-0042",
"price": "3500.00",
"type": "aluguel",
"bedrooms": 2,
"bathrooms": 1,
"parking_spots": 1,
"area_m2": 75,
"is_featured": false,
"created_at": "2026-04-11T14:30:00",
"photos": [
{ "url": "/imoveis/ap-0042/foto1.jpg", "alt_text": "Sala de estar" }
],
"subtype": { "id": 1, "name": "Apartamento" },
"city": { "id": 1, "name": "São Paulo" },
"neighborhood": { "id": 5, "name": "Jardins" }
}
],
"total": 42,
"page": 1,
"per_page": 16,
"pages": 3
}
```
### Erros
| Status | Condição |
|---|---|
| `400` | Não aplicável — parâmetros inválidos são ignorados silenciosamente (comportamento existente) |
| `500` | Erro interno do servidor |
---
## Semântica dos Novos Parâmetros
### `q` — Busca Textual
- **Campos buscados**: `title`, `address`, `code`, `neighborhood.name`
- **Operador**: `ILIKE '%termo%'` (case-insensitive, busca parcial)
- **Lógica**: OR entre os campos (`title ILIKE $q OR address ILIKE $q OR ...`)
- **Combinação com outros filtros**: AND com todos os filtros existentes
- **Sanitização**: `.strip()` + truncamento em 200 chars
- **Segurança**: bind parameter do SQLAlchemy ORM — sem risco de SQL injection
**Exemplo**: `?q=Jardins&listing_type=aluguel` retorna apenas imóveis de aluguel cujo título, endereço, código ou bairro contenha "Jardins".
**Exemplo**: `?q=AP-0042` retorna o imóvel com `code = 'AP-0042'` (ou qualquer imóvel com "AP-0042" no título/endereço).
### `sort` — Ordenação
| Valor | `ORDER BY` gerado | Comportamento |
|---|---|---|
| `relevance` | `property.created_at DESC` | Mais recentes primeiro (comportamento anterior) |
| `price_asc` | `property.price ASC` | Menor preço primeiro |
| `price_desc` | `property.price DESC` | Maior preço primeiro |
| `area_desc` | `property.area_m2 DESC` | Maior área primeiro |
| `newest` | `property.created_at DESC` | Igual a `relevance` |
| *(valor desconhecido)* | `property.created_at DESC` | Fallback para `relevance` |
---
## Exemplos de Chamada
```bash
# Busca por bairro + tipo de negócio ordenado por preço
GET /api/v1/properties?q=Jardins&sort=price_asc&listing_type=aluguel&page=1&per_page=16
# Busca por código exato
GET /api/v1/properties?q=AP-0042
# Imóveis mais recentes de São Paulo
GET /api/v1/properties?sort=newest&city_id=1
# Menor área mínima + maior preço (combinação complexa)
GET /api/v1/properties?sort=price_desc&bedrooms_min=3&city_id=1&q=Pinheiros
```
---
## Implementação Backend
```python
# Em backend/app/routes/properties.py — adicionar antes da paginação
# ── Busca textual (q) ────────────────────────────────────────────────────────
q = args.get("q", "").strip()
if len(q) > 200:
q = q[:200]
if q:
from sqlalchemy import or_
from sqlalchemy.orm import aliased
from app.models.location import Neighborhood as NeighborhoodAlias
nbh_alias = aliased(NeighborhoodAlias)
query = query.outerjoin(nbh_alias, Property.neighborhood_id == nbh_alias.id)
pattern = f"%{q}%"
query = query.filter(or_(
Property.title.ilike(pattern),
Property.address.ilike(pattern),
Property.code.ilike(pattern),
nbh_alias.name.ilike(pattern),
))
# ── Ordenação (sort) — SUBSTITUIR o order_by existente ─────────────────────
sort = args.get("sort", "relevance")
sort_map = {
"price_asc": Property.price.asc(),
"price_desc": Property.price.desc(),
"area_desc": Property.area_m2.desc(),
"newest": Property.created_at.desc(),
}
order_clause = sort_map.get(sort, Property.created_at.desc())
# ── Paginação (existente — apenas mover order_by) ───────────────────────────
props = (
query.order_by(order_clause) # ← substituiu o .order_by(Property.created_at.desc()) fixo
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
```
---
## Implementação Frontend
```ts
// services/properties.ts — adicionar ao PropertyFilters
q?: string
sort?: 'relevance' | 'price_asc' | 'price_desc' | 'area_desc' | 'newest'
// No getProperties():
if (filters.q?.trim()) params.q = filters.q.trim()
if (filters.sort && filters.sort !== 'relevance') params.sort = filters.sort
```
```ts
// PropertiesPage.tsx — filtersFromParams
q: get('q') ?? undefined,
sort: (get('sort') as SortOption) ?? undefined,
// PropertiesPage.tsx — filtersToParams
if (filters.q) p.set('q', filters.q)
if (filters.sort && filters.sort !== 'relevance') p.set('sort', filters.sort)
```

View file

@ -0,0 +1,116 @@
# Data Model — Melhorias UX/UI (023)
> Nenhuma migration de banco necessária. Todos os campos utilizados já existem no modelo `Property`.
---
## Entidades Existentes (campos utilizados nesta feature)
### `Property` (`backend/app/models/property.py`)
| Campo | Tipo SQLAlchemy | Tipo Python | Sprint | Utilização |
|---|---|---|---|---|
| `title` | `VARCHAR(200)` | `str` | 1 | Busca textual `q` (ILIKE) |
| `address` | `VARCHAR(300)` | `str \| None` | 1 | Busca textual `q` (ILIKE) |
| `code` | `VARCHAR(30)` | `str \| None` | 1 | Busca textual `q` (ILIKE) |
| `neighborhood_id` | `INTEGER FK → neighborhoods.id` | `int \| None` | 1 | Join para busca `q` em `Neighborhood.name` |
| `price` | `NUMERIC(12,2)` | `Decimal` | 2 | Ordenação `price_asc` / `price_desc` |
| `area_m2` | `INTEGER` | `int` | 2 | Ordenação `area_desc` |
| `created_at` | `DATETIME` | `datetime` | 2/3 | Ordenação `newest`; badge "Novo" (frontend) |
| `is_featured` | `BOOLEAN` | `bool` | 3 | Badge "Destaque" no card |
### `Neighborhood` (`backend/app/models/location.py`)
| Campo | Tipo SQLAlchemy | Utilização |
|---|---|---|
| `id` | `INTEGER PK` | Join com `Property.neighborhood_id` |
| `name` | `VARCHAR` | Busca textual `q` (ILIKE) |
---
## Tipos Frontend Adicionados (`frontend/src/services/properties.ts`)
### `PropertyFilters` — campos novos
```ts
// Adição aos campos existentes:
q?: string // busca textual livre
sort?: SortOption
```
### `SortOption` (novo tipo)
```ts
type SortOption =
| 'relevance' // default — equivale a created_at DESC no backend
| 'price_asc'
| 'price_desc'
| 'area_desc'
| 'newest'
```
### `ViewMode` (novo tipo local — apenas frontend)
```ts
type ViewMode = 'list' | 'grid'
// Persiste em localStorage com key 'imoveis_view_mode'
```
---
## Entidades de UI (apenas frontend — sem persistência no banco)
### `ActiveFilterChip`
Tipo derivado calculado a partir de `PropertyFilters` + dados do catálogo:
```ts
interface ActiveFilterChip {
key: string // identificador único (ex: 'city_id', 'q', 'bedrooms_min')
label: string // texto exibido no chip (ex: 'São Paulo', 'Busca: "Jardins"')
onRemove: () => void // callback que remove este filtro específico
}
```
### `EmptyStateSuggestion`
```ts
interface EmptyStateSuggestion {
label: string // ex: 'Remover filtro de bairro'
relaxedFilters: PropertyFilters
count: number // total de imóveis com o filtro relaxado
}
```
---
## Validação de Entrada — Backend
O parâmetro `q` não é validado via Pydantic (é query param de GET, sem body).
Sanitização aplicada diretamente na rota:
```python
q = args.get("q", "").strip()
# Comprimento máximo razoável (evitar payloads abusivos)
if len(q) > 200:
q = q[:200]
```
O parâmetro `sort` é validado via whitelist implícita no `sort_map.get(sort, default)`.
---
## Diagrama de Relacionamentos (campos relevantes)
```
Property
├── title (VARCHAR 200) ─── ILIKE com q
├── address (VARCHAR 300) ─── ILIKE com q
├── code (VARCHAR 30) ─── ILIKE com q
├── neighborhood_id (FK) ─┐
│ ├── JOIN → Neighborhood.name ─── ILIKE com q
├── price (NUMERIC 12,2) ─── ORDER BY price_asc/desc
├── area_m2 (INTEGER) ─── ORDER BY area_desc
├── created_at (DATETIME) ─── ORDER BY newest; badge "Novo" no frontend
└── is_featured (BOOLEAN) ─── badge "Destaque" no frontend
```

View file

@ -0,0 +1,104 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
[Extract from feature spec: primary requirement + technical approach from research]
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
src/
├── models/
├── services/
├── cli/
└── lib/
tests/
├── contract/
├── integration/
└── unit/
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
backend/
├── src/
│ ├── models/
│ ├── services/
│ └── api/
└── tests/
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ └── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View file

@ -0,0 +1,160 @@
# Quickstart — 023-ux-melhorias-imoveis
Guia de desenvolvimento local para implementar as melhorias de UX/UI na página `/imoveis`.
---
## Pré-requisitos
- Docker + Docker Compose em execução
- Branch: `023-ux-melhorias-imoveis` (ou trabalhar direto em `master`)
```bash
# Verificar que os containers estão rodando
docker-compose ps
# Backend: http://localhost:5000
# Frontend: http://localhost:5174
```
---
## Sprint 1 — Ordem de Trabalho Recomendada
### 1. Backend: parâmetro `q`
```bash
# Editar a rota
code backend/app/routes/properties.py
# Após editar, reiniciar o backend
docker-compose restart backend
# Testar manualmente
curl "http://localhost:5000/api/v1/properties?q=jardins" | python -m json.tool | head -30
curl "http://localhost:5000/api/v1/properties?q=AP-0042" | python -m json.tool | head -10
curl "http://localhost:5000/api/v1/properties?q=Rua+das+Flores&sort=price_asc" | python -m json.tool
```
### 2. Frontend: refactor `PropertyRowCard`
O frontend tem hot reload — editar e salvar recarrega automaticamente em `http://localhost:5174/imoveis`.
```bash
# Verificar TypeScript após edições
docker-compose exec frontend npx tsc --noEmit
```
Cheklist de validação do refactor semântico:
- [ ] Abrir DevTools → Elements → procurar `<a>` → confirmar que não há `<button>` filho
- [ ] Clicar no card (área de texto) → navega para detalhes
- [ ] Clicar em "Entre em contato" → abre modal (não navega)
- [ ] Clicar em "Comparar" → adiciona à barra de comparação
- [ ] Tab pelo teclado: foca em "Ver detalhes" → "Entre em contato" → "Comparar" independentemente
### 3. Frontend: `SearchBar` + integração
```bash
# Criar o novo componente
code frontend/src/components/SearchBar.tsx
# Verificar que q aparece na URL ao digitar
# http://localhost:5174/imoveis → digitar no campo → URL deve mudar para ?q=termo
```
---
## Sprint 2 — Ordem de Trabalho Recomendada
### 1. Backend: parâmetro `sort`
```bash
docker-compose restart backend
# Testar ordenação
curl "http://localhost:5000/api/v1/properties?sort=price_asc&per_page=5" | \
python -c "import sys,json; d=json.load(sys.stdin); [print(i['price']) for i in d['items']]"
curl "http://localhost:5000/api/v1/properties?sort=price_desc&per_page=5" | \
python -c "import sys,json; d=json.load(sys.stdin); [print(i['price']) for i in d['items']]"
```
### 2. Frontend: `PropertyGridCard`
```bash
code frontend/src/components/PropertyGridCard.tsx
# Testar em http://localhost:5174/imoveis — ativar toggle de Grade
```
### 3. Frontend: `ActiveFiltersBar`
```bash
code frontend/src/components/ActiveFiltersBar.tsx
# Testar: aplicar filtro cidade → chip aparece → clicar × → filtro removido
```
---
## Sprint 3 — Ordem de Trabalho Recomendada
```bash
# Animações: adicionar keyframe em index.css
code frontend/src/index.css
# Badges: testar com imóvel is_featured = true
curl "http://localhost:5000/api/v1/properties?per_page=50" | \
python -c "import sys,json; d=json.load(sys.stdin); [print(i['title']) for i in d['items'] if i.get('is_featured')]"
# Se não houver imóveis com is_featured=true, setar um via psql:
docker-compose exec db psql -U postgres -d saas_imobiliaria \
-c "UPDATE properties SET is_featured = true WHERE id = (SELECT id FROM properties LIMIT 1);"
```
---
## Testes Backend
```bash
# Rodar testes existentes
docker-compose exec backend uv run pytest tests/ -v
# Rodar apenas testes de properties (quando criados para q e sort)
docker-compose exec backend uv run pytest tests/test_properties.py -v -k "search or sort"
```
---
## Checklist de Validação Final (todos os sprints)
### Semântica HTML
```bash
# Verificar no browser: DevTools → Console
document.querySelectorAll('a button, a a').length
# Deve retornar 0
```
### Busca textual
- [ ] `?q=jardins` filtra imóveis com "Jardins" no título/endereço/bairro
- [ ] `?q=AP-0042` encontra imóvel pelo código
- [ ] Campo vazio → sem parâmetro `q` na URL
- [ ] Caracteres especiais: `?q=São+Paulo` não causa erro
### Ordenação
- [ ] `?sort=price_asc` → preços crescentes
- [ ] `?sort=price_desc` → preços decrescentes
- [ ] `?sort=area_desc` → áreas decrescentes
- [ ] `?sort=newest` → mais recentes primeiro
- [ ] Valor inválido (`?sort=invalid`) → sem erro, usa default
### Visualização
- [ ] Toggle Lista → cards horizontais
- [ ] Toggle Grade → grid de 1/2/3 colunas
- [ ] Recarregar página → preferência mantida
### Mobile (testar com DevTools simulando iPhone)
- [ ] Carrossel: botões prev/next visíveis sem hover
- [ ] Card não trunca texto em 768px
- [ ] Drawer de filtros abre corretamente
### Error state
- [ ] Desligar backend → mensagem de erro aparece
- [ ] Clicar "Tentar novamente" → refaz o request

View file

@ -0,0 +1,254 @@
# Feature Specification: Melhorias UX/UI — Listagem de Imóveis
**Feature Branch**: `023-ux-melhorias-imoveis`
**Created**: 2026-04-18
**Status**: Draft
**Fonte**: Auditoria UX/UI `specs/022-ux-audit-imoveis/ux-audit.md`
---
## Contexto
A página `/imoveis` (listagem de imóveis) apresenta 22 problemas identificados em auditoria UX/UI realizada em 18/04/2026. Este spec cobre as 20 melhorias priorizadas em 3 sprints, abrangendo correções críticas de usabilidade, funcionalidades de alto valor para conversão e refinamentos de qualidade percebida.
---
## User Scenarios & Testing
### User Story 1 — Correções Críticas de Usabilidade (Priority: P1)
Um visitante acessa a listagem de imóveis em qualquer dispositivo (desktop, tablet ou mobile) e espera poder navegar, visualizar fotos e receber feedback adequado em caso de falha de rede — sem encontrar elementos quebrados ou comportamento inconsistente.
**Why this priority**: Problemas de semântica HTML inválida, carrossel inacessível em mobile, ausência de tratamento de erro e layout quebrado em tablets impactam diretamente todos os usuários e podem bloquear conversões.
**Independent Test**: Abrir a página `/imoveis` em um dispositivo mobile, navegar pelas fotos de um card tocando nos botões prev/next, simular falha de rede e verificar se mensagem de erro aparece.
**Acceptance Scenarios**:
1. **Given** um card de imóvel com múltiplas fotos, **When** o usuário acessa em dispositivo mobile (touch), **Then** os botões prev/next do carrossel são visíveis e funcionais sem necessidade de hover.
2. **Given** que a API de imóveis retorna erro de rede, **When** a página tenta carregar os imóveis, **Then** uma mensagem de erro é exibida com botão "Tentar novamente".
3. **Given** um card de imóvel, **When** o usuário inspeciona o DOM, **Then** nenhum elemento `<button>` está aninhado dentro de um elemento `<a>`, e todos os botões e links são elementos independentes.
4. **Given** um viewport de 7681023px (tablet), **When** o usuário visualiza os cards de imóveis, **Then** o layout do card se adapta sem altura fixa que trunque o conteúdo.
5. **Given** que múltiplos filtros estão ativos, **When** o usuário aplica novos filtros no desktop, **Then** um indicador visual sutil (opacidade reduzida nos cards) aparece imediatamente, antes do resultado da API chegar.
---
### User Story 2 — Campo de Busca Textual (Priority: P1)
Um visitante que já sabe o endereço, bairro ou código do imóvel que procura quer digitar o termo diretamente na página de listagem e ver resultados filtrados instantaneamente, sem precisar navegar por dropdowns.
**Why this priority**: A ausência de busca textual é o problema de arquitetura de informação mais crítico — todos os grandes portais imobiliários oferecem busca textual como ponto de entrada principal. Bloqueia completamente o fluxo de usuários que chegam com uma intenção específica.
**Independent Test**: Digitar "Barra Funda" no campo de busca e verificar que a URL muda para `/imoveis?q=Barra+Funda` e os resultados são filtrados.
**Acceptance Scenarios**:
1. **Given** a página `/imoveis`, **When** o usuário carrega a página, **Then** um campo de busca textual está visível no topo da área de resultados, com placeholder "Buscar por endereço, bairro ou código...".
2. **Given** o campo de busca preenchido com "Jardins", **When** o usuário para de digitar por 400ms, **Then** a listagem é atualizada com imóveis cujo título, endereço, bairro ou código contenha "Jardins".
3. **Given** uma busca ativa com `q=jardins`, **When** o usuário compartilha o link, **Then** o destinatário vê os mesmos resultados filtrados ao acessar a URL.
4. **Given** o campo de busca preenchido, **When** o usuário clica no botão `×` ou apaga o texto, **Then** o filtro de busca é removido e os resultados voltam ao estado sem filtro de texto.
5. **Given** uma busca que não retorna resultados, **When** a listagem é atualizada, **Then** o estado vazio é exibido com sugestões de termos alternativos.
---
### User Story 3 — Ordenação de Resultados (Priority: P2)
Um visitante quer controlar a ordem de exibição dos imóveis para ver primeiro os mais baratos, os maiores, os mais recentes ou os em destaque, sem precisar percorrer toda a listagem manualmente.
**Why this priority**: A ausência de ordenação obriga o usuário a depender inteiramente da ordem padrão do backend, removendo o controle que usuários esperam em qualquer catálogo de produtos.
**Independent Test**: Selecionar "Menor preço" no dropdown de ordenação e verificar que os cards se reorganizam por ordem crescente de preço.
**Acceptance Scenarios**:
1. **Given** a listagem de imóveis, **When** o usuário vê o header de resultados, **Then** um seletor de ordenação está visível ao lado do contador de resultados.
2. **Given** o seletor de ordenação, **When** o usuário seleciona "Menor preço", **Then** os imóveis são reordenados por preço crescente e o parâmetro `sort=price_asc` aparece na URL.
3. **Given** ordenação "Mais recente" ativa, **When** o usuário compartilha o link, **Then** o destinatário vê os resultados na mesma ordem.
4. **Given** uma ordenação ativa, **When** o usuário troca de página, **Then** a ordenação é mantida na nova página.
**Opções de ordenação disponíveis**:
- Relevância (padrão)
- Menor preço (`price_asc`)
- Maior preço (`price_desc`)
- Maior área (`area_desc`)
- Mais recente (`newest`)
---
### User Story 4 — Chips de Filtros Ativos (Priority: P2)
Um visitante com múltiplos filtros aplicados quer ver imediatamente quais filtros estão ativos e poder remover individualmente cada um deles sem precisar abrir o sidebar.
**Why this priority**: A ausência de chips de filtros ativos cria opacidade no estado atual da busca. O usuário não consegue entender por que o número de resultados é pequeno sem abrir o sidebar.
**Independent Test**: Aplicar filtros de tipo "Aluguel", cidade "São Paulo" e "2+ quartos"; verificar que chips aparecem acima dos resultados com botão de remoção individual em cada um.
**Acceptance Scenarios**:
1. **Given** pelo menos um filtro ativo, **When** o usuário vê a área de resultados, **Then** chips dos filtros ativos aparecem logo abaixo do campo de busca, acima do primeiro card.
2. **Given** chips de filtros visíveis, **When** o usuário clica no `×` de um chip específico, **Then** aquele filtro é removido individualmente e a listagem é atualizada.
3. **Given** dois ou mais filtros ativos, **When** os chips são exibidos, **Then** um botão "Limpar tudo" aparece ao lado dos chips.
4. **Given** nenhum filtro ativo, **When** a listagem é exibida, **Then** a área de chips não é renderizada.
---
### User Story 5 — Toggle de Visualização Lista/Grade (Priority: P2)
Um visitante que prefere comparar imóveis visualmente quer alternar entre visualização em lista (detalhada) e grade (fotos maiores, mais imóveis visíveis), com a preferência salva para próximas visitas.
**Why this priority**: A visualização em grade é especialmente valiosa para imóveis com fotos bonitas e para usuários em fase de descoberta. Aumenta o engajamento e o número de imóveis visualizados por sessão.
**Independent Test**: Clicar no botão de grade, verificar que os cards mudam para layout vertical com 2-3 colunas, e recarregar a página verificando que a preferência foi mantida.
**Acceptance Scenarios**:
1. **Given** a página `/imoveis`, **When** o usuário vê o header de resultados, **Then** dois botões de toggle de visualização estão visíveis: "Lista" (ativo por padrão) e "Grade".
2. **Given** o toggle de Grade selecionado, **When** a listagem é renderizada, **Then** os imóveis aparecem em grade de 1 coluna (mobile), 2 colunas (tablet) e 3 colunas (desktop), com foto em destaque acima das informações.
3. **Given** que o usuário selecionou visualização em grade, **When** ele recarrega a página, **Then** a listagem abre em modo grade (preferência salva localmente).
4. **Given** visualização em grade, **When** o usuário clica em um card, **Then** é redirecionado para a página de detalhes do imóvel normalmente.
---
### User Story 6 — Estado Vazio com Sugestões (Priority: P2)
Um visitante cuja combinação de filtros não retorna resultados recebe sugestões acionáveis de como relaxar os filtros para encontrar imóveis, em vez de uma mensagem genérica de "nenhum resultado".
**Why this priority**: O estado vazio atual desperdiça a oportunidade de reter o usuário. Sugestões de relaxamento de filtros reduzem abandonos e aumentam a chance de conversão.
**Independent Test**: Aplicar filtros impossíveis (ex.: 10+ quartos em bairro específico) e verificar que sugestões com contagem de resultados são exibidas.
**Acceptance Scenarios**:
1. **Given** filtros que retornam zero imóveis, **When** a listagem é atualizada, **Then** o estado vazio exibe sugestões específicas de filtros que podem ser relaxados, cada uma com a quantidade de imóveis que seria encontrada.
2. **Given** o estado vazio com sugestões, **When** o usuário clica em uma sugestão (ex.: "Ampliar faixa de preço"), **Then** o filtro correspondente é ajustado automaticamente e a listagem atualiza com os resultados sugeridos.
3. **Given** o estado vazio, **When** o usuário vê a tela, **Then** um botão "Limpar todos os filtros" é exibido como opção de escape.
---
### User Story 7 — Hierarquia Visual de CTAs no Card (Priority: P2)
Um visitante que vê a listagem de imóveis é guiado visualmente para a ação mais importante do card (ver detalhes), com ações secundárias (contato) e terciárias (comparar) em destaque progressivamente menor.
**Why this priority**: A hierarquia visual de CTAs impacta diretamente a taxa de conversão. Botões com peso visual equivalente não guiam o olho do usuário para a ação desejada.
**Independent Test**: Visualizar a listagem e verificar que "Ver detalhes" tem destaque primário (fundo colorido), "Entre em contato" tem destaque secundário (borda) e "Comparar" tem destaque terciário (ghost/minimal).
**Acceptance Scenarios**:
1. **Given** um card de imóvel, **When** o usuário vê os botões de ação, **Then** "Ver detalhes" tem estilo primário (fundo da cor da marca), "Entre em contato" tem estilo secundário (outline) e "Comparar" tem estilo terciário (ghost).
2. **Given** visualização em mobile, **When** o card é exibido, **Then** o botão "Ver detalhes" continua sendo o CTA mais destacado visualmente.
---
### User Story 8 — Refinamentos de Qualidade (Priority: P3)
Um visitante experimenta a listagem com animações suaves de entrada, indicadores claros de posição na paginação, botão de retorno ao topo, badges de status nos imóveis e navegação por teclado no carrossel.
**Why this priority**: Esses refinamentos aumentam a percepção de qualidade e polimento do produto, mas não bloqueiam nenhum fluxo de uso.
**Independent Test**: Navegar para a página 2, verificar o indicador "Exibindo XY de Z imóveis"; pressionar Tab para focar no carrossel e usar setas do teclado para navegar pelas fotos.
**Acceptance Scenarios**:
1. **Given** uma nova página de resultados carregada, **When** os cards aparecem, **Then** cada card entra com animação sutil de fade-in-up com atraso crescente (stagger de ~40ms por card).
2. **Given** a paginação, **When** o usuário vê o rodapé da listagem, **Then** o texto "Exibindo XY de Z imóveis" está visível acima ou integrado à paginação.
3. **Given** scroll de mais de 400px na página, **When** o botão "Voltar ao topo" flutuante aparece, **Then** clicar nele rola suavemente para o topo da página.
4. **Given** um imóvel marcado como destaque (`is_featured = true`), **When** o card é exibido, **Then** um badge "Destaque" é visível na foto do imóvel.
5. **Given** um imóvel criado nos últimos 7 dias, **When** o card é exibido, **Then** um badge "Novo" é visível na foto do imóvel.
6. **Given** o carrossel de fotos de um card, **When** o usuário navega via teclado (Tab para focar, setas para navegar), **Then** os botões prev/next recebem foco e são ativados por teclas direcionais.
7. **Given** a paginação no rodapé da lista, **When** o usuário está na página 2 ou superior, **Then** uma paginação idêntica também aparece no topo da lista de resultados.
8. **Given** que os dados do catálogo (tipos, comodidades, cidades) ainda estão carregando, **When** o sidebar é exibido, **Then** um skeleton placeholder é mostrado no lugar dos filtros, sem bloquear o carregamento dos imóveis.
---
### Edge Cases
- O que acontece quando o campo de busca textual é combinado com filtros de sidebar ativos ao mesmo tempo?
- Como o sistema lida com o parâmetro `q` contendo caracteres especiais ou SQL injection na URL?
- Como o badge "Novo" é calculado quando o servidor e o cliente estão em fusos horários diferentes?
- O que ocorre quando o carrossel tem apenas 1 foto (botões prev/next devem estar ocultos)?
- Como a paginação se comporta quando o total de resultados muda entre páginas (ex.: imóvel removido)?
- O que acontece se o usuário chegar na página 5 via link e a busca atual só tiver 3 páginas?
- Como a visualização em grade se comporta em dispositivos com largura entre 480640px?
---
## Requirements
### Functional Requirements
#### Sprint 1 — Correções Críticas
- **FR-001**: O sistema DEVE reestruturar o `PropertyRowCard` de forma que nenhum elemento `<button>` ou `<a>` esteja aninhado dentro de outro elemento `<a>`, garantindo HTML semântico válido.
- **FR-002**: Os botões prev/next do carrossel de fotos DEVEM ser visíveis em dispositivos touch sem depender do estado de hover, utilizando visibilidade condicional por breakpoint.
- **FR-003**: O sistema DEVE capturar erros de rede no fetch de imóveis e exibir uma mensagem de erro com botão de "Tentar novamente" em lugar da listagem vazia silenciosa.
- **FR-004**: O `PropertyRowCard` DEVE ter layout responsivo que se adapte entre mobile (coluna única vertical) e desktop (horizontal), sem altura fixa que trunque o conteúdo em tablets (7681023px).
- **FR-005**: A página `/imoveis` DEVE exibir um campo de busca textual proeminente no topo da área de resultados que filtre imóveis por título, endereço, bairro ou código do imóvel.
- **FR-006**: A busca textual DEVE usar debounce de 400ms para evitar requisições excessivas ao backend.
- **FR-007**: O parâmetro `q` DEVE ser sincronizado com a URL (`/imoveis?q=termo`) para permitir compartilhamento e histórico do browser.
- **FR-008**: O backend DEVE aceitar o parâmetro `q` na rota `GET /api/v1/properties` e aplicar busca case-insensitive nos campos `title`, `address`, `code` e `neighborhood.name`.
#### Sprint 2 — Alto Valor
- **FR-009**: A página DEVE exibir um seletor de ordenação ao lado do contador de resultados com as opções: Relevância, Menor preço, Maior preço, Maior área, Mais recente.
- **FR-010**: A ordenação selecionada DEVE ser sincronizada com a URL via parâmetro `sort` e mantida ao trocar de página.
- **FR-011**: O backend DEVE aceitar o parâmetro `sort` na rota `GET /api/v1/properties` com os valores: `relevance`, `price_asc`, `price_desc`, `area_desc`, `newest`.
- **FR-012**: Quando houver filtros ativos, chips removíveis DEVEM aparecer acima da listagem, cada um representando um filtro ativo com botão `×` para remoção individual.
- **FR-013**: Quando houver 2 ou mais filtros ativos, um botão "Limpar tudo" DEVE aparecer ao lado dos chips.
- **FR-014**: A página DEVE oferecer toggle de visualização Lista/Grade, com visualização em Grade exibindo cards verticais em 13 colunas responsivas.
- **FR-015**: A preferência de visualização Lista/Grade DEVE ser persistida em `localStorage` e restaurada na próxima visita.
- **FR-016**: O estado vazio (zero resultados) DEVE exibir sugestões acionáveis de filtros relaxados, cada uma com a quantidade de imóveis que seria retornada.
- **FR-017**: A hierarquia visual dos CTAs nos cards DEVE ser: "Ver detalhes" (primário — fundo da cor da marca), "Entre em contato" (secundário — outline), "Comparar" (terciário — ghost).
#### Sprint 3 — Refinamentos
- **FR-018**: Os cards DEVEM entrar na tela com animação fade-in-up com atraso crescente (stagger de ~40ms por card) a cada carregamento de nova página.
- **FR-019**: A paginação DEVE exibir o indicador de posição "Exibindo XY de Z imóveis".
- **FR-020**: Um botão flutuante "Voltar ao topo" DEVE aparecer após scroll de 400px e scroll suavemente ao topo ao ser clicado.
- **FR-021**: Imóveis com `is_featured = true` DEVEM exibir um badge "Destaque" sobreposto à foto do card.
- **FR-022**: Imóveis criados nos últimos 7 dias DEVEM exibir um badge "Novo" sobreposto à foto do card.
- **FR-023**: O carrossel de fotos DEVE suportar navegação por teclado: Tab para focar nos botões prev/next, setas direcionais para navegar entre slides.
- **FR-024**: A paginação DEVE aparecer também no topo da listagem de resultados (além do rodapé já existente).
- **FR-025**: O sidebar de filtros DEVE exibir um skeleton placeholder enquanto os dados do catálogo (tipos, comodidades, cidades) ainda estão carregando, sem bloquear a exibição dos imóveis.
- **FR-026**: O carrossel DEVE renderizar apenas o slide atual e os slides adjacentes (±1), evitando renderizar todas as fotos no DOM simultaneamente.
### Key Entities
- **Imóvel (Property)**: Unidade de listagem com título, endereço, código, bairro, tipo/subtipo, preço, área, fotos, flags `is_featured` e data de criação.
- **Filtros Ativos**: Estado derivado dos parâmetros de URL que representa a combinação atual de filtros aplicados pelo usuário, incluindo `q` (busca textual) e `sort` (ordenação).
- **Chip de Filtro**: Representação visual de um filtro ativo individual, removível de forma independente.
- **Preferência de Visualização**: Configuração do usuário (Lista ou Grade) persistida localmente entre sessões.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Usuários em dispositivos mobile conseguem navegar pelas fotos do carrossel em 100% dos cards, sem depender de interação de hover.
- **SC-002**: Em caso de falha de rede, 100% das tentativas de carregamento resultam em mensagem de erro visível com opção de retentativa — zero falhas silenciosas.
- **SC-003**: Usuários com intenção específica (endereço, código ou bairro) conseguem filtrar resultados pela busca textual em menos de 5 segundos após digitar o termo.
- **SC-004**: A estrutura HTML da listagem não contém nenhum elemento interativo aninhado ilegalmente (`<button>` dentro de `<a>` ou vice-versa), validada por ferramentas automáticas de lint.
- **SC-005**: Usuários em tablets (7681023px) conseguem ler todas as informações de um card sem conteúdo truncado ou cortado por altura fixa.
- **SC-006**: Usuários conseguem identificar qual CTA é primário em menos de 3 segundos ao olhar para um card de imóvel.
- **SC-007**: Usuários com filtros aplicados conseguem identificar e remover qualquer filtro individual sem abrir o sidebar.
- **SC-008**: A preferência de visualização (Lista/Grade) é mantida entre sessões — 100% de consistência no retorno ao site.
- **SC-009**: O estado vazio apresenta ao menos 2 sugestões acionáveis de relaxamento de filtros, cada uma com contagem de resultados esperados.
- **SC-010**: Navegação completa da listagem (busca, filtros, ordenação, paginação, visualização do card) é realizável inteiramente por teclado, sem necessidade de mouse.
---
## Assumptions
- O campo `is_featured` já existe no modelo `Property` do backend ou pode ser adicionado via migration sem impacto em dados existentes.
- A data de criação (`created_at`) já existe no modelo `Property` e é preenchida automaticamente.
- O campo `code` (código do imóvel) já existe no modelo `Property` e é único.
- O sistema de filtros sincronizados com URL (`filtersToParams`) já está implementado e será estendido para incluir os novos parâmetros `q` e `sort`.
- O banco de dados suporta busca case-insensitive (`ILIKE`) nos campos relevantes sem necessidade de extensão adicional.
- A preferência de visualização (Lista/Grade) é armazenada em `localStorage` — não há necessidade de sincronização com conta de usuário logado nesta versão.
- O componente `PropertyGridCard` (modo grade) é um novo componente a ser criado; o `PropertyRowCard` existente não será removido.
- Animações de entrada respeitam a preferência do sistema `prefers-reduced-motion` — usuários que optaram por menos movimento não verão as animações.
- O indicador de posição na paginação ("Exibindo XY de Z imóveis") usa os dados já retornados pela API (`total`, `page`, `per_page`).
- O botão "Voltar ao topo" não conflita com a `ComparisonBar` existente — quando a barra de comparação está visível, o botão é posicionado acima dela.
- As sugestões do estado vazio são calculadas com requisições paralelas ao backend com filtros relaxados — não requerem endpoint dedicado.
- Nenhuma mudança em autenticação, permissões ou dados de usuário logado está no escopo desta feature.

View file

@ -0,0 +1,426 @@
---
description: "Tasks para a feature 023 - Melhorias UX/UI — Listagem de Imóveis"
---
# Tasks: Melhorias UX/UI — Listagem de Imóveis (023)
**Input**: Design documents de `specs/023-ux-melhorias-imoveis/`
**Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/properties-api.md ✅ · auditoria: specs/022-ux-audit-imoveis/ux-audit.md ✅
**Sem migrations** — todos os campos usados já existem no modelo `Property`
---
## Format: `[ID] [P?] [Story?] Description — arquivo`
- **[P]**: Pode executar em paralelo (arquivo diferente, sem bloqueadores incompletos)
- **[Story]**: User story correspondente (US1US8)
- Arquivo exato indicado em cada task
- **Sprint** de cada fase indicado no cabeçalho
---
## Phase 1: Foundational — Backend (Bloqueador de testes de integração)
**Sprint**: Pré-sprint (deve preceder o início do Sprint 1)
**Purpose**: Adicionar `q` e `sort` na rota existente `GET /api/v1/properties`. Sem migration — campos `title`, `address`, `code`, `neighborhood_id`, `price`, `area_m2`, `created_at`, `is_featured` já existem. Este phase não tem dependências de frontend.
**⚠️ CRÍTICO**: As tasks T003T010 do Sprint 1 que dependem do backend (integração de busca textual) requerem T001 completo. As tasks de refactor de frontend (T004T007) podem ser iniciadas em paralelo com T001/T002.
- [ ] T001 Adicionar parâmetros `q` (busca ILIKE em `title`, `address`, `code`, `neighborhood.name` via `outerjoin` com `aliased(Neighborhood)`) e `sort` (whitelist com `sort_map`) na rota `GET /api/v1/properties` em `backend/app/routes/properties.py` — sanitização de `q`: `.strip()` + truncamento a 200 chars; `sort` com fallback para `created_at.desc()`
**Critérios de aceitação**:
- `GET /api/v1/properties?q=Jardins` retorna apenas imóveis com "Jardins" no título, endereço, código ou bairro
- `GET /api/v1/properties?sort=price_asc` retorna imóveis em ordem crescente de preço
- `GET /api/v1/properties?sort=invalido` retorna imóveis na ordem padrão (sem erro 400/500)
- `GET /api/v1/properties?q=<script>alert(1)</script>` não causa SQL injection nem 500
- [ ] T002 [P] Criar/atualizar testes pytest em `backend/tests/test_properties.py` para validar `q` (busca por título, por bairro, por código) e `sort` (price_asc retorna menor primeiro, area_desc retorna maior primeiro, valor desconhecido usa default) — fixture com ao menos 3 imóveis de preços distintos
**Critérios de aceitação**:
- `test_search_by_title_q`, `test_search_by_neighborhood_q`, `test_search_by_code_q` passam
- `test_sort_price_asc`, `test_sort_price_desc`, `test_sort_area_desc`, `test_sort_unknown_fallback` passam
- `pytest tests/test_properties.py -v` termina verde sem erros
**Checkpoint**: `curl "http://localhost:5000/api/v1/properties?q=test&sort=price_asc"` retorna 200 com `items` e `total`.
---
## Phase 2: Sprint 1 — Correções Críticas (P1)
**Sprint**: 1
**Purpose**: Resolver os 5 problemas 🔴 críticos identificados na auditoria: semântica HTML inválida (FR-001), carrossel inacessível em mobile (FR-002), ausência de tratamento de erro de rede (FR-003), layout fixo em tablets (FR-004) e campo de busca textual (FR-005 a FR-008).
**Independent Test (US1)**: Abrir `/imoveis` em mobile, navegar pelas fotos tocando em prev/next, simular falha de rede e verificar mensagem de erro. Inspecionar DOM e confirmar ausência de `<button>` dentro de `<a>`.
**Independent Test (US2)**: Digitar "Barra Funda" no campo de busca, verificar que URL muda para `/imoveis?q=Barra+Funda` e resultados são filtrados. Limpar busca e verificar retorno ao estado anterior.
---
### US1 — Correções Críticas de Usabilidade
- [ ] T003 [US1] Refatorar estrutura HTML do `frontend/src/components/PropertyRowCard.tsx` — substituir o `<Link>` que envolve toda a seção de informações por um overlay absoluto (`className="absolute inset-0" tabIndex={-1} aria-label="Ver detalhes: {title}"`); mover botões "Comparar" e "Entre em contato" para fora do `<Link>` com `relative z-index: 10`; envolver o card em `<article className="relative group ...">` — **este refactor é pré-requisito para T005, T015 e T019**
**Critérios de aceitação**:
- Nenhum `<button>` aninhado dentro de `<a>` no DOM inspecionado
- Clicar no card (fora dos botões) navega para a página de detalhes
- Clicar em "Comparar" ou "Entre em contato" não dispara navegação
- Leitor de tela anuncia o link com `aria-label` correto
- [ ] T004 [US1] Corrigir visibilidade dos botões prev/next do carrossel em dispositivos touch em `frontend/src/components/PropertyRowCard.tsx` — trocar `opacity-0 group-hover:opacity-100` por `opacity-100 sm:opacity-0 sm:group-hover:opacity-100` nos botões de navegação do carrossel (visível sempre em mobile, hover-only em desktop)
**Critérios de aceitação**:
- Em viewport ≤640px, botões prev/next são visíveis sem toque/hover
- Em viewport ≥640px, botões prev/next aparecem apenas com hover no card
- Botões com apenas 1 foto ficam ocultos (`photos.length <= 1`)
- [ ] T005 [US1] Corrigir layout responsivo do card em `frontend/src/components/PropertyRowCard.tsx` — remover `h-[220px]` fixo do article e `w-[340px]` fixo da imagem; usar `flex flex-col sm:flex-row sm:h-[220px]` no article e `w-full h-48 sm:w-[280px] sm:h-full lg:w-[340px]` na div da imagem — garante que em tablets (7681023px) o conteúdo não seja truncado
**Critérios de aceitação**:
- Em viewport 768px, o card exibe título, endereço e stats sem corte de texto
- Em viewport 1024px, o card mantém layout horizontal com proporções corretas
- Em viewport 375px (mobile), o card exibe layout em coluna única sem overflow
- [ ] T006 [US1] Implementar tratamento de erro de rede em `frontend/src/pages/PropertiesPage.tsx` — adicionar `const [error, setError] = useState<string | null>(null)`; no bloco `catch` do `fetchProperties`, definir `setError('Não foi possível carregar os imóveis. Tente novamente.')` e limpar em nova tentativa; renderizar mensagem de erro com botão "Tentar novamente" que chama `fetchProperties()` no lugar da listagem vazia silenciosa
**Critérios de aceitação**:
- Com API inacessível (ex: container parado), mensagem de erro é exibida
- Botão "Tentar novamente" dispara novo request ao ser clicado
- Erro é limpo quando um request subsequente é bem-sucedido
- Skeleton de loading não aparece durante o estado de erro
- [ ] T007 [US1] Adicionar indicador visual de carregamento sutil em `frontend/src/pages/PropertiesPage.tsx` — aplicar `className={loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}` com `transition-opacity duration-150` na div que envolve os cards; manter cards anteriores visíveis com opacidade reduzida ao invés de mostrar skeleton completo ao trocar filtros
**Critérios de aceitação**:
- Ao mudar qualquer filtro, os cards anteriores ficam com opacidade reduzida imediatamente (antes do response da API)
- Ao completar o request, opacidade volta a 100% com transição suave
- Cliques nos cards são bloqueados durante loading (`pointer-events-none`)
---
### US2 — Campo de Busca Textual
- [ ] T008 [P] [US2] Adicionar `q?: string` ao tipo `PropertyFilters` e criar tipo `SortOption = 'relevance' | 'price_asc' | 'price_desc' | 'area_desc' | 'newest'` com `sort?: SortOption` em `frontend/src/services/properties.ts` — incluir ambos como query params na chamada Axios
**Critérios de aceitação**:
- Compilação TypeScript sem erros após a alteração
- Chamada `getProperties({ q: 'Jardins', sort: 'price_asc' })` gera URL `?q=Jardins&sort=price_asc`
- `q` vazio ou undefined não adiciona `?q=` na URL (usar `params` do Axios com valores falsy omitidos)
- [ ] T009 [P] [US2] Criar `frontend/src/components/SearchBar.tsx` — input controlado com placeholder "Buscar por endereço, bairro ou código...", ícone de lupa, debounce de 400ms via `useEffect` + `setTimeout`, botão `×` visível quando há texto, limpa o campo e dispara `onSearch('')` ao clicar; props: `value: string`, `onSearch: (q: string) => void`
**Critérios de aceitação**:
- Digitar "Jard" não dispara chamada imediata; após 400ms de inatividade, `onSearch('Jard')` é chamado
- Botão `×` aparece quando `value.length > 0` e desaparece quando vazio
- Clicar em `×` chama `onSearch('')` e limpa o input
- Campo tem `role="search"` e `aria-label="Buscar imóveis"`
- [ ] T010 [US2] Integrar `SearchBar` em `frontend/src/pages/PropertiesPage.tsx` — posicionar acima do header de resultados (contador + sort), sincronizar com parâmetro `q` da URL via `useSearchParams`, resetar `page` para 1 ao mudar a busca, exibir estado vazio específico com sugestão de termos quando busca não retorna resultados
**Critérios de aceitação**:
- Digitar "Barra Funda" atualiza URL para `/imoveis?q=Barra+Funda` sem reload completo
- Compartilhar URL com `?q=Jardins` exibe resultados filtrados para o destinatário
- Limpar o campo remove `q` da URL e restaura listagem sem filtro textual
- Busca + filtros de sidebar funcionam combinados (AND lógico)
**Checkpoint Sprint 1**: Abrir `/imoveis`, inspecionar DOM sem `<button>` dentro de `<a>`, navegar fotos em mobile, testar busca por bairro, simular rede off e ver mensagem de erro.
---
## Phase 3: Sprint 2 — Alto Valor de Conversão (P2)
**Sprint**: 2
**Purpose**: Adicionar funcionalidades que aumentam diretamente a taxa de conversão: ordenação de resultados (FR-009 a FR-011), chips de filtros ativos (FR-012, FR-013), toggle Lista/Grade (FR-014, FR-015), estado vazio rico (FR-016) e hierarquia visual de CTAs (FR-017).
**Dependências**: T003 (refactor do card) deve estar completo antes de T019 (CTAs). T008 (PropertyFilters) deve estar completo antes de T011. T015 (PropertyGridCard) deve estar completo antes de T019 aplicar a este.
---
### US3 — Ordenação de Resultados
- [ ] T011 [US3] Adicionar seletor de ordenação no header de resultados em `frontend/src/pages/PropertiesPage.tsx``<select>` com 5 opções mapeadas para `SortOption`, ao lado do contador "X imóveis encontrados"; sincronizar `sort` com URL via `useSearchParams`; resetar `page` para 1 ao mudar ordenação; manter `sort` ao trocar de página
**Critérios de aceitação**:
- Selecionar "Menor preço" atualiza URL para `?sort=price_asc` e reordena a listagem
- Navegar para a página 2 com `sort=price_asc` mantém a ordenação na nova página
- Compartilhar URL com `?sort=newest` exibe mesma ordenação para o destinatário
- Opção "Relevância" é a default quando `sort` está ausente na URL
---
### US4 — Chips de Filtros Ativos
- [ ] T012 [P] [US4] Criar `frontend/src/components/ActiveFiltersBar.tsx` — recebe `filters: PropertyFilters` e `catalogData` (tipos, cidades, bairros); deriva array de `ActiveFilterChip[]` com `key`, `label` legível e `onRemove: () => void`; renderiza chips com botão `×` usando `aria-label="Remover filtro {label}"`; exibe botão "Limpar tudo" apenas quando `chips.length >= 2`; não renderiza nada quando `chips.length === 0`
**Critérios de aceitação**:
- Com filtros `listing_type=aluguel` + `city_id=1` + `bedrooms_min=2`, renderiza 3 chips com labels legíveis
- Clicar no `×` do chip "São Paulo" remove apenas `city_id` dos filtros e dispara `onFilterChange`
- Botão "Limpar tudo" aparece com ≥2 chips e remove todos ao clicar
- Com zero filtros ativos, o componente não renderiza nenhum elemento no DOM
- [ ] T013 [US4] Integrar `ActiveFiltersBar` em `frontend/src/pages/PropertiesPage.tsx` — posicionar abaixo de `SearchBar`, acima do primeiro card; passar `filters` atual e callbacks de remoção individual por chave de filtro (`onRemove(key) => setFilters(prev => omit(prev, key))`)
**Critérios de aceitação**:
- Aplicar filtro de tipo + cidade exibe chips correspondentes acima dos resultados
- Remover chip via `×` atualiza a listagem sem apagar outros filtros ativos
- Chips desaparecem quando todos os filtros são removidos via "Limpar tudo"
---
### US5 — Toggle de Visualização Lista/Grade
- [ ] T014 [P] [US5] Criar `frontend/src/components/PropertyGridCard.tsx` — card vertical com foto em destaque (aspectRatio 4/3, `object-cover`), título, preço, badges básicos (quartos/área/vagas), `<Link to={/imoveis/${slug}}>` como overlay absoluto (`tabIndex={-1}`), botão "Ver detalhes" como CTA primário visível; sem botões "Comparar" e "Entre em contato" (modo grade prioriza descoberta)
**Critérios de aceitação**:
- Card renderiza foto, título e preço sem truncamento em qualquer largura de coluna
- Clicar no card (fora do botão) navega para a página de detalhes
- Clicar em "Ver detalhes" navega para a página de detalhes
- Sem `<button>` aninhado em `<a>` no DOM
- [ ] T015 [US5] Adicionar toggle Lista/Grade no header de `frontend/src/pages/PropertiesPage.tsx` — estado `viewMode: ViewMode` inicializado de `localStorage.getItem('imoveis_view_mode') ?? 'list'`; dois botões de toggle com ícones (≡ Lista / ⊞ Grade) com `aria-pressed`; grid responsivo quando grade (`grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4`) vs flex-col quando lista; persistir no `localStorage` ao mudar
**Critérios de aceitação**:
- Clicar em "Grade" alterna layout para grid de 13 colunas responsivo com `PropertyGridCard`
- Recarregar a página mantém o modo de visualização selecionado (localStorage)
- Botão ativo recebe indicação visual distinta (`aria-pressed="true"`)
- Navegação para detalhe funciona em ambos os modos
---
### US6 — Estado Vazio com Sugestões
- [ ] T016 [P] [US6] Criar `frontend/src/components/EmptyStateWithSuggestions.tsx` — recebe `currentFilters: PropertyFilters` e `onApplySuggestion: (filters: PropertyFilters) => void`; exibe mensagem "Nenhum imóvel encontrado" + lista de sugestões acionáveis (ex: remover filtro de bairro, ampliar faixa de preço, reduzir mínimo de quartos), cada sugestão com contagem de imóveis seria encontrada (recebida via prop `suggestions: EmptyStateSuggestion[]`); botão "Limpar todos os filtros"
**Critérios de aceitação**:
- Exibe ao menos 3 sugestões quando há filtros ativos
- Clicar em sugestão chama `onApplySuggestion` com filtros relaxados e atualiza listagem
- Botão "Limpar todos os filtros" remove todos os filtros e retorna resultados
- Contagem de imóveis por sugestão é exibida (ex: "→ 12 imóveis disponíveis")
- [ ] T017 [US6] Integrar `EmptyStateWithSuggestions` em `frontend/src/pages/PropertiesPage.tsx` — quando `result.total === 0` e `!loading`, fazer 3 requests paralelos (`Promise.all`) com filtros relaxados (sem `neighborhood_id`, sem `bedrooms_min`, sem `price_max`) para calcular contagens; passar `suggestions` para o componente; substituir o estado vazio simples atual
**Critérios de aceitação**:
- Com filtros impossíveis (ex: `bedrooms_min=10`), estado vazio mostra sugestões com contagens reais
- Requests de sugestões são paralelos (não sequenciais), sem bloquear a UI
- Clicar numa sugestão atualiza os filtros ativos e exibe os resultados correspondentes
- Quando não há filtros ativos e o resultado é vazio, exibe mensagem genérica sem sugestões
---
### US7 — Hierarquia Visual de CTAs no Card
- [ ] T018 [US7] Atualizar hierarquia visual dos CTAs em `frontend/src/components/PropertyRowCard.tsx` — "Ver detalhes" como `<Link>` com estilo primário (fundo `var(--color-brand)`, texto branco); "Entre em contato" como `<button>` com estilo outline (borda `var(--color-brand)`, background transparente); "Comparar" como `<button>` com estilo ghost (sem borda, apenas texto muted com hover sutil); manter todos fora do `<Link>` overlay (depende de T003)
**Critérios de aceitação**:
- "Ver detalhes" tem fundo colorido e destaque visual primário
- "Entre em contato" tem borda colorida sem fundo (outline)
- "Comparar" tem aparência discreta sem borda (ghost/minimal)
- Hierarquia mantida em viewport mobile (375px)
- Nenhum `<button>` dentro de `<a>` no DOM
**Checkpoint Sprint 2**: Aplicar filtros de tipo + cidade + quartos, verificar chips aparecem. Selecionar ordenação por preço. Alternar para grade. Aplicar filtro impossível e verificar sugestões. Confirmar hierarquia visual dos CTAs.
---
## Phase 4: Sprint 3 — Refinamentos de Qualidade (P3)
**Sprint**: 3
**Purpose**: Polimento percebido que aumenta a sensação de qualidade do produto sem bloquear fluxos de uso: animações (FR-018), indicador de paginação (FR-019), scroll-to-top (FR-020), badges de status (FR-021, FR-022), teclado no carrossel (FR-023), paginação no topo (FR-024), skeleton no sidebar (FR-025).
**Independent Test (US8)**: Navegar para página 2, verificar "Exibindo XY de Z imóveis"; pressionar Tab no carrossel e usar setas para navegar; verificar badge "Destaque" em imóvel com `is_featured=true`.
---
### US8 — Refinamentos de Qualidade
- [ ] T019 [US8] Adicionar keyframe `@keyframes fade-in-up` em `frontend/src/index.css` (translateY de 8px→0, opacity 0→1, duration 300ms ease-out) e aplicar `style={{ animationDelay: \`${index * 40}ms\` }}` nos cards mapeados em `frontend/src/pages/PropertiesPage.tsx` para stagger; resetar animação ao trocar de página (chave no `key` do item)
**Critérios de aceitação**:
- Cards entram com animação sutil ao carregar nova página
- Stagger visível entre cards consecutivos (~40ms de diferença)
- Animação não ocorre durante loading (cards com opacidade reduzida) — apenas após novo resultado
- Sem `prefers-reduced-motion` override (adicionar `@media (prefers-reduced-motion: reduce)` sem animação)
- [ ] T020 [P] [US8] Adicionar indicador de posição "Exibindo XY de Z imóveis" em `frontend/src/pages/PropertiesPage.tsx` — calcular `from = (page - 1) * perPage + 1`, `to = Math.min(page * perPage, total)`; renderizar próximo ao contador de resultados ou acima da paginação inferior
**Critérios de aceitação**:
- Na página 1 com 16 por página e 45 total: exibe "Exibindo 116 de 45 imóveis"
- Na página 3: exibe "Exibindo 3345 de 45 imóveis"
- Não exibir quando `total === 0` (estado vazio)
- [ ] T021 [P] [US8] Criar `frontend/src/components/ScrollToTopButton.tsx` — botão flutuante fixo (`fixed bottom-6 right-6`), aparece quando `scrollY > 400` via `useEffect` com listener de `scroll`, chama `window.scrollTo({ top: 0, behavior: 'smooth' })` ao clicar; integrar em `frontend/src/pages/PropertiesPage.tsx` como filho direto da página
**Critérios de aceitação**:
- Botão fica oculto antes de 400px de scroll e aparece após esse limiar
- Clicar no botão rola suavemente para o topo
- Botão tem `aria-label="Voltar ao topo"` para acessibilidade
- Listener de scroll é removido no cleanup do `useEffect` (sem leak)
- [ ] T022 [US8] Adicionar badges "Destaque" e "Novo" sobrepostos à foto em `frontend/src/components/PropertyRowCard.tsx` e `frontend/src/components/PropertyGridCard.tsx` — badge "Destaque" quando `property.is_featured === true` (fundo âmbar, `⭐ Destaque`); badge "Novo" quando `created_at` for de até 7 dias atrás — calculado no frontend: `Date.now() - new Date(created_at).getTime() < 7 * 24 * 60 * 60 * 1000`; posicionar `absolute top-2 left-2` na div da foto
**Critérios de aceitação**:
- Imóvel com `is_featured=true` exibe badge "⭐ Destaque" na foto
- Imóvel com `created_at` de ontem exibe badge "Novo" na foto
- Imóvel com `created_at` de 8 dias atrás não exibe badge "Novo"
- Ambos os badges podem coexistir no mesmo card
- [ ] T023 [US8] Adicionar navegação por teclado no carrossel de `frontend/src/components/PropertyRowCard.tsx` — botões prev/next devem ser focáveis via Tab; ao focar qualquer botão do carrossel, adicionar `onKeyDown` que responde a `ArrowLeft` (prev) e `ArrowRight` (next); `aria-label="Foto anterior"` / `"Próxima foto"` nos botões
**Critérios de aceitação**:
- Tab navega para os botões prev/next do carrossel
- Pressionar ArrowRight no botão next avança o slide
- Pressionar ArrowLeft no botão prev retrocede o slide
- Botões com 1 única foto ficam com `aria-disabled="true"` e não respondem a teclado
- [ ] T024 [P] [US8] Adicionar paginação duplicada no topo da listagem em `frontend/src/pages/PropertiesPage.tsx` — renderizar o mesmo componente de paginação (já existente) acima do primeiro card, com `aria-label="Paginação superior"`; visível apenas quando `result.pages > 1`
**Critérios de aceitação**:
- Com mais de 1 página de resultados, paginação aparece no topo E no rodapé
- Com 1 página apenas, apenas o rodapé é exibido
- Ambas as paginações atualizam a página ao mesmo tempo (estado compartilhado)
- [ ] T025 [P] [US8] Adicionar skeleton de carregamento no `frontend/src/components/FilterSidebar.tsx` — exibir placeholders animados (`animate-pulse bg-surface rounded`) no lugar dos filtros de tipo, cidade, bairro e comodidades enquanto `catalogLoading === true`; a listagem de imóveis continua carregando independentemente
**Critérios de aceitação**:
- Enquanto `catalogLoading` for true, skeleton é exibido no sidebar sem bloquear a listagem
- Ao completar o carregamento, skeleton é substituído pelos filtros reais sem flash
- Skeleton tem mesma altura aproximada dos filtros para evitar CLS
**Checkpoint Sprint 3**: Navegar para página 2 e verificar indicador de posição. Rolar 400px e verificar botão flutuante. Verificar badge em imóvel com `is_featured=true`. Testar Tab + setas no carrossel.
---
## Phase 5: Polish & Verificação Final
**Purpose**: Validação cruzada de semântica HTML, acessibilidade, TypeScript e testes backend.
- [ ] T026 Inspecionar DOM de `/imoveis` no browser e verificar ausência de `<button>` dentro de `<a>` em todos os cards (lista e grade) — corrigir qualquer instância remanescente em `frontend/src/components/PropertyRowCard.tsx` ou `frontend/src/components/PropertyGridCard.tsx`
**Critérios de aceitação**:
- DevTools → Elements: nenhum seletor `a button`, `a [role=button]` encontrado
- Validação HTML5 sem erros de aninhamento inválido
- [ ] T027 Executar testes backend e verificar build TypeScript sem erros — `docker-compose exec backend uv run pytest tests/test_properties.py -v` deve terminar verde; `docker-compose exec frontend npx tsc --noEmit` deve terminar sem erros
**Critérios de aceitação**:
- Todos os testes pytest de `test_properties.py` passam
- Compilação TypeScript sem erros de tipo
- Nenhum `console.error` no browser ao carregar `/imoveis`
---
## Dependency Graph
```
T001 (backend q+sort)
└─► T002 (testes backend)
└─► T010 (integração SearchBar — valida endpoint)
T003 (refactor HTML card)
└─► T004 (carrossel mobile — mesmo arquivo)
└─► T005 (layout tablet — mesmo arquivo)
└─► T018 (CTAs — reestrutura botões)
└─► T022 (badges — adiciona na foto já reestruturada)
└─► T023 (teclado carrossel — botões reestruturados)
T008 (PropertyFilters tipos)
└─► T009 (SearchBar usa onSearch callback)
└─► T010 (PropertiesPage usa q no state)
└─► T011 (PropertiesPage usa sort no state)
└─► T012 (ActiveFiltersBar usa PropertyFilters)
└─► T016 (EmptyStateWithSuggestions usa PropertyFilters)
T014 (PropertyGridCard — novo componente)
└─► T015 (toggle grade renderiza PropertyGridCard)
└─► T022 (badges adicionados em PropertyGridCard)
T006 (error state PropertiesPage)
└─► T007 (opacity loading — mesmo arquivo, mesma sessão)
└─► T010 (integração SearchBar — mesmo arquivo)
└─► T011 (seletor sort — mesmo arquivo)
└─► T013 (integra ActiveFiltersBar — mesmo arquivo)
└─► T015 (toggle grade — mesmo arquivo)
└─► T017 (integra EmptyState — mesmo arquivo)
└─► T019 (animação — mesmo arquivo)
└─► T020 (indicador posição — mesmo arquivo)
└─► T024 (paginação top — mesmo arquivo)
```
---
## Parallel Execution Examples
### Sprint 1 — Paralelo possível
```
Thread A: T001 → T002
Thread B: T003 → T004 → T005
Thread C: T008 → T009
Thread D: T006 → T007
```
→ Após threads B e C concluídos: T010 (integra SearchBar em PropertiesPage com q sincronizado)
### Sprint 2 — Paralelo possível
```
Thread A: T011 (sort selector em PropertiesPage)
Thread B: T012 (ActiveFiltersBar — novo arquivo)
Thread C: T014 (PropertyGridCard — novo arquivo)
Thread D: T016 (EmptyStateWithSuggestions — novo arquivo)
```
→ Após thread B: T013 (integra ActiveFiltersBar em PropertiesPage)
→ Após thread C: T015 (toggle grade em PropertiesPage)
→ Após thread D: T017 (integra EmptyState em PropertiesPage)
→ Após T003 completo: T018 (CTAs em PropertyRowCard)
### Sprint 3 — Paralelo possível
```
Thread A: T019 (animações — PropertiesPage + index.css)
Thread B: T020 (indicador posição — PropertiesPage)
Thread C: T021 (ScrollToTopButton — novo arquivo)
Thread D: T024 (paginação top — PropertiesPage)
Thread E: T025 (skeleton sidebar — FilterSidebar)
```
→ Após T003+T014: T022 (badges em ambos os cards)
→ Após T003: T023 (teclado carrossel em PropertyRowCard)
---
## Implementation Strategy
### MVP Scope (Sprint 1 apenas)
Para uma entrega incremental mínima que resolve os problemas críticos bloqueadores de conversão:
- **T001** + **T002**: Backend com `q` e `sort`
- **T003** + **T004** + **T005**: Card sem HTML inválido e funcional em mobile/tablet
- **T006** + **T007**: Tratamento de erro e feedback de loading
- **T008** + **T009** + **T010**: Campo de busca textual funcional
Resultado: `/imoveis` sem erros críticos de HTML, funcional em mobile/tablet, com busca textual e tratamento de erros.
### Sprint 2 — Funcionalidades de Conversão
Adicionar T011 (ordenação), T012T013 (chips), T014T015 (grade), T016T017 (empty state rico), T018 (CTAs).
### Sprint 3 — Polimento
Adicionar T019T025 (animações, badges, teclado, scroll-to-top, paginação dupla, skeleton sidebar).
---
## Summary
| Métrica | Valor |
|---|---|
| Total de tasks | 27 |
| Sprint 1 (P1 — crítico) | T001T010 (10 tasks) |
| Sprint 2 (P2 — alto valor) | T011T018 (8 tasks) |
| Sprint 3 (P3 — refinamentos) | T019T025 (7 tasks) |
| Polish | T026T027 (2 tasks) |
| Tasks backend | T001, T002 (2 tasks) |
| Tasks frontend | T003T025 (23 tasks) |
| Tasks de teste | T002 (pytest backend) |
| Tasks paralelizáveis [P] | T002, T008, T009, T012, T014, T016, T020, T021, T024, T025 |
| Novos componentes | SearchBar, PropertyGridCard, ActiveFiltersBar, EmptyStateWithSuggestions, ScrollToTopButton |
| Arquivos modificados | PropertyRowCard, PropertiesPage, FilterSidebar, services/properties.ts, index.css |
| Migrations de banco | Nenhuma |