feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
38
specs/023-ux-melhorias-imoveis/checklists/requirements.md
Normal file
38
specs/023-ux-melhorias-imoveis/checklists/requirements.md
Normal 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`
|
||||
214
specs/023-ux-melhorias-imoveis/contracts/properties-api.md
Normal file
214
specs/023-ux-melhorias-imoveis/contracts/properties-api.md
Normal 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)
|
||||
```
|
||||
116
specs/023-ux-melhorias-imoveis/data-model.md
Normal file
116
specs/023-ux-melhorias-imoveis/data-model.md
Normal 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
|
||||
```
|
||||
104
specs/023-ux-melhorias-imoveis/plan.md
Normal file
104
specs/023-ux-melhorias-imoveis/plan.md
Normal 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] |
|
||||
160
specs/023-ux-melhorias-imoveis/quickstart.md
Normal file
160
specs/023-ux-melhorias-imoveis/quickstart.md
Normal 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
|
||||
254
specs/023-ux-melhorias-imoveis/spec.md
Normal file
254
specs/023-ux-melhorias-imoveis/spec.md
Normal 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 768–1023px (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 X–Y 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 X–Y 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 480–640px?
|
||||
|
||||
---
|
||||
|
||||
## 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 (768–1023px).
|
||||
- **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 1–3 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 X–Y 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 (768–1023px) 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 X–Y 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.
|
||||
426
specs/023-ux-melhorias-imoveis/tasks.md
Normal file
426
specs/023-ux-melhorias-imoveis/tasks.md
Normal 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 (US1–US8)
|
||||
- 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 T003–T010 do Sprint 1 que dependem do backend (integração de busca textual) requerem T001 completo. As tasks de refactor de frontend (T004–T007) 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 (768–1023px) 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 1–3 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 X–Y 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 X–Y 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 1–16 de 45 imóveis"
|
||||
- Na página 3: exibe "Exibindo 33–45 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), T012–T013 (chips), T014–T015 (grade), T016–T017 (empty state rico), T018 (CTAs).
|
||||
|
||||
### Sprint 3 — Polimento
|
||||
|
||||
Adicionar T019–T025 (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) | T001–T010 (10 tasks) |
|
||||
| Sprint 2 (P2 — alto valor) | T011–T018 (8 tasks) |
|
||||
| Sprint 3 (P3 — refinamentos) | T019–T025 (7 tasks) |
|
||||
| Polish | T026–T027 (2 tasks) |
|
||||
| Tasks backend | T001, T002 (2 tasks) |
|
||||
| Tasks frontend | T003–T025 (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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue