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
34
specs/007-admin-panel/checklists/requirements.md
Normal file
34
specs/007-admin-panel/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Specification Quality Checklist: admin-panel
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [specs/007-admin-panel/spec.md](../../specs/007-admin-panel/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Especificação atende a todos os critérios de qualidade e está pronta para a próxima fase.
|
||||
147
specs/007-admin-panel/spec.md
Normal file
147
specs/007-admin-panel/spec.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Feature Specification: Admin Panel
|
||||
|
||||
**Feature Branch**: `[007-admin-panel]`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Painel administrativo onde o admin pode criar, editar, remover e listar todos os itens do sistema: imóveis, usuários, visitas, boletos, tipos, cidades, bairros, amenidades, etc."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Gerenciar Imóveis (Priority: P1)
|
||||
|
||||
O administrador pode criar, editar, remover e listar imóveis, com busca, filtros e paginação.
|
||||
|
||||
**Why this priority**: Imóveis são o núcleo do sistema imobiliário; garantir controle total é essencial para o funcionamento do negócio.
|
||||
|
||||
**Independent Test**: Testar se um admin consegue realizar todas as operações CRUD em imóveis, com validação e feedback adequado.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin autenticado, **When** acessa a lista de imóveis, **Then** vê imóveis paginados, pode buscar e filtrar.
|
||||
2. **Given** o admin na tela de criação, **When** preenche e submete o formulário, **Then** o imóvel é criado se os dados forem válidos.
|
||||
3. **Given** o admin na tela de edição, **When** altera dados e salva, **Then** as mudanças são persistidas.
|
||||
4. **Given** o admin na lista, **When** clica para remover um imóvel, **Then** vê confirmação e, ao confirmar, o imóvel é removido (com tratamento para dependências).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Gerenciar Usuários (Priority: P2)
|
||||
|
||||
O administrador pode criar, editar, remover e listar usuários do sistema, com busca, filtros e paginação.
|
||||
|
||||
**Why this priority**: Controle de acesso e gestão de usuários são fundamentais para segurança e operação.
|
||||
|
||||
**Independent Test**: Testar se um admin consegue realizar todas as operações CRUD em usuários, com validação e feedback adequado.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin autenticado, **When** acessa a lista de usuários, **Then** vê usuários paginados, pode buscar e filtrar.
|
||||
2. **Given** o admin na tela de criação, **When** preenche e submete o formulário, **Then** o usuário é criado se os dados forem válidos.
|
||||
3. **Given** o admin na tela de edição, **When** altera dados e salva, **Then** as mudanças são persistidas.
|
||||
4. **Given** o admin na lista, **When** clica para remover um usuário, **Then** vê confirmação e, ao confirmar, o usuário é removido (com tratamento para dependências).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Gerenciar Visitas, Boletos, Tipos, Cidades, Bairros, Amenidades (Priority: P3)
|
||||
|
||||
O administrador pode criar, editar, remover e listar visitas, boletos, tipos, cidades, bairros e amenidades, com busca, filtros e paginação.
|
||||
|
||||
**Why this priority**: Permite controle total sobre entidades auxiliares e operacionais do sistema.
|
||||
|
||||
**Independent Test**: Testar se um admin consegue realizar todas as operações CRUD nessas entidades, com validação e feedback adequado.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin autenticado, **When** acessa a lista de cada entidade, **Then** vê itens paginados, pode buscar e filtrar.
|
||||
2. **Given** o admin na tela de criação, **When** preenche e submete o formulário, **Then** o item é criado se os dados forem válidos.
|
||||
3. **Given** o admin na tela de edição, **When** altera dados e salva, **Then** as mudanças são persistidas.
|
||||
4. **Given** o admin na lista, **When** clica para remover um item, **Then** vê confirmação e, ao confirmar, o item é removido (com tratamento para dependências).
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Painel Inicial com KPIs (Priority: P4)
|
||||
|
||||
O administrador visualiza um painel inicial com indicadores-chave (quantidade de imóveis, usuários, visitas, boletos, etc).
|
||||
|
||||
**Why this priority**: Fornece visão rápida do status do sistema e auxilia na tomada de decisão.
|
||||
|
||||
**Independent Test**: Testar se o admin vê os KPIs corretos ao acessar o painel inicial.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin autenticado, **When** acessa o painel inicial, **Then** vê KPIs atualizados e corretos.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Segurança e Acesso (Priority: P1)
|
||||
|
||||
Apenas administradores autenticados podem acessar o painel e suas rotas. Tentativas de acesso não autorizado são bloqueadas e exibem mensagem amigável.
|
||||
|
||||
**Why this priority**: Segurança é crítica para evitar acesso indevido a dados sensíveis.
|
||||
|
||||
**Independent Test**: Testar se usuários não-admin não conseguem acessar rotas do painel e recebem mensagem adequada.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um usuário não-admin, **When** tenta acessar qualquer rota do painel, **Then** o acesso é negado e uma mensagem amigável é exibida.
|
||||
2. **Given** um admin não autenticado, **When** tenta acessar o painel, **Then** é redirecionado para login.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Usabilidade e Navegação (Priority: P2)
|
||||
|
||||
O painel possui navegação lateral (sidebar), breadcrumbs e design consistente com o tema Linear dark.
|
||||
|
||||
**Why this priority**: Facilita o uso, reduz erros e melhora a experiência do usuário.
|
||||
|
||||
**Independent Test**: Testar se a navegação é intuitiva, o tema é aplicado e breadcrumbs refletem a navegação.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin autenticado, **When** navega entre seções, **Then** a sidebar e breadcrumbs refletem corretamente o contexto.
|
||||
2. **Given** o admin autenticado, **When** acessa o painel, **Then** o tema Linear dark é aplicado em todas as telas.
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
1. O painel deve permitir operações CRUD completas para imóveis, usuários, visitas, boletos, tipos, cidades, bairros e amenidades.
|
||||
2. Todas as rotas do painel devem ser protegidas e acessíveis apenas por administradores autenticados.
|
||||
3. Listagens devem ser paginadas, com busca e filtros por campos relevantes.
|
||||
4. Formulários de criação/edição devem validar campos obrigatórios e opcionais, exibindo mensagens de erro amigáveis.
|
||||
5. Exclusão de entidades deve exigir confirmação e tratar dependências (ex: não permitir remoção se houver vínculos).
|
||||
6. O painel inicial deve exibir KPIs atualizados (quantidade de imóveis, usuários, visitas, boletos, etc).
|
||||
7. A interface deve ter navegação lateral (sidebar), breadcrumbs e seguir o tema Linear dark.
|
||||
8. Endpoints RESTful devem seguir o padrão /api/v1/admin/* para todas as entidades gerenciadas.
|
||||
9. Mensagens de erro devem ser claras e orientar o usuário sobre como corrigir problemas.
|
||||
10. Edge cases como tentativas de acesso não autorizado, dados inválidos e remoção de entidades com dependências devem ser tratados com feedback adequado.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Admins conseguem realizar todas as operações CRUD nas entidades listadas, com feedback visual e validação.
|
||||
- Apenas admins autenticados acessam o painel; tentativas de acesso não autorizado são bloqueadas e exibem mensagem amigável.
|
||||
- Listagens apresentam paginação, busca e filtros funcionais.
|
||||
- Formulários exibem mensagens de erro claras para campos obrigatórios/invalidos.
|
||||
- Exclusão exige confirmação e previne remoção de entidades com dependências.
|
||||
- KPIs do painel inicial refletem dados reais e atualizados.
|
||||
- Navegação lateral, breadcrumbs e tema Linear dark estão presentes e funcionais.
|
||||
- Todas as rotas e endpoints seguem o padrão RESTful definido.
|
||||
- Edge cases são cobertos e testados (acesso, dados inválidos, dependências).
|
||||
|
||||
## Key Entities
|
||||
|
||||
- Imóvel (Property): id, título, descrição, tipo, localização, preço, status, amenidades, etc.
|
||||
- Usuário (User): id, nome, email, papel, status, etc.
|
||||
- Visita (Visit): id, imóvel, usuário, data/hora, status, etc.
|
||||
- Boleto: id, usuário, valor, status, vencimento, etc.
|
||||
- Tipo de imóvel: id, nome, descrição
|
||||
- Cidade: id, nome, estado
|
||||
- Bairro: id, nome, cidade
|
||||
- Amenidade: id, nome, descrição
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O sistema já possui autenticação JWT e controle de papéis implementados.
|
||||
- O tema Linear dark está disponível para uso no frontend.
|
||||
- Os endpoints RESTful seguem o padrão /api/v1/admin/*.
|
||||
- Campos obrigatórios e opcionais serão definidos conforme regras de negócio já existentes.
|
||||
- Mensagens de erro e validação seguem padrões de usabilidade já adotados no sistema.
|
||||
34
specs/008-light-theme/checklists/requirements.md
Normal file
34
specs/008-light-theme/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Specification Quality Checklist: light-theme
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Especificação atende todos os critérios para seguir para o planejamento.
|
||||
60
specs/008-light-theme/plan.md
Normal file
60
specs/008-light-theme/plan.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Plano de Implementação — Feature 008: Light Theme
|
||||
|
||||
## Objetivo
|
||||
Implementar tema claro global, alternância dark/light, tokens de cor, guidelines e integração com Tailwind. Atualizar ou criar design.md com guidelines e tokens para futuras implementações.
|
||||
|
||||
## Escopo
|
||||
- Suporte a tema claro e escuro (dark/light) global
|
||||
- Alternância de tema (toggle) no Navbar/Admin
|
||||
- Tokens de cor theme-aware (bg, text, border, accent, muted, etc)
|
||||
- Guidelines e exemplos em design.md
|
||||
- Integração com Tailwind CSS (estratégia 'class')
|
||||
- Refatoração dos principais componentes/páginas para tokens theme-aware
|
||||
- Testes de acessibilidade (contraste, foco, etc)
|
||||
- Documentação para devs
|
||||
|
||||
---
|
||||
|
||||
## 1. Atualização do Tailwind para Suporte a Temas
|
||||
- [ ] Configurar `tailwind.config.ts` para estratégia `darkMode: 'class'`
|
||||
- [ ] Definir tokens de cor para ambos temas (light/dark) via `extend.colors` e CSS custom properties
|
||||
- [ ] Garantir que tokens sejam facilmente consumidos via classes utilitárias e/ou `@apply`
|
||||
|
||||
## 2. Tokens de Cor e Guidelines
|
||||
- [ ] Mapear tokens: bg, text, border, accent, muted, hover, etc para ambos temas
|
||||
- [ ] Documentar tokens e exemplos de uso em `DESIGN.md`
|
||||
- [ ] Adicionar recomendações de contraste e fallback seguro
|
||||
|
||||
## 3. ThemeProvider/Contexto
|
||||
- [ ] Criar contexto React para alternância de tema (`ThemeProvider`)
|
||||
- [ ] Implementar persistência da escolha (localStorage)
|
||||
- [ ] Sincronizar com `prefers-color-scheme` do SO
|
||||
- [ ] Garantir SSR/hidratação sem "flash" de tema incorreto
|
||||
|
||||
## 4. Toggle de Tema
|
||||
- [ ] Adicionar toggle (ícone sol/lua) no Navbar e painel admin
|
||||
- [ ] Garantir acessibilidade do toggle (foco, aria-label, contraste)
|
||||
|
||||
## 5. Refatoração de Componentes
|
||||
- [ ] Refatorar principais componentes/páginas para usar tokens e classes theme-aware
|
||||
- [ ] Garantir que todos respeitem o tema ativo
|
||||
|
||||
## 6. Testes de Acessibilidade
|
||||
- [ ] Testar contraste mínimo AA+ em todos os estados
|
||||
- [ ] Garantir foco visível e navegação por teclado
|
||||
- [ ] Validar ausência de "flash" de tema incorreto
|
||||
|
||||
## 7. Documentação
|
||||
- [ ] Atualizar/criar seção de guidelines de tema em `DESIGN.md`
|
||||
- [ ] Exemplos de uso de tokens, recomendações de contraste, como estender o tema
|
||||
- [ ] Passos para devs integrarem novos componentes ao sistema de temas
|
||||
|
||||
---
|
||||
|
||||
## Critérios de Aceite
|
||||
- Alternância de tema funcional e persistente
|
||||
- Contraste AA+ garantido
|
||||
- Documentação clara para devs
|
||||
- Nenhum componente com cor ilegível ao alternar tema
|
||||
- Preferência do SO respeitada na primeira visita
|
||||
- Sem "flash" de tema incorreto em SSR/hidratação
|
||||
61
specs/008-light-theme/spec.md
Normal file
61
specs/008-light-theme/spec.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Especificação da Feature: Light Theme
|
||||
|
||||
## Visão Geral
|
||||
|
||||
Permitir alternância entre tema escuro (Linear dark) e tema claro em todo o sistema (frontend React e páginas administrativas), promovendo acessibilidade, preferência visual e consistência de design. O tema claro deve ser moderno, acessível (contraste AA+), fácil de alternar e documentado para uso futuro.
|
||||
|
||||
## Atores
|
||||
- Usuário final (clientes, visitantes)
|
||||
- Administrador do sistema
|
||||
|
||||
## Cenários de Usuário & Testes
|
||||
- Usuário acessa o sistema e visualiza o tema de acordo com a preferência do sistema operacional ("prefers-color-scheme")
|
||||
- Usuário alterna manualmente entre tema claro e escuro via toggle global (ícone sol/lua no Navbar e painel admin)
|
||||
- Preferência de tema é persistida (localStorage) e respeitada em visitas futuras
|
||||
- Todas as páginas, componentes e formulários refletem corretamente o tema selecionado
|
||||
- Em SSR/hidratação, o tema correto é aplicado sem "flash" de tema incorreto
|
||||
- Contraste mínimo AA+ garantido em todos os estados (normal, hover, disabled)
|
||||
- Novos componentes podem adotar tokens de cor documentados
|
||||
|
||||
## Requisitos Funcionais
|
||||
1. Implementar paleta de cores clara (backgrounds, textos, bordas, acentos, hover, muted, etc) consistente e acessível
|
||||
2. Adicionar alternância global de tema (toggle) visível no Navbar e painel admin, com ícone sol/lua
|
||||
3. Persistir escolha do usuário em localStorage e sincronizar com prefers-color-scheme do SO
|
||||
4. Configurar Tailwind CSS para suportar dark/light usando a estratégia 'class'
|
||||
5. Garantir que todos os componentes, páginas e formulários respeitem o tema ativo
|
||||
6. Documentar tokens de cor (nomes, exemplos, guidelines de uso e contraste)
|
||||
7. Garantir fallback seguro para SSR/hidratação e acessibilidade (contraste mínimo, foco visível)
|
||||
|
||||
## Critérios de Sucesso
|
||||
- Usuário pode alternar entre temas e a escolha é lembrada em visitas futuras
|
||||
- Tema claro apresenta contraste AA+ em todos os elementos
|
||||
- Nenhum componente exibe cores erradas ou ilegíveis ao alternar tema
|
||||
- Preferência do SO é respeitada na primeira visita
|
||||
- Documentação de tokens e guidelines disponível para desenvolvedores
|
||||
- Não há "flash" de tema incorreto em SSR/hidratação
|
||||
|
||||
## Entidades/Objetos-Chave
|
||||
- Tokens de cor: bg, text, border, accent, muted, hover, etc
|
||||
- Preferência de tema: 'light', 'dark', 'system'
|
||||
- Componente de alternância (toggle)
|
||||
|
||||
## Restrições & Assunções
|
||||
- Tema escuro (Linear dark) já está implementado
|
||||
- Tema claro deve ser consistente com o dark, mas otimizado para acessibilidade
|
||||
- Persistência via localStorage; fallback para prefers-color-scheme
|
||||
- Tailwind já instalado; estratégia 'class' será usada
|
||||
- SSR pode ser relevante para futuras versões/admin
|
||||
|
||||
## Edge Cases
|
||||
- SSR/hidratação: evitar "flash" de tema incorreto
|
||||
- Usuário sem suporte a localStorage: fallback para sistema
|
||||
- Contraste mínimo em todos os estados (hover, disabled, etc)
|
||||
- Acessibilidade: foco visível, sem dependência exclusiva de cor
|
||||
|
||||
## Documentação
|
||||
- Listar tokens de cor, exemplos de uso e guidelines para novos componentes
|
||||
- Instruções para desenvolvedores sobre como aplicar tokens e garantir contraste
|
||||
|
||||
---
|
||||
|
||||
*Esta especificação não inclui detalhes de implementação, frameworks ou código. Foco em valor ao usuário, acessibilidade e consistência visual.*
|
||||
89
specs/008-light-theme/tasks.md
Normal file
89
specs/008-light-theme/tasks.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
description: "Tasks para a feature 008 - Light Theme"
|
||||
---
|
||||
|
||||
# Tasks: Light Theme (008)
|
||||
|
||||
**Input**: Design documents de `/specs/008-light-theme/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required)
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Inicialização e configuração base para suporte a temas
|
||||
|
||||
- [ ] T001 Atualizar Tailwind para `darkMode: 'class'` em frontend/tailwind.config.ts
|
||||
- [ ] T002 [P] Adicionar dependências/utilitários necessários para alternância de tema em frontend/package.json
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Definir tokens, guidelines e base para alternância
|
||||
|
||||
- [ ] T003 Definir tokens de cor (bg, text, border, accent, muted, hover, etc) para ambos temas em frontend/tailwind.config.ts
|
||||
- [ ] T004 [P] Documentar tokens, exemplos e recomendações de contraste em DESIGN.md
|
||||
- [ ] T005 [P] Garantir tokens disponíveis via classes utilitárias e/ou @apply em frontend/src/index.css
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 (P1) — Alternância global, persistência, SSR, documentação
|
||||
|
||||
- [ ] T006 [US1] Criar ThemeProvider/contexto React para alternância de tema em frontend/src/contexts/ThemeContext.tsx
|
||||
- [ ] T007 [P] [US1] Implementar persistência da escolha do tema (localStorage) e sincronização com prefers-color-scheme em ThemeProvider
|
||||
- [ ] T008 [P] [US1] Garantir SSR/hidratação sem "flash" de tema incorreto em frontend/src/main.tsx e ThemeProvider
|
||||
- [ ] T009 [US1] Adicionar toggle de tema (ícone sol/lua) no Navbar em frontend/src/components/Navbar.tsx
|
||||
- [ ] T010 [P] [US1] Adicionar toggle de tema no painel admin em frontend/src/layouts/ClientLayout.tsx
|
||||
- [ ] T011 [P] [US1] Garantir acessibilidade do toggle (foco, aria-label, contraste) em ambos componentes
|
||||
- [ ] T012 [US1] Atualizar/criar seção de guidelines de tema em DESIGN.md (exemplos, recomendações, como estender)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 (P2) — Refatoração de componentes/páginas
|
||||
|
||||
- [ ] T013 [US2] Refatorar principais componentes para usar tokens e classes theme-aware em frontend/src/components/
|
||||
- [ ] T014 [P] [US2] Refatorar principais páginas para usar tokens e classes theme-aware em frontend/src/pages/
|
||||
- [ ] T015 [P] [US2] Garantir que todos respeitem o tema ativo (testar alternância)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 (P3) — Testes de acessibilidade e documentação para devs
|
||||
|
||||
- [ ] T016 [US3] Testar contraste mínimo AA+ em todos os estados (normal, hover, disabled) nos principais componentes/páginas
|
||||
- [ ] T017 [P] [US3] Garantir foco visível e navegação por teclado nos toggles e componentes principais
|
||||
- [ ] T018 [P] [US3] Validar ausência de "flash" de tema incorreto em SSR/hidratação
|
||||
- [ ] T019 [US3] Documentar para devs: exemplos de uso de tokens, recomendações de contraste, como estender o tema e integrar novos componentes em DESIGN.md
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [ ] T020 Revisar documentação e exemplos em DESIGN.md
|
||||
- [ ] T021 [P] Revisar todos os componentes para garantir consistência visual e acessibilidade
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 1 → Phase 2 → Phase 3 (US1) → Phase 4 (US2) → Phase 5 (US3) → Phase 6
|
||||
- Tasks marcadas com [P] podem ser executadas em paralelo dentro da mesma fase
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- T002 e T003 podem rodar em paralelo
|
||||
- T007, T008, T010, T011 podem rodar em paralelo após T006
|
||||
- T014 e T015 podem rodar em paralelo após T013
|
||||
- T017 e T018 podem rodar em paralelo após T016
|
||||
|
||||
## Independent Test Criteria
|
||||
|
||||
- US1: Alternância funcional, persistência, sem flash, documentação clara
|
||||
- US2: Todos componentes/páginas respeitam tema ativo
|
||||
- US3: Contraste AA+, foco visível, documentação para devs
|
||||
|
||||
## MVP Scope
|
||||
|
||||
- Fase 3 (US1): Alternância global, persistência, SSR/hidratação, documentação inicial
|
||||
|
||||
## Formato Validado
|
||||
|
||||
- Todos os tasks seguem o formato checklist: checkbox, ID, [P] se paralelizável, [USx] se de user story, caminho de arquivo
|
||||
36
specs/009-contact-button/checklists/requirements.md
Normal file
36
specs/009-contact-button/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Contact Button
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Non-Functional Requirements
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Endpoint `POST /api/v1/properties/<slug>/contact` já existe e está funcional — nenhuma migração de banco necessária.
|
||||
- Configuração de WhatsApp via painel admin é marcada como Out of Scope; usa somente variável de ambiente `WHATSAPP_NUMBER` nesta entrega.
|
||||
- Spec pronta para `/speckit.plan`.
|
||||
284
specs/009-contact-button/plan.md
Normal file
284
specs/009-contact-button/plan.md
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
# Implementation Plan: Contact Button (009)
|
||||
|
||||
**Feature Branch**: `009-contact-button`
|
||||
**Spec**: [spec.md](./spec.md)
|
||||
**Status**: Ready for implementation
|
||||
**Date**: 2026-04-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Arquitetura da Solução
|
||||
|
||||
### 1.1 Visão Geral
|
||||
|
||||
```
|
||||
Browser (React)
|
||||
├── PropertyRowCard.tsx [MODIFICAR] — adicionar botão + integrar ContactModal
|
||||
├── PropertyCard.tsx [MODIFICAR] — adicionar botão + integrar ContactModal
|
||||
├── ContactModal.tsx [CRIAR] — modal com seleção de canal
|
||||
├── ContactForm.tsx [CRIAR] — formulário de lead (submódul do modal)
|
||||
└── services/contact.ts [CRIAR] — getWhatsappConfig()
|
||||
|
||||
Backend (Flask)
|
||||
├── routes/config.py [CRIAR] — GET /api/v1/config/whatsapp
|
||||
└── routes/admin.py [MODIFICAR] — GET /api/v1/admin/leads (listagem paginada)
|
||||
```
|
||||
|
||||
### 1.2 Componentes Novos
|
||||
|
||||
| Componente | Caminho | Responsabilidade |
|
||||
|---|---|---|
|
||||
| `ContactModal` | `frontend/src/components/ContactModal.tsx` | Orquestra o modal (overlay + escolha de canal) |
|
||||
| `ContactForm` | `frontend/src/components/ContactForm.tsx` | Formulário de lead com validação inline |
|
||||
| `contact.ts` | `frontend/src/services/contact.ts` | `getWhatsappConfig()` — GET /api/v1/config/whatsapp |
|
||||
| `config.py` | `backend/app/routes/config.py` | Blueprint `config_bp`, endpoint público de configuração |
|
||||
|
||||
### 1.3 Componentes Modificados
|
||||
|
||||
| Arquivo | Mudança |
|
||||
|---|---|
|
||||
| `frontend/src/components/PropertyRowCard.tsx` | Adiciona estado `modalOpen`, botão "Entre em Contato", renderiza `<ContactModal>` |
|
||||
| `frontend/src/components/PropertyCard.tsx` | Idem acima |
|
||||
| `frontend/src/types/property.ts` | Adiciona campo `code?: string \| null` na interface `Property` (já consumido em runtime pelo `PropertyRowCard`) |
|
||||
| `backend/app/routes/__init__.py` | Registra o novo `config_bp` |
|
||||
| `backend/app/routes/admin.py` | Adiciona `GET /api/v1/admin/leads` com paginação e filtro por `property_id` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Fluxo de Dados
|
||||
|
||||
### 2.1 Inicialização do Modal (carregamento do número WhatsApp)
|
||||
|
||||
```
|
||||
Componente monta
|
||||
→ useEffect → GET /api/v1/config/whatsapp
|
||||
← { whatsapp_number: "5516999998888" } | { whatsapp_number: null }
|
||||
→ salva em estado local: whatsappNumber
|
||||
```
|
||||
|
||||
**Estratégia**: a chamada é feita uma única vez quando o modal abre (lazy load), não ao montar o card. Pode ser cacheado via `localStorage` por 5 minutos para evitar requisições repetidas.
|
||||
|
||||
### 2.2 Envio do Formulário de Contato
|
||||
|
||||
```
|
||||
Usuário clica "Entre em Contato"
|
||||
→ ContactModal abre (estado local no card)
|
||||
→ Usuário seleciona "Formulário de Contato"
|
||||
→ ContactForm renderiza com mensagem pré-preenchida:
|
||||
"Tenho interesse no imóvel de código {property.code}. Poderia me dar mais informações?"
|
||||
→ Usuário preenche nome, e-mail, telefone(opt) e confirma mensagem
|
||||
→ Validação frontend (espelha ContactLeadIn do Pydantic)
|
||||
→ POST /api/v1/properties/{property.slug}/contact
|
||||
Body: { name, email, phone?, message }
|
||||
← 201 { id, message: "Mensagem enviada com sucesso!" }
|
||||
→ Estado: success → exibe confirmação → fecha modal após 2 s
|
||||
← 422 { error, details } → exibe erros inline por campo
|
||||
← 5xx / network error → exibe banner de erro; mantém dados no form
|
||||
```
|
||||
|
||||
### 2.3 Redirecionamento WhatsApp
|
||||
|
||||
```
|
||||
Usuário seleciona "WhatsApp" no modal
|
||||
→ Link gerado 100% no cliente:
|
||||
url = `https://wa.me/${whatsappNumber}?text=${encodeURIComponent(msg)}`
|
||||
msg = "Olá! Tenho interesse no imóvel de código {property.code}.
|
||||
Poderia me dar mais informações?"
|
||||
→ window.open(url, '_blank', 'noopener,noreferrer')
|
||||
→ Modal fecha (opcional: fechar após 500 ms)
|
||||
```
|
||||
|
||||
Se `whatsappNumber` for `null`: botão WhatsApp renderiza desabilitado com tooltip "Canal indisponível no momento".
|
||||
|
||||
### 2.4 Listagem de Leads no Admin
|
||||
|
||||
```
|
||||
Admin acessa /admin/leads
|
||||
→ GET /api/v1/admin/leads?page=1&per_page=20&property_id=<uuid>(opcional)
|
||||
Header: Authorization: Bearer <token>
|
||||
← { items: [...], total, page, per_page, pages }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisões de Design
|
||||
|
||||
### 3.1 Modal vs. Página
|
||||
|
||||
**Decisão**: Modal (overlay) em vez de navegação para nova página.
|
||||
|
||||
**Rationale**:
|
||||
- O usuário está navegando na listagem; um modal preserva o contexto (ele não abandona a lista).
|
||||
- A spec exige fechamento por clique externo, o que é nativo de modal.
|
||||
- O formulário de contato é simples (4 campos) — não justifica uma rota dedicada.
|
||||
- Consistente com o padrão já adotado nos cards (HeartButton, Comparar) que operam com estado local.
|
||||
|
||||
**Implementação**: Portal React (`ReactDOM.createPortal`) montado em `document.body` para evitar z-index conflicts com o overflow hidden dos cards (`overflow-hidden` no `article` de `PropertyRowCard`).
|
||||
|
||||
### 3.2 Estratégia WhatsApp Link
|
||||
|
||||
**Decisão**: Geração do link inteiramente no cliente, sem round-trip ao backend.
|
||||
|
||||
**Rationale**: O link `wa.me` é uma URL pública que não expõe dados sensíveis. Gerar no cliente elimina latência e simplifica o backend.
|
||||
|
||||
**Formato do link**:
|
||||
```
|
||||
https://wa.me/{número_sem_+}?text={mensagem_codificada}
|
||||
```
|
||||
|
||||
Desktop → abre `web.whatsapp.com` em nova aba. Mobile → redireciona para o app nativo via deep link.
|
||||
|
||||
**Nota de segurança**: usar `window.open(url, '_blank', 'noopener,noreferrer')` para prevenir Tabnabbing.
|
||||
|
||||
### 3.3 Configuração do Número de WhatsApp
|
||||
|
||||
**Decisão**: env var `WHATSAPP_NUMBER` no backend, exposta via endpoint público `GET /api/v1/config/whatsapp`.
|
||||
|
||||
**Rationale**:
|
||||
- Não há sistema de configuração em banco (fora do escopo).
|
||||
- A spec confirma explicitamente: "o número é definido via variável de ambiente `WHATSAPP_NUMBER`".
|
||||
- Um endpoint dedicado permite que o frontend seja agnóstico sobre a origem da config e facilita futura migração para configuração em banco (feature 007).
|
||||
|
||||
**Implementação do endpoint** (`backend/app/routes/config.py`):
|
||||
```python
|
||||
import os
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
config_bp = Blueprint("config", __name__, url_prefix="/api/v1/config")
|
||||
|
||||
@config_bp.get("/whatsapp")
|
||||
def get_whatsapp_config():
|
||||
number = os.environ.get("WHATSAPP_NUMBER") or None
|
||||
return jsonify({"whatsapp_number": number})
|
||||
```
|
||||
|
||||
**Cache no cliente**: `localStorage` com TTL de 5 minutos. Chave: `whatsapp_config_cache`. Estrutura: `{ number, fetchedAt }`.
|
||||
|
||||
### 3.4 Validação do Formulário
|
||||
|
||||
**Decisão**: Validação no frontend espelha exatamente o schema Pydantic `ContactLeadIn`.
|
||||
|
||||
| Campo | Regra |
|
||||
|---|---|
|
||||
| `name` | obrigatório, 2–150 chars |
|
||||
| `email` | obrigatório, formato válido, ≤ 254 chars |
|
||||
| `phone` | opcional, ≤ 20 chars |
|
||||
| `message` | obrigatório, 10–2000 chars |
|
||||
|
||||
Erros do backend (422) são mapeados campo a campo e exibidos inline abaixo de cada input.
|
||||
|
||||
### 3.5 Posicionamento do Botão nos Cards
|
||||
|
||||
**`PropertyRowCard`**: botão "Entre em Contato" posicionado na seção `Footer` (ao lado do botão "Comparar"), substituindo o `span` "Ver detalhes →" ou adjacente a ele. Deve usar `e.preventDefault()` + `e.stopPropagation()` pois está dentro do `<Link>` pai.
|
||||
|
||||
**`PropertyCard`**: botão adicionado à `div.mt-auto.space-y-3`, abaixo do botão "Comparar" (que já ocupa full-width). Botão também full-width.
|
||||
|
||||
### 3.6 Estilo Visual
|
||||
|
||||
Tema Linear dark existente. Referência de classes para o botão principal:
|
||||
```
|
||||
bg-brand text-white rounded-lg px-3 py-1.5 text-xs font-medium
|
||||
hover:bg-brand/90 transition border border-brand/20
|
||||
```
|
||||
|
||||
Modal: overlay `bg-black/60 backdrop-blur-sm`, painel `bg-panel border border-white/10 rounded-2xl`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Riscos e Mitigações
|
||||
|
||||
| # | Risco | Probabilidade | Impacto | Mitigação |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **`property.code` ausente no tipo `Property`** — campo existe em runtime (retornado pela API e já usado no JSX) mas não está declarado na interface TypeScript, causando erro de compilação ao adicionar o botão | Alta | Médio | Adicionar `code?: string \| null` na interface `Property` em `types/property.ts` como primeira tarefa |
|
||||
| R2 | **z-index do modal vs. overflow hidden do card** — `PropertyRowCard` usa `overflow-hidden` no `<article>`, o que poderia clipar o modal | Alta | Alto | Usar `ReactDOM.createPortal` para montar o modal diretamente em `document.body`, fora da hierarquia do card |
|
||||
| R3 | **Clique no botão propaga para o `<Link>` pai** — ambos os cards envolvem o conteúdo em `<Link>`, causando navegação indesejada ao clicar no botão | Alta | Alto | Chamar `e.preventDefault()` e `e.stopPropagation()` no handler do botão (padrão já aplicado no botão "Comparar") |
|
||||
| R4 | **`WHATSAPP_NUMBER` não configurado em produção** — a opção WhatsApp fica desabilitada silenciosamente sem alertar o admin | Média | Médio | Endpoint retorna `null` explicitamente; frontend desabilita o botão com mensagem "Canal indisponível"; adicionar alerta na documentação de deploy |
|
||||
| R5 | **Spam de leads via formulário** — endpoint público sem rate limit pode ser abusado | Média | Médio | Implementar rate limiting por IP no Flask (ex.: `Flask-Limiter`) ou via proxy nginx antes do deploy em produção; fora do escopo desta feature mas deve ser registrado como dívida técnica |
|
||||
| R6 | **Inconsistência entre validação frontend e Pydantic** — mensagens de erro diferentes confundem o usuário | Baixa | Baixo | Manter a validação frontend alinhada ao `ContactLeadIn`; erros do backend (422) são re-exibidos inline field-by-field |
|
||||
| R7 | **`admin.py` sem endpoint de leads** — a spec exige listagem no painel admin (US6), mas não há rota implementada | Alta | Médio | Implementar `GET /api/v1/admin/leads` com `@require_admin`, paginação e filtro por `property_id` como parte desta feature |
|
||||
| R8 | **Cache de config do WhatsApp desatualizado** — após mudança da env var, cliente continua usando número antigo por até 5 min | Baixa | Baixo | TTL de 5 minutos é aceitável conforme NFR da spec; documentar comportamento esperado |
|
||||
|
||||
---
|
||||
|
||||
## 5. Checklist de Arquivos Afetados
|
||||
|
||||
```
|
||||
backend/
|
||||
app/
|
||||
routes/
|
||||
config.py ← CRIAR (Blueprint config_bp)
|
||||
admin.py ← MODIFICAR (GET /admin/leads)
|
||||
__init__.py ← MODIFICAR (registrar config_bp)
|
||||
|
||||
frontend/
|
||||
src/
|
||||
types/
|
||||
property.ts ← MODIFICAR (adicionar code? a Property)
|
||||
components/
|
||||
ContactModal.tsx ← CRIAR
|
||||
ContactForm.tsx ← CRIAR
|
||||
services/
|
||||
contact.ts ← CRIAR (getWhatsappConfig)
|
||||
components/
|
||||
PropertyRowCard.tsx ← MODIFICAR (botão + modal)
|
||||
PropertyCard.tsx ← MODIFICAR (botão + modal)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Contratos de API
|
||||
|
||||
### `GET /api/v1/config/whatsapp`
|
||||
|
||||
- **Auth**: nenhuma
|
||||
- **Cache**: pode ser cacheado por 5 min (cliente)
|
||||
- **Response 200**:
|
||||
```json
|
||||
{ "whatsapp_number": "5516999998888" }
|
||||
```
|
||||
ou
|
||||
```json
|
||||
{ "whatsapp_number": null }
|
||||
```
|
||||
|
||||
### `POST /api/v1/properties/{slug}/contact` *(já existe)*
|
||||
|
||||
- **Auth**: nenhuma
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"name": "string (2-150)",
|
||||
"email": "string (email válido)",
|
||||
"phone": "string (≤20) | null",
|
||||
"message": "string (10-2000)"
|
||||
}
|
||||
```
|
||||
- **Response 201**: `{ "id": int, "message": "Mensagem enviada com sucesso!" }`
|
||||
- **Response 422**: `{ "error": "Dados inválidos", "details": [...] }`
|
||||
|
||||
### `GET /api/v1/admin/leads` *(a criar)*
|
||||
|
||||
- **Auth**: `Bearer <admin_token>` (`@require_admin`)
|
||||
- **Query params**: `page` (default 1), `per_page` (default 20), `property_id` (UUID, opcional)
|
||||
- **Response 200**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "João Silva",
|
||||
"email": "joao@email.com",
|
||||
"phone": "16999998888",
|
||||
"message": "Tenho interesse...",
|
||||
"property_id": "uuid",
|
||||
"property_code": "2880602111",
|
||||
"property_slug": "apartamento-centro",
|
||||
"created_at": "2026-04-17T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"pages": 3
|
||||
}
|
||||
```
|
||||
172
specs/009-contact-button/spec.md
Normal file
172
specs/009-contact-button/spec.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Feature Specification: Contact Button
|
||||
|
||||
**Feature Branch**: `[009-contact-button]`
|
||||
**Created**: 2026-04-17
|
||||
**Status**: Draft
|
||||
**Input**: Adicionar um botão destacado 'Entre em Contato' em cada card de imóvel (PropertyRowCard e PropertyCard). Ao clicar, o cliente pode escolher entre preencher um formulário de contato (gera lead no painel admin) ou entrar em contato via WhatsApp com mensagem pré-preenchida com o código do imóvel. O número de WhatsApp do corretor deve vir de uma configuração (variável de ambiente ou admin).
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Acessar o Botão de Contato no Card do Imóvel (Priority: P1)
|
||||
|
||||
O visitante, ao visualizar qualquer card de imóvel (na listagem ou na seção de destaques), vê um botão destacado "Entre em Contato" e consegue acioná-lo com facilidade.
|
||||
|
||||
**Why this priority**: O botão é o ponto de entrada de toda a feature; sem ele, nenhum outro cenário é alcançável.
|
||||
|
||||
**Independent Test**: Verificar se o botão "Entre em Contato" está visível e clicável em todos os cards de imóvel (PropertyRowCard e PropertyCard).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um visitante na página de listagem de imóveis, **When** visualiza um `PropertyRowCard`, **Then** vê o botão "Entre em Contato" em destaque no card.
|
||||
2. **Given** um visitante na página inicial (seção de destaques), **When** visualiza um `PropertyCard`, **Then** vê o botão "Entre em Contato" em destaque no card.
|
||||
3. **Given** um visitante em dispositivo móvel, **When** visualiza qualquer card de imóvel, **Then** o botão "Entre em Contato" é visível, acessível e clicável sem necessitar de rolagem horizontal.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Escolher Canal de Contato (Priority: P1)
|
||||
|
||||
Ao clicar no botão "Entre em Contato", o visitante vê um modal/painel com duas opções claras: formulário de contato ou WhatsApp.
|
||||
|
||||
**Why this priority**: A escolha de canal é o fluxo central da feature; define qual caminho o usuário toma.
|
||||
|
||||
**Independent Test**: Verificar se o modal exibe as duas opções para cada imóvel diferente e que clicar fora do modal o fecha sem efeitos colaterais.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante clica em "Entre em Contato" em um card, **When** o modal abre, **Then** vê duas opções claramente apresentadas: "Formulário de Contato" e "WhatsApp".
|
||||
2. **Given** o modal está aberto, **When** o visitante clica fora da área do modal ou em um botão de fechar, **Then** o modal é fechado sem enviar nenhum dado.
|
||||
3. **Given** o modal está aberto para o imóvel X, **When** o visitante fecha e abre o modal de um imóvel Y diferente, **Then** o modal exibe o código do imóvel Y corretamente.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Enviar Formulário de Contato (Priority: P1)
|
||||
|
||||
O visitante preenche e envia um formulário de contato com seu nome, e-mail, telefone (opcional) e uma mensagem pré-preenchida com o código do imóvel. O lead é registrado no sistema e visível no painel administrativo.
|
||||
|
||||
**Why this priority**: Captação de leads é um dos objetivos de negócio principais do sistema.
|
||||
|
||||
**Independent Test**: Verificar se um lead é criado no banco de dados com o `property_id` correto após o envio do formulário, e se o registro aparece no painel admin.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante escolhe "Formulário de Contato", **When** o formulário é exibido, **Then** o campo de mensagem já contém o código do imóvel (ex.: "Tenho interesse no imóvel de código 2880602111.").
|
||||
2. **Given** o formulário está aberto, **When** o visitante preenche nome, e-mail e mensagem e clica em "Enviar", **Then** os dados são enviados, o lead é salvo no sistema e o visitante vê uma mensagem de sucesso.
|
||||
3. **Given** o visitante tenta enviar o formulário com campos obrigatórios vazios (nome ou e-mail), **When** clica em "Enviar", **Then** vê mensagens de erro claras nos campos inválidos e o envio é bloqueado.
|
||||
4. **Given** o visitante preenche um e-mail em formato inválido, **When** clica em "Enviar", **Then** vê mensagem de erro específica sobre o e-mail.
|
||||
5. **Given** o visitante clica "Enviar" e ocorre falha de rede ou erro do servidor, **When** a resposta retorna erro, **Then** vê mensagem de erro amigável e pode tentar novamente sem perder os dados preenchidos.
|
||||
6. **Given** o formulário foi enviado com sucesso, **When** o admin acessa o painel de leads, **Then** vê o novo lead com o código/ID do imóvel associado, nome, e-mail, telefone e mensagem do visitante.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Contato via WhatsApp (Priority: P1)
|
||||
|
||||
O visitante escolhe a opção WhatsApp e é redirecionado para o WhatsApp com uma mensagem pré-preenchida contendo o código do imóvel. O número de destino pertence ao corretor responsável.
|
||||
|
||||
**Why this priority**: WhatsApp é o canal de comunicação predominante no mercado imobiliário brasileiro; é esperado como opção principal.
|
||||
|
||||
**Independent Test**: Verificar se o link gerado aponta para o número correto (configurado no sistema) e se a mensagem contém o código do imóvel.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante escolhe a opção "WhatsApp" no modal, **When** confirma a ação, **Then** é redirecionado para o WhatsApp (web ou app) com a mensagem "Olá! Tenho interesse no imóvel de código [CÓDIGO]. Poderia me dar mais informações?" pré-preenchida.
|
||||
2. **Given** o número de WhatsApp do corretor está configurado no sistema, **When** o visitante aciona a opção WhatsApp, **Then** o link aponta para esse número configurado.
|
||||
3. **Given** o número de WhatsApp **não** está configurado, **When** o visitante tenta usar a opção WhatsApp, **Then** a opção é desabilitada ou exibe uma mensagem informando que o canal não está disponível no momento.
|
||||
4. **Given** o visitante está em desktop, **When** clica em WhatsApp, **Then** o link abre o WhatsApp Web em nova aba; em dispositivos móveis, abre o aplicativo nativo do WhatsApp.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Configuração do Número de WhatsApp (Priority: P2)
|
||||
|
||||
O administrador pode configurar o número de WhatsApp do corretor que será usado nos links gerados nos cards de imóvel.
|
||||
|
||||
**Why this priority**: Sem configuração do número, a opção WhatsApp não funciona; mas a configuração em si pode ser feita antes do deploy via variável de ambiente.
|
||||
|
||||
**Independent Test**: Verificar se alternar o número de WhatsApp na configuração reflete imediatamente nos links gerados nos cards de imóvel.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin acessa as configurações do sistema, **When** define ou altera o número de WhatsApp, **Then** os novos links gerados nos cards de imóvel usam o número atualizado.
|
||||
2. **Given** o número é definido via variável de ambiente `WHATSAPP_NUMBER`, **When** o sistema é iniciado, **Then** esse número é utilizado como padrão se não houver configuração salva no banco.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Visualizar Leads no Painel Admin (Priority: P2)
|
||||
|
||||
O administrador pode visualizar, filtrar e exportar a lista de leads gerados pelos formulários de contato, com informação do imóvel associado.
|
||||
|
||||
**Why this priority**: Sem visibilidade dos leads, o objetivo de negócio de captação não se completa.
|
||||
|
||||
**Independent Test**: Verificar se leads aparecem no painel admin com os dados corretos e se os filtros por imóvel e por data funcionam.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin acessa a seção de leads no painel, **When** visualiza a lista, **Then** vê todos os leads com: nome, e-mail, telefone, mensagem, código do imóvel associado e data de criação.
|
||||
2. **Given** o admin deseja encontrar leads de um imóvel específico, **When** filtra pelo código ou ID do imóvel, **Then** apenas os leads daquele imóvel são exibidos.
|
||||
3. **Given** o admin visualiza um lead, **When** clica para ver detalhes, **Then** vê todas as informações do lead e um link para o imóvel correspondente.
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
1. O botão "Entre em Contato" deve ser exibido nos componentes `PropertyRowCard` e `PropertyCard`, com destaque visual adequado ao design existente (Linear dark theme).
|
||||
2. Ao clicar no botão, um modal deve ser aberto apresentando duas opções: "Formulário de Contato" e "WhatsApp".
|
||||
3. O formulário de contato deve conter os campos: nome (obrigatório), e-mail (obrigatório), telefone (opcional) e mensagem (obrigatório, pré-preenchida com o código do imóvel).
|
||||
4. O envio do formulário deve criar um `ContactLead` no banco de dados com o `property_id` do imóvel correspondente, via endpoint existente `POST /api/v1/properties/<slug>/contact`.
|
||||
5. O campo de mensagem deve ser pré-preenchido com o texto: "Tenho interesse no imóvel de código {CÓDIGO}. Poderia me dar mais informações?".
|
||||
6. A opção WhatsApp deve gerar um link `wa.me/{NUMERO}?text={MENSAGEM_CODIFICADA}` que seja aberto em nova aba.
|
||||
7. A mensagem pré-preenchida para o WhatsApp deve conter: "Olá! Tenho interesse no imóvel de código {CÓDIGO}. Poderia me dar mais informações?".
|
||||
8. O número de WhatsApp deve ser lido prioritariamente de uma configuração no banco de dados; como fallback, da variável de ambiente `WHATSAPP_NUMBER`.
|
||||
9. Quando o número de WhatsApp não estiver configurado, a opção WhatsApp deve ser desabilitada visualmente com mensagem informativa.
|
||||
10. O backend deve expor um endpoint `GET /api/v1/config/whatsapp` (sem autenticação) que retorna o número de WhatsApp configurado (ou `null`), para que o frontend possa exibir ou desabilitar a opção.
|
||||
11. O modal deve ser fechável clicando fora dele ou em um botão "X".
|
||||
12. O formulário deve exibir mensagens de erro inline para campos inválidos, sem recarregar a página.
|
||||
13. Após envio bem-sucedido, o formulário deve exibir mensagem de confirmação e fechar automaticamente após breve intervalo.
|
||||
14. Em caso de erro no envio, o formulário deve exibir mensagem de erro amigável e manter os dados preenchidos.
|
||||
15. O painel admin deve listar os leads captados com: nome, e-mail, telefone, mensagem, imóvel associado (código e link) e data de criação.
|
||||
16. A listagem de leads no admin deve suportar paginação e filtro pelo imóvel associado.
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- O modal deve abrir em até 200 ms após o clique no botão (experiência percebida como instantânea).
|
||||
- O envio do formulário deve completar em até 3 segundos em condições normais de rede.
|
||||
- O link do WhatsApp deve ser gerado no lado do cliente, sem round-trip ao servidor.
|
||||
- O botão "Entre em Contato" deve ser acessível via teclado (foco visível, acionável com Enter/Space) e compatível com leitores de tela (atributo `aria-label` adequado).
|
||||
- O modal deve ser responsivo em todos os breakpoints (mobile, tablet, desktop).
|
||||
- Nenhum dado pessoal do visitante deve ser carregado ou exibido antes do visitante interagir com o formulário.
|
||||
- O endpoint `GET /api/v1/config/whatsapp` deve retornar resposta em até 500 ms e pode ser cacheado no cliente por até 5 minutos.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Envio de e-mail de confirmação ao visitante após submissão do formulário (pode ser feature futura).
|
||||
- Integração com CRM externo para sincronização de leads.
|
||||
- Notificação em tempo real (push ou e-mail) ao admin quando um novo lead é criado.
|
||||
- Edição ou exclusão de leads pelo admin (somente leitura no escopo desta feature).
|
||||
- Rastreamento analítico de cliques no botão ou escolha de canal (pode ser feature futura).
|
||||
- Suporte a múltiplos números de WhatsApp por imóvel ou por corretor.
|
||||
- Formulário de contato na página de detalhe do imóvel (já implementado separadamente).
|
||||
|
||||
---
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **ContactLead**: id, property_id (FK → properties.id), name, email, phone, message, created_at — tabela `contact_leads` já existe.
|
||||
- **Property**: id, code, slug, title — campo `code` usado como identificador legível na mensagem pré-preenchida.
|
||||
- **WhatsApp Config**: número de telefone do corretor no formato internacional (ex.: `5516999998888`), armazenado em variável de ambiente `WHATSAPP_NUMBER` e/ou configuração no banco.
|
||||
- **Modal de Contato**: componente React com estado local controlando qual canal está selecionado e o ciclo de vida do formulário (idle → submitting → success/error).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O endpoint `POST /api/v1/properties/<slug>/contact` já está implementado e funcional; esta feature apenas o consome no frontend.
|
||||
- O `code` do imóvel (campo numérico) está disponível no objeto `Property` retornado pela API de listagem e já está presente nos props dos componentes de card.
|
||||
- O design do botão e do modal seguirá o tema Linear dark já adotado no projeto.
|
||||
- O número de WhatsApp será armazenado sem formatação, somente dígitos, no formato internacional sem `+` (ex.: `5516999998888`).
|
||||
- A validação do formulário no frontend espelha as regras do schema Pydantic `ContactLeadIn` já existente (nome ≥ 2 chars, e-mail válido, mensagem ≥ 10 chars, etc.).
|
||||
- A configuração do número de WhatsApp via admin (painel) será implementada como parte da feature 007 (Admin Panel) ou como extensão futura; neste escopo, o número é definido via variável de ambiente `WHATSAPP_NUMBER`.
|
||||
221
specs/009-contact-button/tasks.md
Normal file
221
specs/009-contact-button/tasks.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Tasks: Contact Button (009)
|
||||
|
||||
**Feature Branch**: `009-contact-button`
|
||||
**Spec**: [spec.md](./spec.md)
|
||||
**Plan**: [plan.md](./plan.md)
|
||||
**Generated**: 2026-04-17
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Adicionar campo `code` na interface `Property`
|
||||
|
||||
**Depends on**: none
|
||||
**Files**:
|
||||
- `frontend/src/types/property.ts`
|
||||
|
||||
**Description**:
|
||||
A interface `Property` base (linha ~11) não possui o campo `code`; ele existe apenas em `PropertyDetail`. Como `PropertyRowCard` e `PropertyCard` recebem `Property`, o TypeScript emitirá erro ao tentar ler `property.code` no botão e no modal. Adicionar `code?: string | null` na interface `Property`, após o campo `slug`.
|
||||
|
||||
**Acceptance**:
|
||||
`tsc --noEmit` não reporta erro sobre `code` nas interfaces de `Property`. O campo `code` fica acessível tipado em componentes que recebem `Property`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Criar endpoint `GET /api/v1/config/whatsapp`
|
||||
|
||||
**Depends on**: none
|
||||
**Files**:
|
||||
- `backend/app/routes/config.py` *(novo)*
|
||||
|
||||
**Description**:
|
||||
Criar blueprint `config_bp` com prefixo `/api/v1/config`. Implementar `GET /whatsapp` sem autenticação que lê `os.environ.get("WHATSAPP_NUMBER")` e retorna `{"whatsapp_number": "<número>" | null}`. Usar `jsonify`. Não criar tabela nem model — a configuração é exclusivamente via env var neste escopo.
|
||||
|
||||
Esqueleto esperado:
|
||||
```python
|
||||
import os
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
config_bp = Blueprint("config", __name__, url_prefix="/api/v1/config")
|
||||
|
||||
@config_bp.get("/whatsapp")
|
||||
def get_whatsapp_config():
|
||||
number = os.environ.get("WHATSAPP_NUMBER") or None
|
||||
return jsonify({"whatsapp_number": number})
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
`GET /api/v1/config/whatsapp` retorna HTTP 200 com `{"whatsapp_number": null}` quando env var ausente, e `{"whatsapp_number": "5516999998888"}` quando definida.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Registrar `config_bp` no app Flask
|
||||
|
||||
**Depends on**: Task 2
|
||||
**Files**:
|
||||
- `backend/app/__init__.py`
|
||||
|
||||
**Description**:
|
||||
Importar `config_bp` de `app.routes.config` e registrá-lo via `app.register_blueprint(config_bp)` logo após o registro dos demais blueprints (linha ~60). O prefixo `/api/v1/config` já está definido no próprio blueprint, não passar `url_prefix` aqui.
|
||||
|
||||
**Acceptance**:
|
||||
O Flask resolve a rota `GET /api/v1/config/whatsapp` sem erro 404. Verificar com `flask routes` ou via request HTTP.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Adicionar `GET /api/v1/admin/leads` em `admin.py`
|
||||
|
||||
**Depends on**: none
|
||||
**Files**:
|
||||
- `backend/app/routes/admin.py`
|
||||
|
||||
**Description**:
|
||||
Adicionar rota protegida por `@require_admin` que lista `ContactLead` com paginação e filtro opcional por `property_id`:
|
||||
|
||||
- Query params: `page` (int, default 1), `per_page` (int, default 20, max 100), `property_id` (UUID string, opcional).
|
||||
- Fazer JOIN com `Property` para retornar `property_code` e `property_slug` junto ao lead.
|
||||
- Retornar JSON no formato:
|
||||
```json
|
||||
{
|
||||
"items": [{ "id", "name", "email", "phone", "message", "created_at", "property_id", "property_code", "property_slug" }],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"pages": 3
|
||||
}
|
||||
```
|
||||
- Importar `ContactLead` de `app.models.lead` e `Property` de `app.models.property`.
|
||||
- `created_at` deve ser serializado como ISO 8601 string.
|
||||
|
||||
**Acceptance**:
|
||||
`GET /api/v1/admin/leads` com token de admin retorna HTTP 200 com estrutura paginada. Filtro `?property_id=<uuid>` retorna apenas leads daquele imóvel. Sem token retorna 401.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Criar `services/contact.ts`
|
||||
|
||||
**Depends on**: Task 1
|
||||
**Files**:
|
||||
- `frontend/src/services/contact.ts` *(novo)*
|
||||
|
||||
**Description**:
|
||||
Criar módulo de serviço com duas funções exportadas:
|
||||
|
||||
1. `getWhatsappConfig(): Promise<{ whatsapp_number: string | null }>` — faz `GET /api/v1/config/whatsapp`. Implementar cache em `localStorage` com TTL de 5 minutos (chave `whatsapp_config_cache`, estrutura `{ number: string | null, fetchedAt: number }`).
|
||||
|
||||
2. `submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number, message: string }>` — faz `POST /api/v1/properties/${slug}/contact` com `Content-Type: application/json`. Em caso de resposta 422, lançar erro com `details` mapeados por campo. Em caso de erro de rede ou 5xx, lançar erro genérico preservando status.
|
||||
|
||||
Usar `axios` (já disponível no projeto) ou `fetch` nativo — preferir consistência com o padrão já adotado nos outros services. Importar `ContactFormData` de `../types/property`.
|
||||
|
||||
**Acceptance**:
|
||||
`getWhatsappConfig()` retorna número correto e não refaz request antes de expirar o TTL. `submitContactForm()` entrega payload correto ao endpoint e propaga erros 422 com `details` por campo.
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Criar componente `ContactForm.tsx`
|
||||
|
||||
**Depends on**: Task 5
|
||||
**Files**:
|
||||
- `frontend/src/components/ContactForm.tsx` *(novo)*
|
||||
|
||||
**Description**:
|
||||
Componente de formulário de lead com as seguintes props:
|
||||
```ts
|
||||
interface Props {
|
||||
propertySlug: string
|
||||
propertyCode: string | null
|
||||
onSuccess: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Comportamento:
|
||||
- Campo `message` pré-preenchido com `"Tenho interesse no imóvel de código ${propertyCode}. Poderia me dar mais informações?"`.
|
||||
- Campos: `name` (obrigatório), `email` (obrigatório), `phone` (opcional), `message` (obrigatório, textarea).
|
||||
- Validação inline antes de submeter (espelha `ContactLeadIn`): `name` ≥ 2 chars, `email` formato válido, `message` ≥ 10 chars.
|
||||
- Erros do backend (422) mapeados campo a campo e exibidos abaixo do input correspondente.
|
||||
- Estado `submitting` desabilita o botão e exibe indicador de carregamento.
|
||||
- Em sucesso (201): exibe mensagem "Mensagem enviada com sucesso! Entraremos em contato em breve." e chama `onSuccess()` após 2 s.
|
||||
- Em erro de rede/5xx: exibe banner de erro no topo do form sem perder os dados preenchidos.
|
||||
- Botão "Voltar" chama `onBack()`.
|
||||
- Estilo: classes Tailwind do tema Linear dark do projeto. Botão de submit: classe `bg-brand text-white`.
|
||||
|
||||
**Acceptance**:
|
||||
Formulário valida campos antes de submeter. Erros de backend aparecem inline por campo. Sucesso exibe confirmação e chama `onSuccess`. Dados não são perdidos em caso de erro.
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — Criar componente `ContactModal.tsx`
|
||||
|
||||
**Depends on**: Task 5, Task 6
|
||||
**Files**:
|
||||
- `frontend/src/components/ContactModal.tsx` *(novo)*
|
||||
|
||||
**Description**:
|
||||
Modal orquestrador montado via `ReactDOM.createPortal` em `document.body`. Props:
|
||||
```ts
|
||||
interface Props {
|
||||
property: Property // importar de ../types/property
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Estrutura interna (estado `view: 'select' | 'form'`):
|
||||
|
||||
**Overlay**: `div` com `bg-black/60 backdrop-blur-sm fixed inset-0 z-50`, clique nele chama `onClose()`.
|
||||
|
||||
**Painel**: `div` com `bg-[#1a1a1a] border border-white/10 rounded-2xl` centralizado. Contém botão "X" (`aria-label="Fechar modal"`) que chama `onClose()`.
|
||||
|
||||
**View `select`** (tela inicial):
|
||||
- Título: "Como prefere entrar em contato?"
|
||||
- Botão "Formulário de Contato" → muda `view` para `'form'`.
|
||||
- Botão "WhatsApp" → se `whatsappNumber` não nulo, chama `window.open(waUrl, '_blank', 'noopener,noreferrer')` e chama `onClose()`; se nulo, botão desabilitado com tooltip/texto "Canal indisponível no momento".
|
||||
- URL do WhatsApp: `` `https://wa.me/${whatsappNumber}?text=${encodeURIComponent(`Olá! Tenho interesse no imóvel de código ${property.code}. Poderia me dar mais informações?`)}` ``.
|
||||
- Carregar `whatsappNumber` via `getWhatsappConfig()` em `useEffect` quando `isOpen === true`.
|
||||
|
||||
**View `form`**:
|
||||
- Renderiza `<ContactForm propertySlug={property.slug} propertyCode={property.code} onSuccess={onClose} onBack={() => setView('select')} />`.
|
||||
|
||||
**Acessibilidade**: fechar com tecla `Escape` (listener em `keydown`), `stopPropagation` no clique do painel para não propagar ao overlay.
|
||||
|
||||
**Acceptance**:
|
||||
Modal abre/fecha corretamente. Clicar fora ou pressionar Esc fecha sem erro. Opção WhatsApp desabilitada quando número é null. Link WhatsApp contém número e código do imóvel corretos. Navegação entre views funciona sem perda de estado.
|
||||
|
||||
---
|
||||
|
||||
## Task 8 — Adicionar botão "Entre em Contato" em `PropertyRowCard.tsx`
|
||||
|
||||
**Depends on**: Task 7
|
||||
**Files**:
|
||||
- `frontend/src/components/PropertyRowCard.tsx`
|
||||
|
||||
**Description**:
|
||||
1. Importar `ContactModal` de `./ContactModal` e `useState` do React.
|
||||
2. Adicionar estado: `const [contactOpen, setContactOpen] = useState(false)`.
|
||||
3. Adicionar botão "Entre em Contato" na seção footer do card (próximo ao botão "Comparar"). O botão deve:
|
||||
- Chamar `e.preventDefault()` e `e.stopPropagation()` no `onClick` (necessário pois está dentro do `<Link>` pai).
|
||||
- Acionar `setContactOpen(true)`.
|
||||
- Classes sugeridas: `bg-brand text-white rounded-lg px-3 py-1.5 text-xs font-medium hover:bg-brand/90 transition border border-brand/20`.
|
||||
- Atributo `aria-label="Entrar em contato sobre este imóvel"`.
|
||||
4. Renderizar `<ContactModal property={property} isOpen={contactOpen} onClose={() => setContactOpen(false)} />` fora do `<Link>`, ao final do JSX retornado pelo componente.
|
||||
|
||||
**Acceptance**:
|
||||
Botão visível no card. Clique abre o `ContactModal` sem navegar para a página do imóvel. Modal fecha ao clicar em X, fora do painel ou pressionar Esc. Botão acessível via teclado.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 — Adicionar botão "Entre em Contato" em `PropertyCard.tsx`
|
||||
|
||||
**Depends on**: Task 7
|
||||
**Files**:
|
||||
- `frontend/src/components/PropertyCard.tsx`
|
||||
|
||||
**Description**:
|
||||
Aplicar as mesmas mudanças da Task 8 em `PropertyCard.tsx`:
|
||||
1. Importar `ContactModal` e `useState`.
|
||||
2. Adicionar estado `contactOpen`.
|
||||
3. Adicionar botão "Entre em Contato" na `div.mt-auto` (abaixo do botão "Comparar"), full-width. Handler deve usar `e.preventDefault()` + `e.stopPropagation()`.
|
||||
4. Renderizar `<ContactModal>` ao final do JSX, fora do `<Link>`.
|
||||
|
||||
**Acceptance**:
|
||||
Botão visível no `PropertyCard`. Comportamento idêntico ao da Task 8. Sem regressão no botão "Comparar" existente.
|
||||
36
specs/019-listagem-corretores/checklists/requirements.md
Normal file
36
specs/019-listagem-corretores/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Listagem de Corretores
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Spec validada em iteração única — todos os itens passaram.
|
||||
- Escopo deliberadamente excluído: upload de imagens, drag-and-drop de ordem, link "Ver imóveis" por corretor, página /trabalhe-conosco.
|
||||
- Pronto para prosseguir com `/speckit.plan`.
|
||||
255
specs/019-listagem-corretores/contracts/api-agents.md
Normal file
255
specs/019-listagem-corretores/contracts/api-agents.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# API Contracts: Agents (Corretores)
|
||||
|
||||
**Feature**: 019-listagem-corretores
|
||||
**Base URL**: `/api/v1`
|
||||
**Blueprint público**: `agents_public_bp` — prefixo `/api/v1`
|
||||
**Blueprint admin**: `agents_admin_bp` — prefixo `/api/v1/admin`
|
||||
|
||||
---
|
||||
|
||||
## Tipos compartilhados
|
||||
|
||||
### AgentPublicOut
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "João da Silva",
|
||||
"photo_url": "https://example.com/joao.jpg",
|
||||
"creci": "123456-F",
|
||||
"email": "joao@imobiliaria.com.br",
|
||||
"phone": "(11) 99999-0001",
|
||||
"bio": "Especialista em imóveis residenciais na zona sul."
|
||||
}
|
||||
```
|
||||
|
||||
> `photo_url` pode ser `null`.
|
||||
> `bio` pode ser `null`.
|
||||
|
||||
### AgentOut (admin)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "João da Silva",
|
||||
"photo_url": "https://example.com/joao.jpg",
|
||||
"creci": "123456-F",
|
||||
"email": "joao@imobiliaria.com.br",
|
||||
"phone": "(11) 99999-0001",
|
||||
"bio": "Especialista em imóveis residenciais na zona sul.",
|
||||
"is_active": true,
|
||||
"display_order": 1,
|
||||
"created_at": "2026-04-17T12:00:00",
|
||||
"updated_at": "2026-04-17T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint 1 — Listar corretores ativos (público)
|
||||
|
||||
```
|
||||
GET /api/v1/agents
|
||||
```
|
||||
|
||||
**Autenticação**: Nenhuma
|
||||
**Query params**: Nenhum
|
||||
**Ordenação**: `display_order ASC, id ASC`
|
||||
**Filtro implícito**: `is_active = true`
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "João da Silva",
|
||||
"photo_url": "https://example.com/joao.jpg",
|
||||
"creci": "123456-F",
|
||||
"email": "joao@imobiliaria.com.br",
|
||||
"phone": "(11) 99999-0001",
|
||||
"bio": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
> Array vazio `[]` quando não há corretores ativos — nunca retorna erro.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint 2 — Listar todos os corretores (admin)
|
||||
|
||||
```
|
||||
GET /api/v1/admin/agents
|
||||
```
|
||||
|
||||
**Autenticação**: JWT admin obrigatório (`Authorization: Bearer <token>`)
|
||||
**Ordenação**: `display_order ASC, id ASC`
|
||||
**Retorna**: ativos + inativos
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "João da Silva",
|
||||
"photo_url": null,
|
||||
"creci": "123456-F",
|
||||
"email": "joao@imobiliaria.com.br",
|
||||
"phone": "(11) 99999-0001",
|
||||
"bio": null,
|
||||
"is_active": true,
|
||||
"display_order": 0,
|
||||
"created_at": "2026-04-17T12:00:00",
|
||||
"updated_at": "2026-04-17T12:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Response 401
|
||||
|
||||
```json
|
||||
{ "error": "Unauthorized" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint 3 — Criar corretor (admin)
|
||||
|
||||
```
|
||||
POST /api/v1/admin/agents
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Maria Fernandes",
|
||||
"creci": "654321-F",
|
||||
"email": "maria@imobiliaria.com.br",
|
||||
"phone": "(11) 88888-0002",
|
||||
"photo_url": null,
|
||||
"bio": null,
|
||||
"is_active": true,
|
||||
"display_order": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Obrigatórios**: `name`, `creci`, `email`, `phone`
|
||||
**Opcionais**: `photo_url` (null), `bio` (null), `is_active` (default `true`), `display_order` (default `0`)
|
||||
|
||||
### Response 201
|
||||
|
||||
Retorna `AgentOut` do registro criado.
|
||||
|
||||
### Response 422
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Validation error",
|
||||
"details": [
|
||||
{ "loc": ["email"], "msg": "value is not a valid email address", "type": "value_error.email" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Response 401
|
||||
|
||||
```json
|
||||
{ "error": "Unauthorized" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint 4 — Atualizar corretor (admin)
|
||||
|
||||
```
|
||||
PUT /api/v1/admin/agents/<id>
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Request body (todos os campos opcionais — PATCH semântico via PUT)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Maria Fernandes Silva",
|
||||
"is_active": false
|
||||
}
|
||||
```
|
||||
|
||||
> Apenas os campos enviados são atualizados. Campos ausentes mantêm valor atual.
|
||||
|
||||
### Response 200
|
||||
|
||||
Retorna `AgentOut` atualizado.
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{ "error": "Agent not found" }
|
||||
```
|
||||
|
||||
### Response 422
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Validation error",
|
||||
"details": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Response 401
|
||||
|
||||
```json
|
||||
{ "error": "Unauthorized" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint 5 — Desativar corretor (admin)
|
||||
|
||||
```
|
||||
DELETE /api/v1/admin/agents/<id>
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
> Soft-delete: define `is_active = False`. Não remove fisicamente o registro.
|
||||
|
||||
### Response 200
|
||||
|
||||
```json
|
||||
{ "message": "Agent deactivated" }
|
||||
```
|
||||
|
||||
### Response 404
|
||||
|
||||
```json
|
||||
{ "error": "Agent not found" }
|
||||
```
|
||||
|
||||
### Response 401
|
||||
|
||||
```json
|
||||
{ "error": "Unauthorized" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regras de validação (FR-010)
|
||||
|
||||
| Campo | Obrigatório | Tipo | Regra extra |
|
||||
|---------|-------------|-------------|--------------------------|
|
||||
| `name` | Sim | `str` | min 1 char |
|
||||
| `creci` | Sim | `str` | min 1 char |
|
||||
| `email` | Sim | `EmailStr` | formato RFC-5321 |
|
||||
| `phone` | Sim | `str` | min 1 char |
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de autenticação
|
||||
|
||||
Os endpoints admin reutilizam o decorador `@require_admin` já definido em `backend/app/utils/auth.py`. Requisições sem token ou com token de role `client` recebem `401`.
|
||||
155
specs/019-listagem-corretores/data-model.md
Normal file
155
specs/019-listagem-corretores/data-model.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Data Model: Listagem de Corretores
|
||||
|
||||
**Feature**: 019-listagem-corretores
|
||||
**Phase**: 1 — Design & Contracts
|
||||
**Source**: research.md + spec.md
|
||||
|
||||
---
|
||||
|
||||
## Entidade: Agent (Corretor)
|
||||
|
||||
### Tabela: `agents`
|
||||
|
||||
| Coluna | Tipo SQL | Nullable | Default | Restrições |
|
||||
|------------------|---------------------------------|----------|----------------------|-------------------------|
|
||||
| `id` | `SERIAL` (INTEGER PK) | NOT NULL | auto-increment | PRIMARY KEY |
|
||||
| `name` | `VARCHAR(150)` | NOT NULL | — | campo obrigatório |
|
||||
| `photo_url` | `VARCHAR(512)` | NULL | — | URL externa, sem upload |
|
||||
| `creci` | `VARCHAR(50)` | NOT NULL | — | campo obrigatório |
|
||||
| `email` | `VARCHAR(254)` | NOT NULL | — | formato e-mail válido |
|
||||
| `phone` | `VARCHAR(30)` | NOT NULL | — | campo obrigatório |
|
||||
| `bio` | `TEXT` | NULL | — | apresentação opcional |
|
||||
| `is_active` | `BOOLEAN` | NOT NULL | `TRUE` | soft-delete flag |
|
||||
| `display_order` | `INTEGER` | NOT NULL | `0` | ordenação manual |
|
||||
| `created_at` | `TIMESTAMP WITHOUT TIME ZONE` | NOT NULL | `now()` (server) | imutável após criação |
|
||||
| `updated_at` | `TIMESTAMP WITHOUT TIME ZONE` | NOT NULL | `now()` (server) | atualizado em cada PATCH|
|
||||
|
||||
### Índices
|
||||
|
||||
| Índice | Colunas | Motivo |
|
||||
|----------------------------|----------------------------------|----------------------------------------------|
|
||||
| `ix_agents_is_active` | `is_active` | filtragem rápida na página pública |
|
||||
| `ix_agents_display_order` | `display_order, id` | ordenação da listagem pública (cover index) |
|
||||
|
||||
### Invariantes
|
||||
|
||||
1. `name`, `creci`, `email`, `phone` nunca são deixados em branco (validação Pydantic na entrada).
|
||||
2. `email` deve ser validado com `pydantic.EmailStr` — formato RFC-5321.
|
||||
3. `display_order` pode ter duplicatas — `id` é desempate (order: `display_order ASC, id ASC`).
|
||||
4. Exclusão é lógica apenas (`is_active = False`); nenhum `DELETE` físico é exposto.
|
||||
5. `photo_url` aceita qualquer string URL; a validação de acessibilidade fica a cargo do frontend (fallback de iniciais).
|
||||
|
||||
### Diagrama ER
|
||||
|
||||
```
|
||||
agents
|
||||
├── id PK SERIAL
|
||||
├── name VARCHAR(150) NOT NULL
|
||||
├── photo_url VARCHAR(512) NULL
|
||||
├── creci VARCHAR(50) NOT NULL
|
||||
├── email VARCHAR(254) NOT NULL
|
||||
├── phone VARCHAR(30) NOT NULL
|
||||
├── bio TEXT NULL
|
||||
├── is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
├── display_order INTEGER NOT NULL DEFAULT 0
|
||||
├── created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
└── updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
Sem relacionamentos com outras tabelas nesta versão. Entidade standalone.
|
||||
|
||||
---
|
||||
|
||||
## Modelo SQLAlchemy: `backend/app/models/agent.py`
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
class Agent(db.Model):
|
||||
__tablename__ = "agents"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
photo_url = db.Column(db.String(512), nullable=True)
|
||||
creci = db.Column(db.String(50), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=False)
|
||||
bio = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
display_order = db.Column(db.Integer, nullable=False, default=0)
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
onupdate=datetime.utcnow,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schemas Pydantic: `backend/app/schemas/agent.py`
|
||||
|
||||
### `AgentPublicOut` — resposta pública (visitante)
|
||||
|
||||
Campos expostos: `id`, `name`, `photo_url`, `creci`, `email`, `phone`, `bio`
|
||||
Omitidos propositalmente: `created_at`, `updated_at`, `display_order`, `is_active`
|
||||
|
||||
### `AgentOut` — resposta admin (todos os campos)
|
||||
|
||||
Todos os campos, incluindo `is_active`, `display_order`, `created_at`, `updated_at`.
|
||||
|
||||
### `AgentCreateIn` — criação (admin)
|
||||
|
||||
Campos obrigatórios: `name`, `creci`, `email`, `phone`
|
||||
Campos opcionais: `photo_url`, `bio`, `is_active` (default `True`), `display_order` (default `0`)
|
||||
Validação: `email: EmailStr`
|
||||
|
||||
### `AgentUpdateIn` — edição parcial (admin)
|
||||
|
||||
Todos os campos opcionais (PATCH semântico).
|
||||
Validação: `email: EmailStr | None = None`
|
||||
|
||||
---
|
||||
|
||||
## Migration Alembic
|
||||
|
||||
**Arquivo**: `backend/migrations/versions/e1f2a3b4c5d6_add_agents.py`
|
||||
**Revision ID**: `e1f2a3b4c5d6`
|
||||
**down_revision**: `d1e2f3a4b5c6`
|
||||
|
||||
```sql
|
||||
-- upgrade
|
||||
CREATE TABLE agents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
photo_url VARCHAR(512),
|
||||
creci VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(254) NOT NULL,
|
||||
phone VARCHAR(30) NOT NULL,
|
||||
bio TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ix_agents_is_active ON agents (is_active);
|
||||
CREATE INDEX ix_agents_display_order ON agents (display_order, id);
|
||||
|
||||
-- downgrade
|
||||
DROP INDEX ix_agents_display_order;
|
||||
DROP INDEX ix_agents_is_active;
|
||||
DROP TABLE agents;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alterações em arquivos existentes
|
||||
|
||||
| Arquivo | Tipo | Motivo |
|
||||
|------------------------------------------|-----------|-----------------------------------------------------|
|
||||
| `backend/app/__init__.py` | Modificar | importar model `agent` + registrar blueprints |
|
||||
| `frontend/src/App.tsx` | Modificar | adicionar rotas `/corretores` e `/admin/corretores` |
|
||||
| `frontend/src/components/Navbar.tsx` | Modificar | adicionar link "Corretores" ao menu admin |
|
||||
| `frontend/src/pages/HomePage.tsx` | Modificar | incluir `<AgentsPreviewSection />` |
|
||||
96
specs/019-listagem-corretores/plan.md
Normal file
96
specs/019-listagem-corretores/plan.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Implementation Plan: Listagem de Corretores
|
||||
|
||||
**Branch**: `master` | **Date**: 2026-04-17 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/019-listagem-corretores/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Adicionar uma página pública `/corretores` que exibe os corretores ativos da imobiliária em cards com foto (ou avatar com iniciais como fallback), nome, CRECI, e-mail e telefone. Na área admin, uma página `/admin/corretores` permite CRUD completo. Na HomePage, um bloco "Conheça nossos corretores" com link para `/corretores`. Backend: nova tabela `agents` com migration Alembic, dois blueprints Flask (público + admin), schemas Pydantic para validação. Frontend: nova rota pública, nova rota admin, link no menu admin, componente de prévia na HomePage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12 (backend) / TypeScript 5.5 (frontend)
|
||||
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT, Alembic (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
|
||||
**Storage**: PostgreSQL 16 — nova tabela `agents`
|
||||
**Testing**: pytest (backend)
|
||||
**Target Platform**: Linux server (Docker container)
|
||||
**Project Type**: web-service (Flask REST API) + SPA (React)
|
||||
**Performance Goals**: página pública carrega em < 2s (SC-001); listagem admin sem paginação (~20 registros)
|
||||
**Constraints**: sem upload de imagem (URL externa), sem paginação, soft-delete obrigatório
|
||||
**Scale/Scope**: ~20 corretores ativos; operações CRUD low-frequency
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| **I. Design-First** | ✅ PASS | Página pública e componentes admin seguem design system dark do `DESIGN.md`; avatares usam accent `#5e6ad2` conforme spec visual |
|
||||
| **II. Separation of Concerns** | ✅ PASS | Flask retorna JSON puro; React SPA consome via Axios; nenhuma lógica de renderização no backend |
|
||||
| **III. Spec-Driven** | ✅ PASS | `spec.md` aprovado com user stories e acceptance scenarios; plan derivado do spec |
|
||||
| **IV. Data Integrity** | ✅ PASS | Migration Alembic (`e1f2a3b4c5d6`); Pydantic valida todos os inputs; `email: EmailStr`; sem raw SQL |
|
||||
| **V. Security** | ✅ PASS | Endpoints admin protegidos por `@require_admin` (JWT); endpoint público retorna somente campos não-sensíveis (`AgentPublicOut`) |
|
||||
| **VI. Simplicity First** | ✅ PASS | Sem paginação (justificado: ~20 registros), sem upload (URL externa), sem drag-and-drop; CRUD direto |
|
||||
|
||||
**Veredicto**: Sem violações. Pode prosseguir com implementação.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/019-listagem-corretores/
|
||||
├── plan.md # Este arquivo
|
||||
├── research.md # Decisões técnicas (Phase 0)
|
||||
├── data-model.md # Entidade Agent, migration, schemas (Phase 1)
|
||||
├── quickstart.md # Guia de verificação rápida (Phase 1)
|
||||
├── contracts/
|
||||
│ └── api-agents.md # Contratos dos 5 endpoints REST (Phase 1)
|
||||
└── tasks.md # (Phase 2 — gerado por /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── models/
|
||||
│ │ └── agent.py # NOVO — modelo SQLAlchemy Agent
|
||||
│ ├── schemas/
|
||||
│ │ └── agent.py # NOVO — AgentPublicOut, AgentOut, AgentCreateIn, AgentUpdateIn
|
||||
│ ├── routes/
|
||||
│ │ └── agents.py # NOVO — agents_public_bp + agents_admin_bp
|
||||
│ └── __init__.py # MODIFICAR — importar model + registrar blueprints
|
||||
└── migrations/
|
||||
└── versions/
|
||||
└── e1f2a3b4c5d6_add_agents.py # NOVO — cria tabela agents + índices
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── agent.ts # NOVO — interfaces Agent, AgentAdmin
|
||||
├── services/
|
||||
│ └── agents.ts # NOVO — getActiveAgents(), listAgents(), createAgent(), updateAgent(), deactivateAgent()
|
||||
├── pages/
|
||||
│ ├── AgentsPage.tsx # NOVO — página pública /corretores
|
||||
│ └── admin/
|
||||
│ └── AdminAgentsPage.tsx # NOVO — página admin /admin/corretores
|
||||
├── components/
|
||||
│ └── AgentsPreviewSection.tsx # NOVO — bloco "Conheça nossos corretores" na HomePage
|
||||
├── App.tsx # MODIFICAR — adicionar rotas /corretores e /admin/corretores
|
||||
├── components/
|
||||
│ └── Navbar.tsx # MODIFICAR — adicionar link Corretores no menu admin
|
||||
└── pages/
|
||||
└── HomePage.tsx # MODIFICAR — incluir <AgentsPreviewSection />
|
||||
```
|
||||
|
||||
**Structure Decision**: Projeto web full-stack (Option 2). Backend Flask REST + Frontend React SPA. Cada camada tem seus próprios arquivos por domínio. Dois blueprints no mesmo arquivo `agents.py` para manter coesão sem duplicar ficheiros.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> Sem violações de constituição — seção não aplicável.
|
||||
|
||||
| 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] |
|
||||
142
specs/019-listagem-corretores/quickstart.md
Normal file
142
specs/019-listagem-corretores/quickstart.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Quickstart: Listagem de Corretores
|
||||
|
||||
**Feature**: 019-listagem-corretores
|
||||
**Para**: desenvolvedor implementando a partir das tasks
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Docker + docker-compose rodando (`docker-compose up -d`)
|
||||
- Backend acessível em `http://localhost:5000`
|
||||
- Frontend acessível em `http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
## Ordem de implementação recomendada
|
||||
|
||||
A implementação deve seguir a ordem: **Backend → Migration → Frontend**. Cada etapa é independentemente testável.
|
||||
|
||||
### 1. Backend — Modelo
|
||||
|
||||
Criar `backend/app/models/agent.py` com o modelo `Agent` (ver `data-model.md`).
|
||||
|
||||
Registrar o modelo em `backend/app/__init__.py`:
|
||||
|
||||
```python
|
||||
from app.models import agent as _agent_models # noqa: F401
|
||||
```
|
||||
|
||||
### 2. Backend — Migration
|
||||
|
||||
Criar `backend/migrations/versions/e1f2a3b4c5d6_add_agents.py` com:
|
||||
|
||||
```python
|
||||
revision = "e1f2a3b4c5d6"
|
||||
down_revision = "d1e2f3a4b5c6"
|
||||
```
|
||||
|
||||
Aplicar a migration no container:
|
||||
|
||||
```bash
|
||||
docker-compose exec backend flask db upgrade
|
||||
```
|
||||
|
||||
Verificar:
|
||||
|
||||
```bash
|
||||
docker-compose exec db psql -U postgres -d saas_imobiliaria -c "\d agents"
|
||||
```
|
||||
|
||||
### 3. Backend — Schemas
|
||||
|
||||
Criar `backend/app/schemas/agent.py` com `AgentPublicOut`, `AgentOut`, `AgentCreateIn`, `AgentUpdateIn`.
|
||||
|
||||
### 4. Backend — Rotas
|
||||
|
||||
Criar `backend/app/routes/agents.py` com `agents_public_bp` e `agents_admin_bp`.
|
||||
|
||||
Registrar os blueprints em `backend/app/__init__.py`:
|
||||
|
||||
```python
|
||||
from app.routes.agents import agents_public_bp, agents_admin_bp
|
||||
app.register_blueprint(agents_public_bp, url_prefix="/api/v1")
|
||||
app.register_blueprint(agents_admin_bp, url_prefix="/api/v1/admin")
|
||||
```
|
||||
|
||||
Testar o endpoint público (sem corretores ainda):
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/api/v1/agents
|
||||
# Esperado: []
|
||||
```
|
||||
|
||||
Testar criação (requer token JWT admin):
|
||||
|
||||
```bash
|
||||
TOKEN="<jwt-admin-token>"
|
||||
curl -X POST http://localhost:5000/api/v1/admin/agents \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Teste","creci":"001-F","email":"teste@test.com","phone":"11999990001"}'
|
||||
# Esperado: 201 com AgentOut
|
||||
```
|
||||
|
||||
### 5. Frontend — Tipos e service
|
||||
|
||||
Criar `frontend/src/types/agent.ts` e `frontend/src/services/agents.ts`.
|
||||
|
||||
### 6. Frontend — Página pública `/corretores`
|
||||
|
||||
Criar `frontend/src/pages/AgentsPage.tsx`.
|
||||
|
||||
Adicionar rota em `frontend/src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
<Route path="/corretores" element={<AgentsPage />} />
|
||||
```
|
||||
|
||||
Verificar em `http://localhost:5173/corretores`.
|
||||
|
||||
### 7. Frontend — Página admin `/admin/corretores`
|
||||
|
||||
Criar `frontend/src/pages/admin/AdminAgentsPage.tsx`.
|
||||
|
||||
Adicionar rota em `frontend/src/App.tsx` dentro do bloco admin:
|
||||
|
||||
```tsx
|
||||
<Route path="corretores" element={<AdminAgentsPage />} />
|
||||
```
|
||||
|
||||
Adicionar link no `Navbar.tsx`:
|
||||
|
||||
```tsx
|
||||
{ to: '/admin/corretores', label: 'Corretores' },
|
||||
```
|
||||
|
||||
### 8. Frontend — Seção na HomePage
|
||||
|
||||
Criar `frontend/src/components/AgentsPreviewSection.tsx`.
|
||||
|
||||
Incluir no `HomePage.tsx` após `<FeaturedProperties />`.
|
||||
|
||||
---
|
||||
|
||||
## Verificação rápida (smoke test)
|
||||
|
||||
| Cenário | Esperado |
|
||||
|-----------------------------------------|-----------------------------------------------|
|
||||
| `GET /api/v1/agents` sem auth | `200 []` (vazio) ou lista de corretores ativos |
|
||||
| `GET /api/v1/admin/agents` sem auth | `401 Unauthorized` |
|
||||
| `POST /api/v1/admin/agents` com dados inválidos | `422` com detalhes de validação |
|
||||
| `/corretores` sem corretores ativos | Mensagem "Nenhum corretor disponível" |
|
||||
| Card com `photo_url: null` | Avatar circular com iniciais do nome |
|
||||
| Admin desativa corretor | Corretor some de `/corretores` |
|
||||
|
||||
---
|
||||
|
||||
## Rollback da migration
|
||||
|
||||
```bash
|
||||
docker-compose exec backend flask db downgrade d1e2f3a4b5c6
|
||||
```
|
||||
69
specs/019-listagem-corretores/research.md
Normal file
69
specs/019-listagem-corretores/research.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Research: Listagem de Corretores
|
||||
|
||||
**Feature**: 019-listagem-corretores
|
||||
**Phase**: 0 — Outline & Research
|
||||
**Status**: Complete — sem NEEDS CLARIFICATION
|
||||
|
||||
---
|
||||
|
||||
## Decisões Técnicas
|
||||
|
||||
### 1. Nome do modelo e tabela
|
||||
|
||||
- **Decision**: Modelo `Agent`, tabela `agents`
|
||||
- **Rationale**: "Agent" é o termo em inglês para corretor de imóveis (`real estate agent`), consistente com a convenção do codebase de usar inglês para entidades de banco de dados. Evita `Corretor` (português misturado) ou `Broker` (sentido financeiro).
|
||||
- **Alternatives considered**: `Corretor` (português, inconsistente com o restante), `Realtor` (marca registrada nos EUA), `Broker` (conotação financeira distinta)
|
||||
|
||||
### 2. Estrutura de blueprints para as rotas de agentes
|
||||
|
||||
- **Decision**: Criar `backend/app/routes/agents.py` com dois blueprints no mesmo arquivo: `agents_public_bp` (prefixo `/api/v1`) para a rota pública e `agents_admin_bp` (prefixo `/api/v1/admin`) para as rotas de gerenciamento.
|
||||
- **Rationale**: Mantém um arquivo por domínio de recurso (padrão de todos os outros arquivos em `routes/`). A separação por prefixo entre público e admin é feita via dois Blueprint objects, sem duplicar arquivos.
|
||||
- **Alternatives considered**: Adicionar admin endpoints ao `admin.py` existente (arquivo já longo, prejudica coesão), criar arquivo único com Blueprint de prefixo misto (impossível com Flask sem gambiarras).
|
||||
|
||||
### 3. Soft-delete vs hard-delete
|
||||
|
||||
- **Decision**: Soft-delete via `is_active = False`. Nenhum endpoint de DELETE remove fisicamente o registro.
|
||||
- **Rationale**: Definido explicitamente na spec (FR-007, Assumptions). Consistente com a estratégia adotada para imóveis (`is_active`).
|
||||
- **Alternatives considered**: Hard-delete (rejeitado pela spec).
|
||||
|
||||
### 4. Upload de foto
|
||||
|
||||
- **Decision**: Sem upload — o campo `photo_url` armazena URL externa (string).
|
||||
- **Rationale**: Definido explicitamente nas Assumptions da spec. Elimina a necessidade de storage de arquivos neste escopo.
|
||||
- **Alternatives considered**: Upload direto ao servidor com validação de MIME (fora do escopo — spec explícita).
|
||||
|
||||
### 5. Paginação
|
||||
|
||||
- **Decision**: Sem paginação no endpoint público — retorna todos os corretores ativos em uma única resposta.
|
||||
- **Rationale**: Spec assume máximo de ~20 registros ativos (Assumptions). Paginação adicionaria complexidade sem benefício no escopo atual.
|
||||
- **Alternatives considered**: Paginação cursor-based (prematura), paginação offset (desnecessária no volume esperado).
|
||||
|
||||
### 6. Schemas Pydantic — inline vs arquivo dedicado
|
||||
|
||||
- **Decision**: Criar `backend/app/schemas/agent.py` com os schemas `AgentOut`, `AgentPublicOut`, `AgentCreateIn`, `AgentUpdateIn`.
|
||||
- **Rationale**: `AgentPublicOut` expõe subconjunto de campos (omite `created_at`, `updated_at` que não têm valor para o visitante). Arquivo dedicado evita poluir `admin.py` ou `schemas/property.py`.
|
||||
- **Alternatives considered**: Inline no routes (usado em admin.py para schemas simples; agents justifica arquivo próprio pela separação público/admin).
|
||||
|
||||
### 7. Ordenação na página pública
|
||||
|
||||
- **Decision**: `ORDER BY display_order ASC, id ASC` — `id` serial como desempate natural.
|
||||
- **Rationale**: Definido pela spec (FR-001, Edge Cases). `id` serial garante ordem de inserção como desempate.
|
||||
- **Alternatives considered**: `created_at` como desempate (equivalente mas menos eficiente sem índice).
|
||||
|
||||
### 8. Migration ID
|
||||
|
||||
- **Decision**: Revision ID `e1f2a3b4c5d6`, `down_revision = "d1e2f3a4b5c6"` (héro_image_url foi o último).
|
||||
- **Rationale**: Último down_revision confirmado pelo usuário. ID `e1f2a3b4c5d6` segue o padrão hexadecimal incremental dos outros arquivos do projeto.
|
||||
- **Alternatives considered**: ID gerado por `alembic revision` (equivalente, preferimos manter o padrão visual do projeto).
|
||||
|
||||
### 9. Seção de corretores na HomePage
|
||||
|
||||
- **Decision**: Novo componente `AgentsPreviewSection` em `frontend/src/components/AgentsPreviewSection.tsx`, incluído diretamente no `HomePage.tsx` após a seção de imóveis em destaque.
|
||||
- **Rationale**: Mantém a composição modular da HomePage (que já usa `HeroSection`, `FeaturedProperties`, `CTASection`, etc.). O componente busca os agentes independentemente.
|
||||
- **Alternatives considered**: Inline na HomePage (aumentaria o tamanho do arquivo desnecessariamente).
|
||||
|
||||
### 10. Avatar com iniciais como fallback
|
||||
|
||||
- **Decision**: Componente `AgentAvatar` reutilizável que tenta carregar `photo_url` e em caso de erro (`onError`) renderiza um div circular com as iniciais do nome.
|
||||
- **Rationale**: Cobre os cenários da spec (US1-Scenario 2 e 5, Edge Cases). Padrão já usado em `AdminClientesPage.tsx` (componente `Initials`).
|
||||
- **Alternatives considered**: CSS `content` fallback (não funciona para URLs inválidas sem JS), sempre usar iniciais (prejudica corretores com foto).
|
||||
113
specs/019-listagem-corretores/spec.md
Normal file
113
specs/019-listagem-corretores/spec.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Feature Specification: Listagem de Corretores
|
||||
|
||||
**Feature Branch**: `019-listagem-corretores`
|
||||
**Created**: 2026-04-17
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Página pública /corretores que exibe os corretores cadastrados em um grid com foto, nome, CRECI, e-mail e telefone. Na área admin, uma página de gerenciamento para adicionar, editar e remover corretores."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Visitante explora corretores disponíveis (Priority: P1)
|
||||
|
||||
Um visitante do site deseja conhecer os corretores da imobiliária antes de entrar em contato. Ele navega até `/corretores` e encontra um grid com todos os corretores ativos, cada um exibindo foto (ou avatar com iniciais), nome, CRECI, e-mail e telefone.
|
||||
|
||||
**Why this priority**: É o núcleo da feature — a vitrine pública de corretores. Sem isso, o valor da feature não existe para o visitante.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente acessando `/corretores` sem autenticação e verificando se os corretores ativos são exibidos corretamente com todos os campos esperados.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** que há corretores com `is_active=true` cadastrados, **When** o visitante acessa `/corretores`, **Then** todos eles são exibidos em grid, ordenados crescentemente por `display_order`.
|
||||
2. **Given** um corretor sem foto cadastrada, **When** o card é renderizado, **Then** é exibido um avatar circular com as iniciais do nome no lugar da foto.
|
||||
3. **Given** nenhum corretor ativo cadastrado, **When** o visitante acessa `/corretores`, **Then** uma mensagem informativa é exibida no lugar do grid vazio.
|
||||
4. **Given** a lista de corretores ativos, **When** a página carrega, **Then** nome, CRECI, e-mail e telefone de cada corretor são visíveis no respectivo card.
|
||||
5. **Given** uma URL de foto inválida ou inacessível, **When** o card é renderizado, **Then** o fallback com iniciais é exibido (sem imagem quebrada).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Administrador gerencia corretores (Priority: P2)
|
||||
|
||||
Um administrador acessa `/admin/corretores`, visualiza todos os corretores (ativos e inativos) em uma tabela e pode adicionar novos corretores, editar informações existentes ou desativar corretores que não atuam mais.
|
||||
|
||||
**Why this priority**: Sem gerenciamento admin, o conteúdo da página pública não pode ser mantido atualizado.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente fazendo login como admin e realizando operações CRUD na rota `/admin/corretores`, verificando reflexo na página pública.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** que o admin está autenticado, **When** acessa `/admin/corretores`, **Then** todos os corretores (ativos e inativos) são listados em tabela com nome, CRECI, e-mail, telefone e status.
|
||||
2. **Given** o admin clica em "Adicionar corretor", **When** preenche o formulário com dados válidos e submete, **Then** o novo corretor é salvo e aparece na tabela.
|
||||
3. **Given** um corretor existente, **When** o admin clica em "Editar" e altera algum campo, **Then** as alterações são persistidas e refletidas na tabela imediatamente.
|
||||
4. **Given** um corretor ativo, **When** o admin clica em "Remover", **Then** o corretor é desativado (`is_active=false`) e deixa de aparecer na página pública.
|
||||
5. **Given** dados inválidos no formulário (e.g., e-mail sem `@`, campo obrigatório vazio), **When** o admin tenta salvar, **Then** mensagens de erro de validação são exibidas sem persistir o registro.
|
||||
6. **Given** um usuário não autenticado, **When** tenta acessar `/admin/corretores`, **Then** é redirecionado para a tela de login.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Seção de corretores na HomePage (Priority: P3)
|
||||
|
||||
Na página inicial, um bloco "Conheça nossos corretores" aparece após os imóveis em destaque, convidando o visitante a conhecer a equipe da imobiliária com um link para `/corretores`.
|
||||
|
||||
**Why this priority**: Melhora a descoberta da página de corretores a partir do fluxo principal do site, mas não bloqueia as funcionalidades das histórias P1 e P2.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente acessando a HomePage e verificando se o bloco está presente e o link redireciona corretamente para `/corretores`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** qualquer visitante na HomePage, **When** rola a página até a seção após os imóveis em destaque, **Then** visualiza o bloco "Conheça nossos corretores" com chamada para ação.
|
||||
2. **Given** o bloco visível na HomePage, **When** o visitante clica no link/botão, **Then** é redirecionado para `/corretores`.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece quando um corretor não tem foto? → Avatar com iniciais do nome é exibido (sem imagem quebrada).
|
||||
- O que acontece quando a lista pública de corretores está vazia? → Mensagem amigável exibida, sem grid vazio ou erro visual.
|
||||
- O que acontece ao tentar criar um corretor omitindo campo obrigatório? → Validação impede o salvamento e indica o campo com erro.
|
||||
- O que acontece ao editar um corretor inativo? → O admin ainda pode editar todos os campos, incluindo reativação.
|
||||
- O que acontece com uma URL de foto mal formatada? → O sistema aceita o cadastro e exibe o fallback de iniciais na renderização.
|
||||
- O que acontece se dois corretores têm o mesmo `display_order`? → São exibidos na ordem em que foram cadastrados (por `id` como critério de desempate).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE exibir uma página pública `/corretores` com todos os corretores com `is_active=true`, ordenados por `display_order` crescente (desempate por `id`).
|
||||
- **FR-002**: Cada card de corretor DEVE exibir foto circular (ou avatar com iniciais como fallback), nome completo, CRECI, e-mail e telefone.
|
||||
- **FR-003**: O sistema DEVE fornecer um endpoint público que retorna somente corretores ativos, sem expor dados sensíveis de administração.
|
||||
- **FR-004**: Administradores autenticados DEVEM poder listar todos os corretores (ativos e inativos) na área admin.
|
||||
- **FR-005**: Administradores DEVEM poder criar novos corretores com os campos: nome (obrigatório), URL da foto (opcional), CRECI (obrigatório), e-mail (obrigatório), telefone (obrigatório), bio (opcional), status ativo, e ordem de exibição.
|
||||
- **FR-006**: Administradores DEVEM poder editar todos os campos de um corretor existente, incluindo reativação de corretores inativos.
|
||||
- **FR-007**: Administradores DEVEM poder desativar um corretor (soft-delete: `is_active=false`), tornando-o imediatamente invisível na página pública.
|
||||
- **FR-008**: Todos os endpoints de gerenciamento de corretores DEVEM exigir autenticação com perfil de administrador; requisições não autenticadas DEVEM ser rejeitadas.
|
||||
- **FR-009**: A HomePage DEVE conter um bloco "Conheça nossos corretores" posicionado após a seção de imóveis em destaque, com link para `/corretores`.
|
||||
- **FR-010**: O sistema DEVE validar que nome, CRECI, e-mail e telefone estão preenchidos e que e-mail tem formato válido ao criar ou editar um corretor.
|
||||
- **FR-011**: A sidebar de navegação do painel admin DEVE incluir link para `/admin/corretores`.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Agent (Corretor)**: Representa um corretor de imóveis da imobiliária. Atributos: identificador único, nome completo, URL da foto (opcional), número do CRECI, endereço de e-mail, telefone de contato, bio de apresentação (opcional), indicador de status ativo/inativo, ordem numérica de exibição, data de criação e data da última atualização.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A página `/corretores` carrega e exibe todos os corretores ativos em menos de 2 segundos em conexão padrão.
|
||||
- **SC-002**: Um administrador consegue adicionar um novo corretor em menos de 1 minuto, do clique em "Adicionar" até a confirmação de salvamento.
|
||||
- **SC-003**: 100% dos corretores com `is_active=true` são exibidos na página pública; nenhum corretor inativo é exposto.
|
||||
- **SC-004**: Após desativar um corretor no admin, ele deixa de aparecer na página pública na próxima atualização da página (sem necessidade de cache especial).
|
||||
- **SC-005**: Cards sem foto ou com URL de foto inválida exibem avatar com iniciais em 100% dos casos, sem imagens quebradas.
|
||||
- **SC-006**: O bloco de corretores na HomePage direciona corretamente para `/corretores` em 100% dos acessos.
|
||||
- **SC-007**: Tentativas de acesso não autenticado aos endpoints admin resultam em rejeição em 100% dos casos.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Autenticação de administrador (JWT) já está implementada no projeto; os novos endpoints admin seguirão o mesmo padrão das rotas existentes.
|
||||
- O projeto já possui dark theme configurado globalmente; a página `/corretores` e os componentes admin seguirão o padrão visual das features anteriores sem necessidade de novo sistema de temas.
|
||||
- Não há upload direto de imagens ao servidor neste escopo — a foto do corretor é referenciada por URL externa (hospedagem de imagens fora do sistema).
|
||||
- O número do CRECI é armazenado como texto livre, sem validação de formato contra base de dados do CRECI-SP ou similares.
|
||||
- Soft-delete (`is_active=false`) é a estratégia de remoção adotada; não haverá exclusão física de registros nesta versão.
|
||||
- A seção "Seja nosso colaborador — Envie seu currículo" (rota `/trabalhe-conosco`) está fora do escopo desta feature e não será implementada agora.
|
||||
- A ordem de exibição (`display_order`) é gerenciada manualmente pelo admin via campo numérico; não há drag-and-drop nesta versão.
|
||||
- Paginação não é necessária nesta versão, assumindo um número reduzido de corretores (até ~20 registros ativos).
|
||||
- O campo "link Ver imóveis" por corretor (filtragem de imóveis por corretor) está fora do escopo desta feature.
|
||||
177
specs/019-listagem-corretores/tasks.md
Normal file
177
specs/019-listagem-corretores/tasks.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
description: "Tasks para a feature 019 - Listagem de Corretores"
|
||||
---
|
||||
|
||||
# Tasks: Listagem de Corretores (019)
|
||||
|
||||
**Input**: Design documents de `specs/019-listagem-corretores/`
|
||||
**Prerequisites**: plan.md ✅ · spec.md ✅ · research.md ✅ · data-model.md ✅ · contracts/api-agents.md ✅ · quickstart.md ✅
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description — arquivo`
|
||||
|
||||
- **[P]**: Pode executar em paralelo (arquivo diferente, sem bloqueadores incompletos)
|
||||
- **[Story]**: User story correspondente (US1, US2, US3)
|
||||
- Arquivo exato indicado em cada task
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational — Backend (Bloqueador de tudo)
|
||||
|
||||
**Purpose**: Modelo, migration, schemas e rotas Flask precisam existir antes de qualquer trabalho de frontend poder ser integrado.
|
||||
|
||||
**⚠️ CRÍTICO**: Nenhuma fase de user story pode começar até esta fase estar completa.
|
||||
|
||||
- [ ] T001 Criar modelo SQLAlchemy `Agent` em `backend/app/models/agent.py` conforme data-model.md (colunas: id, name, photo_url, creci, email, phone, bio, is_active, display_order, created_at, updated_at)
|
||||
- [ ] T002 Criar migration Alembic `e1f2a3b4c5d6` em `backend/migrations/versions/e1f2a3b4c5d6_add_agents.py` (`down_revision = "d1e2f3a4b5c6"`) com CREATE TABLE agents + 2 índices (ix_agents_is_active, ix_agents_display_order)
|
||||
- [ ] T003 Criar schemas Pydantic em `backend/app/schemas/agent.py`: `AgentPublicOut` (7 campos públicos), `AgentOut` (todos os campos), `AgentCreateIn` (name/creci/email/phone obrigatórios, demais opcionais), `AgentUpdateIn` (todos opcionais — PATCH semântico via PUT)
|
||||
- [ ] T004 Criar rotas em `backend/app/routes/agents.py` com dois blueprints no mesmo arquivo: `agents_public_bp` (GET /agents — filtro is_active=True, ordem display_order/id) e `agents_admin_bp` (GET /agents, POST /agents, PUT /agents/<id>, DELETE /agents/<id> com soft-delete) — todos os endpoints admin decorados com `@require_admin`
|
||||
- [ ] T005 Registrar model e blueprints em `backend/app/__init__.py`: importar `from app.models import agent as _agent_models` (seção de models) e registrar `agents_public_bp` com `url_prefix="/api/v1"` e `agents_admin_bp` com `url_prefix="/api/v1/admin"` (seção de blueprints)
|
||||
- [ ] T006 Aplicar migration no container e verificar tabela: `docker-compose exec backend flask db upgrade` → `docker-compose exec db psql -U postgres -d saas_imobiliaria -c "\d agents"`
|
||||
|
||||
**Checkpoint**: `curl http://localhost:5000/api/v1/agents` retorna `[]`. Endpoints admin retornam 401 sem token.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Visitante explora corretores (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Página pública `/corretores` com grid de cards mostrando todos os corretores ativos com foto (ou avatar com iniciais), nome, CRECI, e-mail e telefone.
|
||||
|
||||
**Independent Test**: Acessar `/corretores` sem autenticação. Verificar: grid renderiza, card com foto exibe imagem circular, card sem foto exibe avatar com iniciais, e-mail/telefone/CRECI visíveis, lista vazia exibe mensagem amigável.
|
||||
|
||||
- [ ] T007 [P] [US1] Criar tipos TypeScript em `frontend/src/types/agent.ts`: interface `Agent` (campos públicos: id, name, photo_url, creci, email, phone, bio) e interface `AgentAdmin` (estende Agent com is_active, display_order, created_at, updated_at)
|
||||
- [ ] T008 [P] [US1] Criar `frontend/src/services/agents.ts` com função `getActiveAgents(): Promise<Agent[]>` usando Axios (`GET /api/v1/agents`)
|
||||
- [ ] T009 [US1] Criar `frontend/src/components/AgentCard.tsx`: card com foto circular (tag `<img>` com `onError` para fallback) + sub-componente `AgentAvatar` (div circular com iniciais do nome, cor accent `#5e6ad2`) — exibe nome, CRECI, e-mail e telefone
|
||||
- [ ] T010 [US1] Criar `frontend/src/pages/AgentsPage.tsx`: busca agentes via `getActiveAgents()`, renderiza grid responsivo de `<AgentCard />`, exibe mensagem amigável quando lista vazia, estado de loading com skeleton ou spinner
|
||||
- [ ] T011 [US1] Adicionar rota `/corretores` em `frontend/src/App.tsx`: importar `AgentsPage` e inserir `<Route path="/corretores" element={<AgentsPage />} />` entre as rotas públicas
|
||||
- [ ] T012 [P] [US1] Adicionar `{ label: 'Corretores', href: '/corretores', internal: true }` ao array `navLinks` em `frontend/src/components/Navbar.tsx`
|
||||
|
||||
**Checkpoint**: `/corretores` carrega, exibe corretores retornados pela API, fallback de iniciais visível quando sem foto.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Administrador gerencia corretores (Priority: P2)
|
||||
|
||||
**Goal**: Página `/admin/corretores` com tabela listando todos os corretores (ativos e inativos) e modal CRUD para adicionar, editar e desativar.
|
||||
|
||||
**Independent Test**: Fazer login como admin, acessar `/admin/corretores`. Verificar: tabela exibe nome/CRECI/e-mail/telefone/status, criar corretor reflete na tabela, editar persiste mudanças, desativar some da página pública, formulário com e-mail inválido exibe erro sem salvar.
|
||||
|
||||
- [ ] T013 [P] [US2] Adicionar funções admin em `frontend/src/services/agents.ts`: `listAgents()` (GET /api/v1/admin/agents, retorna `AgentAdmin[]`), `createAgent(data)` (POST), `updateAgent(id, data)` (PUT), `deactivateAgent(id)` (PUT com `{ is_active: false }`) — todas com header `Authorization: Bearer <token>`
|
||||
- [ ] T014 [US2] Criar `frontend/src/pages/admin/AdminAgentsPage.tsx`: tabela com colunas nome/CRECI/e-mail/telefone/status (badge ativo/inativo), botão "Adicionar corretor", botões Editar e Desativar por linha; modal com formulário usando campos do `AgentCreateIn`/`AgentUpdateIn` (validação de e-mail no frontend), feedback de sucesso/erro após operações
|
||||
- [ ] T015 [US2] Adicionar rota `/admin/corretores` em `frontend/src/App.tsx`: importar `AdminAgentsPage` e inserir `<Route path="corretores" element={<AdminAgentsPage />} />` dentro do bloco de rotas admin (filho de `/admin`)
|
||||
- [ ] T016 [P] [US2] Adicionar `{ to: '/admin/corretores', label: 'Corretores' }` ao array `adminNavItems` em `frontend/src/components/Navbar.tsx`
|
||||
|
||||
**Checkpoint**: Admin consegue adicionar corretor via modal, corretor aparece em `/corretores`; desativar corretor via admin remove da página pública na próxima recarga.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Seção de corretores na HomePage (Priority: P3)
|
||||
|
||||
**Goal**: Bloco "Conheça nossos corretores" na HomePage após `HomeScrollScene`/seção de imóveis em destaque, com link para `/corretores`.
|
||||
|
||||
**Independent Test**: Acessar `/` sem autenticação, rolar até depois do bloco de imóveis em destaque. Verificar: bloco "Conheça nossos corretores" visível, botão/link redireciona para `/corretores`.
|
||||
|
||||
- [ ] T017 [US3] Criar `frontend/src/components/AgentsPreviewSection.tsx`: seção estática (sem chamada API) com título "Conheça nossos corretores", texto de chamada e botão/link `<Link to="/corretores">` — seguindo design system dark do projeto (bg, text-primary, accent `#5e6ad2`)
|
||||
- [ ] T018 [US3] Incluir `<AgentsPreviewSection />` em `frontend/src/pages/HomePage.tsx` após o componente `HomeScrollScene` (ou após o bloco de imóveis em destaque)
|
||||
|
||||
**Checkpoint**: `/` exibe o bloco CTA e link navega corretamente para `/corretores`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Verificação Final
|
||||
|
||||
- [ ] T019 Executar verificação do `quickstart.md`: testar `GET /api/v1/agents`, criar corretor via `POST /api/v1/admin/agents` com TOKEN admin, verificar `/corretores` no browser, desativar via `PUT` e confirmar remoção da página pública
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependências entre fases
|
||||
|
||||
```
|
||||
Phase 1 (Foundational) ──→ Phase 2 (US1) ──→ Phase 3 (US2) ──→ Phase 4 (US3) ──→ Phase 5
|
||||
```
|
||||
|
||||
- **Phase 1**: Nenhuma dependência — começa imediatamente
|
||||
- **Phase 2 (US1)**: Pode iniciar após T001 (tipos TypeScript são independentes do backend)
|
||||
- T007 e T008 podem começar em paralelo com T001–T006 (sem dependência de backend para criação dos arquivos)
|
||||
- T009, T010 dependem de T007
|
||||
- T011 depende de T010
|
||||
- T012 independente dentro da fase
|
||||
- **Phase 3 (US2)**: T013 depende de T007; T014 depende de T007 + T013; T015 depende de T014; T016 independente
|
||||
- **Phase 4 (US3)**: T017 depende de T007 + T008; T018 depende de T017
|
||||
- **Phase 5**: Depende de todas as fases anteriores
|
||||
|
||||
### Dependências dentro de cada fase
|
||||
|
||||
| Task | Depende de |
|
||||
|------|-----------|
|
||||
| T002 | T001 |
|
||||
| T003 | — (paralelo a T001-T002) |
|
||||
| T004 | T001, T003 |
|
||||
| T005 | T001, T004 |
|
||||
| T006 | T005 |
|
||||
| T007 | — (paralelo a Phase 1) |
|
||||
| T008 | T007 |
|
||||
| T009 | T007 |
|
||||
| T010 | T007, T008, T009 |
|
||||
| T011 | T010 |
|
||||
| T012 | — (paralelo dentro de US1) |
|
||||
| T013 | T007 |
|
||||
| T014 | T007, T013 |
|
||||
| T015 | T014 |
|
||||
| T016 | — (paralelo dentro de US2) |
|
||||
| T017 | T007, T008 |
|
||||
| T018 | T017 |
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Phase 1 — Backend
|
||||
|
||||
```
|
||||
T001 (model)
|
||||
└─→ T002 (migration) ← paralelo com T003
|
||||
T003 (schemas)
|
||||
└─→ T004 (routes, depende T001+T003)
|
||||
└─→ T005 (registrar blueprints)
|
||||
└─→ T006 (aplicar migration)
|
||||
```
|
||||
|
||||
### US1 — Pode começar antes do backend finalizar
|
||||
|
||||
```
|
||||
T007 (types) ← começa junto com Phase 1
|
||||
├─→ T008 (service público)
|
||||
└─→ T009 (AgentCard)
|
||||
└─→ T010 (AgentsPage) ← + T008
|
||||
└─→ T011 (route App.tsx)
|
||||
T012 (navbar link) ← paralelo com T009
|
||||
```
|
||||
|
||||
### US2 — Paralelo ao backend após T007
|
||||
|
||||
```
|
||||
T013 (service admin) ← paralelo a US1 após T007
|
||||
└─→ T014 (AdminAgentsPage)
|
||||
└─→ T015 (route App.tsx)
|
||||
T016 (adminNavItems) ← paralelo com T013
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Independent Test Criteria por User Story
|
||||
|
||||
| Story | Critério de Teste Independente |
|
||||
|-------|-------------------------------|
|
||||
| US1 | `GET /api/v1/agents` retorna array; `/corretores` exibe cards; fallback de iniciais sem foto funciona; lista vazia mostra mensagem amigável |
|
||||
| US2 | Login admin → `/admin/corretores` exibe tabela; criar/editar/desativar via modal; corretor desativado some de `/corretores` |
|
||||
| US3 | `/` exibe bloco "Conheça nossos corretores"; link redireciona para `/corretores` |
|
||||
|
||||
---
|
||||
|
||||
## MVP Scope
|
||||
|
||||
**Entrega mínima viável**: Phase 1 + Phase 2 (US1)
|
||||
- Backend completo (modelo, migration, schemas, 2 blueprints) + página pública `/corretores`
|
||||
- US2 e US3 adicionam valor incremental sem bloquear o núcleo da feature
|
||||
36
specs/020-politica-de-privacidade/checklists/requirements.md
Normal file
36
specs/020-politica-de-privacidade/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Política de Privacidade + Link no Footer
|
||||
|
||||
**Purpose**: Validar completude e qualidade da especificação antes de avançar para o planejamento
|
||||
**Created**: 2026-04-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Todos os itens passaram na validação inicial.
|
||||
- Placeholders para nome/CNPJ/endereço da empresa e e-mail do DPO estão documentados nas Assumptions — devem ser preenchidos antes da implementação.
|
||||
- Pronto para `/speckit.plan`.
|
||||
163
specs/020-politica-de-privacidade/data-model.md
Normal file
163
specs/020-politica-de-privacidade/data-model.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Data Model: Página de Política de Privacidade
|
||||
|
||||
**Feature**: 020-politica-de-privacidade
|
||||
**Phase**: 1 — Design & Contracts
|
||||
**Status**: Complete
|
||||
|
||||
---
|
||||
|
||||
## Nota sobre "Data Model" para feature 100% frontend
|
||||
|
||||
Esta feature não envolve entidades de banco de dados nem estado gerenciado em store. O "modelo de dados" aqui descreve a estrutura lógica do conteúdo estático e o contrato de componentes React.
|
||||
|
||||
---
|
||||
|
||||
## Entidades de Componente
|
||||
|
||||
### PrivacyPolicyPage
|
||||
|
||||
| Propriedade | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| — | — | Componente sem props (stateless, sem hooks) |
|
||||
|
||||
**Estrutura JSX**:
|
||||
|
||||
```
|
||||
PrivacyPolicyPage
|
||||
├── <Navbar />
|
||||
├── <main id="main-content" className="min-h-screen bg-canvas">
|
||||
│ └── <div className="max-w-[800px] mx-auto px-6 pt-16 pb-20">
|
||||
│ ├── Header (label + título + data de atualização)
|
||||
│ └── Seções (1–8)
|
||||
│ └── <section> com <h2> numerado + <p>/<ul> de conteúdo
|
||||
└── <Footer />
|
||||
```
|
||||
|
||||
**Seções (hardcoded)**:
|
||||
|
||||
```typescript
|
||||
type PolicySection = {
|
||||
id: number // 1–8
|
||||
title: string // "1. Instituição Responsável" etc.
|
||||
content: ReactNode // JSX com paragrafos / listas
|
||||
}
|
||||
```
|
||||
|
||||
*Observação*: O tipo `PolicySection` NÃO precisa ser exportado nem criado como shared type — é usado somente internamente no componente. Principle VI: sem abstração prematura.
|
||||
|
||||
---
|
||||
|
||||
### Footer (atualizado)
|
||||
|
||||
Mudança pontual: adicionar `<Link to="/politica-de-privacidade">` no bloco `<nav>` existente.
|
||||
|
||||
**Estado atual do array `footerLinks`**:
|
||||
```typescript
|
||||
const footerLinks = [
|
||||
{ label: 'Imóveis', href: '/imoveis' },
|
||||
{ label: 'Sobre', href: '#sobre' },
|
||||
{ label: 'Contato', href: '#contato' },
|
||||
]
|
||||
```
|
||||
|
||||
**Após a mudança** — o array `footerLinks` **não é alterado**. Um novo link `<Link>` do react-router-dom é adicionado manualmente no `<ul>` após os links existentes:
|
||||
|
||||
```tsx
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// no <ul> dos footerLinks, após o map existente:
|
||||
<li>
|
||||
<Link
|
||||
to="/politica-de-privacidade"
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
>
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</li>
|
||||
```
|
||||
|
||||
**Rationale**: Adicionar ao array seria inconsistente (o array usa `href` para `<a>`, mas esse precisa de `<Link>`). Manter separado é mais claro e não requer refatorar o loop existente.
|
||||
|
||||
---
|
||||
|
||||
### Rota (App.tsx)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| `path` | `/politica-de-privacidade` |
|
||||
| `element` | `<PrivacyPolicyPage />` |
|
||||
| Posição no arquivo | Após a rota `/corretores`, antes da rota `/login` |
|
||||
| Guards | Nenhum — rota pública |
|
||||
|
||||
---
|
||||
|
||||
## Conteúdo estático das seções
|
||||
|
||||
### Seção 1 — Instituição Responsável
|
||||
|
||||
Nome: `[NOME DA EMPRESA]`
|
||||
CNPJ: `[CNPJ]`
|
||||
Endereço: `[ENDEREÇO COMPLETO]`
|
||||
*Esta empresa é a controladora dos dados pessoais tratados neste site.*
|
||||
|
||||
### Seção 2 — Coleta e Uso de Dados Pessoais
|
||||
|
||||
Dados coletados:
|
||||
- Nome completo, e-mail e telefone ao preencher formulários de contato
|
||||
- Dados de navegação (páginas visitadas, tempo de sessão) via cookies analíticos
|
||||
- Dados fornecidos voluntariamente ao solicitar visitas a imóveis
|
||||
|
||||
Finalidades:
|
||||
- Responder solicitações de contato e agendamento de visitas
|
||||
- Melhorar a experiência de uso do site
|
||||
- Enviar comunicações sobre imóveis compatíveis com o perfil do usuário (com consentimento)
|
||||
|
||||
### Seção 3 — Transferência de Dados Pessoais
|
||||
|
||||
Compartilhamento com:
|
||||
- Corretores parceiros, apenas dados necessários para conduzir visitas
|
||||
- Plataformas de análise de tráfego (ex.: Google Analytics) — dados anonimizados
|
||||
- Não vendemos ou alugamos dados pessoais a terceiros
|
||||
|
||||
### Seção 4 — Uso de Cookies
|
||||
|
||||
Tipos de cookies utilizados:
|
||||
- **Essenciais**: necessários para o funcionamento do site (autenticação, preferências)
|
||||
- **Analíticos**: medem o uso do site para melhoria contínua
|
||||
- O usuário pode desativar cookies analíticos nas configurações do navegador
|
||||
|
||||
### Seção 5 — Direitos do Usuário (LGPD)
|
||||
|
||||
Conforme a Lei 13.709/2018 (LGPD), o titular dos dados tem direito a:
|
||||
- **Acesso**: confirmar a existência e solicitar cópia dos dados tratados
|
||||
- **Correção**: atualizar dados incompletos, inexatos ou desatualizados
|
||||
- **Exclusão**: solicitar a eliminação de dados desnecessários ou tratados com consentimento
|
||||
- **Portabilidade**: receber seus dados em formato estruturado
|
||||
- **Revogação de consentimento**: retirar o consentimento a qualquer momento
|
||||
|
||||
### Seção 6 — Segurança dos Dados
|
||||
|
||||
- Utilizamos conexões HTTPS em todo o site
|
||||
- Dados sensíveis são armazenados com criptografia
|
||||
- Acesso interno limitado ao pessoal autorizado
|
||||
- Revisamos regularmente nossas práticas de segurança
|
||||
|
||||
### Seção 7 — Alterações nesta Política
|
||||
|
||||
Podemos atualizar esta Política periodicamente. A data de "última atualização" é exibida no topo da página. Recomendamos revisão periódica.
|
||||
|
||||
### Seção 8 — Contato / DPO
|
||||
|
||||
Para exercer seus direitos ou esclarecer dúvidas sobre o tratamento de seus dados:
|
||||
E-mail do Encarregado (DPO): `[EMAIL DO DPO]`
|
||||
Link exibido como `<a href="mailto:[EMAIL DO DPO]">` abrindo cliente de e-mail.
|
||||
|
||||
---
|
||||
|
||||
## Validações e regras de estado
|
||||
|
||||
| Regra | Implementação |
|
||||
|---|---|
|
||||
| Sem chamadas de API | Componente não usa `useEffect`, `useState`, nem imports de `services/` |
|
||||
| Sem estado de loading | Nenhum skeleton, spinner ou estado condicional |
|
||||
| Links externos abrem em nova aba | `target="_blank" rel="noopener noreferrer"` no link do e-mail DPO |
|
||||
95
specs/020-politica-de-privacidade/plan.md
Normal file
95
specs/020-politica-de-privacidade/plan.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Implementation Plan: Página de Política de Privacidade + Link no Footer
|
||||
|
||||
**Branch**: `master` | **Date**: 2026-04-17 | **Spec**: [specs/020-politica-de-privacidade/spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/020-politica-de-privacidade/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Criar a página estática `/politica-de-privacidade` com 8 seções LGPD e adicioná-la ao sistema de roteamento SPA. Adicionar link client-side (`<Link>`) no Footer existente. Feature 100% frontend — sem backend, sem API, sem estado.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.5 / React 18
|
||||
**Primary Dependencies**: react-router-dom v6 (já instalado), Tailwind CSS 3.4 (já configurado)
|
||||
**Storage**: N/A — componente puramente estático
|
||||
**Testing**: `npm run build` (TypeScript type-check via tsc); verificação visual manual
|
||||
**Target Platform**: Browser (SPA — Vite/React)
|
||||
**Project Type**: Web application (frontend only)
|
||||
**Performance Goals**: Navegação client-side < 500 ms (SC-003); sem requisições de rede na carga da página
|
||||
**Constraints**: Layout max-width 800px; responsivo ≥ 320px sem overflow horizontal; conformidade total com tema dark do DESIGN.md
|
||||
**Scale/Scope**: 1 componente novo (`PrivacyPolicyPage.tsx`), 2 arquivos modificados (`App.tsx`, `Footer.tsx`)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Evidência |
|
||||
|---|---|---|
|
||||
| **I. Design-First** | ✅ PASS | Componente usa `bg-canvas`, `text-text-primary`, `text-text-secondary`, `border-white/[0.06]`, `fontFeatureSettings: '"cv01","ss03"'` — tokens idênticos às demais páginas. Max-width 800px, responsivo. Nenhum estilo inline que desvie do design system. |
|
||||
| **II. Separation of Concerns** | ✅ PASS | Feature 100% frontend. Nenhuma rota Flask nova. Nenhum modelo de dados alterado. |
|
||||
| **III. Spec-Driven Development** | ✅ PASS | `spec.md` aprovado com user stories e acceptance scenarios. Ordem: spec → plan → tasks → implement. |
|
||||
| **IV. Data Integrity** | ✅ N/A | Sem entidades de banco. Sem Pydantic schemas. Sem migrations. |
|
||||
| **V. Security** | ✅ PASS | Rota pública sem guards — correto (política de privacidade é pública). Nenhum token ou secret no frontend. |
|
||||
| **VI. Simplicity First** | ✅ PASS | Componente stateless. Sem hooks. Sem services. Sem abstrações novas. 3 arquivos alterados/criados. Conteúdo inline no componente. |
|
||||
|
||||
**Resultado**: Nenhuma violação. Pode avançar para implementação.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/020-politica-de-privacidade/
|
||||
├── plan.md ← este arquivo
|
||||
├── research.md ← gerado (Phase 0)
|
||||
├── data-model.md ← gerado (Phase 1)
|
||||
├── quickstart.md ← gerado (Phase 1)
|
||||
└── tasks.md ← será gerado pelo /speckit.tasks
|
||||
```
|
||||
|
||||
### Source Code (arquivos desta feature)
|
||||
|
||||
```text
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── pages/
|
||||
│ │ └── PrivacyPolicyPage.tsx ← CRIAR (novo)
|
||||
│ ├── components/
|
||||
│ │ └── Footer.tsx ← MODIFICAR (adicionar Link)
|
||||
│ └── App.tsx ← MODIFICAR (adicionar rota)
|
||||
```
|
||||
|
||||
**Sem backend envolvido.** Sem migrations, sem novos endpoints, sem schemas Pydantic.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Fase de implementação (executada via /speckit.tasks)
|
||||
|
||||
**Task 1 — Criar `PrivacyPolicyPage.tsx`**
|
||||
Componente stateless com Navbar + main + Footer. Conteúdo estático das 8 seções LGPD inline. Design conforme tokens do DESIGN.md. Ver `data-model.md` para estrutura e conteúdo de cada seção.
|
||||
|
||||
**Task 2 — Adicionar rota em `App.tsx`**
|
||||
Importar `PrivacyPolicyPage` e adicionar `<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />` após a rota `/corretores`.
|
||||
|
||||
**Task 3 — Atualizar `Footer.tsx`**
|
||||
Importar `Link` do react-router-dom. Adicionar `<li><Link to="/politica-de-privacidade" ...>Política de Privacidade</Link></li>` no `<ul>` de navegação, após o map de `footerLinks`.
|
||||
|
||||
### Ordem de execução
|
||||
|
||||
Tasks 1, 2 e 3 são independentes em si, mas a verificação funcional requer todas completas. Ordem sugerida: 1 → 2 → 3 (criar página primeiro, depois rota, depois link no footer).
|
||||
|
||||
---
|
||||
|
||||
## Artefatos gerados
|
||||
|
||||
| Artefato | Caminho |
|
||||
|---|---|
|
||||
| Spec | [specs/020-politica-de-privacidade/spec.md](spec.md) |
|
||||
| Research | [specs/020-politica-de-privacidade/research.md](research.md) |
|
||||
| Data Model | [specs/020-politica-de-privacidade/data-model.md](data-model.md) |
|
||||
| Quickstart | [specs/020-politica-de-privacidade/quickstart.md](quickstart.md) |
|
||||
| Tasks | `specs/020-politica-de-privacidade/tasks.md` *(gerado por /speckit.tasks)* |
|
||||
150
specs/020-politica-de-privacidade/quickstart.md
Normal file
150
specs/020-politica-de-privacidade/quickstart.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Quickstart: Página de Política de Privacidade
|
||||
|
||||
**Feature**: 020-politica-de-privacidade
|
||||
**Branch**: `master` (feature branch recomendada: `feat/020-politica-de-privacidade`)
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Node.js instalado
|
||||
- Dependências do frontend instaladas: `cd frontend && npm install`
|
||||
- Docker em execução (opcional — apenas se precisar do backend para outras páginas)
|
||||
|
||||
---
|
||||
|
||||
## Arquivos envolvidos
|
||||
|
||||
| Ação | Arquivo |
|
||||
|---|---|
|
||||
| **Criar** | `frontend/src/pages/PrivacyPolicyPage.tsx` |
|
||||
| **Modificar** | `frontend/src/components/Footer.tsx` |
|
||||
| **Modificar** | `frontend/src/App.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## Como rodar o frontend em dev
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Acesse `http://localhost:5173/politica-de-privacidade` para verificar a nova página.
|
||||
|
||||
---
|
||||
|
||||
## Passo a passo de implementação
|
||||
|
||||
### 1. Criar `PrivacyPolicyPage.tsx`
|
||||
|
||||
```tsx
|
||||
// frontend/src/pages/PrivacyPolicyPage.tsx
|
||||
import Footer from '../components/Footer'
|
||||
import Navbar from '../components/Navbar'
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main id="main-content" className="min-h-screen bg-canvas">
|
||||
<div className="max-w-[800px] mx-auto px-6 pt-16 pb-20">
|
||||
{/* Header */}
|
||||
<p className="text-[#5e6ad2] text-sm font-medium tracking-widest uppercase mb-3">
|
||||
Legal
|
||||
</p>
|
||||
<h1
|
||||
className="text-3xl md:text-4xl font-semibold text-text-primary tracking-tight mb-2"
|
||||
style={{ fontFeatureSettings: '"cv01","ss03"' }}
|
||||
>
|
||||
Política de Privacidade
|
||||
</h1>
|
||||
<p className="text-text-tertiary text-sm mb-12">
|
||||
Última atualização: abril de 2026
|
||||
</p>
|
||||
|
||||
{/* Seções */}
|
||||
<div className="space-y-10">
|
||||
{/* Seção 1 */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3"
|
||||
style={{ fontFeatureSettings: '"cv01","ss03"' }}>
|
||||
1. Instituição Responsável
|
||||
</h2>
|
||||
<p className="text-text-secondary text-sm leading-relaxed">
|
||||
[NOME DA EMPRESA], inscrita no CNPJ sob o nº [CNPJ], com sede em [ENDEREÇO COMPLETO],
|
||||
é a controladora dos dados pessoais tratados por meio deste site.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Seção 2 */}
|
||||
<section>
|
||||
<h2 ...>2. Coleta e Uso de Dados Pessoais</h2>
|
||||
...
|
||||
</section>
|
||||
|
||||
{/* ... demais seções ... */}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> **Ver data-model.md para o conteúdo completo de cada seção.**
|
||||
|
||||
---
|
||||
|
||||
### 2. Adicionar rota em `App.tsx`
|
||||
|
||||
```tsx
|
||||
// após import de AgentsPage
|
||||
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
|
||||
|
||||
// dentro de <Routes>, após a rota /corretores:
|
||||
<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Atualizar `Footer.tsx`
|
||||
|
||||
```tsx
|
||||
// Adicionar import no topo:
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// No <ul> dos footerLinks, adicionar após o map existente:
|
||||
<li>
|
||||
<Link
|
||||
to="/politica-de-privacidade"
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
>
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</li>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verificação de conformidade
|
||||
|
||||
| Critério | Como verificar |
|
||||
|---|---|
|
||||
| 8 seções presentes | Contar visualmente em `http://localhost:5173/politica-de-privacidade` |
|
||||
| Link no Footer funciona sem reload | Abrir DevTools > Network, clicar no link — nenhuma requisição de documento nova |
|
||||
| Sem chamadas de API | DevTools > Network > XHR/Fetch — vazio ao carregar a página |
|
||||
| Responsivo em 375px | DevTools > Device Toolbar → iPhone SE |
|
||||
| Tema dark consistente | Comparar com `http://localhost:5173/corretores` — mesmas cores/fontes |
|
||||
|
||||
---
|
||||
|
||||
## Build de validação
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Sem erros de TypeScript = pronto para PR.
|
||||
100
specs/020-politica-de-privacidade/research.md
Normal file
100
specs/020-politica-de-privacidade/research.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Research: Página de Política de Privacidade
|
||||
|
||||
**Feature**: 020-politica-de-privacidade
|
||||
**Phase**: 0 — Outline & Research
|
||||
**Status**: Complete — sem NEEDS CLARIFICATION
|
||||
|
||||
---
|
||||
|
||||
## 1. Padrão de página estática no projeto
|
||||
|
||||
**Decision**: Usar o mesmo padrão de `AgentsPage.tsx` — `<Navbar />` + `<main className="min-h-screen bg-canvas">` + `<Footer />`.
|
||||
|
||||
**Rationale**: Todas as páginas públicas do projeto seguem este padrão. `AgentsPage.tsx` é o exemplo mais recente e pode ser usado como referência direta.
|
||||
|
||||
**Alternatives considered**: Criar um componente `PublicLayout` com Navbar/Footer embutidos. Rejeitado por violar Principle VI (YAGNI — só 1 page nova, sem reutilização real).
|
||||
|
||||
---
|
||||
|
||||
## 2. Roteamento client-side
|
||||
|
||||
**Decision**: Adicionar `<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />` direto no arquivo `frontend/src/App.tsx`, na mesma camada das rotas públicas (`/`, `/imoveis`, `/corretores`).
|
||||
|
||||
**Rationale**: Padrão já usado por todas as rotas públicas. Sem guards de autenticação necessários.
|
||||
|
||||
**Alternatives considered**: Rota aninhada num layout público. Rejeitado — nenhuma outra rota pública usa layout aninhado; seria over-engineering.
|
||||
|
||||
---
|
||||
|
||||
## 3. Link no Footer — `<a>` vs `<Link>`
|
||||
|
||||
**Decision**: Usar `<Link to="/politica-de-privacidade">` do `react-router-dom` para o novo link no Footer. Os links existentes (`#sobre`, `#contato`) permanecem como `<a>` (são âncoras intra-página, não rotas SPA). O link `/imoveis` existente também fica como `<a>` para não alterar comportamento legado fora do escopo.
|
||||
|
||||
**Rationale**: FR-006 exige navegação sem recarregamento. O `<Link>` do React Router garante transição client-side sem `window.location` reload. O Footer está dentro do `<BrowserRouter>` (App.tsx), então `<Link>` funciona sem nenhuma mudança estrutural.
|
||||
|
||||
**Alternatives considered**:
|
||||
1. Converter todos os links do footer para `<Link>` — fora de escopo, Principle VI proíbe mudanças além do especificado.
|
||||
2. Usar `<a href="/politica-de-privacidade">` — causaria reload completo da página, violando FR-006 e SC-003.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design tokens a utilizar
|
||||
|
||||
**Decision**: Seguir exatamente as classes Tailwind mapeadas para os tokens do DESIGN.md:
|
||||
|
||||
| Elemento | Classe Tailwind |
|
||||
|---|---|
|
||||
| Fundo da página | `bg-canvas` |
|
||||
| Fundo de seção alternada (opcional) | N/A — fundo único para simplicidade |
|
||||
| Texto principal (título, corpo) | `text-text-primary` |
|
||||
| Texto secundário | `text-text-secondary` |
|
||||
| Texto terciário (metadata) | `text-text-tertiary` |
|
||||
| Separador entre seções | `border-white/[0.06]` |
|
||||
| Largura máxima do conteúdo | `max-w-[800px] mx-auto px-6` |
|
||||
| Títulos de seção | `font-semibold text-text-primary` + `fontFeatureSettings: '"cv01","ss03"'` |
|
||||
| Label de categoria | `text-[#5e6ad2] text-sm font-medium tracking-widest uppercase` |
|
||||
|
||||
**Rationale**: Alinhamento com Principle I (Design-First). Tokens já existentes no `tailwind.config.ts`; sem novos tokens necessários.
|
||||
|
||||
---
|
||||
|
||||
## 5. Estrutura das 8 seções LGPD
|
||||
|
||||
**Decision**: Conteúdo estático inline no componente. Sem fonte de dados externa, sem CMS, sem JSON separado.
|
||||
|
||||
**Rationale**: Principle VI — a solução mais simples. A política muda raramente; extrair para arquivo JSON seria over-abstraction sem benefício concreto.
|
||||
|
||||
**Seções obrigatórias (FR-003)**:
|
||||
|
||||
| # | Título | Conteúdo-chave |
|
||||
|---|---|---|
|
||||
| 1 | Instituição Responsável | Nome, CNPJ, endereço (placeholders) |
|
||||
| 2 | Coleta e Uso de Dados Pessoais | Quais dados, finalidade de uso |
|
||||
| 3 | Transferência de Dados Pessoais | Compartilhamento com terceiros (parceiros, financeiras) |
|
||||
| 4 | Uso de Cookies | Tipos de cookies, finalidade |
|
||||
| 5 | Direitos do Usuário | Acesso, correção, exclusão, portabilidade, revogação de consentimento |
|
||||
| 6 | Segurança dos Dados | Medidas técnicas adotadas |
|
||||
| 7 | Alterações nesta Política | Versionamento e data de última atualização |
|
||||
| 8 | Contato / DPO | E-mail do encarregado (placeholder `[EMAIL DO DPO]`) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsividade
|
||||
|
||||
**Decision**: `max-w-[800px] mx-auto px-6` para o container do conteúdo. Em mobile, o padding `px-6` garante margens laterais. Sem media queries adicionais — Tailwind + text-base resolve legibilidade.
|
||||
|
||||
**Rationale**: SC-004 exige legibilidade a partir de 320px sem overflow horizontal. O container com `px-6` e `max-w-[800px]` atende naturalmente por ser menor que qualquer viewport padrão >375px.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependências novas
|
||||
|
||||
**Decision**: Nenhuma dependência nova.
|
||||
|
||||
**Rationale**: `react-router-dom` já está instalado. Tailwind já configurado. Principle VI — sem pacotes novos sem justificativa.
|
||||
|
||||
---
|
||||
|
||||
## Resolução de NEEDS CLARIFICATION
|
||||
|
||||
Não havia ambiguidades de natureza técnica que exigissem pesquisa externa. Todos os pontos foram resolvidos com base no código existente no projeto.
|
||||
120
specs/020-politica-de-privacidade/spec.md
Normal file
120
specs/020-politica-de-privacidade/spec.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Feature Specification: Página de Política de Privacidade + Link no Footer
|
||||
|
||||
**Feature Branch**: `020-politica-de-privacidade`
|
||||
**Created**: 2026-04-17
|
||||
**Status**: Draft
|
||||
**Input**: Criar página estática /politica-de-privacidade com conteúdo LGPD adaptado para imobiliária e link no Footer existente.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Visitante lê a Política de Privacidade (Priority: P1)
|
||||
|
||||
Um visitante do site deseja conhecer como seus dados pessoais são tratados pela imobiliária. Ele navega até `/politica-de-privacidade` e encontra o documento completo, com todas as seções exigidas pela LGPD, organizado de forma legível.
|
||||
|
||||
**Why this priority**: Requisito de conformidade legal (LGPD). Toda empresa que coleta dados pessoais deve disponibilizar a política de privacidade de forma clara e acessível.
|
||||
|
||||
**Independent Test**: Pode ser testado acessando diretamente `/politica-de-privacidade` no navegador e verificando que o conteúdo é exibido com todas as 8 seções, Navbar e Footer presentes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está em qualquer página do site, **When** digita `/politica-de-privacidade` na URL, **Then** a página carrega com título "Política de Privacidade", Navbar, Footer e as 8 seções numeradas.
|
||||
2. **Given** o visitante está na página de política, **When** a página carrega, **Then** todo o conteúdo é exibido sem chamadas de API, sem estado de carregamento e sem erros.
|
||||
3. **Given** o visitante lê a página em dispositivo móvel, **When** a página renderiza, **Then** o texto é legível, sem overflow horizontal e com espaçamento adequado.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Visitante encontra o link da Política no Footer (Priority: P2)
|
||||
|
||||
Um visitante está no rodapé do site (Footer) em qualquer página e deseja localizar o link para a Política de Privacidade.
|
||||
|
||||
**Why this priority**: O Footer é o local padrão e esperado pelos usuários para links legais. Garantir a descoberta do link é essencial para conformidade e UX.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando o componente Footer em qualquer página — o link "Política de Privacidade" deve estar visível e clicar nele deve navegar para `/politica-de-privacidade` sem recarregar a página.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está em qualquer página com Footer, **When** rola até o rodapé, **Then** o link "Política de Privacidade" está visível.
|
||||
2. **Given** o visitante clica no link "Política de Privacidade" no Footer, **When** o clique é registrado, **Then** o roteamento navega para `/politica-de-privacidade` sem recarregamento completo da página.
|
||||
3. **Given** o visitante está na página de Política e clica no logo ou em um link de navegação, **When** o clique é registrado, **Then** ele retorna à página anterior normalmente.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Visitante identifica a empresa responsável e o canal de contato (Priority: P3)
|
||||
|
||||
Um visitante que lê a política deseja saber qual empresa é responsável pelo tratamento dos seus dados e como entrar em contato com o encarregado (DPO).
|
||||
|
||||
**Why this priority**: A LGPD exige identificação do controlador e disponibilização do canal de comunicação com o DPO. É requisito legal e melhora a confiança do usuário.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando que a seção "Instituição Responsável" e a seção "Contato / DPO" contêm nome da empresa, informações de contato e canal para exercício de direitos LGPD.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está na página de Política, **When** lê a seção 1, **Then** encontra o nome, endereço e CNPJ da empresa responsável.
|
||||
2. **Given** o visitante deseja exercer seus direitos (acesso, correção, exclusão), **When** lê a seção 8, **Then** encontra um endereço de e-mail ou formulário para contato com o DPO.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece se o usuário acessa `/politica-de-privacidade` com JavaScript desabilitado? (SPA — página não renderiza; comportamento aceitável para PWA/SPA)
|
||||
- O que acontece se o usuário acessar a rota diretamente após um refresh? (deve funcionar normalmente com o roteamento configurado)
|
||||
- O conteúdo é longo — a rolagem da página deve funcionar normalmente sem quebras de layout.
|
||||
- Links externos na seção de contato devem abrir em nova aba para não desviar o usuário do site.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE exibir a página de Política de Privacidade na rota `/politica-de-privacidade`.
|
||||
- **FR-002**: A página DEVE incluir o componente Navbar no topo e o componente Footer no rodapé, idênticos aos demais do site.
|
||||
- **FR-003**: A página DEVE apresentar as seguintes 8 seções numeradas, nesta ordem:
|
||||
1. Instituição Responsável
|
||||
2. Coleta e Uso de Dados Pessoais
|
||||
3. Transferência de Dados Pessoais
|
||||
4. Uso de Cookies
|
||||
5. Direitos do Usuário (acesso, correção, exclusão, portabilidade — conforme LGPD)
|
||||
6. Segurança dos Dados
|
||||
7. Alterações nesta Política
|
||||
8. Contato / DPO
|
||||
- **FR-004**: Cada seção DEVE ter um título com numeração visível (ex.: "1. Instituição Responsável") e conteúdo textual explicativo.
|
||||
- **FR-005**: O conteúdo da página DEVE ser puramente estático — sem chamadas de API, sem fetchs de dados externos.
|
||||
- **FR-006**: O Footer DEVE conter um link de texto "Política de Privacidade" que navega para `/politica-de-privacidade` usando roteamento client-side (sem reload da página).
|
||||
- **FR-007**: O layout do conteúdo principal DEVE ter largura máxima de 800 px e ser centralizado horizontalmente na tela.
|
||||
- **FR-008**: O design DEVE ser consistente com o tema escuro (dark) existente no site, utilizando as mesmas variáveis de cor e tipografia do restante das páginas.
|
||||
- **FR-009**: A seção de Direitos do Usuário DEVE mencionar explicitamente: direito de acesso, correção, exclusão, portabilidade e revogação de consentimento, conforme a LGPD.
|
||||
- **FR-010**: A seção de Contato / DPO DEVE incluir um canal de comunicação (e-mail) para que o usuário exerça seus direitos LGPD.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PrivacyPolicyPage**: Componente de página React, sem estado próprio (stateless), responsável por renderizar o conteúdo da política. Localizado em `frontend/src/pages/PrivacyPolicyPage.tsx`.
|
||||
- **Footer (atualizado)**: Componente existente que receberá o link adicional para a política de privacidade. Localizado em `frontend/src/components/Footer.tsx`.
|
||||
- **Rota /politica-de-privacidade**: Entrada no sistema de roteamento client-side que mapeia a URL para `PrivacyPolicyPage`.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: O link "Política de Privacidade" está presente e clicável no Footer em 100% das páginas que incluem o Footer.
|
||||
- **SC-002**: A página `/politica-de-privacidade` exibe as 8 seções obrigatórias, cada uma com título numerado e conteúdo.
|
||||
- **SC-003**: A navegação para a página a partir do Footer ocorre em menos de 500 ms (transição client-side, sem recarregamento).
|
||||
- **SC-004**: O layout é responsivo — legível em telas a partir de 320 px de largura sem overflow horizontal.
|
||||
- **SC-005**: A conformidade visual é total: as mesmas cores, fontes e espaçamentos do tema dark são aplicados, sem elementos que "quebrem" o padrão visual do site.
|
||||
- **SC-006**: O conteúdo da seção 5 (Direitos do Usuário) abrange todos os 5 direitos previstos na LGPD mencionados nos requisitos.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O nome, CNPJ e endereço da empresa imobiliária serão fornecidos para preenchimento do conteúdo da seção 1 (Instituição Responsável); enquanto não fornecidos, placeholders serão utilizados como `[NOME DA EMPRESA]`, `[CNPJ]` e `[ENDEREÇO]`.
|
||||
- O e-mail do DPO (encarregado de dados) será fornecido para a seção 8; enquanto não fornecido, será usado o placeholder `[EMAIL DO DPO]`.
|
||||
- O componente Layout que envolve Navbar + Footer já existe e será reutilizado da mesma forma que nas demais páginas.
|
||||
- Não há necessidade de versionamento do conteúdo da política — a data de "última atualização" será estática no código.
|
||||
- A página não precisa suportar múltiplos idiomas (apenas português brasileiro).
|
||||
- Acessibilidade básica (contraste adequado, semântica HTML) é esperada mas não requer certificação WCAG formal nesta versão.
|
||||
- O conteúdo textual da política sera adequado para uma imobiliária genérica, baseado na LGPD; não é um documento jurídico revisado por advogado.
|
||||
132
specs/020-politica-de-privacidade/tasks.md
Normal file
132
specs/020-politica-de-privacidade/tasks.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Tasks: Página de Política de Privacidade + Link no Footer
|
||||
|
||||
**Input**: Design documents from `specs/020-politica-de-privacidade/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, data-model.md ✅, quickstart.md ✅, research.md ✅
|
||||
|
||||
**Tests**: Não solicitados — verificação por `npm run build` e inspeção visual (conforme plan.md).
|
||||
|
||||
**Escopo**: 1 arquivo criado, 2 arquivos modificados — 100% frontend, sem backend, sem dependências novas.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Pode rodar em paralelo (arquivos diferentes, sem dependências entre si)
|
||||
- **[Story]**: User story a que a task pertence (US1, US2, US3)
|
||||
- Todos os caminhos são relativos à raiz do repositório
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup & Foundational
|
||||
|
||||
> **Não aplicável** — projeto já inicializado, sem novas dependências, sem infraestrutura nova.
|
||||
> Pode avançar diretamente para as user stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Visitante lê a Política de Privacidade (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Criar o componente `PrivacyPolicyPage` e registrá-lo no roteador para que a rota `/politica-de-privacidade` funcione.
|
||||
|
||||
**Independent Test**: Acessar `http://localhost:5173/politica-de-privacidade` no navegador após `npm run dev`. A página deve renderizar com Navbar, 8 seções numeradas e Footer, sem erros no console e sem chamadas de rede.
|
||||
|
||||
- [ ] T001 [P] [US1] Criar componente `PrivacyPolicyPage` stateless com Navbar, `<main>` centralizado (max-width 800 px), 8 seções LGPD numeradas e Footer, usando tokens `bg-canvas`, `text-text-primary`, `text-text-secondary`, `text-text-tertiary` e `fontFeatureSettings: '"cv01","ss03"'` em `frontend/src/pages/PrivacyPolicyPage.tsx`
|
||||
- [ ] T002 [US1] Importar `PrivacyPolicyPage` e adicionar `<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />` após a rota `/corretores` em `frontend/src/App.tsx`
|
||||
|
||||
**Checkpoint**: Rota `/politica-de-privacidade` acessível no browser com as 8 seções visíveis. User Story 1 completa e testável de forma independente.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Visitante encontra o link da Política no Footer (Priority: P2)
|
||||
|
||||
**Goal**: Adicionar link "Política de Privacidade" no Footer usando roteamento client-side (sem reload da página).
|
||||
|
||||
**Independent Test**: Em qualquer página do site com Footer visível, verificar que o link "Política de Privacidade" está presente no rodapé e que clicar nele navega para `/politica-de-privacidade` sem recarregar a página (SPA navigation).
|
||||
|
||||
- [ ] T003 [P] [US2] Importar `Link` de `react-router-dom` e adicionar `<li><Link to="/politica-de-privacidade" ...>Política de Privacidade</Link></li>` no `<ul>` de navegação, após o `map` de `footerLinks` existente, com classes `text-xs text-text-tertiary hover:text-text-secondary transition-colors duration-150` em `frontend/src/components/Footer.tsx`
|
||||
|
||||
**Checkpoint**: Link visível no Footer de qualquer página; clique navega via SPA sem recarregamento. User Story 2 completa e testável de forma independente.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Visitante identifica empresa responsável e canal de contato (Priority: P3)
|
||||
|
||||
**Goal**: Substituir os placeholders `[NOME DA EMPRESA]`, `[CNPJ]`, `[ENDEREÇO COMPLETO]` e `[E-MAIL DO DPO]` nas seções 1 e 8 pelos dados reais da imobiliária.
|
||||
|
||||
**Independent Test**: Na página `/politica-de-privacidade`, seção 1 deve conter nome completo, CNPJ e endereço da empresa. Seção 8 deve conter endereço de e-mail válido para contato com o DPO.
|
||||
|
||||
- [ ] T004 [US3] Substituir placeholders na seção 1 (nome, CNPJ, endereço) e na seção 8 (e-mail do DPO) pelos dados reais da empresa em `frontend/src/pages/PrivacyPolicyPage.tsx`
|
||||
|
||||
**Checkpoint**: Seções 1 e 8 sem placeholders; conformidade LGPD com identificação do controlador e canal de comunicação com DPO. User Story 3 completa.
|
||||
|
||||
---
|
||||
|
||||
## Phase Final: Polish & Verificação
|
||||
|
||||
**Purpose**: Validação de tipos, build e verificação final de responsividade.
|
||||
|
||||
- [ ] T005 Executar `npm run build` no diretório `frontend/` e confirmar que não há erros de TypeScript nem de compilação
|
||||
- [ ] T006 [P] Verificar responsividade da `PrivacyPolicyPage` em larguras ≥ 320 px (mobile) e ≥ 1280 px (desktop) — sem overflow horizontal, texto legível, espaçamento adequado
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependências entre tasks
|
||||
|
||||
| Task | Depende de | Motivo |
|
||||
|---|---|---|
|
||||
| T002 | T001 | Importa `PrivacyPolicyPage` criado em T001 |
|
||||
| T004 | T001 | Edita o arquivo criado em T001 |
|
||||
| T005 | T001, T002, T003, T004 | Build final após todas as mudanças |
|
||||
| T006 | T001, T002 | Requer a página funcional para testar responsividade |
|
||||
|
||||
### Tasks independentes (sem dependências entre si)
|
||||
|
||||
- **T001** e **T003** podem ser executadas em paralelo por devs diferentes — arquivos distintos, zero conflito.
|
||||
|
||||
### Ordem sugerida (desenvolvedor solo)
|
||||
|
||||
```
|
||||
T001 → T002 → T003 → T004 → T005 → T006
|
||||
```
|
||||
|
||||
### Ordem sugerida (dois devs em paralelo)
|
||||
|
||||
```
|
||||
Dev A: T001 → T002 → T004 → T005
|
||||
Dev B: T003 → T006
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1 (MVP solo)
|
||||
|
||||
```bash
|
||||
# 1. Criar o componente
|
||||
# frontend/src/pages/PrivacyPolicyPage.tsx — novo arquivo
|
||||
|
||||
# 2. Adicionar rota
|
||||
# frontend/src/App.tsx — adicionar Route após /corretores
|
||||
|
||||
# 3. Verificar
|
||||
cd frontend && npm run dev
|
||||
# Abrir http://localhost:5173/politica-de-privacidade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (apenas US1 — T001 + T002)
|
||||
|
||||
Entrega mínima: a página existe e é acessível via URL. Footer sem link ainda. Dados reais preenchidos depois.
|
||||
|
||||
### Incremental delivery
|
||||
|
||||
1. **Sprint 1** — T001 + T002: Página acessível (US1 ✅)
|
||||
2. **Sprint 1** — T003: Link no Footer (US2 ✅)
|
||||
3. **Sprint 2** — T004: Dados reais da empresa (US3 ✅)
|
||||
4. **Sprint 2** — T005 + T006: Build limpo + verificação visual (Polish ✅)
|
||||
|
||||
### Sem testes automatizados
|
||||
|
||||
Conforme `plan.md` ("Testing: `npm run build` (TypeScript type-check via tsc); verificação visual manual"), não foram geradas tasks de teste automatizado para esta feature.
|
||||
36
specs/021-pagina-sobre/checklists/requirements.md
Normal file
36
specs/021-pagina-sobre/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Página Sobre
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Todos os itens aprovados na primeira iteração de validação.
|
||||
- O conteúdo textual definitivo (história, diferenciais, métricas, número de WhatsApp) deverá ser fornecido pelo cliente antes da implementação — documentado nas Assumptions.
|
||||
- Pronto para `/speckit.plan`.
|
||||
150
specs/021-pagina-sobre/data-model.md
Normal file
150
specs/021-pagina-sobre/data-model.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Data Model: Página Sobre
|
||||
|
||||
**Feature**: 021-pagina-sobre
|
||||
**Phase**: 1 — Design & Contracts
|
||||
**Status**: Completo
|
||||
|
||||
---
|
||||
|
||||
## Nota sobre "Data Model" para feature 100% frontend
|
||||
|
||||
Esta feature não envolve entidades de banco de dados nem estado gerenciado em store. O "modelo de dados" descreve a estrutura lógica do conteúdo estático e os contratos dos componentes React afetados.
|
||||
|
||||
---
|
||||
|
||||
## Componentes — contratos de props
|
||||
|
||||
### `AboutPage` (novo)
|
||||
|
||||
| Prop | Tipo | Descrição |
|
||||
|------|------|-----------|
|
||||
| — | — | Componente sem props (stateless, sem hooks) |
|
||||
|
||||
**Estrutura JSX de alto nível**:
|
||||
|
||||
```
|
||||
AboutPage
|
||||
├── <Navbar />
|
||||
├── <main id="main-content" className="min-h-screen bg-canvas">
|
||||
│ ├── SectionHero
|
||||
│ │ └── título, subtítulo, parágrafo de missão
|
||||
│ ├── SectionHistoria
|
||||
│ │ └── texto narrativo (2–3 parágrafos)
|
||||
│ ├── SectionDiferenciais
|
||||
│ │ └── grid de 3–4 DiferencialCard
|
||||
│ │ └── { icon, title, description }
|
||||
│ ├── SectionNumeros
|
||||
│ │ └── grid de 4 MetricCard
|
||||
│ │ └── { value, label }
|
||||
│ ├── SectionEquipe
|
||||
│ │ └── texto introdutório + <Link to="/corretores">
|
||||
│ └── SectionCTA
|
||||
│ ├── <Link to="/imoveis"> "Ver imóveis disponíveis"
|
||||
│ └── <a href="https://wa.me/5500000000000?text=..."> "Falar com corretor"
|
||||
└── <Footer />
|
||||
```
|
||||
|
||||
**Tipos locais (apenas internos ao arquivo, não exportados)**:
|
||||
|
||||
```typescript
|
||||
// Apenas para as seções mapeadas com .map()
|
||||
|
||||
type DiferencialItem = {
|
||||
icon: string // emoji ou símbolo UTF-8 (ex.: "🏠")
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type MetricItem = {
|
||||
value: string // ex.: "10+" | "2.500+" | "98%"
|
||||
label: string // ex.: "Anos de mercado"
|
||||
}
|
||||
```
|
||||
|
||||
> **Nota**: Os tipos `DiferencialItem` e `MetricItem` NÃO são exportados. São usados somente internamente via arrays locais mapeados por `.map()`. Principle VI: sem abstração prematura.
|
||||
|
||||
---
|
||||
|
||||
### `Footer` (alterado)
|
||||
|
||||
Mudança pontual no array `footerLinks` e no JSX de renderização:
|
||||
|
||||
**Estado atual**:
|
||||
```typescript
|
||||
const footerLinks = [
|
||||
{ label: 'Imóveis', href: '/imoveis' },
|
||||
{ label: 'Sobre', href: '#sobre' }, // ← alterar
|
||||
{ label: 'Contato', href: '#contato' },
|
||||
{ label: 'Política de Privacidade', href: '/politica-de-privacidade' },
|
||||
]
|
||||
```
|
||||
|
||||
**Estado após a feature**:
|
||||
```typescript
|
||||
// O array footerLinks perde o href de '#sobre'
|
||||
// O link "Sobre" passa a ser <Link to="/sobre"> em vez de <a href="#sobre">
|
||||
```
|
||||
|
||||
**Estratégia de implementação no Footer**: O mesmo padrão introduzido pela feature 020 para `/politica-de-privacidade` — o link "Sobre" usa `<Link>` do react-router-dom enquanto os links com `#` permanecem como `<a>`.
|
||||
|
||||
---
|
||||
|
||||
### `App.tsx` (alterado)
|
||||
|
||||
Nova rota pública adicionada ao bloco existente:
|
||||
|
||||
```typescript
|
||||
// Bloco atual (público)
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/imoveis" element={<PropertiesPage />} />
|
||||
<Route path="/imoveis/:slug" element={<PropertyDetailPage />} />
|
||||
<Route path="/corretores" element={<AgentsPage />} />
|
||||
<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />
|
||||
|
||||
// Nova rota (esta feature)
|
||||
<Route path="/sobre" element={<AboutPage />} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conteúdo estático (dados hardcoded em `AboutPage.tsx`)
|
||||
|
||||
### Diferenciais (3 cards)
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| [1] icon | `🏆` |
|
||||
| [1] title | `Experiência comprovada` |
|
||||
| [1] description | Mais de 10 anos conectando famílias aos melhores imóveis da região, com histórico de negociações bem-sucedidas. |
|
||||
| [2] icon | `🤝` |
|
||||
| [2] title | `Atendimento personalizado` |
|
||||
| [2] description | Cada cliente é único. Nossa equipe ouve suas necessidades e apresenta opções alinhadas ao seu perfil e orçamento. |
|
||||
| [3] icon | `🔑` |
|
||||
| [3] title | `Processo transparente` |
|
||||
| [3] description | Do primeiro contato à entrega das chaves, mantemos você informado em cada etapa da negociação. |
|
||||
|
||||
### Números em destaque (4 métricas)
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| [1] value | `10+` |
|
||||
| [1] label | Anos de mercado |
|
||||
| [2] value | `2.500+` |
|
||||
| [2] label | Imóveis negociados |
|
||||
| [3] value | `98%` |
|
||||
| [3] label | Clientes satisfeitos |
|
||||
| [4] value | `30+` |
|
||||
| [4] label | Corretores especializados |
|
||||
|
||||
> **Todos os valores são placeholders** a serem confirmados pelo cliente antes do go-live. Substituição é feita diretamente no array local de `AboutPage.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Contratos de navegação interna
|
||||
|
||||
| Origem | Destino | Mecanismo | Requisito |
|
||||
|--------|---------|-----------|-----------|
|
||||
| Footer — link "Sobre" | `/sobre` | `<Link to="/sobre">` | FR-009, FR-012 |
|
||||
| CTA Final — "Ver imóveis" | `/imoveis` | `<Link to="/imoveis">` | FR-008, FR-012 |
|
||||
| CTA Final — "Falar com corretor" | `https://wa.me/5500000000000?text=...` | `<a href target="_blank" rel="noopener noreferrer">` | FR-008 |
|
||||
| Seção Equipe — "Conheça nosso time" | `/corretores` | `<Link to="/corretores">` | FR-007, FR-012 |
|
||||
66
specs/021-pagina-sobre/plan.md
Normal file
66
specs/021-pagina-sobre/plan.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Implementation Plan: Página Sobre
|
||||
|
||||
**Branch**: `021-pagina-sobre` | **Date**: 2026-04-17 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Criar página estática `/sobre` com hero, história, diferenciais, métricas, equipe e CTA final.
|
||||
|
||||
## Summary
|
||||
|
||||
Criar `AboutPage.tsx` — uma página 100% estática com seis seções institucionais — e integrá-la ao roteamento SPA via `App.tsx`. Adicionalmente, atualizar o link "Sobre" no `Footer.tsx` de `#sobre` para `/sobre` (navegação client-side via `<Link>`). Sem backend, sem chamadas a API, sem novo estado global.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.5 / React 18
|
||||
**Primary Dependencies**: react-router-dom v6 (já instalado), Tailwind CSS 3.4 (já configurado)
|
||||
**Storage**: N/A — página 100% estática
|
||||
**Testing**: N/A — componente puramente estático
|
||||
**Target Platform**: SPA browser (desktop + mobile, 320 px → 1920 px)
|
||||
**Project Type**: Web application — frontend SPA
|
||||
**Performance Goals**: Tempo de renderização equivalente ao das demais páginas estáticas do site
|
||||
**Constraints**: Layout responsivo sem overflow horizontal; sem chamadas a APIs; WhatsApp placeholder `5500000000000`
|
||||
**Scale/Scope**: 1 nova page, 2 arquivos editados
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| I. Design-First | ✅ PASS | Todos os tokens do DESIGN.md são aplicados: `bg-canvas`, `bg-panel`, `text-text-primary/secondary/tertiary`, `border-white/[0.06]`, `rounded-2xl`, accent `#5e6ad2`. Nenhum inline style fora do sistema. |
|
||||
| II. Separation of Concerns | ✅ PASS | Feature 100% frontend. Sem nenhuma chamada ao backend Flask. |
|
||||
| III. Spec-Driven Development | ✅ PASS | `spec.md` aprovado; ciclo plan → tasks → implement respeitado. |
|
||||
| IV. Data Integrity | ✅ N/A | Sem dados de API, sem banco de dados. Todo conteúdo é estático. |
|
||||
| V. Security | ✅ PASS | Página pública, sem segredos, sem autenticação. Link WhatsApp usa `wa.me` com placeholder substituível. |
|
||||
| VI. Simplicity First | ✅ PASS | 1 componente de page, sem abstração extra, sem helpers, sem contexto novo. |
|
||||
|
||||
**Resultado**: Sem violações. Pode prosseguir para Phase 0.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/021-pagina-sobre/
|
||||
├── plan.md # Este arquivo
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks — NÃO gerado aqui)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
frontend/
|
||||
└── src/
|
||||
├── pages/
|
||||
│ └── AboutPage.tsx ← NOVO
|
||||
├── App.tsx ← EDITADO (nova rota /sobre)
|
||||
└── components/
|
||||
└── Footer.tsx ← EDITADO (href #sobre → /sobre via <Link>)
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application — apenas camada frontend. Sem novos serviços, hooks, contextos ou tipos compartilhados. `AboutPage.tsx` segue exatamente o padrão de `PrivacyPolicyPage.tsx`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*Nenhuma violação de Constitution detectada. Seção omitida conforme orientação do template.*
|
||||
98
specs/021-pagina-sobre/quickstart.md
Normal file
98
specs/021-pagina-sobre/quickstart.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Quickstart: Página Sobre
|
||||
|
||||
**Feature**: 021-pagina-sobre
|
||||
**Branch**: `master` (branch recomendada para esta feature: `feat/021-pagina-sobre`)
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Node.js instalado
|
||||
- Dependências do frontend instaladas: `cd frontend && npm install`
|
||||
- Docker não é necessário — feature 100% estática, sem backend
|
||||
|
||||
---
|
||||
|
||||
## Arquivos envolvidos
|
||||
|
||||
| Ação | Arquivo |
|
||||
|------|---------|
|
||||
| **Criar** | `frontend/src/pages/AboutPage.tsx` |
|
||||
| **Modificar** | `frontend/src/App.tsx` (1 import + 1 `<Route>`) |
|
||||
| **Modificar** | `frontend/src/components/Footer.tsx` (link `#sobre` → `<Link to="/sobre">`) |
|
||||
|
||||
---
|
||||
|
||||
## Como rodar o frontend em dev
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Acesse `http://localhost:5173/sobre` para ver a nova página.
|
||||
|
||||
---
|
||||
|
||||
## Passo a passo de implementação
|
||||
|
||||
### Passo 1 — Criar `AboutPage.tsx`
|
||||
|
||||
Criar o arquivo `frontend/src/pages/AboutPage.tsx` seguindo o padrão de `PrivacyPolicyPage.tsx`:
|
||||
- Sem props, sem estado, sem hooks
|
||||
- Estrutura: `<Navbar />` + `<main>` com 6 seções + `<Footer />`
|
||||
- Importar `Link` de `react-router-dom` para CTAs internos
|
||||
- Usar tokens do DESIGN.md (ver `data-model.md` — seção "Design tokens")
|
||||
|
||||
Seções que usam `.map()` sobre array local:
|
||||
- **Diferenciais**: array `diferenciais: DiferencialItem[]` com 3 itens
|
||||
- **Números**: array `numeros: MetricItem[]` com 4 itens
|
||||
|
||||
Seções implementadas como JSX direto:
|
||||
- Hero, Nossa História, Nossa Equipe, CTA Final
|
||||
|
||||
### Passo 2 — Registrar rota em `App.tsx`
|
||||
|
||||
```diff
|
||||
import AgentsPage from './pages/AgentsPage';
|
||||
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
|
||||
+import AboutPage from './pages/AboutPage';
|
||||
// ...
|
||||
<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />
|
||||
+<Route path="/sobre" element={<AboutPage />} />
|
||||
```
|
||||
|
||||
### Passo 3 — Atualizar Footer
|
||||
|
||||
Em `Footer.tsx`, converter o link "Sobre" de `<a href="#sobre">` para `<Link to="/sobre">`.
|
||||
|
||||
Garantir que `Link` está importado de `react-router-dom` (provavelmente já está após feature 020).
|
||||
|
||||
---
|
||||
|
||||
## Checklist de validação manual
|
||||
|
||||
Após implementar, verificar:
|
||||
|
||||
- [ ] `http://localhost:5173/sobre` carrega sem erros de console
|
||||
- [ ] As 6 seções são visíveis (Hero, História, Diferenciais, Números, Equipe, CTA)
|
||||
- [ ] Navbar e Footer são exibidos corretamente
|
||||
- [ ] Link "Sobre" no Footer navega para `/sobre` sem recarregar a página
|
||||
- [ ] Botão "Ver imóveis disponíveis" navega para `/imoveis`
|
||||
- [ ] Link "Conheça nosso time" navega para `/corretores`
|
||||
- [ ] Link WhatsApp abre `wa.me` (verificar URL em inspeção de elemento)
|
||||
- [ ] Layout responsivo funciona em 375 px (mobile) e 1280 px (desktop)
|
||||
- [ ] Estilo visual consistente com `PrivacyPolicyPage` e demais páginas
|
||||
|
||||
---
|
||||
|
||||
## Critérios de aceite (mapeados da spec)
|
||||
|
||||
| Critério | Como verificar |
|
||||
|----------|---------------|
|
||||
| SC-001: `/sobre` acessível sem auth | Abrir em aba anônima |
|
||||
| SC-002: Responsivo 320–1920 px | DevTools → breakpoints 320, 375, 768, 1280, 1920 |
|
||||
| SC-003: Footer "Sobre" → `/sobre` | Clicar no link e confirmar URL sem reload |
|
||||
| SC-004: CTAs corretos | Clicar em cada um e verificar destino |
|
||||
| SC-005: Sem erros de console | Console limpo após carregamento |
|
||||
| SC-006: Performance equivalente | Primeira renderização comparável a `/politica-de-privacidade` |
|
||||
102
specs/021-pagina-sobre/research.md
Normal file
102
specs/021-pagina-sobre/research.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Research: Página Sobre
|
||||
|
||||
**Feature**: 021-pagina-sobre
|
||||
**Phase**: 0 — Outline & Research
|
||||
**Status**: Completo — sem NEEDS CLARIFICATION
|
||||
|
||||
---
|
||||
|
||||
## 1. Padrão de página estática no projeto
|
||||
|
||||
**Decision**: Seguir exatamente o padrão de `PrivacyPolicyPage.tsx` — `<Navbar />` + `<main id="main-content" className="min-h-screen bg-canvas">` + `<Footer />`, com conteúdo dentro de `max-w-[800px] mx-auto px-6`.
|
||||
|
||||
**Rationale**: `PrivacyPolicyPage.tsx` foi a página estática mais recente criada no projeto e já foi validada visualmente. É a referência canônica para páginas sem estado. A Página Sobre tem natureza idêntica: conteúdo textual organizado em seções, sem API calls.
|
||||
|
||||
**Alternatives considered**: Usar `AgentsPage.tsx` como template. Rejeitado — AgentsPage tem estado (`useState`, `useEffect`, skeleton loaders) que não se aplica a conteúdo estático. Usá-la como base adicionaria código morto desnecessário.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roteamento — nova rota `/sobre`
|
||||
|
||||
**Decision**: Adicionar `<Route path="/sobre" element={<AboutPage />} />` diretamente em `frontend/src/App.tsx`, na mesma camada das rotas públicas existentes (`/`, `/imoveis`, `/corretores`, `/politica-de-privacidade`).
|
||||
|
||||
**Rationale**: Padrão já estabelecido para todas as rotas públicas. Sem guards de autenticação necessários — FR-001 exige acesso público irrestrito.
|
||||
|
||||
**Alternatives considered**: Rota aninhada num layout público compartilhado. Rejeitado — nenhuma outra rota pública usa layout aninhado; seria over-engineering (Principle VI).
|
||||
|
||||
---
|
||||
|
||||
## 3. Link "Sobre" no Footer — `<a>` vs `<Link>`
|
||||
|
||||
**Decision**: Converter `{ label: 'Sobre', href: '#sobre' }` para usar `<Link to="/sobre">` do `react-router-dom`, de forma análoga ao que foi feito com `/politica-de-privacidade` na feature 020.
|
||||
|
||||
**Rationale**: FR-009 e FR-012 exigem navegação SPA sem recarregar a aplicação. `<Link>` do React Router garante transição client-side. O Footer está dentro do `<BrowserRouter>` em App.tsx, portanto `<Link>` funciona sem mudança estrutural adicional. Os demais links `#contato` permanecem como `<a>` (âncoras intra-página, fora de escopo).
|
||||
|
||||
**Alternatives considered**: Usar `<a href="/sobre">` — causaria reload completo, violando FR-012 e SC-003.
|
||||
|
||||
> **Nota de implementação**: O Footer já usa uma mistura de `<a>` e `<Link>` (introduzida na feature 020 para `/politica-de-privacidade`). Basta adicionar o mesmo padrão para `/sobre`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Estrutura de seções da AboutPage
|
||||
|
||||
**Decision**: Seis seções fixas, implementadas como JSX inline (sem array de seções mapeado, diferente do `PrivacyPolicyPage`):
|
||||
|
||||
| # | Nome | Conteúdo principal |
|
||||
|---|------|--------------------|
|
||||
| 1 | Hero | Título, subtítulo, parágrafo de missão/valores |
|
||||
| 2 | Nossa História | Texto narrativo com origem e trajetória |
|
||||
| 3 | Nossos Diferenciais | 3–4 cards `border border-white/[0.06] rounded-2xl` |
|
||||
| 4 | Números em Destaque | 4 métricas em grid (anos, imóveis, satisfação, corretores) |
|
||||
| 5 | Nossa Equipe | Texto introdutório + CTA `<Link to="/corretores">` |
|
||||
| 6 | CTA Final | `<Link to="/imoveis">` + `<a href="https://wa.me/5500000000000?text=...">` |
|
||||
|
||||
**Rationale**: Seções simples, sem iteração sobre array, sem estado. Para as seções Hero/História/Equipe/CTA, JSX direto é mais legível. Para Diferenciais e Números, um array local mapeado por `.map()` reduz repetição sem criar abstração desnecessária.
|
||||
|
||||
**Alternatives considered**: Criar um array de seções tipadas para todas as 6 seções (padrão de PrivacyPolicyPage). Rejeitado — as seções têm layouts muito diferentes entre si; forçar um tipo `Section` genérico exigiria `ReactNode` em tudo, que é menos legível.
|
||||
|
||||
---
|
||||
|
||||
## 5. Design tokens para as seções
|
||||
|
||||
**Decision**: Seguir exatamente os tokens do DESIGN.md mapeados no Tailwind:
|
||||
|
||||
| Elemento | Classe Tailwind |
|
||||
|---|---|
|
||||
| Fundo da página | `bg-canvas` |
|
||||
| Cards de diferenciais | `bg-panel border border-white/[0.06] rounded-2xl p-6` |
|
||||
| Métricas em destaque | `bg-panel border border-white/[0.06] rounded-2xl` |
|
||||
| Texto principal | `text-text-primary` |
|
||||
| Texto secundário | `text-text-secondary` |
|
||||
| Texto terciário | `text-text-tertiary` |
|
||||
| Label de categoria ("Sobre nós", etc.) | `text-[#5e6ad2] text-sm font-medium tracking-widest uppercase` |
|
||||
| Títulos de seção | `font-semibold text-text-primary tracking-tight` + `style={{ fontFeatureSettings: '"cv01","ss03"' }}` |
|
||||
| CTA primário (Ver imóveis) | `bg-[#5e6ad2] hover:bg-[#6872e5] text-white rounded-lg px-5 py-2.5` |
|
||||
| CTA secundário (WhatsApp) | `border border-white/[0.08] text-text-secondary hover:text-text-primary rounded-lg px-5 py-2.5` |
|
||||
| Largura máxima | `max-w-[1200px] mx-auto px-6` |
|
||||
|
||||
**Rationale**: Alinhamento com Principle I (Design-First). Sem novos tokens; apenas os já existentes no `tailwind.config.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Link de WhatsApp
|
||||
|
||||
**Decision**: `https://wa.me/5500000000000?text=Ol%C3%A1%2C%20gostaria%20de%20falar%20com%20um%20corretor`
|
||||
|
||||
- Número placeholder: `5500000000000` (substituir antes do go-live)
|
||||
- `wa.me` abre WhatsApp nativo no mobile e web no desktop, satisfazendo US-2 AC-3
|
||||
- URL encoding da mensagem pré-preenchida
|
||||
|
||||
**Rationale**: Spec/Assumptions confirma uso de placeholder. Nenhum segredo ou token real envolvido.
|
||||
|
||||
---
|
||||
|
||||
## 7. Navegação interna (CTAs)
|
||||
|
||||
**Decision**: Usar `<Link to="...">` do `react-router-dom` para `/imoveis` e `/corretores`.
|
||||
|
||||
**Rationale**: FR-012 exige SPA navigation sem reload. Ambas as rotas já existem e estão funcionais (FR prevê `/corretores` como existente).
|
||||
|
||||
---
|
||||
|
||||
*Todos os NEEDS CLARIFICATION resolvidos. Sem dependências externas. Pode prosseguir para Phase 1.*
|
||||
101
specs/021-pagina-sobre/spec.md
Normal file
101
specs/021-pagina-sobre/spec.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Feature Specification: Página Sobre
|
||||
|
||||
**Feature Branch**: `021-pagina-sobre`
|
||||
**Created**: 2026-04-17
|
||||
**Status**: Draft
|
||||
**Input**: Criar uma página estática /sobre com informações da empresa, incluindo hero, história, diferenciais, métricas, referência ao time e CTA final.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Visitante conhece a empresa na página Sobre (Priority: P1)
|
||||
|
||||
Um visitante do portal imobiliário deseja saber mais sobre a empresa antes de entrar em contato ou buscar imóveis. Ele acessa "/sobre" diretamente pela URL, pelo link no footer ou por qualquer chamada para ação do site, e navega pelas seções que descrevem a história, diferenciais e números da empresa.
|
||||
|
||||
**Why this priority**: É o fluxo principal da feature. Sem ele a página não entrega valor algum. Todo o restante depende de a página ser acessível e exibir seu conteúdo.
|
||||
|
||||
**Independent Test**: Pode ser testado acessando "/sobre" e verificando que as seis seções são renderizadas com conteúdo visível em dispositivos móveis e desktop.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está em qualquer página do site, **When** ele acessa a URL "/sobre", **Then** a página é exibida com navbar, todas as seis seções conteúdo e footer, sem erros visíveis.
|
||||
2. **Given** o visitante está na página inicial ou em outra página qualquer, **When** ele clica no link "Sobre" no footer, **Then** é redirecionado para "/sobre" (navegação interna, sem recarregar a aplicação).
|
||||
3. **Given** o visitante está na página "/sobre" em um dispositivo móvel, **When** ele rola a página, **Then** todas as seções são legíveis e os elementos se reorganizam adequadamente para telas pequenas.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Visitante usa o CTA final para iniciar contato ou buscar imóveis (Priority: P2)
|
||||
|
||||
Após ler sobre a empresa, o visitante quer dar o próximo passo: buscar imóveis disponíveis ou entrar em contato diretamente com a equipe via WhatsApp.
|
||||
|
||||
**Why this priority**: Os CTAs são o principal canal de conversão da página; sem eles a página seria informativa mas não geraria ação.
|
||||
|
||||
**Independent Test**: Pode ser testado clicando nos botões da seção CTA final e verificando que levam aos destinos corretos ("/imoveis" e WhatsApp).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante leu o conteúdo e chegou ao CTA final, **When** ele clica em "Ver imóveis disponíveis" (ou equivalente), **Then** é redirecionado para "/imoveis".
|
||||
2. **Given** o visitante prefere falar com um corretor, **When** ele clica no link de contato por WhatsApp no CTA final, **Then** o aplicativo ou web do WhatsApp é aberto com uma mensagem pré-preenchida direcionada à imobiliária.
|
||||
3. **Given** o CTA de WhatsApp é exibido, **When** o visitante acessa pelo celular, **Then** o link abre o WhatsApp nativo (não o web).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Visitante descobre a equipe e navega para a listagem de corretores (Priority: P3)
|
||||
|
||||
O visitante quer conhecer a equipe da imobiliária. A seção "Nossa Equipe" na página Sobre apresenta uma chamada para ação que leva para "/corretores".
|
||||
|
||||
**Why this priority**: Complementa a apresentação institucional, mas o valor principal da feature já é entregue pelas histórias P1 e P2.
|
||||
|
||||
**Independent Test**: Pode ser testado clicando no CTA da seção "Nossa Equipe" e verificando que o visitante é levado para "/corretores".
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está na seção "Nossa Equipe" da página "/sobre", **When** ele clica no botão/link "Conheça nosso time" (ou equivalente), **Then** é redirecionado para "/corretores".
|
||||
2. **Given** a seção "Nossa Equipe" é exibida, **When** o visitante a visualiza, **Then** vê um texto introdutório sobre a equipe e o CTA claramente identificável.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece se o visitante acessar "/sobre" sem conexão com a internet após o carregamento inicial? A página deve exibir o conteúdo estático já renderizado (sem dependência de chamadas externas).
|
||||
- O que acontece se o número de WhatsApp configurado estiver incorreto? O link abrirá o WhatsApp, mas direcionará para um número inválido — isso é um problema de conteúdo, não de comportamento da feature.
|
||||
- O que acontece se o visitante acessar a página em um navegador muito antigo sem suporte a CSS moderno? A página deve manter legibilidade mínima; estilos avançados podem degradar graciosamente.
|
||||
- Como a página se comporta com zoom de acessibilidade acima de 200%? O layout responsivo deve garantir que o conteúdo não transborde horizontalmente.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: A página "/sobre" DEVE ser acessível para todos os visitantes sem necessidade de autenticação.
|
||||
- **FR-002**: A página DEVE exibir a barra de navegação (navbar) e o rodapé (footer) presentes nas demais páginas do site.
|
||||
- **FR-003**: A página DEVE conter uma seção Hero com título da empresa, subtítulo e parágrafo de apresentação (missão, história breve e valores).
|
||||
- **FR-004**: A página DEVE conter uma seção "Nossa História" com texto narrativo descrevendo a origem, trajetória e conquistas da empresa.
|
||||
- **FR-005**: A página DEVE conter uma seção "Nossos Diferenciais" com 3 a 4 cards, cada um com título e descrição de um diferencial competitivo.
|
||||
- **FR-006**: A página DEVE conter uma seção "Números em Destaque" com pelo menos 4 métricas estáticas representativas da empresa (ex.: anos de mercado, imóveis negociados, satisfação de clientes, número de corretores).
|
||||
- **FR-007**: A página DEVE conter uma seção "Nossa Equipe" com texto introdutório sobre a equipe e um CTA que navega para "/corretores".
|
||||
- **FR-008**: A página DEVE conter uma seção CTA Final com chamada para ação "Pronto para encontrar seu imóvel?" (ou equivalente), um botão que navega para "/imoveis" e um link que abre o WhatsApp para contato.
|
||||
- **FR-009**: O link "Sobre" no footer DEVE navegar para "/sobre" (não mais para "#sobre").
|
||||
- **FR-010**: Todo o conteúdo da página DEVE ser estático — sem chamadas a APIs ou serviços externos durante a renderização.
|
||||
- **FR-011**: A página DEVE ser responsiva, adaptando layout e tipografia para dispositivos móveis (a partir de 320 px de largura) e desktop.
|
||||
- **FR-012**: A navegação interna (links para "/imoveis" e "/corretores") DEVE funcionar como navegação de página única (SPA), sem recarregar a aplicação.
|
||||
- **FR-013**: A página DEVE ser visualmente consistente com o tema e identidade visual das demais páginas do site.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% dos visitantes conseguem acessar "/sobre" sem autenticação e visualizar todas as seis seções de conteúdo.
|
||||
- **SC-002**: A página é exibida corretamente em dispositivos com largura de tela entre 320 px e 1920 px, sem transbordamento horizontal ou conteúdo cortado.
|
||||
- **SC-003**: O link "Sobre" no footer navega para "/sobre" em 100% dos cenários testados (navegação interna sem recarga de página).
|
||||
- **SC-004**: Todos os CTAs da página (botão para "/imoveis", link para "/corretores" e link de WhatsApp) levam ao destino correto em 100% dos cliques.
|
||||
- **SC-005**: A página carrega sem erros de console relacionados à feature e sem conteúdo quebrado visível.
|
||||
- **SC-006**: O tempo percebido pelo visitante para visualizar o conteúdo principal (acima da dobra) é equivalente ao das demais páginas estáticas do site.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O conteúdo textual (história da empresa, diferenciais, métricas e descrição da equipe) será preenchido com dados representativos de uma imobiliária; o conteúdo definitivo será definido pelo cliente.
|
||||
- O número de telefone do WhatsApp será um placeholder (`5500000000000`) a ser substituído pelo número real antes do lançamento em produção.
|
||||
- A página "/corretores" já existe e está funcional, portanto o CTA da seção "Nossa Equipe" pode referenciar essa rota sem risco de link quebrado.
|
||||
- A navbar utilizada nas demais páginas já suporta a inclusão de novos itens ou a página "/sobre" será adicionada a ela conforme o padrão existente.
|
||||
- Não há requisito de internacionalização; o idioma da página é exclusivamente português brasileiro.
|
||||
- Não são necessários recursos de acessibilidade além do que já é padrão no restante do site (texto alternativo em imagens, contraste adequado).
|
||||
- As métricas exibidas na seção "Números em Destaque" são valores de marketing estáticos e não refletem dados em tempo real do sistema.
|
||||
192
specs/021-pagina-sobre/tasks.md
Normal file
192
specs/021-pagina-sobre/tasks.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Tasks: Página Sobre
|
||||
|
||||
**Input**: Design documents from `/specs/021-pagina-sobre/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: Não solicitados — feature 100% estática sem lógica testável via testes unitários.
|
||||
|
||||
**Organização**: Tarefas agrupadas por user story para permitir implementação e validação independente.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Descrição`
|
||||
|
||||
- **[P]**: Pode rodar em paralelo (arquivos diferentes, sem dependências entre si)
|
||||
- **[Story]**: User story correspondente (US1, US2, US3)
|
||||
- Caminhos de arquivo relativos à raiz do repositório
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Propósito**: Verificação do ambiente. Feature sem dependências novas — tudo já está instalado.
|
||||
|
||||
- [ ] T001 Confirmar que `react-router-dom` v6 está listado em `frontend/package.json` e que `tailwind.config.ts` define os tokens `bg-canvas`, `bg-panel`, `text-text-primary`, `text-text-secondary` e `text-text-tertiary`
|
||||
|
||||
**Checkpoint**: Ambiente pronto — nenhuma instalação necessária.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Pré-requisitos Bloqueantes)
|
||||
|
||||
**Propósito**: Garantir que os arquivos de referência foram lidos antes de qualquer implementação.
|
||||
|
||||
**⚠️ CRÍTICO**: Leia os arquivos de referência abaixo antes de iniciar qualquer tarefa das fases seguintes.
|
||||
|
||||
- [ ] T002 Ler `frontend/src/pages/PrivacyPolicyPage.tsx` para entender o padrão de página estática adotado no projeto (estrutura `<Navbar />` + `<main>` + `<Footer />`)
|
||||
- [ ] T003 [P] Ler `frontend/src/App.tsx` para identificar onde inserir a nova rota `/sobre` e qual import seguir como padrão
|
||||
- [ ] T003 [P] Ler `frontend/src/components/Footer.tsx` para entender como o link "Sobre" está implementado atualmente (`#sobre`) e confirmar que `Link` de `react-router-dom` já está importado (padrão introduzido na feature 020)
|
||||
|
||||
**Checkpoint**: Padrões de referência entendidos — implementação pode iniciar.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Visitante conhece a empresa na página Sobre (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: A rota `/sobre` é acessível publicamente, exibe as seis seções institucionais (Hero, História, Diferenciais, Números, Equipe, CTA Final) com Navbar e Footer, e o link "Sobre" no Footer navega para `/sobre` sem recarregar a aplicação.
|
||||
|
||||
**Independent Test**: Acessar `http://localhost:5173/sobre` e verificar que as seis seções são renderizadas com conteúdo visível em mobile (320 px) e desktop (1280 px), sem erros de console.
|
||||
|
||||
### Implementação da User Story 1
|
||||
|
||||
- [ ] T004 [US1] Criar `frontend/src/pages/AboutPage.tsx` — componente sem props e sem estado seguindo o padrão de `PrivacyPolicyPage.tsx`:
|
||||
- Importar `Link` de `react-router-dom`, `Navbar` e `Footer`
|
||||
- Estrutura raiz: `<Navbar />` + `<main id="main-content" className="min-h-screen bg-canvas">` + `<Footer />`
|
||||
- Largura máxima do conteúdo: `max-w-[1200px] mx-auto px-6`
|
||||
- Definir tipos locais (não exportados): `DiferencialItem { icon, title, description }` e `MetricItem { value, label }`
|
||||
- **Seção Hero** (JSX direto): label `text-[#5e6ad2] text-sm font-medium tracking-widest uppercase` + título da empresa `font-semibold text-text-primary tracking-tight` + subtítulo `text-text-secondary` + parágrafo de missão/valores `text-text-tertiary`
|
||||
- **Seção Nossa História** (JSX direto): título de seção + 2–3 parágrafos narrativos `text-text-secondary`
|
||||
- **Seção Nossos Diferenciais** (`.map()` sobre array `diferenciais: DiferencialItem[]` com 3 itens): grid de cards `bg-panel border border-white/[0.06] rounded-2xl p-6`, cada card com ícone (emoji UTF-8), título `text-text-primary font-semibold` e descrição `text-text-secondary`
|
||||
- **Seção Números em Destaque** (`.map()` sobre array `numeros: MetricItem[]` com 4 itens — anos de mercado, imóveis negociados, satisfação de clientes, número de corretores): grid de cards `bg-panel border border-white/[0.06] rounded-2xl`, cada card com valor `text-[#5e6ad2] font-bold text-3xl` e label `text-text-secondary text-sm`
|
||||
- **Seção Nossa Equipe** (JSX direto): título de seção + texto introdutório + `<Link to="/corretores">` "Conheça nosso time"
|
||||
- **Seção CTA Final** (JSX direto): título "Pronto para encontrar seu imóvel?" + `<Link to="/imoveis">` com classe `bg-[#5e6ad2] hover:bg-[#6872e5] text-white rounded-lg px-5 py-2.5` + `<a href="https://wa.me/5500000000000?text=Olá!%20Gostaria%20de%20saber%20mais%20sobre%20os%20imóveis%20disponíveis." target="_blank" rel="noopener noreferrer">` com classe `border border-white/[0.08] text-text-secondary hover:text-text-primary rounded-lg px-5 py-2.5`
|
||||
|
||||
- [ ] T005 [US1] Editar `frontend/src/App.tsx` — adicionar import e rota para `AboutPage`:
|
||||
- Adicionar `import AboutPage from './pages/AboutPage';` junto aos demais imports de páginas (após `PrivacyPolicyPage` seguindo ordem alfabética/cronológica)
|
||||
- Adicionar `<Route path="/sobre" element={<AboutPage />} />` na mesma camada das rotas públicas, após `<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />`
|
||||
|
||||
- [ ] T006 [US1] Editar `frontend/src/components/Footer.tsx` — converter link "Sobre" de âncora para navegação SPA:
|
||||
- Localizar o item `{ label: 'Sobre', href: '#sobre' }` (ou equivalente)
|
||||
- Substituir por `<Link to="/sobre">Sobre</Link>` seguindo o mesmo padrão do link `/politica-de-privacidade` introduzido na feature 020
|
||||
- Garantir que `Link` de `react-router-dom` já está importado (não duplicar import)
|
||||
|
||||
**Checkpoint**: User Story 1 completa — `http://localhost:5173/sobre` exibe as seis seções, Navbar e Footer; link "Sobre" no Footer navega sem recarregar a página.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Visitante usa o CTA final para iniciar contato ou buscar imóveis (Priority: P2)
|
||||
|
||||
**Goal**: O botão "Ver imóveis disponíveis" navega para `/imoveis` e o link de WhatsApp abre o app com mensagem pré-preenchida.
|
||||
|
||||
**Independent Test**: Na página `/sobre`, clicar em "Ver imóveis disponíveis" e confirmar navegação para `/imoveis`; clicar no link de WhatsApp e confirmar abertura do app/web com mensagem pré-preenchida.
|
||||
|
||||
> **Nota**: Os CTAs de US2 são implementados dentro de `AboutPage.tsx` na tarefa T004 (Seção CTA Final). Esta fase consiste apenas em validar o comportamento.
|
||||
|
||||
### Validação da User Story 2
|
||||
|
||||
- [ ] T007 [US2] Validar na `AboutPage.tsx` (já criada em T004) se os dois CTAs da seção final estão corretos:
|
||||
- `<Link to="/imoveis">` usa classe primária `bg-[#5e6ad2] hover:bg-[#6872e5] text-white rounded-lg px-5 py-2.5`
|
||||
- `<a href="https://wa.me/5500000000000?text=...">` tem atributos `target="_blank"` e `rel="noopener noreferrer"` (requisito de segurança — evita `window.opener` exploit)
|
||||
- Em mobile, confirmar que o link `wa.me` abre o WhatsApp nativo (comportamento padrão do protocolo)
|
||||
|
||||
**Checkpoint**: US1 e US2 funcionais — navegação e CTAs de conversão operantes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Visitante descobre a equipe e navega para listagem de corretores (Priority: P3)
|
||||
|
||||
**Goal**: A seção "Nossa Equipe" exibe texto introdutório e um CTA `<Link to="/corretores">` funcional.
|
||||
|
||||
**Independent Test**: Na página `/sobre`, clicar em "Conheça nosso time" (ou equivalente) e confirmar navegação SPA para `/corretores`.
|
||||
|
||||
> **Nota**: O CTA de US3 é implementado dentro de `AboutPage.tsx` na tarefa T004 (Seção Nossa Equipe). Esta fase consiste apenas em validar o comportamento.
|
||||
|
||||
### Validação da User Story 3
|
||||
|
||||
- [ ] T008 [US3] Validar na `AboutPage.tsx` (já criada em T004) se a seção "Nossa Equipe" está correta:
|
||||
- `<Link to="/corretores">` presente e com texto identificável (ex.: "Conheça nosso time")
|
||||
- Texto introdutório sobre a equipe visível acima do CTA
|
||||
|
||||
**Checkpoint**: Todas as três user stories funcionais e independentemente testáveis.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Validação Final
|
||||
|
||||
**Propósito**: Verificação de responsividade, consistência visual e ausência de erros.
|
||||
|
||||
- [ ] T009 [P] Executar checklist do `quickstart.md` em `http://localhost:5173/sobre`: verificar as seis seções visíveis, Navbar/Footer presentes, ausência de erros de console, link "Sobre" no Footer navegando sem reload
|
||||
- [ ] T010 [P] Validar responsividade em viewport mobile (320 px e 375 px) e desktop (1280 px e 1920 px) — garantir que nenhum elemento transborda horizontalmente (`overflow-x` ausente)
|
||||
- [ ] T011 Revisar se todos os tokens Tailwind usados (`bg-canvas`, `bg-panel`, `text-text-primary`, `text-text-secondary`, `text-text-tertiary`, `border-white/[0.06]`, `border-white/[0.08]`) estão definidos em `frontend/tailwind.config.ts` e não foram usados valores inline fora do sistema de design
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependências entre Fases
|
||||
|
||||
- **Phase 1 (Setup)**: Sem dependências — iniciar imediatamente
|
||||
- **Phase 2 (Foundational)**: Depende do Phase 1 — BLOQUEIA todas as user stories
|
||||
- **Phase 3 (US1)**: Depende do Phase 2 — MVP entregável ao final desta fase
|
||||
- **Phase 4 (US2)**: Depende do Phase 3 (T004 deve estar completo)
|
||||
- **Phase 5 (US3)**: Depende do Phase 3 (T004 deve estar completo)
|
||||
- **Phase 6 (Polish)**: Depende de todas as fases anteriores
|
||||
|
||||
### Dependências entre User Stories
|
||||
|
||||
- **US1 (P1)**: Independente — inicia após Phase 2
|
||||
- **US2 (P2)**: Depende de US1 (T004 cria os CTAs validados em T007)
|
||||
- **US3 (P3)**: Depende de US1 (T004 cria o CTA validado em T008)
|
||||
|
||||
### Dentro de cada User Story (Phase 3)
|
||||
|
||||
- T004 → T005 → T006 (sequencial: página criada → rota registrada → footer atualizado)
|
||||
- T005 e T006 podem ser feitos em paralelo após T004
|
||||
|
||||
### Paralelo dentro da Phase 3
|
||||
|
||||
```
|
||||
Após T004 (AboutPage.tsx criado):
|
||||
[P] T005 — editar App.tsx (rota)
|
||||
[P] T006 — editar Footer.tsx (link)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```
|
||||
# T004 deve estar completo antes de T005 e T006
|
||||
# T005 e T006 podem ser executados em paralelo:
|
||||
|
||||
Task T005: "Editar frontend/src/App.tsx — adicionar import e rota /sobre"
|
||||
Task T006: "Editar frontend/src/components/Footer.tsx — converter link #sobre para /sobre"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Completar Phase 1: Setup (verificação rápida)
|
||||
2. Completar Phase 2: Foundational (leitura dos arquivos de referência)
|
||||
3. Completar Phase 3: User Story 1 (T004 → T005 + T006)
|
||||
4. **PARAR E VALIDAR**: Acessar `http://localhost:5173/sobre` e confirmar as seis seções
|
||||
5. Deploy se validado
|
||||
|
||||
### Entrega Incremental
|
||||
|
||||
1. Setup + Foundational → Referências entendidas
|
||||
2. US1 completa → Página acessível, todas as seções visíveis, Footer atualizado → **MVP!**
|
||||
3. US2 validada → CTAs de conversão verificados
|
||||
4. US3 validada → CTA da equipe verificado
|
||||
5. Polish → Responsividade e consistência visual confirmadas
|
||||
|
||||
### Estratégia para Desenvolvedor Único
|
||||
|
||||
Com um único desenvolvedor (cenário desta feature):
|
||||
|
||||
1. Phase 1 + Phase 2: leitura e setup (~10 min)
|
||||
2. T004: criar `AboutPage.tsx` com todas as 6 seções (~30–45 min)
|
||||
3. T005 + T006 em paralelo: editar `App.tsx` e `Footer.tsx` (~5 min cada)
|
||||
4. T007 + T008: validação dos CTAs (~10 min)
|
||||
5. T009–T011 (Polish): checklist e responsividade (~15 min)
|
||||
742
specs/022-ux-audit-imoveis/ux-audit.md
Normal file
742
specs/022-ux-audit-imoveis/ux-audit.md
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
# UX/UI Audit — Página `/imoveis`
|
||||
|
||||
> Análise realizada em 18/04/2026. Baseada no código-fonte atual da `PropertiesPage`, `FilterSidebar`, `PropertyRowCard`, `ContactModal` e componentes relacionados.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Diagnóstico Geral](#1-diagnóstico-geral)
|
||||
2. [Arquitetura de Informação](#2-arquitetura-de-informação)
|
||||
3. [Filtros e Busca](#3-filtros-e-busca)
|
||||
4. [Cards de Imóvel](#4-cards-de-imóvel)
|
||||
5. [Layout e Visualização](#5-layout-e-visualização)
|
||||
6. [Paginação](#6-paginação)
|
||||
7. [Estado Vazio e Erros](#7-estado-vazio-e-erros)
|
||||
8. [Micro-interações e Animações](#8-micro-interações-e-animações)
|
||||
9. [Acessibilidade (A11y)](#9-acessibilidade-a11y)
|
||||
10. [Mobile Experience](#10-mobile-experience)
|
||||
11. [Performance Percebida](#11-performance-percebida)
|
||||
12. [Fluxo de Conversão](#12-fluxo-de-conversão)
|
||||
13. [Roadmap Priorizado](#13-roadmap-priorizado)
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnóstico Geral
|
||||
|
||||
### Pontos positivos atuais
|
||||
- Filtros sincronizados com a URL — links são compartilháveis e o histórico do browser funciona corretamente.
|
||||
- Skeleton loading implementado — evita CLS (Cumulative Layout Shift).
|
||||
- Carrossel de fotos com lazy load por slide individual.
|
||||
- Sidebar sticky no desktop, drawer no mobile.
|
||||
- Favoritos e comparação com contexto global persistente.
|
||||
- Badge de contagem de filtros ativos no botão mobile.
|
||||
|
||||
### Problemas críticos identificados
|
||||
| Severidade | Quantidade | Descrição resumida |
|
||||
|---|---|---|
|
||||
| 🔴 Alta | 5 | Impactam diretamente conversão ou usabilidade fundamental |
|
||||
| 🟡 Média | 9 | Degradam a experiência sem bloquear o uso |
|
||||
| 🟢 Baixa | 8 | Refinamentos e polish |
|
||||
|
||||
---
|
||||
|
||||
## 2. Arquitetura de Informação
|
||||
|
||||
### 2.1 Ausência de campo de busca textual 🔴
|
||||
|
||||
**Problema:** Não existe um input de busca livre (por endereço, bairro, título ou código do imóvel). O usuário só consegue filtrar via dropdowns/chips. Para alguém que já sabe o endereço ou código do imóvel, o fluxo é completamente ineficiente.
|
||||
|
||||
**Referências:** Todos os grandes portais imobiliários (Zap, VivaReal, OLX) colocam a busca textual como primeiro ponto de entrada.
|
||||
|
||||
**Solução recomendada:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 🔍 Buscar por endereço, bairro ou código... │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
- Barra de busca proeminente no topo da área de resultados (não na sidebar).
|
||||
- Debounce de 400ms para evitar requests excessivos.
|
||||
- Parâmetro `q` na URL: `/imoveis?q=Barra+Funda`.
|
||||
- Busca no backend via `ILIKE` em `title`, `address`, `code`, `neighborhood.name`.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Ausência de ordenação 🔴
|
||||
|
||||
**Problema:** Não há opção para ordenar os resultados. O usuário não controla se quer ver por menor preço, maior área, mais recente ou destaque.
|
||||
|
||||
**Solução recomendada:**
|
||||
```tsx
|
||||
// Dropdown de ordenação ao lado do contador de resultados
|
||||
<select name="sort">
|
||||
<option value="relevance">Relevância</option>
|
||||
<option value="price_asc">Menor preço</option>
|
||||
<option value="price_desc">Maior preço</option>
|
||||
<option value="area_desc">Maior área</option>
|
||||
<option value="newest">Mais recente</option>
|
||||
</select>
|
||||
```
|
||||
- Parâmetro `sort` na URL.
|
||||
- Persistir a preferência de ordenação na URL (já funciona com o sistema atual de `filtersToParams`).
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Falta de chips/tags de filtros ativos 🟡
|
||||
|
||||
**Problema:** Quando o usuário aplica filtros no desktop, não há feedback visual na área de resultados mostrando *quais* filtros estão ativos. O contador "87 imóveis encontrados" não revela *por quê* esse número é aquele.
|
||||
|
||||
**Solução recomendada:**
|
||||
```
|
||||
┌─ Filtros ativos ──────────────────────────────────────┐
|
||||
│ [Aluguel ×] [São Paulo ×] [2+ quartos ×] [Limpar tudo] │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
- Chips removíveis logo abaixo do header, acima do primeiro card.
|
||||
- Cada chip tem `×` para remover individualmente.
|
||||
- Botão "Limpar tudo" só aparece quando há ≥ 2 chips.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Sem breadcrumbs 🟢
|
||||
|
||||
**Problema:** Não há indicação de onde o usuário está na hierarquia do site. Especialmente útil quando vem de uma busca filtrada.
|
||||
|
||||
**Solução:** Breadcrumb minimalista no header: `Início > Imóveis > Apartamentos em São Paulo`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Filtros e Busca
|
||||
|
||||
### 3.1 Sidebar muito estreita para inputs de range 🟡
|
||||
|
||||
**Problema:** A sidebar tem `w-56` (224px). Os `RangeInputs` de preço e área ficam comprimidos, forçando o usuário a digitar em campos muito pequenos sem feedback visual do range selecionado.
|
||||
|
||||
**Solução recomendada:**
|
||||
- Aumentar para `w-64` (256px) ou `w-72` (288px) no desktop.
|
||||
- Adicionar slider visual (range input duplo) acima dos inputs numéricos para preço — visualmente mais intuitivo e mais rápido que digitar valores.
|
||||
|
||||
```tsx
|
||||
// Price range com slider + inputs
|
||||
<div>
|
||||
<RangeSlider min={0} max={5_000_000} value={[priceMin, priceMax]} />
|
||||
<RangeInputs ... />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Drawer mobile abre à direita 🟡
|
||||
|
||||
**Problema:** O drawer de filtros mobile abre pela direita (`absolute right-0`). O padrão de mercado (Google, Apple HIG) é que filtros e navegação secundária abram pela **esquerda**. Abrir pela direita quebra a expectativa de usuários que associam o lado direito a ações e notificações.
|
||||
|
||||
**Solução:** Mudar para `left-0` com largura de `85vw` máx `360px`. O botão de filtro pode ficar onde está.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Nenhum feedback de "aplicando filtros" 🟡
|
||||
|
||||
**Problema:** Ao mudar qualquer filtro no sidebar desktop, a lista já recarrega (debounced pelo `useEffect`). Porém o usuário não tem feedback imediato de que *algo está acontecendo* — o skeleton só aparece depois do delay de rede, criando uma janela de latência percebida.
|
||||
|
||||
**Solução:**
|
||||
- Mostrar um indicador sutil (spinner ou barra de progresso no topo da lista) imediatamente ao alterar filtros, antes do resultado da API.
|
||||
- Adicionar transição `opacity: 0.5` na lista enquanto `loading === true`, mantendo os cards anteriores visíveis em vez de sumindo e mostrando skeletons.
|
||||
|
||||
```tsx
|
||||
// Ao invés de mostrar skeleton completo, apenas escurecer
|
||||
<div className={`flex flex-col gap-3 transition-opacity ${loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}`}>
|
||||
{result?.items.map(p => <PropertyRowCard ... />)}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Checkbox "incluir condomínio" ambíguo 🟢
|
||||
|
||||
**Problema:** O checkbox "incluir condomínio" no filtro de preço não é óbvio. Um usuário leigo pode não entender se isso soma o valor do condomínio ao preço de aluguel para filtrar, ou se está filtrando imóveis que *tenham* condomínio.
|
||||
|
||||
**Solução:**
|
||||
- Tooltip (?) com explicação: *"Quando ativo, o filtro de preço inclui a taxa de condomínio no total considerado."*
|
||||
- Ou reescrever o label para: `Preço total (com condomínio)`.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Comodidades sempre colapsadas 🟢
|
||||
|
||||
**Problema:** Os grupos de comodidades (Lazer, Segurança, etc.) têm `defaultOpen = false`. Em um sidebar já scrollável, isso exige que o usuário descubra que essas seções existem e clique para expandir.
|
||||
|
||||
**Solução:** Manter colapsado por padrão, mas mostrar os chips das comodidades selecionadas *mesmo colapsado*, para que o usuário veja que há filtros ativos ali.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cards de Imóvel
|
||||
|
||||
### 4.1 Altura fixa quebra o layout em telas menores 🔴
|
||||
|
||||
**Problema:** `h-[220px]` fixo no card e `w-[340px]` fixo na imagem. Em viewports entre 768px e 1024px (tablets), o card fica comprimido mas mantém a altura rígida, fazendo o texto ser truncado prematuramente.
|
||||
|
||||
**Impacto:** Usuários em tablets iPad (768px) ficam no breakpoint errado — recebem o layout desktop comprimido, não o mobile drawer.
|
||||
|
||||
**Solução recomendada:**
|
||||
```tsx
|
||||
// Card com altura adaptável
|
||||
<article className="group bg-panel border border-borderSubtle rounded-2xl overflow-hidden
|
||||
hover:border-borderStandard transition-all duration-200
|
||||
flex flex-col sm:flex-row sm:h-[220px]">
|
||||
|
||||
{/* Imagem: full-width em mobile, fixed em desktop */}
|
||||
<div className="relative flex-shrink-0 w-full h-48 sm:w-[280px] sm:h-full lg:w-[340px]">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Botões dentro de um elemento `<Link>` (problema de semântica) 🔴
|
||||
|
||||
**Problema:** O elemento `<Link>` envolve toda a seção de informações do card, incluindo os botões "Comparar" e "Entre em contato". Isso cria elementos interativos aninhados (`<button>` dentro de `<a>`), o que é **inválido no HTML5** e pode causar comportamento inconsistente em diferentes browsers e leitores de tela.
|
||||
|
||||
**Solução:** Reestruturar o card para que o `<Link>` seja apenas um elemento de fundo clicável (via `position: absolute`) e os botões fiquem fora do DOM do Link:
|
||||
|
||||
```tsx
|
||||
<article className="relative group ...">
|
||||
{/* Imagem */}
|
||||
<div className="...carousel..."></div>
|
||||
|
||||
{/* Info section — sem ser um Link diretamente */}
|
||||
<div className="flex flex-col flex-1 p-5 gap-2">
|
||||
<Link to={...} className="absolute inset-0" aria-label="Ver detalhes" />
|
||||
|
||||
{/* Todo o conteúdo */}
|
||||
<h3>...</h3>
|
||||
|
||||
{/* Botões ficam acima do Link absoluto com z-index */}
|
||||
<div className="mt-auto flex items-center gap-2 relative z-10">
|
||||
<button onClick={handleCompareClick}>Comparar</button>
|
||||
<button onClick={handleContactClick}>Entre em contato</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 "Ver detalhes →" como `<span>` não interativo 🟡
|
||||
|
||||
**Problema:** O texto `Ver detalhes →` é um `<span>` dentro de um `<Link>`. Visualmente parece um link separado, mas não tem estados de hover/focus próprios. Usuários podem tentar clicar especificamente nele achando que é um CTA distinto.
|
||||
|
||||
**Solução:** Transformar em um botão visual real com seta animada no hover:
|
||||
```tsx
|
||||
<span className="ml-auto text-xs font-medium text-accent-violet group-hover:translate-x-0.5 transition-transform inline-flex items-center gap-1">
|
||||
Ver detalhes <ArrowRightIcon />
|
||||
</span>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Tipo de imóvel ausente no card 🟡
|
||||
|
||||
**Problema:** O card não exibe o tipo/subtipo do imóvel (Apartamento, Casa, Sala Comercial, etc.). O usuário precisa entrar na página de detalhes para descobrir o tipo, mesmo que já esteja filtrando por tipo.
|
||||
|
||||
**Solução:** Adicionar o subtipo como um chip pequeno abaixo do título:
|
||||
```tsx
|
||||
{property.subtype && (
|
||||
<span className="text-[10px] text-textTertiary bg-surface rounded px-1.5 py-0.5 w-fit">
|
||||
{property.subtype.name}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Carrossel inacessível em mobile (hover-only) 🔴
|
||||
|
||||
**Problema:** Os botões prev/next do carrossel têm `opacity-0 group-hover:opacity-100`. Em dispositivos touch, não existe o estado `:hover`, tornando os botões de navegação **completamente invisíveis e inacessíveis** em mobile.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
// Tornar visível em mobile, apenas com hover no desktop
|
||||
className="... opacity-100 sm:opacity-0 sm:group-hover:opacity-100 ..."
|
||||
```
|
||||
Ou usar swipe gesture nativo em mobile (touchstart/touchend).
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Ausência de badge "Novo" e "Destaque" 🟢
|
||||
|
||||
**Problema:** Não há diferenciação visual entre imóveis recém adicionados, imóveis em destaque ou imóveis com preço reduzido. Isso reduz a urgência percebida e o valor editorial da listagem.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
{property.is_featured && (
|
||||
<span className="bg-amber-500/90 text-white ...">⭐ Destaque</span>
|
||||
)}
|
||||
{property.is_new && ( // criado nos últimos 7 dias
|
||||
<span className="bg-emerald-500/90 text-white ...">Novo</span>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Abreviações confusas nas stats 🟢
|
||||
|
||||
**Problema:** `qts` (quartos) e `ban` (banheiros) são abreviações pouco intuitivas, especialmente para usuários menos familiarizados. `m²` não tem o label "Área".
|
||||
|
||||
**Solução:**
|
||||
```
|
||||
3 quartos · 2 banheiros · 85 m² · 1 vaga
|
||||
```
|
||||
Em espaços comprimidos, usar tooltips:
|
||||
```tsx
|
||||
<span title="Quartos"><BedIcon /> 3</span>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Layout e Visualização
|
||||
|
||||
### 5.1 Único modo de visualização (lista) 🟡
|
||||
|
||||
**Problema:** A página só oferece visualização em lista horizontal. Muitos usuários preferem visualização em **grade** (especialmente para imóveis com fotos bonitas) pois permite comparar mais imóveis visualmente de uma vez.
|
||||
|
||||
**Solução:** Toggle de visualização no header:
|
||||
```
|
||||
[≡ Lista] [⊞ Grade]
|
||||
```
|
||||
- Lista: layout atual `PropertyRowCard` (horizontal, 1 coluna).
|
||||
- Grade: cards verticais `PropertyGridCard` (2-3 colunas, foto em cima), com menos detalhes mas foto maior.
|
||||
- Persistir preferência em `localStorage`.
|
||||
|
||||
```tsx
|
||||
// Grade: 2-3 colunas responsivas
|
||||
<div className={view === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4'
|
||||
: 'flex flex-col gap-3'}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Per page fixo em 16 🟢
|
||||
|
||||
**Problema:** O `per_page: 16` é hardcoded em dois lugares (`handleFiltersChange` e `handleClear`). O usuário não pode escolher ver mais ou menos resultados por página.
|
||||
|
||||
**Solução:** Seletor discreto no header: `Exibir: [16] [32] [48]`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Paginação
|
||||
|
||||
### 6.1 Sem indicador de posição 🟡
|
||||
|
||||
**Problema:** A paginação mostra apenas os números de página mas não informa *quantos resultados* estão sendo exibidos em relação ao total. "Página 3 de 12" não comunica que estamos vendo "imóveis 33-48 de 185".
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
// Acima da paginação ou integrado ao header
|
||||
<p className="text-xs text-textTertiary text-center mt-6">
|
||||
Exibindo {(page - 1) * perPage + 1}–{Math.min(page * perPage, total)} de {total} imóveis
|
||||
</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Scroll to top abrupto 🟡
|
||||
|
||||
**Problema:** `window.scrollTo({ top: 0, behavior: 'smooth' })` faz o scroll mas não há indicação visual de que os resultados mudaram. O usuário pode não perceber que está vendo novos resultados.
|
||||
|
||||
**Solução:**
|
||||
- Adicionar uma transição sutil de `opacity` nos cards ao trocar de página.
|
||||
- Ou scroll para o topo do container de resultados, não da janela inteira (para não esconder o sidebar).
|
||||
|
||||
```tsx
|
||||
// Scroll para o topo da área de resultados
|
||||
gridRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Paginação duplicada 🟢
|
||||
|
||||
**Melhoria:** Adicionar paginação também no **topo** da lista de resultados (acima do primeiro card). Útil para quando o usuário quer mudar de página sem scrollar até o fim.
|
||||
|
||||
---
|
||||
|
||||
## 7. Estado Vazio e Erros
|
||||
|
||||
### 7.1 Estado vazio sem sugestões 🟡
|
||||
|
||||
**Problema atual:**
|
||||
```
|
||||
Nenhum imóvel encontrado com esses filtros.
|
||||
[Limpar filtros]
|
||||
```
|
||||
|
||||
Esse estado desperdiça uma oportunidade de reter o usuário e ajudá-lo a encontrar algo relevante.
|
||||
|
||||
**Solução — Empty State rico:**
|
||||
```
|
||||
😕 Nenhum imóvel encontrado
|
||||
|
||||
Tente estas sugestões:
|
||||
• [Remover filtro de bairro] → 12 imóveis disponíveis
|
||||
• [Ampliar faixa de preço] → 8 imóveis disponíveis
|
||||
• [Mudar para 1+ quartos] → 5 imóveis disponíveis
|
||||
|
||||
[Ou limpar todos os filtros →]
|
||||
```
|
||||
|
||||
Para calcular as sugestões, fazer requests paralelos com filtros relaxados.
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Sem tratamento de erro de rede 🔴
|
||||
|
||||
**Problema:** O `fetchProperties` usa `try/finally` mas não guarda o erro. Se a API falhar, a lista simplesmente permanece em estado anterior ou vazia, sem nenhuma mensagem ao usuário.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// no fetchProperties:
|
||||
} catch (err) {
|
||||
setError('Não foi possível carregar os imóveis. Tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// no render:
|
||||
{error && (
|
||||
<div className="...">
|
||||
<p>{error}</p>
|
||||
<button onClick={() => fetchProperties(filters)}>Tentar novamente</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Micro-interações e Animações
|
||||
|
||||
### 8.1 Sem animação de entrada nos cards 🟢
|
||||
|
||||
**Problema:** Os cards aparecem abruptamente após o loading. Uma animação sutil de entrada melhora a percepção de qualidade.
|
||||
|
||||
**Solução com Tailwind + delay staggered:**
|
||||
```tsx
|
||||
{result.items.map((property, i) => (
|
||||
<div
|
||||
key={property.id}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<PropertyRowCard property={property} />
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Em index.css */
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.25s ease both;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.2 Sem botão "Voltar ao topo" 🟢
|
||||
|
||||
**Problema:** Em listas longas (16 cards), o usuário precisa scrollar muito para voltar ao topo e ajustar filtros no sidebar desktop ou clicar no botão de filtros mobile.
|
||||
|
||||
**Solução:** Botão flutuante que aparece após scrollar 400px:
|
||||
```tsx
|
||||
{scrollY > 400 && (
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="fixed bottom-24 right-6 z-40 w-10 h-10 rounded-full bg-panel border border-borderStandard shadow-lg ..."
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.3 Barra de comparação conflita com ComparisonBar 🟢
|
||||
|
||||
**Problema:** A `ComparisonBar` fica fixa no `bottom-0`. Quando ativa, ela sobrepõe o rodapé e pode encobrir o botão "Voltar ao topo". Sem margem dinâmica no conteúdo principal, cards podem ficar atrás da barra.
|
||||
|
||||
**Solução:** Quando `ComparisonBar` está visível, adicionar `pb-20` no container principal.
|
||||
|
||||
---
|
||||
|
||||
## 9. Acessibilidade (A11y)
|
||||
|
||||
### 9.1 Elementos interativos aninhados 🔴
|
||||
|
||||
*(Ver seção 4.2 — `<button>` dentro de `<a>` é HTML inválido)*
|
||||
|
||||
---
|
||||
|
||||
### 9.2 Carrossel sem suporte a teclado 🟡
|
||||
|
||||
**Problema:** Os botões prev/next do carrossel não têm `tabIndex` e estão ocultos visualmente (`opacity-0`). Um usuário que navega por teclado não consegue navegar pelas fotos.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
<button
|
||||
onClick={prev}
|
||||
onFocus={() => /* mostrar botões */}
|
||||
tabIndex={0}
|
||||
aria-label="Foto anterior"
|
||||
// Remover opacity-0 do tabIndex focus state
|
||||
className="... focus:opacity-100"
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9.3 Dots do carrossel com área clicável pequena 🟡
|
||||
|
||||
**Problema:** Os dots têm `w-1.5 h-1.5` (6px). A área mínima recomendada pela WCAG é 24×24px.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
// Área de toque maior com padding
|
||||
<button
|
||||
className="p-2 -m-2" // área de toque 22px+padding
|
||||
>
|
||||
<span className="block w-1.5 h-1.5 rounded-full ..." />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9.4 `<main>` sem `id` ou `aria-label` 🟢
|
||||
|
||||
**Problema:** O `<main>` existe, mas não tem `aria-label` ou `id="main-content"` para skip links.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
<main id="main-content" aria-label="Listagem de imóveis" className="pt-14">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9.5 Filtros sidebar sem `role="search"` ou `aria-label` 🟢
|
||||
|
||||
**Problema:** O sidebar de filtros não tem semântica ARIA adequada.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
<aside aria-label="Filtros de busca">
|
||||
<FilterSidebar ... />
|
||||
</aside>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Mobile Experience
|
||||
|
||||
### 10.1 Layout horizontal comprimido em tablet 🔴
|
||||
|
||||
*(Ver seção 4.1 — altura fixa do card)*
|
||||
|
||||
O breakpoint `lg:hidden` para o sidebar significa que em tablets (768px–1023px), o usuário recebe o layout desktop mas com espaço insuficiente.
|
||||
|
||||
**Solução:** Mudar o breakpoint do sidebar para `md:block` ou `xl:block` e ajustar o layout do card para ser vertical até `xl`.
|
||||
|
||||
---
|
||||
|
||||
### 10.2 Sem infinite scroll como alternativa 🟢
|
||||
|
||||
**Problema:** A paginação tradicional obriga o usuário a clicar e esperar. Em mobile, infinite scroll ou "Carregar mais" é mais natural.
|
||||
|
||||
**Solução opcional:** Botão "Ver mais imóveis" no final da lista (append, não replace) como alternativa à paginação:
|
||||
```tsx
|
||||
{result.page < result.pages && (
|
||||
<button
|
||||
onClick={() => loadMore()}
|
||||
className="w-full mt-6 py-3 rounded-xl border border-borderStandard ..."
|
||||
>
|
||||
Carregar mais imóveis ({remaining} restantes)
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10.3 Tipografia pequena demais em mobile 🟡
|
||||
|
||||
**Problema:** `text-xs` (12px) em múltiplos lugares (stats do card, labels de filtro). A WCAG recomenda mínimo 16px para corpo de texto em mobile.
|
||||
|
||||
**Elementos afetados:**
|
||||
- Stats do card (`text-xs text-textSecondary`)
|
||||
- Label do botão "Entre em contato" (`text-xs font-semibold`)
|
||||
- Paginação (`text-xs`)
|
||||
- Seção titles da sidebar (`text-xs font-medium uppercase`)
|
||||
|
||||
**Solução:** Escalar para `text-sm` (14px) em mobile, mantendo `text-xs` apenas em elementos secundários decorativos.
|
||||
|
||||
---
|
||||
|
||||
## 11. Performance Percebida
|
||||
|
||||
### 11.1 5 requests paralelos antes de renderizar qualquer coisa 🟡
|
||||
|
||||
**Problema:** O `Promise.all` na montagem carrega tipos, comodidades, cidades, bairros e imobiliárias antes de mostrar o sidebar. Enquanto isso, a sidebar fica vazia.
|
||||
|
||||
**Solução:**
|
||||
- Mostrar skeleton da sidebar enquanto os dados de catálogo carregam.
|
||||
- Priorizar o request de `getProperties` (o mais importante) e deixar o catálogo carregar em segundo plano.
|
||||
|
||||
```tsx
|
||||
// Separar o loading do catálogo do loading dos imóveis
|
||||
const [catalogLoading, setCatalogLoading] = useState(true)
|
||||
|
||||
// Catalog carrega em background, não bloqueia os imóveis
|
||||
useEffect(() => {
|
||||
Promise.all([...]).then(([...]) => {
|
||||
// set states
|
||||
setCatalogLoading(false)
|
||||
})
|
||||
}, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11.2 Todas as fotos do carrossel são renderizadas no DOM 🟡
|
||||
|
||||
**Problema:** O `PhotoCarousel` renderiza *todos* os slides no DOM desde o início, apenas mudando a `opacity`. Um imóvel com 10 fotos renderiza 10 `<img>` tags, mesmo que o usuário veja apenas 1.
|
||||
|
||||
**Solução:** Renderizar apenas o slide atual e os adjacentes (virtualização simples):
|
||||
```tsx
|
||||
// Renderizar apenas slide atual ± 1
|
||||
{slides.map((photo, i) => (
|
||||
Math.abs(i - current) <= 1 && (
|
||||
<SlideImage key={i} src={photo.url} alt={...} />
|
||||
)
|
||||
))}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Fluxo de Conversão
|
||||
|
||||
### 12.1 "Entre em contato" visualmente fraco 🟡
|
||||
|
||||
**Problema:** O botão verde `bg-emerald-500` é a única ação de conversão no card, mas divide espaço igualmente com "Comparar" (border simples). O olho do usuário não é guiado para o CTA principal.
|
||||
|
||||
**Hierarquia visual atual:** `[Comparar]` ≈ `[Entre em contato]` `Ver detalhes →`
|
||||
|
||||
**Solução — hierarquia clara:**
|
||||
```
|
||||
[Ver detalhes] → ação primária (fundo brand)
|
||||
[Entre em contato] → ação secundária (outline brand)
|
||||
[Comparar] → ação terciária (ghost/minimal)
|
||||
```
|
||||
|
||||
Ou: tornar "Entre em contato" o único botão com cor de fundo, ampliado:
|
||||
```tsx
|
||||
<button className="rounded-lg px-4 py-2 text-xs font-semibold bg-brand text-white
|
||||
hover:bg-accentHover transition-colors shadow-sm">
|
||||
Falar com corretor
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12.2 Comparação sem limite visual claro 🟢
|
||||
|
||||
**Problema:** Quando o usuário tenta adicionar um 4º imóvel à comparação, o botão simplesmente não funciona (por lógica no contexto), sem feedback visual do motivo.
|
||||
|
||||
**Solução:**
|
||||
```tsx
|
||||
// Quando comparação está cheia e imóvel não está na lista
|
||||
{!inComparison && comparisonFull && (
|
||||
<Tooltip content="Máximo de 3 imóveis para comparar. Remova um para adicionar este.">
|
||||
<button disabled className="opacity-40 cursor-not-allowed ...">Comparar</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12.3 WhatsApp como CTA não aparece no card 🟢
|
||||
|
||||
**Problema:** O número de WhatsApp existe no sistema e o `ContactModal` o utiliza, mas o card não oferece acesso direto. Muitos usuários preferem o WhatsApp a preencher um formulário.
|
||||
|
||||
**Solução (opcional):** Ícone de WhatsApp como botão terciário no card:
|
||||
```tsx
|
||||
<a
|
||||
href={`https://wa.me/${whatsapp}?text=...`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="..."
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<WhatsAppIcon />
|
||||
</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Roadmap Priorizado
|
||||
|
||||
### Sprint 1 — Crítico (impacto imediato na usabilidade)
|
||||
|
||||
| # | Item | Esforço | Impacto |
|
||||
|---|---|---|---|
|
||||
| 1 | Corrigir botões dentro de `<Link>` (semântica HTML) | P | 🔴 |
|
||||
| 2 | Carrossel visível em mobile (remover opacity-0 em touch) | P | 🔴 |
|
||||
| 3 | Tratamento de erro de rede no fetch de imóveis | P | 🔴 |
|
||||
| 4 | Layout do card responsivo (sem altura fixa) | M | 🔴 |
|
||||
| 5 | Campo de busca textual | G | 🔴 |
|
||||
|
||||
> P = Pequeno (< 2h) · M = Médio (2–6h) · G = Grande (> 6h)
|
||||
|
||||
---
|
||||
|
||||
### Sprint 2 — Alto valor (conversão e descoberta)
|
||||
|
||||
| # | Item | Esforço | Impacto |
|
||||
|---|---|---|---|
|
||||
| 6 | Ordenação de resultados (preço, área, data) | M | 🟡 |
|
||||
| 7 | Chips de filtros ativos com remoção individual | M | 🟡 |
|
||||
| 8 | Toggle lista/grade com `PropertyGridCard` | G | 🟡 |
|
||||
| 9 | Estado vazio com sugestões de filtros relaxados | M | 🟡 |
|
||||
| 10 | Hierarquia de CTAs no card (primário/secundário/terciário) | P | 🟡 |
|
||||
|
||||
---
|
||||
|
||||
### Sprint 3 — Refinamentos (qualidade percebida)
|
||||
|
||||
| # | Item | Esforço | Impacto |
|
||||
|---|---|---|---|
|
||||
| 11 | Animação de entrada dos cards (stagger) | P | 🟢 |
|
||||
| 12 | Indicador de posição na paginação ("X–Y de Z imóveis") | P | 🟢 |
|
||||
| 13 | Botão "Voltar ao topo" flutuante | P | 🟢 |
|
||||
| 14 | Badge "Novo" e "Destaque" no card | P | 🟢 |
|
||||
| 15 | Suporte a teclado no carrossel | M | 🟢 |
|
||||
| 16 | Tipo de imóvel no card (subtipo) | P | 🟢 |
|
||||
| 17 | Virtualização de slides do carrossel | M | 🟢 |
|
||||
| 18 | Skeleton do sidebar enquanto catálogo carrega | P | 🟢 |
|
||||
| 19 | Slider visual para range de preço | G | 🟢 |
|
||||
| 20 | Paginação no topo da lista | P | 🟢 |
|
||||
|
||||
---
|
||||
|
||||
## Referências e Benchmarks
|
||||
|
||||
| Portal | Funcionalidade de referência |
|
||||
|---|---|
|
||||
| **VivaReal** | Chips de filtros ativos, toggle lista/mapa/grade, busca textual no topo |
|
||||
| **Zap Imóveis** | Ordenação proeminente, cards com subtipo e badges, infinite scroll mobile |
|
||||
| **Airbnb** | Slider de preço com histograma de distribuição, filtros modais ricos |
|
||||
| **Booking.com** | Estado vazio com sugestões de relaxamento de filtros |
|
||||
| **Rightmove (UK)** | Paginação com "X–Y de Z", salvar busca, alertas por email |
|
||||
|
||||
---
|
||||
|
||||
*Documento gerado para uso interno do projeto saas_imobiliaria. Revisão recomendada após implementação de cada sprint.*
|
||||
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 |
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
# API Catalog Enhancements — Contrato de Interface
|
||||
|
||||
**Feature**: `024-filtro-busca-avancada`
|
||||
**Tipo de mudança**: Adição de campo somente-leitura em endpoints existentes (backward-compatible)
|
||||
**Versão da API**: `/api/v1` (sem mudança de versão — campo adicional não quebra clientes existentes)
|
||||
|
||||
---
|
||||
|
||||
## Resumo das Mudanças
|
||||
|
||||
Três endpoints existentes passam a incluir o campo `property_count` na resposta. Nenhum endpoint novo é criado. Nenhum parâmetro de entrada é modificado.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/cities
|
||||
|
||||
**Sem alteração na assinatura.** O campo `property_count` é adicionado à resposta.
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Rio de Janeiro",
|
||||
"slug": "rio-de-janeiro",
|
||||
"state": "RJ",
|
||||
"property_count": 47
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "São Paulo",
|
||||
"slug": "sao-paulo",
|
||||
"state": "SP",
|
||||
"property_count": 12
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Belo Horizonte",
|
||||
"slug": "belo-horizonte",
|
||||
"state": "MG",
|
||||
"property_count": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Regras**:
|
||||
- `property_count` conta apenas imóveis com `is_active = true` associados à cidade via `properties.city_id`
|
||||
- Cidades sem imóveis ativos retornam `property_count: 0` (não são omitidas da lista)
|
||||
- Ordenação mantida: `state ASC, name ASC`
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/neighborhoods
|
||||
|
||||
**Sem alteração na assinatura.** Parâmetro opcional `?city_id=<int>` permanece inalterado.
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Copacabana",
|
||||
"slug": "copacabana",
|
||||
"city_id": 1,
|
||||
"property_count": 23
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Ipanema",
|
||||
"slug": "ipanema",
|
||||
"city_id": 1,
|
||||
"property_count": 15
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Santa Teresa",
|
||||
"slug": "santa-teresa",
|
||||
"city_id": 1,
|
||||
"property_count": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Regras**:
|
||||
- `property_count` conta apenas imóveis com `is_active = true` via `properties.neighborhood_id`
|
||||
- Bairros sem imóveis ativos retornam `property_count: 0`
|
||||
- Ordenação mantida: `name ASC`
|
||||
- Filtro `?city_id` mantém comportamento existente
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/property-types
|
||||
|
||||
**Sem alteração na assinatura.** `property_count` é adicionado nos **subtypes** (leaf nodes). Os tipos pai (`parent_id = null`) retornam `property_count: 0` (sem significado — contagem relevante é nos subtipos).
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Residencial",
|
||||
"slug": "residencial",
|
||||
"parent_id": null,
|
||||
"property_count": 0,
|
||||
"subtypes": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Apartamento",
|
||||
"slug": "apartamento",
|
||||
"parent_id": 1,
|
||||
"property_count": 38,
|
||||
"subtypes": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Casa",
|
||||
"slug": "casa",
|
||||
"parent_id": 1,
|
||||
"property_count": 14,
|
||||
"subtypes": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Cobertura",
|
||||
"slug": "cobertura",
|
||||
"parent_id": 1,
|
||||
"property_count": 5,
|
||||
"subtypes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Comercial",
|
||||
"slug": "comercial",
|
||||
"parent_id": null,
|
||||
"property_count": 0,
|
||||
"subtypes": [
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Sala Comercial",
|
||||
"slug": "sala-comercial",
|
||||
"parent_id": 5,
|
||||
"property_count": 7,
|
||||
"subtypes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Regras**:
|
||||
- `property_count` nos subtypes conta imóveis com `is_active = true` via `properties.subtype_id`
|
||||
- Tipos pai recebem `property_count: 0` (campo presente para consistência de schema, não usado pelo frontend)
|
||||
- Ordenação mantida: por `PropertyType.id ASC` (categorias pai); subtypes herdados via SQLAlchemy relationship
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
| Aspecto | Garantia |
|
||||
|---------|----------|
|
||||
| Clientes existentes que ignoram campos extras | ✅ Não quebram — campo adicional em JSON é ignorado |
|
||||
| Frontend antes da feature 024 | ✅ `property_count?: number` (opcional no TypeScript) — não causa erro de tipo |
|
||||
| Testes existentes (`test_properties.py`, `test_homepage.py`) | ✅ Não testam payload de catálogo em detalhe; COUNT adicional não altera filtros |
|
||||
| Admin panel | ✅ Não consome esses endpoints; sem impacto |
|
||||
270
specs/024-filtro-busca-avancada/data-model.md
Normal file
270
specs/024-filtro-busca-avancada/data-model.md
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# Data Model: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Feature**: `024-filtro-busca-avancada` | **Phase**: 1 | **Date**: 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## Resumo
|
||||
|
||||
Sem novas tabelas ou migrations. A única alteração de "modelo de dados" é a extensão dos schemas Pydantic de saída com o campo calculado `property_count: int = 0`. No frontend, os tipos TypeScript espelham a mudança com `property_count?: number` (opcional para backward-compatibility).
|
||||
|
||||
São adicionados dois tipos internos do TypeScript exclusivos ao `FilterSidebar.tsx`, sem impacto em outros componentes.
|
||||
|
||||
---
|
||||
|
||||
## Backend — Schemas Pydantic (catalog.py)
|
||||
|
||||
### Antes e depois
|
||||
|
||||
```python
|
||||
# ANTES
|
||||
class PropertyTypeOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
parent_id: int | None
|
||||
subtypes: list["PropertyTypeOut"] = []
|
||||
|
||||
class CityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
state: str
|
||||
|
||||
class NeighborhoodOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
city_id: int
|
||||
|
||||
# DEPOIS (adição de property_count: int = 0 em cada schema)
|
||||
class PropertyTypeOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
parent_id: int | None
|
||||
subtypes: list["PropertyTypeOut"] = []
|
||||
property_count: int = 0 # ← NOVO
|
||||
|
||||
class CityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
state: str
|
||||
property_count: int = 0 # ← NOVO
|
||||
|
||||
class NeighborhoodOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
city_id: int
|
||||
property_count: int = 0 # ← NOVO
|
||||
```
|
||||
|
||||
**Regras de validação**:
|
||||
- `property_count >= 0` (COUNT nunca é negativo)
|
||||
- Default `0` garante que entidades sem imóveis ativos retornem um valor válido
|
||||
- Campo somente-leitura (nenhum endpoint de escrita o aceita como input)
|
||||
|
||||
---
|
||||
|
||||
## Backend — Queries (routes)
|
||||
|
||||
### `list_cities()` — locations.py
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
from app.models.property import Property
|
||||
|
||||
rows = (
|
||||
db.session.query(City, func.count(Property.id).label("cnt"))
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.city_id == City.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(City.id)
|
||||
.order_by(City.state, City.name)
|
||||
.all()
|
||||
)
|
||||
return jsonify([
|
||||
{**CityOut.model_validate(city).model_dump(), "property_count": cnt}
|
||||
for city, cnt in rows
|
||||
])
|
||||
```
|
||||
|
||||
### `list_neighborhoods()` — locations.py
|
||||
|
||||
```python
|
||||
q = (
|
||||
db.session.query(Neighborhood, func.count(Property.id).label("cnt"))
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(Neighborhood.id)
|
||||
)
|
||||
if city_id:
|
||||
q = q.filter(Neighborhood.city_id == int(city_id))
|
||||
rows = q.order_by(Neighborhood.name).all()
|
||||
return jsonify([
|
||||
{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}
|
||||
for n, cnt in rows
|
||||
])
|
||||
```
|
||||
|
||||
### `list_property_types()` — catalog.py
|
||||
|
||||
A hierarquia pai → subtypes torna a injeção de `property_count` ligeiramente diferente: o COUNT é calculado por `subtype_id` (leaf nodes), não por parent. Os tipos pai não recebem `property_count` (deixam default `0`).
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
from app.models.property import Property
|
||||
|
||||
# 1. Calcular counts por subtype em uma query plana
|
||||
subtype_rows = (
|
||||
db.session.query(
|
||||
PropertyType.id,
|
||||
func.count(Property.id).label("cnt")
|
||||
)
|
||||
.filter(PropertyType.parent_id.isnot(None)) # apenas leaf nodes
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(PropertyType.id)
|
||||
.all()
|
||||
)
|
||||
count_map: dict[int, int] = {row.id: row.cnt for row in subtype_rows}
|
||||
|
||||
# 2. Buscar hierarquia normalmente
|
||||
categories = (
|
||||
PropertyType.query.filter_by(parent_id=None).order_by(PropertyType.id).all()
|
||||
)
|
||||
|
||||
# 3. Serializar injetando property_count nos subtypes
|
||||
def serialize_category(cat: PropertyType) -> dict:
|
||||
data = PropertyTypeOut.model_validate(cat).model_dump(mode="json")
|
||||
data["subtypes"] = [
|
||||
{**sub, "property_count": count_map.get(sub["id"], 0)}
|
||||
for sub in data["subtypes"]
|
||||
]
|
||||
return data
|
||||
|
||||
return jsonify([serialize_category(c) for c in categories])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Tipos TypeScript (catalog.ts)
|
||||
|
||||
```ts
|
||||
// ANTES
|
||||
export interface PropertyType {
|
||||
id: number; name: string; slug: string
|
||||
parent_id: number | null; subtypes: PropertyType[]
|
||||
}
|
||||
export interface City {
|
||||
id: number; name: string; slug: string; state: string
|
||||
}
|
||||
export interface Neighborhood {
|
||||
id: number; name: string; slug: string; city_id: number
|
||||
}
|
||||
|
||||
// DEPOIS
|
||||
export interface PropertyType {
|
||||
id: number; name: string; slug: string
|
||||
parent_id: number | null; subtypes: PropertyType[]
|
||||
property_count?: number // ← NOVO (opcional — backward-compat)
|
||||
}
|
||||
export interface City {
|
||||
id: number; name: string; slug: string; state: string
|
||||
property_count?: number // ← NOVO
|
||||
}
|
||||
export interface Neighborhood {
|
||||
id: number; name: string; slug: string; city_id: number
|
||||
property_count?: number // ← NOVO
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Tipos Internos (FilterSidebar.tsx)
|
||||
|
||||
Estes tipos são declarados localmente no arquivo `FilterSidebar.tsx` e não exportados.
|
||||
|
||||
### `SectionKey`
|
||||
|
||||
```ts
|
||||
type SectionKey =
|
||||
| 'imobiliaria'
|
||||
| 'localizacao'
|
||||
| 'tipo'
|
||||
| 'preco'
|
||||
| 'quartos'
|
||||
| 'area'
|
||||
| 'comodidades'
|
||||
```
|
||||
|
||||
### `FilterSuggestion`
|
||||
|
||||
```ts
|
||||
interface FilterSuggestion {
|
||||
category: string // Label do grupo exibido na UI (ex.: "Tipo de imóvel")
|
||||
sectionKey: SectionKey // Chave para expandir a seção correta ao selecionar
|
||||
label: string // Texto exibido na sugestão
|
||||
filterKey: keyof PropertyFilters
|
||||
value: number | string | undefined
|
||||
isAmenity?: boolean // true quando a ação é toggle em amenity_ids
|
||||
amenityId?: number // preenchido quando isAmenity = true
|
||||
}
|
||||
```
|
||||
|
||||
**Mapeamento de categorias**:
|
||||
|
||||
| `category` (label UI) | `filterKey` | `sectionKey` | Fonte |
|
||||
|-----------------------|-------------|--------------|-------|
|
||||
| "Tipo de imóvel" | `subtype_id` | `tipo` | `propertyTypes[*].subtypes[*]` |
|
||||
| "Cidade" | `city_id` | `localizacao` | `cities` |
|
||||
| "Bairro" | `neighborhood_id` | `localizacao` | `neighborhoods` |
|
||||
| "Comodidade" | n/a (`amenity_ids` toggle) | `comodidades` | `amenities` |
|
||||
|
||||
### `initOpenSections` — lógica de estado inicial
|
||||
|
||||
```ts
|
||||
function initOpenSections(filters: PropertyFilters): Record<SectionKey, boolean> {
|
||||
return {
|
||||
imobiliaria: filters.imobiliaria_id != null,
|
||||
localizacao: filters.city_id != null || filters.neighborhood_id != null,
|
||||
tipo: filters.subtype_id != null,
|
||||
preco: true, // sempre aberta (FR-009)
|
||||
quartos: filters.bedrooms_min != null
|
||||
|| filters.bathrooms_min != null
|
||||
|| filters.parking_min != null,
|
||||
area: filters.area_min != null || filters.area_max != null,
|
||||
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estado do Banco de Dados
|
||||
|
||||
| Aspecto | Situação |
|
||||
|---------|----------|
|
||||
| Novas tabelas | Nenhuma |
|
||||
| Migrations Alembic | Nenhuma |
|
||||
| Colunas adicionadas | Nenhuma |
|
||||
| Índices adicionados | Nenhum (os índices em `city_id`, `neighborhood_id`, `subtype_id` em `properties` já existem) |
|
||||
| Triggers | Nenhum |
|
||||
|
||||
`property_count` é calculado em tempo de execução. O overhead da query é negligenciável:
|
||||
- `cities`: esperado < 50 linhas × JOIN com `properties` (< 5 k linhas) → < 5 ms
|
||||
- `neighborhoods`: esperado < 200 linhas → < 10 ms
|
||||
- `property_types`: contagem por subtypes separada das categorias → < 5 ms
|
||||
334
specs/024-filtro-busca-avancada/plan.md
Normal file
334
specs/024-filtro-busca-avancada/plan.md
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
# Implementation Plan: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Branch**: `024-filtro-busca-avancada` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/024-filtro-busca-avancada/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Enriquecer os endpoints de catálogo existentes com o campo `property_count` (COUNT dinâmico via subquery SQLAlchemy, sem migration) e reformular o `FilterSidebar.tsx` com três melhorias de UX: (1) campo de busca cross-categoria com debounce 200 ms e sugestões agrupadas inline, (2) estado inicial controlado com apenas a seção "Preço" aberta e auto-expansão das seções que contêm filtros ativos da URL, e (3) truncamento das listas (top-5 visíveis + "Ver mais") com ordenação por popularidade e badge "Popular" nos 3 mais populares. Sem novas tabelas, sem novos endpoints, sem novas páginas, sem alteração de rotas.
|
||||
|
||||
---
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
|
||||
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
|
||||
**Storage**: PostgreSQL 16 — sem novas tabelas ou migrations (`property_count` é calculado via `func.count` + `outerjoin` no ORM, não persistido)
|
||||
**Testing**: pytest (backend — testes de integração nos endpoints enriquecidos)
|
||||
**Target Platform**: Browser SPA (desktop); Linux server via Docker
|
||||
**Project Type**: web-service (Flask REST API) + SPA (React)
|
||||
**Performance Goals**: busca cross-categoria < 50 ms (processamento local, NFR-001); endpoints de catálogo < 300 ms p95 (COUNT em tabelas pequenas, < 5 k registros)
|
||||
**Constraints**: sem nova dependência npm ou Python; sem rotas novas; filtros mobile fora de escopo; estado de expansão das seções não persistido em `localStorage` (NFR per spec)
|
||||
**Scale/Scope**: 3 schemas Pydantic editados, 2 rotas Flask editadas, 1 componente React reformulado (~600 linhas → ~800 linhas), 2 arquivos de tipos TypeScript editados
|
||||
|
||||
---
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| **I. Design-First** | ✅ PASS | Campo de busca, badge "Popular" e botão "Ver mais" usam exclusivamente tokens do `DESIGN.md`: `textTertiary`, `textSecondary`, `borderSubtle`, `borderStandard`, `surface`, `brand`; animação `duration-200 ease-out` do CSS grid trick existente é reutilizada |
|
||||
| **II. Separation of Concerns** | ✅ PASS | Backend calcula e expõe `property_count` em JSON; toda lógica de busca, ordenação, truncamento e expansão de seções ocorre no cliente; Flask não renderiza HTML |
|
||||
| **III. Spec-Driven** | ✅ PASS | `spec.md` aprovado com user stories P1/P2 e acceptance scenarios; este plano é derivado do spec |
|
||||
| **IV. Data Integrity** | ✅ PASS | `property_count` é campo somente-leitura calculado por COUNT (não alterável via API); Pydantic v2 declara `property_count: int = 0` com default; NFR-005: COUNT filtra `is_active == True` |
|
||||
| **V. Security** | ✅ PASS | NFR-005: contagem exclui imóveis inativos; nenhum dado privado exposto; campo é adicional somente-leitura nos endpoints públicos já existentes |
|
||||
| **VI. Simplicity First** | ✅ PASS | Debounce manual via `useEffect`/`setTimeout` (sem lodash); normalização nativa `String.normalize('NFD')`; sem nova biblioteca; estado de seções em `Record<string, boolean>` simples; COUNT via subquery SQLAlchemy sem hybrid property ou stored procedure |
|
||||
|
||||
**Veredicto**: Sem violações. Pode prosseguir para Phase 0.
|
||||
|
||||
**Re-check pós-design (Phase 1)**: ✅ Confirmado — nenhuma abstração prematura introduzida; `property_count` via subquery é o mecanismo mais simples que atende NFR-005 sem migration.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/024-filtro-busca-avancada/
|
||||
├── plan.md ← Este arquivo
|
||||
├── research.md ← Phase 0 output
|
||||
├── data-model.md ← Phase 1 output
|
||||
├── quickstart.md ← Phase 1 output
|
||||
├── contracts/
|
||||
│ └── api-catalog-enhancements.md ← Phase 1 output
|
||||
└── tasks.md ← Phase 2 output (/speckit.tasks — NÃO gerado aqui)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
└── app/
|
||||
├── schemas/
|
||||
│ └── catalog.py ← EDITADO — property_count: int = 0 em PropertyTypeOut, CityOut, NeighborhoodOut
|
||||
└── routes/
|
||||
├── catalog.py ← EDITADO — subquery COUNT em list_property_types()
|
||||
└── locations.py ← EDITADO — subquery COUNT em list_cities() e list_neighborhoods()
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── catalog.ts ← EDITADO — property_count?: number em PropertyType, City, Neighborhood
|
||||
└── components/
|
||||
└── FilterSidebar.tsx ← EDITADO (principal — ~200 linhas adicionadas)
|
||||
```
|
||||
|
||||
**Structure Decision**: Projeto web full-stack (Option 2). Sem novos arquivos — apenas edições cirúrgicas em arquivos existentes. Toda a lógica nova de sidebar fica contida em `FilterSidebar.tsx` (sub-componentes locais); nenhum hook ou serviço separado é criado porque a lógica não é compartilhada com outros componentes (YAGNI).
|
||||
|
||||
---
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*Nenhuma violação de Constitution detectada. Seção não aplicável.*
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
### Fluxo de dados: backend → frontend
|
||||
|
||||
```
|
||||
PostgreSQL
|
||||
└─ properties (is_active=TRUE) ──COUNT──┐
|
||||
↓
|
||||
Flask routes subquery via SQLAlchemy func.count + outerjoin
|
||||
├─ GET /api/v1/cities → CityOut[] (+ property_count)
|
||||
├─ GET /api/v1/neighborhoods → NeighborhoodOut[] (+ property_count)
|
||||
└─ GET /api/v1/property-types → PropertyTypeOut[] (subtypes + property_count)
|
||||
↓
|
||||
catalog.ts getCities(), getNeighborhoods(), getPropertyTypes()
|
||||
↓
|
||||
FilterSidebar.tsx props cities[], neighborhoods[], propertyTypes[]
|
||||
├─ ordena por property_count DESC (localmente)
|
||||
├─ mostra top-5, botão "Ver mais (N)" se > 5
|
||||
├─ badge "Popular" nos 3 primeiros (index < 3)
|
||||
└─ item selecionado sempre visível (promoted ao topo se oculto)
|
||||
```
|
||||
|
||||
### Fluxo de dados: busca cross-categoria
|
||||
|
||||
```
|
||||
[usuário digita no campo "Buscar filtro…"]
|
||||
↓
|
||||
filterSearch (state) ── debounce 200ms ──→ searchQuery (state)
|
||||
↓
|
||||
searchQuery !== '' ?
|
||||
├─ YES → computeSuggestions(searchQuery, propertyTypes, cities, neighborhoods, amenities)
|
||||
│ normaliza (NFD, lowercase, sem acento)
|
||||
│ → FilterSuggestion[] agrupados por category
|
||||
│ → <SuggestionList> inline sob o campo
|
||||
└─ NO → renderização normal das seções accordion
|
||||
↓
|
||||
[clique em sugestão]
|
||||
├─ set({ [filterKey]: value, page: 1 }) (aplica filtro)
|
||||
├─ expandSection(sectionKey) (abre seção relevante)
|
||||
└─ setFilterSearch('') (limpa campo)
|
||||
```
|
||||
|
||||
### Fluxo de dados: estado de expansão das seções
|
||||
|
||||
```
|
||||
URL params (filters.city_id, filters.subtype_id, …) ─→ initOpenSections(filters)
|
||||
↓
|
||||
openSections: Record<SectionKey, boolean>
|
||||
{
|
||||
imobiliaria: false,
|
||||
localizacao: filters.city_id != null || filters.neighborhood_id != null,
|
||||
tipo: filters.subtype_id != null,
|
||||
preco: true, ← sempre aberta por padrão
|
||||
quartos: filters.bedrooms_min != null || …,
|
||||
area: filters.area_min != null || filters.area_max != null,
|
||||
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
|
||||
}
|
||||
↓
|
||||
Section recebe `open={openSections[key]}` + `onToggle={() => toggleSection(key)}`
|
||||
(Section passa para controlled mode, mantendo uncontrolled como fallback)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components Affected
|
||||
|
||||
### Backend
|
||||
|
||||
| Arquivo | Tipo | Mudança |
|
||||
|---------|------|---------|
|
||||
| `backend/app/schemas/catalog.py` | EDIT | Adicionar `property_count: int = 0` em `PropertyTypeOut`, `CityOut`, `NeighborhoodOut` |
|
||||
| `backend/app/routes/catalog.py` | EDIT | `list_property_types()`: calcular `property_count` por subtype via subquery COUNT; injetar no dict antes de serializar |
|
||||
| `backend/app/routes/locations.py` | EDIT | `list_cities()`: query com `outerjoin(Property)` + `func.count` + `group_by`; `list_neighborhoods()`: idem |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Arquivo | Tipo | Mudança |
|
||||
|---------|------|---------|
|
||||
| `frontend/src/types/catalog.ts` | EDIT | Adicionar `property_count?: number` em `PropertyType`, `City`, `Neighborhood` |
|
||||
| `frontend/src/components/FilterSidebar.tsx` | EDIT | Reformulação principal — ver detalhes abaixo |
|
||||
|
||||
### FilterSidebar.tsx — mudanças internas
|
||||
|
||||
| Sub-componente / Lógica | Status | Descrição |
|
||||
|-------------------------|--------|-----------|
|
||||
| `Section` | EDIT | Suporte a `open?: boolean` + `onToggle?: () => void` (controlled mode); mantém `useState(defaultOpen)` como fallback quando `open` não é passado |
|
||||
| `SidebarSearchInput` | NEW (local) | `<input>` com ícone de lupa, placeholder "Buscar filtro…", desabilitado quando `catalogLoading` |
|
||||
| `SuggestionList` | NEW (local) | Lista inline de `FilterSuggestion[]` agrupados, com navegação por teclado (↑↓ Enter Escape) |
|
||||
| `PopularBadge` | NEW (local) | `<span>Popular</span>` com tokens `brand/20` bg e `brand` text, `text-[10px]` |
|
||||
| `TruncatedFilterList` | NEW (local) | Wrapper que exibe top-5 + botão "Ver mais (N)" / "Ver menos"; garante visibilidade de item selecionado |
|
||||
| `computeSuggestions()` | NEW (função local) | Normaliza query, filtra todos os itens de catálogo, retorna `FilterSuggestion[]` |
|
||||
| `initOpenSections()` | NEW (função local) | Deriva `Record<SectionKey, boolean>` a partir de `PropertyFilters` — seção Preço sempre `true` |
|
||||
| `openSections` state | NEW | `useState<Record<SectionKey, boolean>>` inicializado via `initOpenSections(filters)` |
|
||||
| `filterSearch` / `searchQuery` state | NEW | `useState<string>` para input + estado debounced |
|
||||
| Seção "Imobiliária" | EDIT | `defaultOpen={false}` → controlled via `openSections` |
|
||||
| Seção "Localização" | EDIT | `defaultOpen` condicional → controlled via `openSections` |
|
||||
| Seção "Tipo de imóvel" | EDIT | idem + `TruncatedFilterList` nos subtypes de cada categoria |
|
||||
| Seção "Preço" | EDIT | `defaultOpen={true}` já existe → agora controlled via `openSections.preco = true` |
|
||||
| Seção "Quartos e vagas" | EDIT | controlled via `openSections` |
|
||||
| Seção "Área" | EDIT | controlled via `openSections` |
|
||||
| Seção "Comodidades" | EDIT | controlled via `openSections`; `TruncatedFilterList` por grupo |
|
||||
|
||||
---
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### TD-001: `property_count` via subquery dinâmica (sem migration, sem hybrid property)
|
||||
|
||||
**Decisão**: Calcular `property_count` nos route handlers via `db.session.query(City, func.count(Property.id)).outerjoin(…).group_by(City.id)`. Adicionar `property_count: int = 0` aos schemas Pydantic com default `0`.
|
||||
|
||||
**Rationale**: Evita migration desnecessária (Constitution IV). `property_count` é dado de leitura; persistir seria denormalização sem benefício real dado o volume (< 5 k imóveis). Subquery em tabelas pequenas é negligenciável em performance.
|
||||
|
||||
**Alternativas descartadas**:
|
||||
- SQLAlchemy `column_property` com correlated subquery: mais limpo no ORM mas adiciona complexidade ao modelo sem ganho real (usado uma vez).
|
||||
- Coluna persistida com trigger: over-engineering (Constitution VI); requer migration + lógica de atualização.
|
||||
|
||||
**Impacto na serialização**: Os routes handlers passam a construir dicts manualmente para City/Neighborhood. Para PropertyType (hierárquico), o `property_count` é injetado nos subtypes após serialização com `model_dump() | {'property_count': count_map.get(sub.id, 0)}`.
|
||||
|
||||
### TD-002: Section em controlled mode com fallback uncontrolled
|
||||
|
||||
**Decisão**: Estender `Section` para aceitar props opcionais `open?: boolean` e `onToggle?: () => void`. Quando `open` é definido, o componente é controlled; caso contrário mantém `useState(defaultOpen)` atual.
|
||||
|
||||
**Rationale**: Backward-compatible — nenhum caller externo é quebrado. O `FilterSidebar` passa a controlar o estado de todas as seções via `openSections`. A busca cross-categoria pode então expandir a seção relevante via `expandSection(key)` sem lógica especial.
|
||||
|
||||
**Impacto**: Apenas `Section` dentro de `FilterSidebar.tsx` é afetado; `Section` não é exportado.
|
||||
|
||||
### TD-003: Debounce manual (sem lodash/use-debounce)
|
||||
|
||||
**Decisão**: Implementar debounce 200 ms via `useEffect(() => { const t = setTimeout(..., 200); return () => clearTimeout(t); }, [filterSearch])`.
|
||||
|
||||
**Rationale**: NFR-001 exige processamento local. Adicionar `lodash` ou `use-debounce` só para isso viola Constitution VI (YAGNI). O padrão `useEffect` + `setTimeout` é idiomático em React e sem dependência extra.
|
||||
|
||||
### TD-004: Normalização de texto sem biblioteca
|
||||
|
||||
**Decisão**: `text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()`.
|
||||
|
||||
**Rationale**: Cobre 100% dos casos do spec (acentos, cedilha — FR-005) sem adicionar dependência. Suportado em todos os browsers modernos e Node 18+.
|
||||
|
||||
### TD-005: Badge "Popular" — top-3 por seção após ordenação
|
||||
|
||||
**Decisão**: Após ordenar `items` por `property_count DESC` no frontend, aplicar badge nos itens com `index < 3` (os 3 primeiros). A lógica reside em `TruncatedFilterList`.
|
||||
|
||||
**Rationale**: Spec FR-014. Simples e correto. O backend já envia ordenado; o frontend apenas exibe badge nos primeiros três.
|
||||
|
||||
### TD-006: Item selecionado sempre visível no truncamento
|
||||
|
||||
**Decisão**: Antes de exibir top-5, verificar se o item ativo (ex.: `filters.subtype_id`) está entre os 5 primeiros. Se não estiver, promovê-lo para o início da lista (sem alterar a ordem geral) garantindo que apareça sem "Ver mais". Spec FR-015.
|
||||
|
||||
**Rationale**: Evita confusão do usuário que selecionou um filtro mas não o vê no sidebar. Promoção temporária é local e não altera dados.
|
||||
|
||||
---
|
||||
|
||||
## Diagram of Changes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND │
|
||||
│ │
|
||||
│ schemas/catalog.py │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ PropertyTypeOut + property_count: int = 0 │ │
|
||||
│ │ CityOut + property_count: int = 0 │ │
|
||||
│ │ NeighborhoodOut + property_count: int = 0 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ routes/locations.py │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ list_cities() │ │
|
||||
│ │ query(City, func.count(Property.id)) │ │
|
||||
│ │ .outerjoin(Property.city_id == City.id │ │
|
||||
│ │ & Property.is_active == True) │ │
|
||||
│ │ .group_by(City.id) │ │
|
||||
│ │ │ │
|
||||
│ │ list_neighborhoods() [mesma lógica] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ routes/catalog.py │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ list_property_types() │ │
|
||||
│ │ count_map: dict[int, int] ← subquery por subtype_id │ │
|
||||
│ │ injeta property_count em cada subtype dict │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ JSON (enriquecido)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND │
|
||||
│ │
|
||||
│ types/catalog.ts │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ PropertyType + property_count?: number │ │
|
||||
│ │ City + property_count?: number │ │
|
||||
│ │ Neighborhood + property_count?: number │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FilterSidebar.tsx │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ NOVOS estados: │ │
|
||||
│ │ filterSearch: string │ │
|
||||
│ │ searchQuery: string (debounced) │ │
|
||||
│ │ openSections: Record<SectionKey, boolean> │ │
|
||||
│ │ │ │
|
||||
│ │ NOVOS componentes locais: │ │
|
||||
│ │ SidebarSearchInput ← campo "Buscar filtro…" │ │
|
||||
│ │ SuggestionList ← sugestões agrupadas inline │ │
|
||||
│ │ TruncatedFilterList ← top-5 + "Ver mais" + badge │ │
|
||||
│ │ PopularBadge ← badge "Popular" (brand token) │ │
|
||||
│ │ │ │
|
||||
│ │ NOVAS funções locais: │ │
|
||||
│ │ computeSuggestions() ← normaliza + filtra catálogo │ │
|
||||
│ │ initOpenSections() ← deriva estado de URL filters │ │
|
||||
│ │ │ │
|
||||
│ │ EDITADOS: │ │
|
||||
│ │ Section ← controlled mode opcional │ │
|
||||
│ │ Todas as seções ← usam openSections │ │
|
||||
│ │ Seções com listas ← usam TruncatedFilterList │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Mitigations
|
||||
|
||||
| Edge case (spec) | Mitigação implementada |
|
||||
|------------------|----------------------|
|
||||
| Mesmo nome em cidade e bairro (ex.: "Santos") | `FilterSuggestion.filterKey` distingue `city_id` vs `neighborhood_id`; ambos aparecem em grupos diferentes na `SuggestionList` |
|
||||
| Busca com acentos/cedilha/hífen | `normalize('NFD') + replace(/\p{Mn}/gu, '')` no query e nos labels antes da comparação |
|
||||
| Filtro selecionado removido do catálogo | O item ativo não aparece na lista mas o badge de contagem no `Section` continua mostrando `1`; o filtro permanece aplicado até o usuário limpar — comportamento existente, não alterado |
|
||||
| Item selecionado entre os ocultos no "Ver mais" | `TruncatedFilterList` promove item ativo ao topo quando `showAll = false`; sempre visível sem precisar expandir |
|
||||
| Dados de `property_count` ainda carregando | `catalogLoading = true` → `SidebarSearchInput` desabilitado (FR-007); listas sem badge/ordenação especial até dados chegarem |
|
||||
| Seção de preço com `listing_type` mudando | `openSections` é inicializado uma vez (no mount); mudança de `listing_type` não recolapsa seção Preço — comportamento correto per spec |
|
||||
| Campo de busca quando `catalogLoading` | `disabled` + `opacity-50 cursor-not-allowed` via Tailwind; nenhuma sugestão computada |
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (confirmed from spec)
|
||||
|
||||
- Filtros mobile (sheet/modal) — feature separada
|
||||
- Histórico ou salvamento de filtros
|
||||
- Busca por imóveis individuais (código, endereço) — coberto pela feature 023
|
||||
- Badge "Popular" em comodidades (amenities não têm `property_count` — a contagem seria N:N e menos relevante; a spec cobre tipos, cidades e bairros)
|
||||
- Paginação de sugestões
|
||||
- Internacionalização de labels
|
||||
181
specs/024-filtro-busca-avancada/quickstart.md
Normal file
181
specs/024-filtro-busca-avancada/quickstart.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# Quickstart: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Feature**: `024-filtro-busca-avancada` | **Date**: 2026-04-20
|
||||
|
||||
Guia de verificação rápida após implementação. Execute em ordem.
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
```powershell
|
||||
# Containers rodando
|
||||
docker compose up -d
|
||||
|
||||
# Backend saudável
|
||||
curl http://localhost:5000/api/v1/cities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Verificar `property_count` no backend
|
||||
|
||||
### Cidades
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5000/api/v1/cities | python -m json.tool | grep property_count
|
||||
```
|
||||
|
||||
**Esperado**: cada objeto de cidade tem `"property_count": <número inteiro ≥ 0>`.
|
||||
|
||||
### Bairros
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:5000/api/v1/neighborhoods?city_id=1" | python -m json.tool | grep property_count
|
||||
```
|
||||
|
||||
**Esperado**: campo presente, valores numéricos, bairros sem imóvel retornam `0`.
|
||||
|
||||
### Tipos de imóvel
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5000/api/v1/property-types | python -m json.tool
|
||||
```
|
||||
|
||||
**Esperado**:
|
||||
- `subtypes[*].property_count` presente e ≥ 0
|
||||
- Tipos pai (`parent_id: null`) têm `property_count: 0`
|
||||
|
||||
### NFR-005 — apenas imóveis ativos
|
||||
|
||||
```bash
|
||||
# Desativar um imóvel e verificar que property_count decresce
|
||||
docker exec saas_imobiliaria-backend-1 uv run python -c "
|
||||
from app import create_app; from app.extensions import db; from app.models.property import Property
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
p = Property.query.filter_by(is_active=True).first()
|
||||
if p:
|
||||
print('Imóvel:', p.city_id, p.neighborhood_id)
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Verificar estado inicial das seções (User Story 2)
|
||||
|
||||
1. Acessar `http://localhost:5173/imoveis` **sem parâmetros de URL**
|
||||
2. Verificar que **apenas** a seção "Preço" está expandida ao carregar
|
||||
3. Verificar que "Localização", "Tipo de imóvel", "Quartos e vagas", "Área", "Comodidades" estão **colapsadas**
|
||||
|
||||
```
|
||||
✅ Apenas "Preço" expandida → FR-009 PASS
|
||||
```
|
||||
|
||||
4. Acessar `http://localhost:5173/imoveis?city_id=1&bedrooms_min=2`
|
||||
5. Verificar que as seções "Localização" e "Quartos e vagas" estão **expandidas** além de "Preço"
|
||||
|
||||
```
|
||||
✅ Seções com filtros ativos expandidas → FR-010 PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Verificar truncamento e popularidade (User Story 3)
|
||||
|
||||
1. Abrir a seção "Localização" → subseção Bairros (com cidade selecionada que tenha > 5 bairros)
|
||||
2. Verificar que **apenas 5 bairros** são exibidos inicialmente
|
||||
3. Verificar que os **3 primeiros** têm badge "Popular" visível
|
||||
4. Verificar que os 5 exibidos são os **mais populares** (maiores `property_count`)
|
||||
5. Clicar em "Ver mais (N)" → todos os bairros são exibidos
|
||||
6. Clicar em "Ver menos" → volta aos 5 primeiros
|
||||
|
||||
```
|
||||
✅ Truncamento top-5 → FR-012 PASS
|
||||
✅ Ordenação por popularidade → FR-013 PASS
|
||||
✅ Badge "Popular" → FR-014 PASS
|
||||
✅ "Ver mais" toggle → FR-017 PASS
|
||||
```
|
||||
|
||||
7. Selecionar um bairro que **não está entre os 5 primeiros**
|
||||
8. Colapsar e reabrir a seção
|
||||
9. Verificar que o bairro selecionado **aparece visível** mesmo sem clicar em "Ver mais"
|
||||
|
||||
```
|
||||
✅ Item selecionado sempre visível → FR-015 PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verificar busca cross-categoria (User Story 1)
|
||||
|
||||
1. Verificar que o campo "Buscar filtro…" aparece **acima** de todas as seções accordion
|
||||
2. Digitar `"apar"` e aguardar 200 ms
|
||||
3. Verificar sugestão inline com grupo "Tipo de imóvel" → "Apartamento"
|
||||
4. Clicar em "Apartamento" → filtro `subtype_id` aplicado, campo limpo, seção "Tipo de imóvel" expandida
|
||||
|
||||
```
|
||||
✅ FR-001, FR-002, FR-003, FR-004, FR-006 PASS
|
||||
```
|
||||
|
||||
5. Digitar `"copa"` → verificar "Copacabana" sob grupo "Bairro"
|
||||
6. Digitar `"apto xyz 999"` → verificar mensagem "Nenhum filtro encontrado para "apto xyz 999""
|
||||
|
||||
```
|
||||
✅ FR-005 (normalização), FR-005 (case-insensitive) PASS
|
||||
```
|
||||
|
||||
7. Com sugestões visíveis:
|
||||
- Pressionar `↓` → primeiro item highlighted
|
||||
- Pressionar `↓` novamente → segundo item
|
||||
- Pressionar `Enter` → filtro aplicado
|
||||
- Abrir novamente, pressionar `Escape` → sugestões fechadas
|
||||
|
||||
```
|
||||
✅ FR-008 (navegação por teclado) PASS
|
||||
```
|
||||
|
||||
8. Testar com `catalogLoading = true` (simular estado de carregamento):
|
||||
- Campo deve aparecer desabilitado (opacidade reduzida, cursor not-allowed)
|
||||
|
||||
```
|
||||
✅ FR-007 PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Verificar design tokens (NFR-003)
|
||||
|
||||
Inspecionar no DevTools:
|
||||
|
||||
| Elemento | Token esperado | Classe Tailwind |
|
||||
|----------|---------------|-----------------|
|
||||
| Campo de busca (borda) | `borderSubtle` | `border-borderSubtle` |
|
||||
| Campo de busca (texto placeholder) | `textTertiary` | `placeholder-textTertiary` |
|
||||
| Badge "Popular" (fundo) | `brand/20` | `bg-brand/20` |
|
||||
| Badge "Popular" (texto) | `brand` | `text-brand` |
|
||||
| Botão "Ver mais" | `textTertiary` | `text-textTertiary` |
|
||||
| Cabeçalhos de grupos de sugestão | `textTertiary` uppercase | `text-textTertiary uppercase` |
|
||||
| Animação sugestões | `duration-200 ease-out` | CSS grid trick |
|
||||
|
||||
---
|
||||
|
||||
## 6. Verificar que filtros existentes não quebraram (NFR-004 / SC-005)
|
||||
|
||||
```bash
|
||||
cd backend && uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
**Esperado**: todos os testes passando.
|
||||
|
||||
Testar manualmente na UI:
|
||||
- Filtro por preço (min/max + presets)
|
||||
- Filtro por quartos/banheiros/vagas (chips)
|
||||
- Filtro por área
|
||||
- Filtro por comodidades (checkboxes)
|
||||
- Botão "Limpar (N)"
|
||||
- Toggle Venda / Aluguel
|
||||
|
||||
```
|
||||
✅ Nenhum filtro existente quebrado → NFR-004, SC-005 PASS
|
||||
```
|
||||
177
specs/024-filtro-busca-avancada/research.md
Normal file
177
specs/024-filtro-busca-avancada/research.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Research: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Feature**: `024-filtro-busca-avancada` | **Phase**: 0 | **Date**: 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## Unknowns Resolved
|
||||
|
||||
### R-001: Como calcular `property_count` sem migration?
|
||||
|
||||
**Decision**: Subquery dinâmica no route handler via `func.count(Property.id)` + `outerjoin` + `group_by`.
|
||||
|
||||
**Rationale**: Os endpoints de catálogo já existem e têm baixo volume de dados (< 50 cidades, < 200 bairros, < 30 tipos). Um COUNT em `outerjoin` com filtro `is_active=True` é instantâneo. Não há necessidade de persistir o valor: mudaria a cada imóvel inserido/removido, exigindo triggers ou re-sincronização.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `column_property` SQLAlchemy com correlated subquery: mais elegante no modelo, mas acopla lógica de negócio ao ORM model; requer importação de `Property` em `catalog.py` (circular import risk). Descartado.
|
||||
- Coluna `property_count INTEGER` persistida: requer migration Alembic + lógica de atualização (trigger ou chamada explícita). Over-engineering para < 5 k imóveis. Descartado.
|
||||
- Endpoint separado `GET /api/v1/catalog-stats`: cria endpoint extra sem necessidade; o cliente faria duas chamadas para montar o sidebar. Descartado (NFR-001 prefere menos chamadas).
|
||||
|
||||
**Implementation pattern**:
|
||||
```python
|
||||
# locations.py — list_cities()
|
||||
from sqlalchemy import func
|
||||
from app.models.property import Property
|
||||
|
||||
rows = (
|
||||
db.session.query(City, func.count(Property.id).label("cnt"))
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.city_id == City.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(City.id)
|
||||
.order_by(City.state, City.name)
|
||||
.all()
|
||||
)
|
||||
return jsonify([
|
||||
{**CityOut.model_validate(city).model_dump(), "property_count": cnt}
|
||||
for city, cnt in rows
|
||||
])
|
||||
```
|
||||
|
||||
**Circular import**: `Property` é importado nos routes, não no model `catalog.py`/`location.py` — sem risco.
|
||||
|
||||
---
|
||||
|
||||
### R-002: Como tornar o componente `Section` controlável externamente?
|
||||
|
||||
**Decision**: Adicionar props opcionais `open?: boolean` e `onToggle?: () => void`. Quando `open` é `undefined`, manter comportamento atual com `useState(defaultOpen)` (uncontrolled). Quando `open` é passado, ignorar o state interno e usar o prop.
|
||||
|
||||
**Rationale**: Backward-compatible — nenhum chamador atual do `Section` (dentro de `FilterSidebar.tsx`) precisa ser alterado para continuar funcionando; só as seções que precisam de controle externo recebem `open` + `onToggle`. Solução mais simples que refatorar para controlled-only.
|
||||
|
||||
**Pattern**:
|
||||
```tsx
|
||||
function Section({
|
||||
title, badge, children, defaultOpen = true,
|
||||
open: controlledOpen, onToggle,
|
||||
}: {
|
||||
title: string; badge?: number; children: React.ReactNode
|
||||
defaultOpen?: boolean; open?: boolean; onToggle?: () => void
|
||||
}) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isControlled) onToggle?.()
|
||||
else setUncontrolledOpen(v => !v)
|
||||
}
|
||||
// ... resto igual
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### R-003: Estratégia de debounce sem biblioteca?
|
||||
|
||||
**Decision**: `useEffect` com `setTimeout`/`clearTimeout` — padrão idiomático em React.
|
||||
|
||||
**Rationale**: Adicionar `lodash` ou `use-debounce` apenas para um `setTimeout` de 200 ms viola Constitution VI (YAGNI). O padrão abaixo é well-known, testável e zero-dependency:
|
||||
|
||||
```tsx
|
||||
const [filterSearch, setFilterSearch] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearchQuery(filterSearch), 200)
|
||||
return () => clearTimeout(t)
|
||||
}, [filterSearch])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### R-004: Normalização de texto para busca cross-categoria?
|
||||
|
||||
**Decision**: `String.prototype.normalize('NFD')` + regex Unicode property escape para remover diacríticos.
|
||||
|
||||
**Rationale**: Cobre todos os casos do FR-005 (acentos, cedilha). Suportado em todos os browsers modernos (Chrome 64+, Firefox 78+, Safari 12+) e Node 18+. Zero dependência.
|
||||
|
||||
```ts
|
||||
function normalizeText(s: string): string {
|
||||
return s.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()
|
||||
}
|
||||
```
|
||||
|
||||
Para hífens/espaços: a comparação `includes()` cobre casos como "São Paulo" → `"sao paulo".includes("sao paulo")`.
|
||||
|
||||
---
|
||||
|
||||
### R-005: Como estruturar `FilterSuggestion` para cobrir todos os tipos de filtro?
|
||||
|
||||
**Decision**:
|
||||
```ts
|
||||
type SectionKey = 'imobiliaria' | 'localizacao' | 'tipo' | 'preco' | 'quartos' | 'area' | 'comodidades'
|
||||
|
||||
interface FilterSuggestion {
|
||||
category: string // label do grupo (ex.: "Tipo de imóvel")
|
||||
sectionKey: SectionKey // para expandir a seção correta
|
||||
label: string // texto exibido na sugestão
|
||||
filterKey: keyof PropertyFilters
|
||||
value: number | string | undefined
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale**: `sectionKey` e `filterKey` são separados porque o mesmo `filterKey` (`city_id`) pertence à seção `localizacao`. Ter ambos permite `expandSection(sectionKey)` e `set({ [filterKey]: value })` independentemente.
|
||||
|
||||
**Cobertura por categoria**:
|
||||
| Categoria | `filterKey` | `sectionKey` |
|
||||
|-----------|-------------|--------------|
|
||||
| Tipo de imóvel | `subtype_id` | `tipo` |
|
||||
| Cidade | `city_id` | `localizacao` |
|
||||
| Bairro | `neighborhood_id` | `localizacao` |
|
||||
| Comodidade | n/a (toggle em `amenity_ids`) | `comodidades` |
|
||||
|
||||
---
|
||||
|
||||
### R-006: Como garantir que item selecionado seja visível no truncamento?
|
||||
|
||||
**Decision**: "Promoção ao topo" — antes de fatiar `items.slice(0, 5)`, verificar se o item ativo está entre os primeiros 5. Se não, movê-lo para a primeira posição na lista truncada (sem alterar `items` original).
|
||||
|
||||
```ts
|
||||
function getVisibleItems<T extends { id: number }>(
|
||||
items: T[],
|
||||
activeId: number | undefined,
|
||||
showAll: boolean,
|
||||
limit = 5,
|
||||
): T[] {
|
||||
if (showAll) return items
|
||||
const top = items.slice(0, limit)
|
||||
if (activeId == null || top.some(i => i.id === activeId)) return top
|
||||
const active = items.find(i => i.id === activeId)
|
||||
if (!active) return top
|
||||
return [active, ...top.slice(0, limit - 1)]
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale**: Simples, sem efeitos colaterais no estado global. O usuário sempre vê o item que selecionou, mesmo após reabrir a seção.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices Confirmed
|
||||
|
||||
### SQLAlchemy `func.count` com `outerjoin`
|
||||
|
||||
- `outerjoin` (LEFT OUTER JOIN) garante que entidades sem imóveis retornem `count = 0` em vez de serem omitidas.
|
||||
- `func.count(Property.id)` conta apenas linhas não-nulas (imóveis existentes), diferente de `func.count('*')`.
|
||||
- `& (Property.is_active.is_(True))` na condição do join (não no WHERE) garante que cidades sem imóveis ativos retornem `0`, em vez de serem filtradas.
|
||||
|
||||
### Pydantic v2 `model_dump()` + dict merge
|
||||
|
||||
- `CityOut.model_validate(city).model_dump() | {'property_count': cnt}` produz dict Python puro válido.
|
||||
- `jsonify()` do Flask aceita dicts Python diretamente.
|
||||
- O campo `property_count: int = 0` no schema garante que se o merge falhar silenciosamente, o valor default é `0` (não None).
|
||||
|
||||
### React controlled vs uncontrolled components
|
||||
|
||||
- O padrão de controlled mode com fallback uncontrolled é documentado na RFC do React e é considerado backward-compatible.
|
||||
- Usar `open !== undefined` como discriminador é mais robusto que verificar se `onToggle` está definido.
|
||||
165
specs/024-filtro-busca-avancada/spec.md
Normal file
165
specs/024-filtro-busca-avancada/spec.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Feature Specification: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Feature Branch**: `024-filtro-busca-avancada`
|
||||
**Created**: 2026-04-20
|
||||
**Status**: Draft
|
||||
**Fonte**: Solicitação de melhoria UX no sidebar de filtros da página `/imoveis`
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
O `FilterSidebar` da página `/imoveis` já existe e é funcional, mas apresenta três lacunas de usabilidade que impactam a descoberta de imóveis:
|
||||
|
||||
1. **Ausência de busca cross-categoria dentro do sidebar**: o usuário precisa abrir seção por seção para encontrar um tipo de imóvel ou bairro específico.
|
||||
2. **Todas as seções abertas por padrão**: ao carregar a página, o sidebar está completamente expandido, gerando sobrecarga visual e exigindo scroll antes mesmo de ver os resultados.
|
||||
3. **Listas longas sem hierarquia de popularidade**: bairros e tipos são listados em ordem arbitrária; itens raramente usados ocupam o mesmo espaço visual que os mais procurados, dificultando a seleção rápida.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 — Campo de Busca Cross-Categoria no Sidebar (Priority: P1)
|
||||
|
||||
Um visitante que sabe exatamente o que procura (ex.: "Copacabana", "Cobertura") digita o termo no campo de busca do sidebar e vê instantaneamente em qual categoria aquela opção se enquadra, podendo selecioná-la com um clique sem precisar abrir seções manualmente.
|
||||
|
||||
**Why this priority**: A busca cross-categoria resolve o maior obstáculo de navegação do sidebar: o usuário com intenção definida não sabe em qual seção procurar. É o ganho de UX mais alto com menor complexidade de implementação — nenhum dado novo de backend é necessário, pois os dados já chegam via `catalog.ts`.
|
||||
|
||||
**Independent Test**: Digitar "Copa" no campo de busca do sidebar e verificar que aparece uma lista de sugestões agrupadas mostrando "Copacabana" sob o grupo "Bairro"; clicar em "Copacabana" e confirmar que o filtro de bairro é aplicado.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a página `/imoveis` com o sidebar visível, **When** o usuário vê o topo do sidebar, **Then** um campo de busca com placeholder "Buscar filtro…" está disponível acima das seções accordion.
|
||||
2. **Given** o campo de busca do sidebar com o valor "apar", **When** o usuário para de digitar por 200ms, **Then** uma lista de sugestões aparece inline (não em dropdown popup) mostrando entradas agrupadas, por exemplo: grupo "Tipo de imóvel" → "Apartamento".
|
||||
3. **Given** sugestões de busca visíveis com múltiplas categorias correspondentes, **When** o usuário vê a lista, **Then** cada grupo tem um cabeçalho de categoria (ex.: "Tipo de imóvel", "Bairro", "Cidade") e os itens correspondentes abaixo.
|
||||
4. **Given** uma sugestão visível, **When** o usuário clica nela, **Then** o filtro correspondente é aplicado (equivalente a selecionar o item na seção accordion), o campo de busca é limpo e a seção relevante é expandida para mostrar o item selecionado.
|
||||
5. **Given** o campo de busca preenchido sem nenhuma correspondência, **When** a busca é executada, **Then** uma mensagem "Nenhum filtro encontrado para "[termo]"" é exibida no lugar das sugestões.
|
||||
6. **Given** o campo de busca preenchido, **When** o usuário pressiona Escape ou limpa o campo, **Then** as sugestões são ocultadas e o estado das seções retorna ao normal.
|
||||
7. **Given** a navegação por teclado com sugestões visíveis, **When** o usuário pressiona as teclas de seta (↓↑) e Enter, **Then** ele pode selecionar uma sugestão sem usar o mouse.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Seção de Preço Aberta por Padrão, Demais Colapsadas (Priority: P1)
|
||||
|
||||
Um visitante que acaba de chegar na página `/imoveis` encontra o sidebar com uma experiência limpa: apenas a seção de preço está expandida, tornando o filtro mais importante imediatamente visível, enquanto as demais seções ficam colapsadas e acessíveis sob demanda.
|
||||
|
||||
**Why this priority**: Juntamente com a busca cross-categoria, esta mudança tem impacto imediato na percepção de organização do sidebar sem exigir dados adicionais do backend. A seção de preço é o filtro de maior influência na decisão do usuário, justificando seu destaque inicial.
|
||||
|
||||
**Independent Test**: Carregar `/imoveis` pela primeira vez (ou sem parâmetros de URL) e verificar que somente a seção "Preço" está expandida; todas as demais seções (Tipo, Quartos, Bairros, etc.) estão colapsadas.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a página `/imoveis` carregada sem filtros ativos, **When** o sidebar renderiza, **Then** apenas a seção "Preço" está expandida (`defaultOpen = true`); todas as demais seções têm `defaultOpen = false`.
|
||||
2. **Given** filtros ativos presentes na URL (ex.: `?city=1&bedrooms=2`), **When** o sidebar renderiza, **Then** as seções que contêm filtros ativos ficam automaticamente expandidas, além da seção de Preço.
|
||||
3. **Given** o usuário colapsou manualmente a seção de Preço e navega para outra página e retorna, **When** o sidebar renderiza, **Then** o estado de colapso/expansão das seções volta ao padrão (Preço aberto, demais fechados), pois esse estado não é persistido.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Listas com "Ver mais" e Ordenação por Popularidade (Priority: P2)
|
||||
|
||||
Um visitante que navega pelos filtros de bairro ou tipo de imóvel vê imediatamente os N itens mais relevantes (com mais imóveis disponíveis), pode expandir para ver todos com "Ver mais", e identifica visualmente os itens mais populares por meio de badges ou destaque.
|
||||
|
||||
**Why this priority**: Listas longas sem hierarquia sobrecarregam a interface e enterram as opções mais úteis. Exibir os mais populares primeiro e truncar com "Ver mais" é o padrão de portais imobiliários líderes. Requer dados de contagem do backend, tornando-o de implementação mais complexa que as stories P1.
|
||||
|
||||
**Independent Test**: Abrir a seção "Bairros" no sidebar e verificar que apenas os 5 bairros com mais imóveis são exibidos inicialmente, com badge "Popular" no primeiro; clicar em "Ver mais" e confirmar que todos os bairros são exibidos.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** uma seção de filtro com mais de 5 itens (ex.: Bairros), **When** o accordion é expandido, **Then** apenas os 5 primeiros itens são exibidos, ordenados do mais popular (mais imóveis associados) para o menos popular.
|
||||
2. **Given** uma seção com mais de 5 itens exibindo os primeiros 5, **When** o usuário clica no botão "Ver mais (N)", **Then** todos os itens restantes são exibidos inline (sem modal ou navegação), e o botão muda para "Ver menos".
|
||||
3. **Given** uma seção expandida com todos os itens visíveis, **When** o usuário clica em "Ver menos", **Then** a lista retorna a exibir apenas os 5 primeiros e o scroll retorna ao início da seção.
|
||||
4. **Given** os itens de uma seção ordenados por popularidade, **When** o usuário vê a lista, **Then** os 3 itens com mais imóveis associados exibem um badge "Popular" ao lado do nome.
|
||||
5. **Given** um filtro já selecionado que não está entre os 5 primeiros da lista, **When** o usuário reabre a seção com o filtro ativo, **Then** o item selecionado é exibido mesmo que esteja além dos 5 iniciais (o truncamento não oculta itens selecionados).
|
||||
6. **Given** uma seção com 5 itens ou menos, **When** o accordion é expandido, **Then** o botão "Ver mais" não é exibido.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece quando a busca cross-categoria retorna o mesmo item em múltiplas categorias (ex.: cidade e bairro com o mesmo nome)?
|
||||
- Como o sistema lida com termos de busca contendo caracteres especiais (acentos, cedilha, hifens)?
|
||||
- O que acontece quando um filtro selecionado não aparece mais na lista porque o backend não o retornou (ex.: cidade removida do catálogo)?
|
||||
- Como o "Ver mais" se comporta quando um item selecionado está entre os ocultos — ele precisa estar visível mesmo sem clicar em "Ver mais"?
|
||||
- O que acontece se os dados de popularidade (contagem de imóveis por bairro/tipo) ainda estão carregando quando o accordion é expandido?
|
||||
- Como a seção de preço se comporta quando o parâmetro `listing_type` muda entre "venda" e "aluguel" (os presets já mudam — o estado de expansão da seção é preservado)?
|
||||
- Qual é o comportamento do campo de busca do sidebar quando o catálogo ainda está carregando (`catalogLoading = true`)?
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Busca Cross-Categoria no Sidebar
|
||||
|
||||
- **FR-001**: O `FilterSidebar` DEVE exibir um campo de busca textual no topo, acima de todas as seções accordion, com placeholder "Buscar filtro…".
|
||||
- **FR-002**: O campo de busca DEVE pesquisar simultaneamente em todas as categorias de filtro disponíveis: tipos de imóvel, cidades, bairros e comodidades.
|
||||
- **FR-003**: A busca DEVE aplicar debounce de 200ms antes de exibir resultados, para não travar a interface durante digitação rápida.
|
||||
- **FR-004**: Os resultados DEVEM ser apresentados agrupados por categoria, com o cabeçalho de cada grupo claramente identificado (ex.: "Tipo de imóvel", "Bairro", "Cidade", "Comodidade").
|
||||
- **FR-005**: A busca DEVE ser case-insensitive e ignorar acentuação para maximizar correspondências (ex.: "copacabana" deve encontrar "Copacabana", "apto" deve encontrar "Apartamento").
|
||||
- **FR-006**: Ao clicar em uma sugestão, o filtro correspondente DEVE ser aplicado imediatamente, o campo de busca DEVE ser limpo e a seção do accordion correspondente DEVE ser expandida.
|
||||
- **FR-007**: Quando `catalogLoading = true`, o campo de busca DEVE estar desabilitado com estado visual de loading (cursor não permitido, opacidade reduzida).
|
||||
- **FR-008**: A navegação por teclado (↑↓ para mover entre sugestões, Enter para selecionar, Escape para fechar) DEVE ser suportada para acessibilidade.
|
||||
|
||||
#### Estado Inicial das Seções
|
||||
|
||||
- **FR-009**: Ao carregar o `FilterSidebar` sem filtros pré-ativos na URL, apenas a seção "Preço" DEVE ter `defaultOpen = true`; todas as demais seções DEVEM ter `defaultOpen = false`.
|
||||
- **FR-010**: Quando filtros pré-ativos existem na URL, as seções que contêm esses filtros ativos DEVEM ser inicialmente expandidas além da seção de Preço.
|
||||
- **FR-011**: O estado de expansão das seções NÃO DEVE ser persistido entre sessões — cada carregamento de página retorna ao estado padrão.
|
||||
|
||||
#### Listas com Truncamento e Popularidade
|
||||
|
||||
- **FR-012**: Seções com mais de 5 itens DEVEM exibir apenas os 5 primeiros inicialmente, com um botão "Ver mais (N)" indicando quantos itens adicionais existem.
|
||||
- **FR-013**: Os itens de cada seção DEVEM ser ordenados por popularidade, definida como a contagem de imóveis ativos associados àquele item (ex.: número de imóveis em cada bairro, número de imóveis de cada tipo).
|
||||
- **FR-014**: Os 3 itens mais populares de cada seção DEVEM exibir um badge "Popular" ao lado do label.
|
||||
- **FR-015**: Itens com filtro ativo (selecionados) DEVEM sempre ser visíveis, independentemente de estarem entre os 5 primeiros ou não.
|
||||
- **FR-016**: O backend DEVE fornecer dados de contagem de imóveis por categoria (tipo de imóvel, cidade, bairro) para permitir ordenação por popularidade na camada de catálogo.
|
||||
- **FR-017**: O botão "Ver mais" DEVE ter comportamento toggle: ao ser clicado novamente, exibe "Ver menos" e retorna a lista ao truncamento inicial.
|
||||
|
||||
### Requisitos Não-Funcionais
|
||||
|
||||
- **NFR-001**: A busca cross-categoria DEVE executar inteiramente no cliente (sem chamadas adicionais à API), utilizando os dados de catálogo já carregados.
|
||||
- **NFR-002**: A animação de expansão/colapso das sugestões de busca DEVE seguir a mesma curva de animação das seções accordion existentes (CSS grid trick, `duration-200 ease-out`).
|
||||
- **NFR-003**: O visual de todos os elementos novos (campo de busca, badges, botão "Ver mais") DEVE seguir os design tokens do projeto: `textTertiary`, `textSecondary`, `borderSubtle`, `borderStandard`, `surface`, `brand`.
|
||||
- **NFR-004**: Nenhuma alteração visual ou de comportamento DEVE impactar o funcionamento dos filtros existentes (os filtros aplicados via accordion continuam funcionando normalmente).
|
||||
- **NFR-005**: A contagem de imóveis por categoria retornada pelo backend DEVE ser calculada apenas sobre imóveis ativos e disponíveis, sem expor dados de imóveis inativos.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **CatalogItem com Contagem**: Extensão das entidades de catálogo existentes (tipo de imóvel, cidade, bairro) com o campo `property_count` indicando quantos imóveis ativos estão associados.
|
||||
- **SugestãoDeFiltro**: Resultado da busca cross-categoria, composto por `category` (grupo), `label` (texto exibido), `value` (identificador) e `filterKey` (chave do filtro a ser aplicado em `PropertyFilters`).
|
||||
- **EstadoDeExpansãoDaSeção**: Mapeamento interno de `sectionKey → boolean` controlando quais seções do accordion estão abertas; inicializado a partir dos filtros ativos da URL.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Usuários com intenção definida (sabem o bairro ou tipo que buscam) conseguem aplicar o filtro desejado em menos de 3 interações a partir do campo de busca do sidebar.
|
||||
- **SC-002**: A seção de preço é a primeira coisa interativa que o usuário vê ao abrir o sidebar em 100% dos carregamentos sem filtros pré-ativos.
|
||||
- **SC-003**: Em seções com mais de 5 itens, os 5 exibidos inicialmente cobrem pelo menos 70% do volume de imóveis disponíveis (validação pelo ordenamento por popularidade).
|
||||
- **SC-004**: O tempo de resposta do campo de busca cross-categoria (desde o fim do debounce até a exibição das sugestões) é imperceptível para o usuário (abaixo de 50ms, pois é processamento local).
|
||||
- **SC-005**: Nenhum filtro previamente funcional quebra após a implementação — todos os cenários de aceitação da spec `023-ux-melhorias-imoveis` continuam passando.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Os dados de catálogo (tipos, cidades, bairros, comodidades) já estão disponíveis via `catalog.ts` no momento em que o `FilterSidebar` renderiza; não há nova chamada de API necessária para a busca cross-categoria.
|
||||
- A ordenação por popularidade requer uma única adição ao endpoint de catálogo existente (`property_count` por item), não um endpoint separado.
|
||||
- O número de itens por seção raramente ultrapassa 20–30 em produção para este SaaS (portfólio de uma imobiliária), tornando o processamento de busca local viável sem paginação.
|
||||
- O estado de expansão das seções não será persistido em `localStorage` nesta versão; a persistência pode ser adicionada em iteração futura se houver demanda.
|
||||
- O badge "Popular" é puramente visual e não afeta a lógica de filtragem.
|
||||
- A busca cross-categoria opera sobre dados do catálogo (tipos, cidades, bairros, comodidades) e não sobre dados de imóveis individuais (endereços, códigos) — a busca por endereço/código já está coberta pelo campo de busca global da feature `023`.
|
||||
|
||||
---
|
||||
|
||||
## Fora do Escopo
|
||||
|
||||
- Salvar buscas favoritas ou histórico de filtros por usuário.
|
||||
- Busca cross-categoria que inclua imóveis individuais (por código, endereço ou título) — isso é responsabilidade do campo de busca global já especificado em `023-ux-melhorias-imoveis`.
|
||||
- Ordenação de resultados de imóveis por popularidade de bairro (apenas os filtros do sidebar são ordenados por popularidade, não os cards de imóveis).
|
||||
- Criação de um sistema completo de analytics de filtros — `property_count` é suficiente como proxy de popularidade nesta versão.
|
||||
- Filtros de busca cross-categoria em tela mobile (sheet/modal) — este spec cobre apenas o sidebar em desktop; a experiência mobile é tratada em feature separada.
|
||||
- Internacionalização dos labels de categoria ou dos badges.
|
||||
243
specs/024-filtro-busca-avancada/tasks.md
Normal file
243
specs/024-filtro-busca-avancada/tasks.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# Tasks: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Input**: Design documents from `/specs/024-filtro-busca-avancada/`
|
||||
**Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/api-catalog-enhancements.md ✅
|
||||
**Branch**: `024-filtro-busca-avancada`
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description`
|
||||
|
||||
- **[P]**: pode ser executada em paralelo (arquivos distintos, sem dependência de tarefa incompleta)
|
||||
- **[Story]**: a qual user story pertence (`US1`, `US2`, `US3`)
|
||||
- Caminhos exatos incluídos em cada tarefa
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational — Backend `property_count`
|
||||
|
||||
**Purpose**: Enriquecer os três endpoints de catálogo com `property_count` calculado via COUNT dinâmico (SQLAlchemy subquery). Sem migration — campo somente-leitura calculado em tempo de execução. Este é o pré-requisito de US3; US1 e US2 podem prosseguir em paralelo independentemente desta fase.
|
||||
|
||||
**⚠️ BLOQUEANTE para US3**: US3 não pode ser iniciada até T004 estar completo.
|
||||
|
||||
- [X] T001 Adicionar `property_count: int = 0` às classes `PropertyTypeOut`, `CityOut` e `NeighborhoodOut` em `backend/app/schemas/catalog.py`
|
||||
- **Done**: Os três schemas Pydantic possuem o campo `property_count: int = 0` como atributo opcional de saída; `PropertyTypeOut.model_rebuild()` continua presente após a mudança; testes de serialização passam com o campo default.
|
||||
|
||||
- [X] T002 Atualizar `list_property_types()` em `backend/app/routes/catalog.py` para calcular `property_count` por subtype via subquery SQLAlchemy (`func.count + outerjoin + group_by`) e injetar no dict serializado
|
||||
- **Detalhes**: importar `func` de `sqlalchemy` e `Property` de `app.models.property`; query plana de subtypes (`parent_id IS NOT NULL`) com `outerjoin(Property, (Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True)))` + `group_by(PropertyType.id)`; construir `count_map: dict[int, int]`; substituir o `jsonify` atual por `serialize_category()` que injeta `property_count` em cada subtype via `count_map.get(sub["id"], 0)`; tipos pai mantêm `property_count: 0`.
|
||||
- **Done**: `GET /api/v1/property-types` retorna cada subtype com `property_count >= 0`; tipos pai retornam `property_count: 0`; resposta é válida com ou sem imóveis ativos.
|
||||
|
||||
- [X] T003 [P] Atualizar `list_cities()` em `backend/app/routes/locations.py` para calcular `property_count` via `outerjoin(Property) + func.count + group_by`
|
||||
- **Detalhes**: importar `func` de `sqlalchemy` e `Property` de `app.models.property`; substituir `City.query.order_by(...)` por `db.session.query(City, func.count(Property.id).label("cnt")).outerjoin(Property, (Property.city_id == City.id) & (Property.is_active.is_(True))).group_by(City.id).order_by(City.state, City.name).all()`; retornar `{**CityOut.model_validate(city).model_dump(), "property_count": cnt}`.
|
||||
- **Done**: `GET /api/v1/cities` retorna cada cidade com `property_count >= 0`; cidades sem imóveis retornam `0`; ordenação `state ASC, name ASC` mantida.
|
||||
|
||||
- [X] T004 [P] Atualizar `list_neighborhoods()` em `backend/app/routes/locations.py` para calcular `property_count` via `outerjoin(Property) + func.count + group_by`
|
||||
- **Detalhes**: substituir `Neighborhood.query` por `db.session.query(Neighborhood, func.count(Property.id).label("cnt")).outerjoin(Property, (Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))).group_by(Neighborhood.id)`; preservar filtro `?city_id` existente aplicado antes de `.all()`; retornar `{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}`.
|
||||
- **Done**: `GET /api/v1/neighborhoods` e `GET /api/v1/neighborhoods?city_id=N` retornam bairros com `property_count >= 0`; filtro `city_id` ainda funciona.
|
||||
|
||||
**Checkpoint Phase 1**: Os três endpoints retornam `property_count` correto. Validar manualmente com `curl http://localhost:5000/api/v1/cities` e confirmar campo presente na resposta JSON.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational — Frontend Types
|
||||
|
||||
**Purpose**: Espelhar o campo `property_count` do backend nos tipos TypeScript. Backward-compatible (campo opcional). Pré-requisito de US3 no frontend.
|
||||
|
||||
- [X] T005 [P] Adicionar `property_count?: number` às interfaces `PropertyType`, `City` e `Neighborhood` em `frontend/src/types/catalog.ts`
|
||||
- **Detalhes**: campo opcional (`?`) para backward-compatibility — componentes que não usam o campo continuam compilando sem alteração.
|
||||
- **Done**: `frontend/src/types/catalog.ts` compila sem erros (`tsc --noEmit`); as três interfaces possuem `property_count?: number`; nenhum componente existente quebra.
|
||||
|
||||
**Checkpoint Phase 2**: `tsc --noEmit` passa. T005 pode ser executada em paralelo com a Phase 1 inteira.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: US2 — Estado Inicial Controlado das Seções (Priority: P1)
|
||||
|
||||
**Goal**: Ao carregar `/imoveis`, apenas a seção "Preço" está expandida. Seções com filtros ativos na URL são auto-expandidas. Estado não persistido entre sessões.
|
||||
|
||||
**Independent Test**: Carregar `/imoveis` sem parâmetros de URL e verificar que somente a seção "Preço" está expandida; todas as demais (Imobiliária, Localização, Tipo, Quartos, Área, Comodidades) estão colapsadas.
|
||||
|
||||
**Dependências**: nenhuma tarefa anterior é bloqueante para US2.
|
||||
|
||||
- [X] T006 [US2] Declarar o tipo `SectionKey` localmente em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: adicionar antes da definição de `Section`; valores: `'imobiliaria' | 'localizacao' | 'tipo' | 'preco' | 'quartos' | 'area' | 'comodidades'`; não exportar.
|
||||
- **Done**: tipo `SectionKey` declarado no arquivo; sem erro de compilação.
|
||||
|
||||
- [ ] T007 [US2] Implementar a função pura `initOpenSections(filters: PropertyFilters): Record<SectionKey, boolean>` localmente em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: `preco: true` sempre; `imobiliaria: filters.imobiliaria_id != null`; `localizacao: filters.city_id != null || filters.neighborhood_id != null`; `tipo: filters.subtype_id != null`; `quartos: filters.bedrooms_min != null || filters.bathrooms_min != null || filters.parking_min != null`; `area: filters.area_min != null || filters.area_max != null`; `comodidades: (filters.amenity_ids?.length ?? 0) > 0`.
|
||||
- **Done**: função declarada antes do componente; retorna objeto com todas as 7 chaves; `preco` sempre `true`.
|
||||
|
||||
- [X] T008 [US2] Converter o sub-componente `Section` para suportar modo controlled em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: adicionar props opcionais `open?: boolean` e `onToggle?: () => void` à interface do componente; quando `open !== undefined`, usar `open` como state do accordion e chamar `onToggle` no click do botão; quando `open === undefined`, manter comportamento atual via `useState(defaultOpen)` (fallback uncontrolled); nenhum caller externo é quebrado.
|
||||
- **Done**: `Section` aceita `open` e `onToggle` opcionais; modo uncontrolled continua funcionando; modo controlled expande/colapsa corretamente ao passar `open={true/false}` + `onToggle`.
|
||||
|
||||
- [X] T009 [US2] Adicionar estado `openSections` e helper `toggleSection` no componente principal `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: `const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>(() => initOpenSections(filters))`; `function toggleSection(key: SectionKey) { setOpenSections(prev => ({ ...prev, [key]: !prev[key] })) }`; adicionar também `function expandSection(key: SectionKey) { setOpenSections(prev => ({ ...prev, [key]: true })) }` (usado por US1 na T015).
|
||||
- **Done**: estado `openSections` inicializado corretamente; `toggleSection` alterna o valor da chave; `expandSection` garante `true` sem alterar demais.
|
||||
|
||||
- [X] T010 [US2] Conectar todas as seções ao estado `openSections` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: cada `<Section>` deve receber `open={openSections['<key>']}` e `onToggle={() => toggleSection('<key>')}` — aplicar nas seções: Imobiliária (`imobiliaria`), Localização (`localizacao`), Tipo de imóvel (`tipo`), Preço (`preco`), Quartos e vagas (`quartos`), Área (`area`), Comodidades (`comodidades`); remover `defaultOpen` props onde `open` é passado.
|
||||
- **Done**: todas as seções do sidebar são controlled; ao carregar sem URL params, apenas "Preço" está aberta; seções com filtros ativos na URL são abertas automaticamente; toggle manual funciona em cada seção.
|
||||
|
||||
**Checkpoint US2**: Carregar `/imoveis` — somente seção "Preço" expandida ✓. Carregar `/imoveis?city_id=1` — seções "Preço" e "Localização" expandidas ✓. Toggle manual colapsa/expande corretamente ✓.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: US1 — Campo de Busca Cross-Categoria (Priority: P1)
|
||||
|
||||
**Goal**: Campo de busca no topo do sidebar que filtra todos os itens do catálogo instantaneamente (debounce 200 ms), exibe sugestões agrupadas por categoria com highlight, seleciona ao clicar ou pressionar Enter, expande a seção relevante e limpa o campo.
|
||||
|
||||
**Independent Test**: Digitar "Copa" no campo de busca do sidebar e verificar que aparece sugestão "Copacabana" sob o grupo "Bairro"; clicar na sugestão e confirmar que o filtro de bairro é aplicado, o campo é limpo e a seção "Localização" é expandida.
|
||||
|
||||
**Dependências**: T008 e T009 (US2) devem estar completos para que `expandSection` esteja disponível.
|
||||
|
||||
- [X] T011 [US1] Declarar a interface `FilterSuggestion` e implementar a função `computeSuggestions()` localmente em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: interface `FilterSuggestion { category: string; sectionKey: SectionKey; label: string; filterKey: keyof PropertyFilters; value: number | string | undefined; isAmenity?: boolean; amenityId?: number }`; função `computeSuggestions(query: string, propertyTypes: PropertyType[], cities: City[], neighborhoods: Neighborhood[], amenities: Amenity[]): FilterSuggestion[]`; normalização: `text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()`; varrer `propertyTypes[*].subtypes` → categoria `"Tipo de imóvel"`, `sectionKey: 'tipo'`, `filterKey: 'subtype_id'`; `cities` → `"Cidade"`, `sectionKey: 'localizacao'`, `filterKey: 'city_id'`; `neighborhoods` → `"Bairro"`, `sectionKey: 'localizacao'`, `filterKey: 'neighborhood_id'`; `amenities` → `"Comodidade"`, `sectionKey: 'comodidades'`, `isAmenity: true`; retornar array vazio se `query.trim() === ''`.
|
||||
- **Done**: `computeSuggestions('copa', [...], [...], [...], [...])` retorna ao menos uma entrada com `label: 'Copacabana'` e `category: 'Bairro'`; busca é case-insensitive e ignora acentos; query vazia retorna `[]`.
|
||||
|
||||
- [X] T012 [US1] Criar sub-componente local `SidebarSearchInput` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: props `{ value: string; onChange: (v: string) => void; disabled?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void }`; renderiza `<input>` com placeholder `"Buscar filtro…"`, ícone de lupa (SVG inline), classes Tailwind: `w-full text-xs`; quando `disabled`: `cursor-not-allowed opacity-50`; `aria-label="Buscar filtro"`.
|
||||
- **Done**: campo renderiza com placeholder correto; estado `disabled` visualmente diferenciado; sem dependências externas.
|
||||
|
||||
- [X] T013 [US1] Criar sub-componente local `SuggestionList` com navegação por teclado em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: props `{ suggestions: FilterSuggestion[]; onSelect: (s: FilterSuggestion) => void; activeIndex: number }`; agrupar por `category` e renderizar cabeçalhos de grupo (`text-[10px] font-semibold text-textTertiary uppercase`); cada item: `<button>` com `data-index`, texto com highlight do termo buscado (fragmento `<mark>` com `bg-brand/20 text-brand`); item ativo recebe classe de destaque `bg-surface`; quando `suggestions.length === 0` e o caller passou query não-vazia, exibir `"Nenhum filtro encontrado"` em texto terciário; renderizado inline sob o campo (não é popup/portal).
|
||||
- **Done**: grupos renderizados com cabeçalho; clique em item chama `onSelect`; item com `activeIndex` visualmente destacado; mensagem de "sem resultados" visível quando array vazio mas busca ativa.
|
||||
|
||||
- [X] T014 [US1] Adicionar estados `filterSearch`, `searchQuery` e `activeIndex` com debounce 200 ms no componente `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: `const [filterSearch, setFilterSearch] = useState('')`; `const [searchQuery, setSearchQuery] = useState('')`; `const [activeIndex, setActiveIndex] = useState(-1)`; `useEffect(() => { const t = setTimeout(() => { setSearchQuery(filterSearch); setActiveIndex(-1) }, 200); return () => clearTimeout(t) }, [filterSearch])`; `const suggestions = useMemo(() => computeSuggestions(searchQuery, propertyTypes, cities, neighborhoods, amenities), [searchQuery, propertyTypes, cities, neighborhoods, amenities])`.
|
||||
- **Done**: alterar `filterSearch` só atualiza `searchQuery` após 200 ms; `suggestions` recomputa quando `searchQuery` muda; `activeIndex` reseta ao mudar query.
|
||||
|
||||
- [X] T015 [US1] Implementar a função `handleSuggestionSelect` e o handler de teclado `handleSearchKeyDown` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: `handleSuggestionSelect(s: FilterSuggestion)`: se `s.isAmenity`, chamar `toggleAmenity(s.amenityId!)`; senão, chamar `set({ [s.filterKey]: s.value })`; depois: `expandSection(s.sectionKey)`; `setFilterSearch('')`; `handleSearchKeyDown(e: React.KeyboardEvent)`: `ArrowDown` → `setActiveIndex(i => Math.min(i + 1, suggestions.length - 1))`; `ArrowUp` → `setActiveIndex(i => Math.max(i - 1, -1))`; `Enter` → se `activeIndex >= 0`, selecionar `suggestions[activeIndex]`; `Escape` → `setFilterSearch('')`.
|
||||
- **Done**: clicar em sugestão aplica filtro + expande seção + limpa campo; navegação ↑↓ move destaque; Enter seleciona; Escape limpa; "Copacabana" selecionado aplica `city_id` ou `neighborhood_id` conforme categoria.
|
||||
|
||||
- [X] T016 [US1] Renderizar `SidebarSearchInput` e `SuggestionList` no topo do componente `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: inserir `<SidebarSearchInput>` após o bloco "Tipo de negócio" (selector Venda/Aluguel/Todos) e antes do primeiro `<div className="h-px bg-borderSubtle">` que divide as seções; abaixo, renderizar condicionalmente `{filterSearch.length > 0 && <SuggestionList suggestions={suggestions} onSelect={handleSuggestionSelect} activeIndex={activeIndex} />}`; passar `disabled={catalogLoading}` e `onKeyDown={handleSearchKeyDown}` ao `SidebarSearchInput`; quando `SuggestionList` está visível, as seções accordion continuam montadas (não desmontadas).
|
||||
- **Done**: campo visível no topo do sidebar; sugestões aparecem ao digitar; desabilitado quando `catalogLoading=true`; sugestões somem ao limpar campo ou pressionar Escape.
|
||||
|
||||
**Checkpoint US1**: Digitar "apar" → sugestão "Apartamento" sob "Tipo de imóvel" aparece em < 50 ms ✓. Clicar → filtro aplicado, campo limpo, seção "Tipo de imóvel" expandida ✓. Digitar "xxxxxxxxx" → "Nenhum filtro encontrado" ✓. Teclado ↑↓Enter funciona ✓.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: US3 — Truncamento, Popularidade e Badge (Priority: P2)
|
||||
|
||||
**Goal**: Cada seção mostra os 5 itens mais populares (por `property_count DESC`). Botão "Ver mais (N)" expande; "Ver menos" retrai. Os 3 mais populares exibem badge "Popular". Itens com filtro ativo são sempre visíveis.
|
||||
|
||||
**Independent Test**: Abrir seção "Bairros" no sidebar — apenas os 5 bairros com mais imóveis exibidos; badge "Popular" no primeiro; clicar "Ver mais" → todos visíveis; clicar "Ver menos" → volta a 5.
|
||||
|
||||
**Dependências**: T001–T005 (property_count no backend e frontend types) + T008–T010 (Section controlled, para integração harmoniosa).
|
||||
|
||||
- [X] T017 [US3] Criar sub-componente local `PopularBadge` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: sem props além de children opcionais; renderiza `<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-brand/20 text-brand leading-none ml-1.5">Popular</span>`.
|
||||
- **Done**: componente renderiza o badge com tokens de design corretos; sem dependências externas.
|
||||
|
||||
- [X] T018 [US3] Criar sub-componente local `TruncatedFilterList<T>` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: props genéricos `{ items: T[]; selectedId?: number | null; renderItem: (item: T, isPopular: boolean) => React.ReactNode; getId: (item: T) => number; getCount: (item: T) => number; topN?: number }`; lógica: ordenar por `getCount(item) DESC` → `sorted`; se item `selectedId` não está nos top `topN` (padrão 5), promovê-lo para início de `sorted` (sem alterar restante); `const [expanded, setExpanded] = useState(false)`; exibir `expanded ? sorted : sorted.slice(0, topN)`; `isPopular = index < 3` (após promoção, se o item promovido não era top-3, não recebe badge); botão "Ver mais (N)" / "Ver menos" somente quando `sorted.length > topN`; ao clicar "Ver menos", não fazer scroll automático.
|
||||
- **Done**: com 8 itens exibe 5; botão "Ver mais (3)" aparece; clicar expande para 8; "Ver menos" recolhe; com 3 itens, botão não aparece; item selecionado fora do top-5 é promovido e visível sem clicar "Ver mais"; badge "Popular" nos índices 0, 1, 2 da lista ordenada.
|
||||
|
||||
- [X] T019 [US3] Aplicar `TruncatedFilterList` na seção "Tipo de imóvel" para os subtypes em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: para cada categoria (`propertyType`), substituir o map de subtypes atual por `<TruncatedFilterList items={propertyType.subtypes} selectedId={filters.subtype_id ?? null} getId={s => s.id} getCount={s => s.property_count ?? 0} renderItem={(s, isPopular) => <...chip existente...>{isPopular && <PopularBadge />}</...>} />`; ordenação ocorre dentro de `TruncatedFilterList`.
|
||||
- **Done**: seção "Tipo de imóvel" exibe top-5 subtypes por property_count; badge "Popular" nos 3 primeiros; "Ver mais" aparece quando subtypes > 5; subtype selecionado sempre visível.
|
||||
|
||||
- [X] T020 [P] [US3] Aplicar `TruncatedFilterList` na lista de bairros (`visibleNeighborhoods`) na seção "Localização" em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: substituir o `visibleNeighborhoods.map(...)` existente por `<TruncatedFilterList items={visibleNeighborhoods} selectedId={filters.neighborhood_id ?? null} getId={n => n.id} getCount={n => n.property_count ?? 0} renderItem={(n, isPopular) => <...checkbox/chip existente...>{isPopular && <PopularBadge />}</...>} />`; aplica somente quando `visibleNeighborhoods.length > 0`.
|
||||
- **Done**: bairros ordenados por property_count DESC; top-5 visíveis; badge nos 3 primeiros; bairro selecionado sempre visível.
|
||||
|
||||
- [X] T021 [P] [US3] Aplicar `TruncatedFilterList` nos grupos de comodidades na seção "Comodidades" em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: para cada `AmenityGroup`, substituir o `amenities.filter(...).map(...)` por `<TruncatedFilterList>` passando `getCount={a => a.property_count ?? 0}`; como `Amenity` não tem `id` como filterKey direto (usa `amenity_ids` toggle), usar `selectedId={undefined}` (amenidades não têm seleção única) — botão "Ver mais" ainda funciona para truncar a lista longa.
|
||||
- **Done**: cada grupo de comodidades exibe top-5; "Ver mais" expande; badge nos 3 mais populares por grupo; sem quebra no toggle de amenidades existente.
|
||||
|
||||
**Checkpoint US3**: Abrir `/imoveis` → seção "Bairros" mostra top-5 com badge "Popular" no primeiro ✓. Selecionar bairro fora do top-5 → reabre seção → bairro visível mesmo sem "Ver mais" ✓. `property_count: 0` para cidade sem imóveis ✓.
|
||||
|
||||
---
|
||||
|
||||
## Phase Final: Polish & Validação
|
||||
|
||||
**Purpose**: Verificações de qualidade cross-cutting após todas as user stories implementadas.
|
||||
|
||||
- [X] T022 [P] Verificar integração end-to-end em `frontend/src/components/FilterSidebar.tsx` e endpoints
|
||||
- **Detalhes**: com o backend rodando, abrir `/imoveis`, inspecionar Network tab — confirmar que `GET /api/v1/cities`, `/api/v1/neighborhoods` e `/api/v1/property-types` retornam `property_count` em todos os itens; confirmar no sidebar que a ordenação por popularidade está correta e os badges aparecem nos 3 itens com maior contagem em cada seção.
|
||||
- **Done**: nenhuma seção exibe itens sem `property_count`; ordenação no sidebar reflete os valores do backend; badge "Popular" nos 3 corretos por categoria.
|
||||
|
||||
- [X] T023 [P] Validar acessibilidade do `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
|
||||
- **Detalhes**: verificar `aria-expanded` correto nas seções (deve refletir `openSections[key]`); `SidebarSearchInput` tem `aria-label="Buscar filtro"`; `SuggestionList` itens têm `role="option"` ou são `<button>` com label descritivo; navegação ↑↓ não produz erro de console; `PopularBadge` tem `aria-label="Popular"` ou é `aria-hidden` conforme contexto.
|
||||
- **Done**: nenhum erro de acessibilidade no console; `aria-expanded` correto em todas as seções; field de busca anunciado por screen reader; `tsc --noEmit` passa.
|
||||
|
||||
- [X] T024 Executar cenários do `quickstart.md` para a feature 024 (quando disponível)
|
||||
- **Done**: todos os acceptance scenarios de US1, US2 e US3 do `spec.md` são verificados manualmente ou via quickstart; nenhuma regressão em funcionalidades existentes do sidebar.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
```
|
||||
Phase 1 (Backend) ──────────────────────────────────────────────┐
|
||||
Phase 2 (Frontend Types) ─────────────────────────────────────── ┤→ Phase 5 (US3)
|
||||
Phase 3 (US2 — Section Controlled) ──────────────────────────── ┤→ Phase 5 (US3)
|
||||
Phase 3 (US2 — Section Controlled) ──────────────────────────── ┘→ Phase 4 (US1)
|
||||
Phase 4 (US1 — Busca) → Phase Final
|
||||
Phase 5 (US3 — Truncamento) → Phase Final
|
||||
```
|
||||
|
||||
- **Phase 1 e Phase 2**: independentes entre si — podem rodar em paralelo
|
||||
- **Phase 3 (US2)**: independente de Phase 1/2 — pode iniciar imediatamente
|
||||
- **Phase 4 (US1)**: depende de T009 (US2) para `expandSection`
|
||||
- **Phase 5 (US3)**: depende de T001–T005 (property_count) + T008 (Section controlled)
|
||||
- **Phase Final**: depende de todas as fases anteriores
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US2 (P1)**: sem dependências — pode iniciar imediatamente
|
||||
- **US1 (P1)**: depende de US2 (T008, T009) para `expandSection`; pode ser implementada em paralelo com Phase 1/2
|
||||
- **US3 (P2)**: depende de Phase 1 (T001–T004), Phase 2 (T005) e US2 (T008–T010)
|
||||
|
||||
### Parallel Opportunities (dentro das fases)
|
||||
|
||||
- **Phase 1**: T003 e T004 marcadas [P] — edições em funções distintas do mesmo arquivo (`locations.py`)
|
||||
- **Phase 2**: T005 [P] — arquivo distinto de toda a Phase 1
|
||||
- **Phase 5**: T020 e T021 marcadas [P] — seções distintas do componente
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution — MVP Scope
|
||||
|
||||
O MVP mínimo para entregar valor imediato é **US2 + US1** (ambas P1, sem dados novos de backend):
|
||||
|
||||
```
|
||||
Sequência MVP (US2 → US1):
|
||||
T006 → T007 → T008 → T009 → T010 (US2: ~2h)
|
||||
↓
|
||||
T011 → T012 → T013 → T014 → T015 → T016 (US1: ~3h)
|
||||
```
|
||||
|
||||
Para US3, adicionar antes:
|
||||
```
|
||||
Paralelo (pode rodar simultâneo ao MVP):
|
||||
T001 → T002 (catalog.py)
|
||||
T001 → T003 → T004 (locations.py)
|
||||
T005 (catalog.ts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. **Iniciar com US2** (T006–T010): menor risco, sem dados novos, entrega imediata de UX limpa
|
||||
2. **Continuar com US1** (T011–T016): depende de US2; lógica mais complexa mas totalmente local
|
||||
3. **Backend em paralelo** (T001–T004): pode ser feito enquanto US1/US2 avançam no frontend
|
||||
4. **Finalizar com US3** (T017–T021): após backend + US2; adiciona camada de popularidade
|
||||
5. **Polish** (T022–T024): validação final antes do merge
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Fase | Tarefas | User Story | Paralelo |
|
||||
|------|---------|------------|---------|
|
||||
| Phase 1 — Backend | T001–T004 | (foundational US3) | T003, T004 [P] |
|
||||
| Phase 2 — Types | T005 | (foundational US3) | T005 [P] |
|
||||
| Phase 3 — US2 | T006–T010 | US2 | — |
|
||||
| Phase 4 — US1 | T011–T016 | US1 | — |
|
||||
| Phase 5 — US3 | T017–T021 | US3 | T020, T021 [P] |
|
||||
| Phase Final | T022–T024 | — | T022, T023 [P] |
|
||||
| **Total** | **24 tarefas** | **3 user stories** | **5 paralelas** |
|
||||
Loading…
Add table
Add a link
Reference in a new issue