feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s

- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
  preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
  keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint

migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
            i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
MatheusAlves96 2026-04-22 22:35:17 -03:00
parent 6ef5a7a17e
commit cf5603243c
106 changed files with 11927 additions and 1367 deletions

View file

@ -1,7 +1,7 @@
# 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)
**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)
---

View file

@ -1,6 +1,6 @@
# Implementation Plan: Filtro de Busca Avançada — FilterSidebar
**Branch**: `024-filtro-busca-avancada` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
**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`
---
@ -13,14 +13,14 @@ Enriquecer os endpoints de catálogo existentes com o campo `property_count` (CO
## 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)
**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
---
@ -200,8 +200,8 @@ Section recebe `open={openSections[key]}` + `onToggle={() => toggleSection(key)}
**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).
**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)}`.

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Favoritos Locais para Visitantes Não Autenticados
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**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 aprovado na primeira validação — todos os itens passam.
- A page `/favoritos` (pública) e `/area-do-cliente/favoritos` (autenticada) são destinos distintos, conforme explicitado nas Assumptions.
- Sincronização de favoritos locais ao login está coberta pela US4 e FR-013 a FR-017.

View file

@ -0,0 +1,255 @@
# Plan: Feature 025 — Favoritos Locais para Visitantes
**Branch**: `025-favoritos-locais`
**Spec**: `specs/025-favoritos-locais/spec.md`
**Status**: Ready to implement
**Backend changes**: Nenhum
---
## Visão Geral
Extensão do sistema de favoritos existente (autenticado via API) para suportar visitantes não autenticados via `localStorage`. Inclui:
1. `FavoritesContext` armazena favoritos localmente quando não autenticado
2. Merge automático ao fazer login (local → servidor)
3. Página pública `/favoritos` acessível sem conta
4. `HeartButton` permite toggle sem redirecionar para login
---
## Análise do Estado Atual
### O que já existe
| Arquivo | Responsabilidade atual |
|---|---|
| `FavoritesContext.tsx` | Gerencia favoritos autenticados via API; limpa estado no logout |
| `HeartButton.tsx` | Redireciona para `/login` se não autenticado |
| `FavoritesPage.tsx` | Lista favoritos da conta (`/area-do-cliente/favoritos`) |
| `AuthContext.tsx` | `login()` atualiza `user` e `token`; sem callback pós-login |
| `clientArea.ts` | `addFavorite`, `removeFavorite`, `getFavorites` via API |
### Problema de dados para a página pública
A `getProperty(slug)` busca por slug — e a API só expõe propriedades por slug. Como `localStorage` armazenaria apenas IDs, buscar dados da propriedade exigiria uma chamada extra por imóvel.
**Decisão**: Armazenar snapshots mínimos junto com o ID. O `HeartButton` recebe o objeto `Property` como prop opcional e o contexto o persiste localmente.
---
## Arquitetura de Dados
### localStorage
```
Chave : "local_favorites"
Valor : JSON.stringify(LocalFavoriteEntry[])
```
```typescript
// Definido em FavoritesContext.tsx
interface LocalFavoriteEntry {
id: string; // property UUID
title: string;
slug: string;
price: string;
type: 'venda' | 'aluguel';
photos: Array<{ url: string; alt_text: string }>;
city: { name: string } | null;
}
```
`favoriteIds` (Set<string>) é **derivado** das entries → lookup O(1) para o `HeartButton`.
---
## Decisões Técnicas
### 1. Merge no login — onde colocar?
**Opção A**: Hook dentro de `AuthContext.login()` (callback explícito)
**Opção B**: `useEffect` em `FavoritesContext` reagindo à mudança `isAuthenticated` false → true ✅
**Escolha: Opção B** — sem acoplamento entre contextos; o `FavoritesContext` já observa `isAuthenticated`.
Lógica no `useEffect([isAuthenticated])`:
```
Se isAuthenticated acabou de virar true:
1. Carregar local entries do localStorage
2. Se entries.length > 0:
a. Carregar favoritos do servidor (getFavorites)
b. Para cada entry local não presente no servidor → addFavorite(entry.id)
c. Limpar localStorage["local_favorites"]
3. Carregar favoritos do servidor normalmente (estado final)
```
**Rollback no merge**: Se uma chamada `addFavorite` falhar, os favoritos locais são preservados e não apagados. O merge é retentado no próximo login.
### 2. `toggle()` — assinatura
```typescript
// Atual (autenticado only):
toggle(propertyId: string): Promise<void>
// Novo (suporta ambos):
toggle(propertyId: string, snapshot?: LocalFavoriteEntry): Promise<void>
```
Quando não autenticado: atualiza localStorage + estado. Sem chamada API.
Quando autenticado: comportamento atual (API + optimistic update). `snapshot` ignorado.
### 3. `HeartButton` — passar o snapshot
Prop `property?: Property` adicionada. Usada para construir o `LocalFavoriteEntry` ao toggle local.
Quando `property` não é passado e o usuário não está autenticado: toggle funciona **somente pelo ID** (sem snapshot armazenado). O imóvel aparecerá como favorito, mas não exibirá dados na `PublicFavoritesPage`. Isso é aceitável para backward compatibility.
Remoção do `navigate('/login')` — usuários não autenticados podem favoritar diretamente.
### 4. Página pública `/favoritos`
- Lê `localEntries` exposto pelo `FavoritesContext`
- Se `isAuthenticated`: exibe banner de redirecionamento para `/area-do-cliente/favoritos`
- Se não autenticado: exibe cards com base nos snapshots locais
- Estado vazio: link para `/imoveis`
- Banner de incentivo ao cadastro (P2): sempre visível quando não autenticado
### 5. Favoritos ao fazer logout
**Decisão**: Manter localStorage — o visitante não perde favoritos ao deslogar.
O `useEffect` continua carregando do localStorage quando `isAuthenticated = false`.
---
## Fluxo de Dados
```
[Não autenticado]
Clique HeartButton
toggle(id, snapshot)
localStorage["local_favorites"] updated
favoriteIds state updated (derivado)
HeartButton muda visual imediatamente
[Login]
AuthContext.login() → setUser() → isAuthenticated vira true
FavoritesContext useEffect([isAuthenticated]) dispara
Lê localEntries do localStorage
Se localEntries.length > 0:
getFavorites() → serverIds
Para cada entry não em serverIds → addFavorite(entry.id)
Se todos addFavorite OK → removeItem("local_favorites")
Se erro → preserva localStorage (sem limpar)
setFavoriteIds(serverIds + merged)
```
---
## Arquivos a Criar
### `frontend/src/pages/PublicFavoritesPage.tsx` (novo)
Página pública em `/favoritos`:
- Usa `useFavorites().localEntries` para listar cards
- `useAuth().isAuthenticated` para condicionar banner/redirecionamento
- Cards com foto, título, preço, cidade, link para `/imoveis/:slug`
- Botão de remoção em cada card (chama `toggle(id)`)
- Estado vazio com link para `/imoveis`
- Banner de incentivo: link para `/cadastro` e `/login?next=/favoritos`
---
## Arquivos a Modificar
### `frontend/src/contexts/FavoritesContext.tsx`
Mudanças:
1. Adicionar `LOCAL_KEY = 'local_favorites'`
2. Adicionar `LocalFavoriteEntry` interface
3. Adicionar `localEntries: LocalFavoriteEntry[]` ao estado
4. Adicionar `readLocal()` / `writeLocal()` helpers
5. `useEffect([isAuthenticated])`:
- Se `false`: carregar do localStorage → derivar `favoriteIds`
- Se `true` (transition): executar merge → depois carregar do servidor
6. Atualizar `toggle()`: sem auth → localStorage; com auth → API (existente)
7. Expor `localEntries` no `FavoritesContextValue`
### `frontend/src/components/HeartButton.tsx`
Mudanças:
1. Adicionar prop `property?: Property`
2. Remover `navigate('/login')` no path não autenticado
3. Construir `LocalFavoriteEntry` a partir de `property` e passar para `toggle()`
### `frontend/src/App.tsx`
Mudanças:
1. Importar `PublicFavoritesPage`
2. Adicionar `<Route path="/favoritos" element={<PublicFavoritesPage />} />` (rota pública)
### `frontend/src/components/PropertyRowCard.tsx`
Mudanças:
1. Passar `property={property}` para `<HeartButton />`
### `frontend/src/components/PropertyGridCard.tsx`
Mudanças:
1. Passar `property={property}` para `<HeartButton />`
### `frontend/src/pages/PropertyDetailPage.tsx`
Mudanças:
1. Passar `property={property}` para `<HeartButton />`
---
## Arquivos Sem Mudanças
- `AuthContext.tsx` — sem acoplamento necessário
- `FavoritesPage.tsx` (cliente autenticado) — sem mudanças
- `clientArea.ts` — sem mudanças
- Backend — zero alterações
---
## Tratamento de Edge Cases
| Cenário | Comportamento |
|---|---|
| Modo navegação anônima (sem localStorage) | `try/catch` em `readLocal()`; array vazio como fallback |
| Imóvel removido do sistema | Card exibe dados do snapshot; link para detalhe pode retornar 404 (aceitável) |
| Merge falha por erro de rede | localStorage preservado; retentado no próximo login |
| 50+ favoritos locais | Renderização React lista longa; sem paginação (aceitável na v1) |
| HeartButton sem `property` prop | Toggle funciona; sem snapshot salvo; imóvel não aparece em PublicFavoritesPage |
| Usuário autenticado acessa `/favoritos` | Banner com link para `/area-do-cliente/favoritos`; não redireciona automaticamente |
---
## Sequência de Implementação
```
1. FavoritesContext.tsx ← base de tudo
2. HeartButton.tsx ← unlock não autenticado
3. PropertyRowCard.tsx ← passa property ao HeartButton
4. PropertyGridCard.tsx ← idem
5. PropertyDetailPage.tsx ← idem
6. PublicFavoritesPage.tsx ← página pública
7. App.tsx ← registrar rota
```
---
## Validação Manual (Smoke Tests)
1. Sem login → clicar coração em card → ícone preenchido → reload da página → continua preenchido
2. Sem login → `/favoritos` → card aparece com foto e link
3. Fazer login com 2 favoritos locais → `/area-do-cliente/favoritos` → imóveis aparecem
4. Favorito já no servidor antes do login → sem duplicata após merge
5. Usuário autenticado → `/favoritos` → banner exibe link para área do cliente

View file

@ -0,0 +1,166 @@
# Feature Specification: Favoritos Locais para Visitantes Não Autenticados
**Feature Branch**: `025-favoritos-locais`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
O sistema já oferece uma lista de favoritos para usuários autenticados, acessível em `/area-do-cliente/favoritos`. No entanto, visitantes sem cadastro não podem salvar imóveis de interesse durante a navegação — qualquer imóvel marcado como favorito é esquecido ao trocar de página ou fechar o browser.
Este spec cobre a adição de uma experiência de favoritos para visitantes não autenticados, inteiramente do lado do cliente, sem necessidade de cadastro imediato. Inclui também uma página pública `/favoritos` acessível a qualquer visitante e a sincronização automática dos favoritos locais com a conta do servidor quando o visitante decide se cadastrar ou fazer login.
---
## User Scenarios & Testing
### User Story 1 — Visitante Favorita Imóvel Sem Se Cadastrar (Priority: P1)
Um visitante sem cadastro que encontrou um imóvel interessante na listagem ou na página de detalhes quer marcá-lo como favorito para revisitar depois — sem precisar criar uma conta no momento.
**Why this priority**: Exigir cadastro para salvar favoritos cria uma barreira de entrada que reduz o engajamento de visitantes em fase de descoberta. Permitir favoritos locais elimina essa fricção e aumenta o tempo médio de sessão e a probabilidade de conversão posterior.
**Independent Test**: Sem estar logado, clicar no ícone de coração de um card de imóvel na listagem `/imoveis`, verificar que o ícone muda visualmente para preenchido, navegar para outra página e retornar — o imóvel deve continuar marcado como favorito.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado na página `/imoveis`, **When** ele clica no ícone de favorito de um card, **Then** o ícone muda para o estado "favoritado" (coração preenchido) instantaneamente e o imóvel é salvo localmente.
2. **Given** um visitante não autenticado na página de detalhes de um imóvel, **When** ele clica no botão de favorito, **Then** o ícone muda para o estado "favoritado" e o imóvel é salvo localmente.
3. **Given** um imóvel já favoritado localmente, **When** o visitante clica novamente no ícone de favorito, **Then** o imóvel é removido dos favoritos locais e o ícone volta ao estado vazio.
4. **Given** um imóvel favoritado por um visitante não autenticado, **When** o visitante fecha o browser e reabre o site, **Then** o ícone de favorito daquele imóvel ainda aparece preenchido.
5. **Given** múltiplos imóveis favoritados localmente, **When** o visitante navega pela listagem, **Then** todos os imóveis favoritados exibem o ícone preenchido consistentemente.
---
### User Story 2 — Visitante Acessa a Página Pública de Favoritos (Priority: P1)
Um visitante não autenticado que favoritou imóveis quer ver todos os seus imóveis salvos em uma única página, com informações resumidas e acesso direto à página de detalhes de cada um.
**Why this priority**: Sem uma página dedicada, os favoritos locais têm valor limitado — o usuário não consegue revisitar rapidamente os imóveis que marcou. A página `/favoritos` é o destino principal da funcionalidade e torna a experiência comparável à dos usuários autenticados.
**Independent Test**: Favoritar 3 imóveis como visitante não autenticado, navegar para `/favoritos`, verificar que os 3 imóveis aparecem com foto, título, preço e link para o detalhe.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado com imóveis favoritados localmente, **When** ele acessa `/favoritos`, **Then** a página exibe todos os imóveis salvos com foto, título, preço e link para a página de detalhes.
2. **Given** a página `/favoritos` com imóveis exibidos, **When** o visitante clica no ícone de remoção de um imóvel, **Then** o imóvel é removido da lista imediatamente, sem recarregar a página.
3. **Given** um visitante não autenticado sem nenhum favorito salvo, **When** ele acessa `/favoritos`, **Then** uma mensagem de estado vazio é exibida com orientação para navegar na listagem e salvar imóveis.
4. **Given** a página `/favoritos` acessível sem autenticação, **When** um visitante não autenticado acessa `/favoritos` diretamente pela URL, **Then** a página carrega normalmente sem redirecionar para login.
5. **Given** a página `/favoritos` com imóveis exibidos, **When** o visitante clica no card de um imóvel, **Then** é redirecionado para a página de detalhes daquele imóvel.
---
### User Story 3 — Banner de Incentivo ao Cadastro na Página de Favoritos (Priority: P2)
Um visitante não autenticado na página `/favoritos` é informado de que pode salvar sua lista de favoritos permanentemente criando uma conta, e recebe um caminho claro para o cadastro.
**Why this priority**: A página `/favoritos` é o momento de maior intenção do visitante — ele demonstrou interesse explícito em imóveis e está revisitando-os. É o ponto de conversão mais natural para incentivar o cadastro.
**Independent Test**: Acessar `/favoritos` sem estar autenticado e verificar que um banner/card de convite ao cadastro é exibido, com botão para criar conta e explicação dos benefícios de sincronização.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado na página `/favoritos`, **When** a página é carregada, **Then** um banner informativo é exibido explicando que criar uma conta permite salvar os favoritos na nuvem e acessá-los em qualquer dispositivo.
2. **Given** o banner de incentivo visível, **When** o visitante clica em "Criar conta", **Then** é redirecionado para a página de cadastro.
3. **Given** o banner de incentivo visível, **When** o visitante clica em "Entrar", **Then** é redirecionado para a página de login com retorno automático para `/favoritos` após autenticação.
4. **Given** um usuário autenticado na página `/favoritos`, **When** a página é carregada, **Then** o banner de incentivo ao cadastro não é exibido.
---
### User Story 4 — Sincronização de Favoritos Locais ao Fazer Login (Priority: P2)
Um visitante que acumulou favoritos locais e decide se autenticar tem seus favoritos locais automaticamente mesclados com os favoritos já salvos em sua conta, sem precisar refavoritar os imóveis manualmente.
**Why this priority**: Sem sincronização, o usuário perde toda a lista de favoritos locais ao fazer login, o que penaliza exatamente o comportamento desejado (visitar imóveis, favoritar e depois se cadastrar). A perda de dados cria frustração e desincentiva o uso da funcionalidade.
**Independent Test**: Favoritar 3 imóveis sem estar logado (A, B, C). Fazer login em uma conta que já tem o imóvel A nos favoritos do servidor. Navegar para `/area-do-cliente/favoritos` e verificar que A, B e C aparecem — sem duplicatas.
**Acceptance Scenarios**:
1. **Given** um visitante com imóveis A, B e C favoritados localmente, **When** ele faz login com sucesso, **Then** os imóveis B e C (que não estavam nos favoritos do servidor) são adicionados automaticamente à conta do servidor.
2. **Given** que o imóvel A já estava nos favoritos do servidor antes do login, **When** o visitante faz login com A, B e C nos favoritos locais, **Then** o imóvel A não é duplicado nos favoritos do servidor.
3. **Given** que a sincronização foi realizada com sucesso, **When** o login é concluído, **Then** os favoritos locais são removidos do armazenamento local do browser.
4. **Given** um visitante sem nenhum favorito local, **When** ele faz login, **Then** nenhuma operação de merge é realizada e os favoritos do servidor não são alterados.
5. **Given** que a sincronização falha por erro de rede após o login, **When** o visitante acessa a área de favoritos, **Then** os favoritos locais são preservados (não apagados) para retentativa futura.
---
### Edge Cases
- O que acontece com os favoritos locais quando o visitante usa o modo de navegação anônima (aba privada)?
- Como o sistema exibe o estado de carregamento enquanto busca os dados dos imóveis favoritados na página `/favoritos`?
- O que acontece se um imóvel favoritado localmente for removido ou desativado no sistema antes de o visitante acessar `/favoritos`?
- Como o ícone de favorito se comporta em imóveis que aparecem em múltiplos contextos (listagem, detalhe, resultados de busca) simultaneamente na mesma sessão?
- O que acontece com os favoritos locais ao fazer logout — eles são mantidos ou limpos?
- Como o sistema lida com uma lista muito grande de favoritos locais (ex.: 50+ imóveis) em termos de desempenho na página `/favoritos`?
- O que acontece se o visitante tentar acessar `/favoritos` em um browser que não suporta armazenamento local?
---
## Requirements
### Functional Requirements
#### Grupo 1 — Favoritar Sem Autenticação
- **FR-001**: O sistema DEVE permitir que visitantes não autenticados adicionem e removam imóveis de uma lista de favoritos local sem exigir cadastro ou login.
- **FR-002**: O estado de favorito (favoritado ou não) de cada imóvel DEVE ser persistido entre sessões do browser para visitantes não autenticados.
- **FR-003**: O ícone de favorito em cards de listagem e na página de detalhes DEVE refletir o estado local em tempo real para visitantes não autenticados.
- **FR-004**: O toggle de favorito DEVE funcionar de forma idêntica (mesma interface visual e resposta imediata) tanto para usuários autenticados quanto para visitantes não autenticados.
#### Grupo 2 — Página Pública de Favoritos
- **FR-005**: O sistema DEVE disponibilizar uma rota pública `/favoritos` acessível sem autenticação que exiba todos os imóveis favoritados pelo visitante.
- **FR-006**: A página `/favoritos` DEVE buscar os dados atualizados de cada imóvel favoritado pelo seu identificador e exibi-los com foto, título, preço, tipo, número de quartos, área e link para o detalhe.
- **FR-007**: A página `/favoritos` DEVE exibir um estado vazio com orientação de navegação quando o visitante não tiver nenhum favorito salvo.
- **FR-008**: O visitante DEVE poder remover imóveis individualmente da lista na página `/favoritos` sem recarregar a página.
- **FR-009**: A página `/favoritos` NÃO DEVE redirecionar visitantes não autenticados para a página de login.
#### Grupo 3 — Banner de Incentivo ao Cadastro
- **FR-010**: A página `/favoritos` DEVE exibir um banner de incentivo ao cadastro quando o visitante não estiver autenticado, explicando os benefícios de sincronização entre dispositivos.
- **FR-011**: O banner DEVE conter ações claras para cadastro e para login, com retorno automático à página `/favoritos` após autenticação.
- **FR-012**: O banner de incentivo ao cadastro NÃO DEVE ser exibido para usuários autenticados.
#### Grupo 4 — Sincronização no Login
- **FR-013**: Ao concluir o processo de autenticação com sucesso, o sistema DEVE verificar se há favoritos locais armazenados no browser.
- **FR-014**: Caso existam favoritos locais, o sistema DEVE adicionar à conta do servidor apenas os imóveis que ainda não constam nos favoritos do usuário, evitando duplicatas.
- **FR-015**: Após a sincronização bem-sucedida, o sistema DEVE limpar os favoritos locais do browser.
- **FR-016**: Em caso de falha na sincronização, os favoritos locais DEVEM ser preservados para retentativa e o usuário DEVE ser informado discretamente.
- **FR-017**: Usuários que fazem login sem favoritos locais NÃO DEVEM ter os favoritos do servidor alterados.
### Key Entities
- **Favorito Local**: Registro do lado do cliente representando o interesse de um visitante em um imóvel. Identificado pelo `property_id` do imóvel. Não tem representação no banco de dados — existe exclusivamente no armazenamento local do browser do visitante.
- **Imóvel (Property)**: Unidade imobiliária com identificador único, título, preço, tipo, área, quartos e fotos. Consultado pelo identificador na página `/favoritos` para exibir informações atualizadas.
- **Lista de Favoritos Locais**: Conjunto de `property_id`s armazenados localmente pelo visitante, sem limite explícito de tamanho nesta versão.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Visitantes não autenticados conseguem favoritar um imóvel em 1 clique, sem fluxo de cadastro ou modal intermediário.
- **SC-002**: O estado de favorito é preservado em 100% das navegações entre páginas e reabertas de browser (sem autenticação).
- **SC-003**: Visitantes conseguem acessar e visualizar todos os seus imóveis favoritados em `/favoritos` em menos de 3 segundos após o carregamento da página.
- **SC-004**: Após o login, 100% dos favoritos locais que não estavam no servidor são sincronizados automaticamente, sem nenhuma ação adicional do usuário.
- **SC-005**: Zero duplicatas de favoritos são criadas no servidor durante o processo de merge ao fazer login.
- **SC-006**: A página `/favoritos` é acessível sem autenticação — 0% de redirecionamentos indesejados para a tela de login para visitantes não autenticados.
- **SC-007**: O banner de incentivo ao cadastro na página `/favoritos` exibe claramente os benefícios e os caminhos para cadastro e login em uma leitura de menos de 10 segundos.
---
## Assumptions
- Os identificadores (`property_id`) dos imóveis são valores estáveis e únicos que não mudam após a criação do imóvel.
- O contexto de autenticação já expõe o estado do usuário (autenticado/não autenticado) de forma acessível aos componentes de favorito existentes.
- A página `/area-do-cliente/favoritos` (para usuários autenticados) permanece inalterada por este spec; a nova página `/favoritos` é um destino separado.
- Imóveis removidos ou desativados no sistema durante o período em que estavam favoritados localmente são tratados exibindo um estado de "imóvel indisponível" na página `/favoritos`, sem remover automaticamente da lista local.
- A lista de favoritos locais NÃO é sincronizada em tempo real entre múltiplos dispositivos do mesmo visitante (sem autenticação não há como identificar o usuário entre dispositivos).
- Favoritos locais são mantidos após o logout — um usuário que se desloga não perde imóveis que eventualmente tenha favoritado antes de se autenticar naquela sessão.
- O backend já possui ou pode receber uma rota para consultar múltiplos imóveis por lista de identificadores, usada pela página `/favoritos` para buscar dados em lote.
- Não há novas tabelas ou alterações no banco de dados nesta feature — toda a persistência para visitantes não autenticados é exclusivamente client-side.

View file

@ -0,0 +1,265 @@
# Tasks: Feature 025 — Favoritos Locais para Visitantes
**Branch**: `025-favoritos-locais`
**Spec**: `specs/025-favoritos-locais/spec.md`
**Plan**: `specs/025-favoritos-locais/plan.md`
**Backend changes**: Nenhum
---
## Fase 1 — Foundational: Interface e Dados (Prerequisito para todos os user stories)
> Objetivo: Definir o contrato de dados `LocalFavoriteEntry` e os utilitários de
> localStorage usados por todo o restante da feature. Nenhuma mudança visível ao usuário.
- [ ] T001 Adicionar interface `LocalFavoriteEntry` e constante `LOCAL_FAV_KEY` em `frontend/src/contexts/FavoritesContext.tsx`
```typescript
// Logo antes de FavoritesContextValue
const LOCAL_FAV_KEY = 'local_favorites';
export interface LocalFavoriteEntry {
id: string;
title: string;
slug: string;
price: string;
type: 'venda' | 'aluguel';
photos: Array<{ url: string; alt_text: string }>;
city: { name: string } | null;
}
```
- [ ] T002 Adicionar `localEntries` ao tipo `FavoritesContextValue` em `frontend/src/contexts/FavoritesContext.tsx`
```typescript
interface FavoritesContextValue {
favoriteIds: Set<string>;
localEntries: LocalFavoriteEntry[]; // novo
toggle: (propertyId: string, snapshot?: LocalFavoriteEntry) => Promise<void>;
isLoading: boolean;
}
```
---
## Fase 2 — User Story 1: Visitante Favorita Imóvel Sem Se Cadastrar (P1)
> **Objetivo**: Ícone de coração funciona para não-autenticados, persiste via localStorage
> e sobrevive a navegação e recarga do browser.
>
> **Teste independente**: Sem login, clicar no coração de um card em `/imoveis`,
> navegar para outra página e retornar — imóvel ainda marcado como favorito.
- [ ] T003 [US1] Refatorar `useEffect([isAuthenticated])` em `frontend/src/contexts/FavoritesContext.tsx` para inicializar `favoriteIds` e `localEntries` a partir do `localStorage` quando não autenticado
**Lógica**:
- Se `!isAuthenticated`: lê `localStorage.getItem(LOCAL_FAV_KEY)`, faz parse para `LocalFavoriteEntry[]`, popula `localEntries` e deriva `favoriteIds` como `new Set(entries.map(e => e.id))`
- Se `isAuthenticated`: comportamento atual (fetch da API); `localEntries` permanece `[]`
- [ ] T004 [US1] Atualizar `toggle()` em `frontend/src/contexts/FavoritesContext.tsx` para aceitar `snapshot?: LocalFavoriteEntry` e tratar o caso não-autenticado via localStorage
**Lógica quando `!isAuthenticated`**:
```typescript
const wasIn = favoriteIds.has(propertyId);
const next = wasIn
? localEntries.filter(e => e.id !== propertyId)
: [...localEntries, snapshot ?? { id: propertyId, title: '', slug: '', price: '', type: 'venda', photos: [], city: null }];
localStorage.setItem(LOCAL_FAV_KEY, JSON.stringify(next));
setLocalEntries(next);
setFavoriteIds(new Set(next.map(e => e.id)));
return;
```
Quando autenticado: comportamento atual; `snapshot` ignorado.
- [ ] T005 [US1] Atualizar `frontend/src/components/HeartButton.tsx` — adicionar prop `snapshot?: LocalFavoriteEntry`, remover redirecionamento para `/login` e chamar `toggle(propertyId, snapshot)`
```typescript
interface HeartButtonProps {
propertyId: string;
snapshot?: LocalFavoriteEntry; // novo
className?: string;
}
// handleClick — remover: if (!isAuthenticated) { navigate('/login'); return; }
// Novo handleClick:
async function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
await toggle(propertyId, snapshot);
}
```
Remover import de `useNavigate` e `useAuth` se não forem mais necessários após esta mudança.
- [ ] T006 [P] [US1] Atualizar `frontend/src/components/PropertyRowCard.tsx` — construir snapshot e passar para `<HeartButton>`
A prop `property` já é do tipo `Property`. Compor `LocalFavoriteEntry` a partir de `property`:
```tsx
const favSnapshot: LocalFavoriteEntry = {
id: property.id,
title: property.title,
slug: property.slug,
price: property.price,
type: property.type,
photos: property.photos.slice(0, 1).map(p => ({ url: p.url, alt_text: p.alt_text })),
city: property.city ? { name: property.city.name } : null,
};
// Usar em: <HeartButton propertyId={property.id} snapshot={favSnapshot} />
```
- [ ] T007 [P] [US1] Atualizar `frontend/src/components/PropertyCard.tsx` — construir snapshot e passar para `<HeartButton>` (mesma lógica de T006)
- [ ] T008 [P] [US1] Atualizar `frontend/src/pages/PropertyDetailPage.tsx` — construir snapshot a partir de `property` e passar para `<HeartButton propertyId={property.id} snapshot={favSnapshot} />`
> O objeto `property` completo está disponível no escopo onde `HeartButton` é renderizado (linha 117).
---
## Fase 3 — User Story 2: Página Pública de Favoritos `/favoritos` (P1)
> **Objetivo**: Visitante não autenticado acessa `/favoritos` e vê os imóveis salvos
> localmente com foto, título, preço e link para o detalhe.
>
> **Teste independente**: Favoritar 3 imóveis, acessar `/favoritos` — 3 imóveis exibidos.
- [ ] T009 [US2] Criar `frontend/src/pages/PublicFavoritesPage.tsx`
**Requisitos**:
- Usa `useFavorites()` para ler `localEntries`, `favoriteIds`, `toggle`
- Estado de carregamento: verificar `isLoading`
- Estado vazio: mensagem + link para `/imoveis`
- Grid responsivo: 1 col mobile → 2 col sm → 3 col lg (igual ao `FavoritesPage.tsx` existente)
- Cada card exibe: foto (primeira do array `photos`), título, preço formatado (`Intl.NumberFormat pt-BR`), badge de tipo (`venda`/`aluguel`), cidade, link para `/imoveis/{slug}`, botão de remover (chama `toggle(entry.id)`)
- **Não** chama API — usa apenas `localEntries` do contexto
- Acessível sem autenticação (sem `ProtectedRoute`)
- [ ] T010 [US2] Registrar rota `/favoritos` em `frontend/src/App.tsx`
```tsx
import PublicFavoritesPage from './pages/PublicFavoritesPage';
// Dentro de <Routes>, após /politica-de-privacidade:
<Route path="/favoritos" element={<PublicFavoritesPage />} />
```
---
## Fase 4 — User Story 3: Banner de Incentivo ao Cadastro (P2)
> **Objetivo**: Visitante não autenticado em `/favoritos` vê banner com CTA para
> criar conta ou fazer login.
>
> **Teste independente**: Acessar `/favoritos` sem autenticação e verificar banner com
> botões "Criar conta" e "Entrar".
- [ ] T011 [US3] Adicionar `SignupBanner` inline em `frontend/src/pages/PublicFavoritesPage.tsx`
**Regra**: Banner visível apenas quando `!isAuthenticated` (obter de `useAuth()`).
**Conteúdo**:
- Ícone de coração ou nuvem
- Título: "Salve seus favoritos em qualquer dispositivo"
- Texto: "Crie uma conta gratuita para sincronizar sua lista de imóveis favoritos e acessá-la de qualquer lugar."
- Botão primário: "Criar conta" → navega para `/cadastro`
- Link secundário: "Já tenho conta — Entrar" → navega para `/login?next=/favoritos`
**Posicionamento**: Acima do grid de imóveis (ou abaixo, se a lista for vazia — nesse caso, deve ser o destaque principal da tela).
---
## Fase 5 — User Story 4: Sincronização de Favoritos Locais ao Fazer Login (P2)
> **Objetivo**: Ao fazer login, favoritos locais são mesclados automaticamente com os
> favoritos do servidor, sem duplicatas, e o localStorage é limpo.
>
> **Teste independente**: Favoritar A, B, C sem login; login em conta com A no servidor;
> `/area-do-cliente/favoritos` exibe A, B, C sem duplicatas.
- [ ] T012 [US4] Adicionar `useEffect([isAuthenticated])` de merge em `frontend/src/contexts/FavoritesContext.tsx`
**Lógica** (executada quando `isAuthenticated` muda de `false` para `true`):
```typescript
useEffect(() => {
if (!isAuthenticated) return;
const raw = localStorage.getItem(LOCAL_FAV_KEY);
if (!raw) return; // nada para sincronizar
const localEntries: LocalFavoriteEntry[] = JSON.parse(raw);
if (localEntries.length === 0) return;
(async () => {
try {
const serverFavs = await getFavorites();
const serverIds = new Set(serverFavs.filter((s: any) => s.property_id).map((s: any) => s.property_id as string));
const toAdd = localEntries.filter(e => !serverIds.has(e.id));
await Promise.allSettled(toAdd.map(e => addFavorite(e.id)));
// Limpar localStorage somente se todas as chamadas foram resolvidas (success ou already-exists)
localStorage.removeItem(LOCAL_FAV_KEY);
setLocalEntries([]);
} catch {
// Falha de rede: preservar localStorage para retentativa no próximo login
}
})();
}, [isAuthenticated]);
```
> Este `useEffect` é **separado** do `useEffect` de carregamento inicial (T003).
> Ordem de execução no mesmo ciclo de `isAuthenticated=true`:
> 1. `useEffect` de merge (T012) → adiciona ao servidor e limpa localStorage
> 2. `useEffect` de carregamento (T003) → busca favoritos atualizados do servidor
---
## Fase 6 — Polish & Verificações Finais
- [ ] T013 [P] Verificar `frontend/src/contexts/FavoritesContext.tsx` — confirmar que `localEntries` é exposto no valor do contexto e que o estado é reiniciado corretamente no logout (`isAuthenticated = false` → limpar `localEntries` **do estado**, mas **preservar** o `localStorage["local_favorites"]`)
> Ao fazer logout, os favoritos locais do localStorage são mantidos para que o visitante não os perca caso retorne sem estar logado.
- [ ] T014 [P] Verificar `frontend/src/pages/client/FavoritesPage.tsx` — confirmar que não há quebras de tipo após a alteração da assinatura de `toggle` (o `snapshot` é opcional, não deve impactar chamadas existentes)
---
## Dependências entre Fases
```
T001 → T002 → T003 → T004 ─┬─ T006 (P)
├─ T007 (P)
└─ T008 (P)
T004 → T005 (HeartButton)
T003, T004 → T009 (PublicFavoritesPage)
T009 → T010 (Rota App.tsx)
T009 → T011 (Banner)
T003, T004 → T012 (Merge login)
```
## Execução em Paralelo por Fase
| Fase | Tasks paralelas |
|------|----------------|
| Fase 1 | T001 → T002 (sequencial — mesmo arquivo) |
| Fase 2 | T003 → T004 → T005 (sequencial — mesmo arquivo); T006, T007, T008 em paralelo após T005 |
| Fase 3 | T009 → T010 (sequencial — T010 depende de T009) |
| Fase 4 | T011 (independente de T010, só depende de T009) |
| Fase 5 | T012 (independente das fases 3-4) |
| Fase 6 | T013, T014 em paralelo |
## Escopo MVP (entrega mínima P1)
Fases 1 + 2 + 3 (T001T010): visitante favorita imóveis localmente e acessa a página `/favoritos`.
Fases 45 (T011T012) entregam as histórias P2 (banner + merge no login).
---
## Contagem de Tasks
| Fase | Tasks | User Story |
|------|-------|-----------|
| Foundational | T001, T002 | — |
| US1 | T003T008 | P1 |
| US2 | T009T010 | P1 |
| US3 | T011 | P2 |
| US4 | T012 | P2 |
| Polish | T013T014 | — |
| **Total** | **14 tasks** | |

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Central de Contatos com Rastreamento de Origem
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**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 na criação — todos os itens passaram na primeira iteração.
- Leads legados (sem `source`) tratados via Assumption de compatibilidade retroativa (FR-022 + Assumptions).
- Assunto do formulário `/contato` mapeado na Assumption como parte da `message` para evitar nova coluna não especificada.

View file

@ -0,0 +1,201 @@
# Feature Specification: Central de Contatos com Rastreamento de Origem
**Feature Branch**: `026-central-contatos`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
O sistema já possui um mecanismo básico de captura de leads: visitantes podem enviar mensagens a partir da página de detalhes de um imóvel, e esses registros são armazenados como `ContactLead`. No entanto, a origem de cada contato não é rastreada, o formulário de contato geral na navbar aponta para uma âncora na homepage (sem página própria) e não existe uma página para proprietários interessados em anunciar seus imóveis.
Esta spec cobre a criação de uma **Central de Contatos** unificada com três origens rastreáveis: formulário público de contato geral (`/contato`), formulário de cadastro de residência para anúncio (`/cadastro-residencia`), e o formulário de contato de imóvel já existente. Inclui também a página de administração consolidada para visualizar e filtrar todos os leads por origem, e a correção dos links da navbar para destinos internos.
---
## User Scenarios & Testing
### User Story 1 — Visitante Envia Contato Geral via `/contato` (Priority: P1)
Um visitante do site que deseja tirar dúvidas, solicitar informações ou propor parceria quer enviar uma mensagem para a imobiliária sem precisar acessar a página de um imóvel específico.
**Why this priority**: É o ponto de contato principal do site. Atualmente a navbar aponta "Contato" para uma âncora que pode não estar visível, criando uma experiência frustrante. Disponibilizar uma página dedicada com formulário funcional é o requisito mínimo de contato da imobiliária.
**Independent Test**: Acessar `/contato` sem estar autenticado, preencher todos os campos obrigatórios (nome, e-mail, telefone, assunto, mensagem) e submeter. Verificar que a submissão é aceita com mensagem de confirmação e que o lead aparece na base com `source = "contato"`.
**Acceptance Scenarios**:
1. **Given** um visitante na página `/contato`, **When** ele preenche nome, e-mail, telefone, assunto e mensagem e clica em enviar, **Then** o formulário é submetido com sucesso, uma mensagem de confirmação é exibida e o visitante permanece na página.
2. **Given** um visitante na página `/contato`, **When** ele submete o formulário sem preencher um campo obrigatório, **Then** o campo em falta é destacado com uma mensagem de erro inline e o formulário não é enviado.
3. **Given** um visitante que submeteu o formulário com sucesso, **When** o lead é registrado no sistema, **Then** o campo `source` do registro é `"contato"` e `source_detail` fica vazio.
4. **Given** um visitante na página `/contato`, **When** ele informa um e-mail com formato inválido, **Then** o campo e-mail é destacado com erro de validação antes do envio.
5. **Given** um visitante na página `/contato`, **When** o assunto selecionado é "Anúncio", **Then** o formulário é submetido normalmente com o assunto incluído na mensagem registrada.
---
### User Story 2 — Proprietário Cadastra Imóvel para Anúncio via `/cadastro-residencia` (Priority: P1)
Um proprietário de imóvel que deseja anunciá-lo através da imobiliária quer enviar os dados básicos do imóvel e suas informações de contato para que a equipe entre em contato e dê continuidade ao processo.
**Why this priority**: Representa uma fonte direta de captação de novos imóveis para o portfólio da imobiliária. Sem esse canal, proprietários interessados não têm caminho claro para iniciar o processo de anúncio.
**Independent Test**: Acessar `/cadastro-residencia`, preencher todos os campos (nome, e-mail, telefone, endereço, tipo de imóvel, área, finalidade, observações) e submeter. Verificar que o lead é criado com `source = "cadastro_residencia"` e que os detalhes do imóvel estão na mensagem ou no `source_detail`.
**Acceptance Scenarios**:
1. **Given** um proprietário na página `/cadastro-residencia`, **When** ele preenche todos os campos e clica em enviar, **Then** o formulário é submetido com sucesso e uma mensagem de confirmação é exibida informando que a equipe entrará em contato.
2. **Given** um proprietário na página `/cadastro-residencia`, **When** ele submete sem preencher campos obrigatórios (nome, e-mail, telefone, endereço, tipo e finalidade), **Then** os campos em falta são destacados com erros inline e o envio é bloqueado.
3. **Given** um proprietário que submeteu o formulário com sucesso, **When** o lead é registrado, **Then** `source` é `"cadastro_residencia"` e `source_detail` contém informação identificável do imóvel (ex.: tipo + finalidade ou endereço).
4. **Given** um proprietário na página `/cadastro-residencia`, **When** ele seleciona "Apartamento" como tipo e "Aluguel" como finalidade, **Then** esses valores são incluídos no registro enviado ao sistema.
5. **Given** um proprietário na página `/cadastro-residencia`, **When** o campo "Área m²" recebe um valor não numérico, **Then** o campo é destacado com erro de validação e o envio é bloqueado.
---
### User Story 3 — Visitante Envia Contato de Imóvel com Origem Rastreada (Priority: P2)
Um visitante interessado em um imóvel específico envia uma mensagem a partir da página de detalhes do imóvel. O sistema deve registrar automaticamente que o contato veio daquele imóvel específico, sem que o visitante precise fazer nada diferente.
**Why this priority**: O formulário de contato de imóvel já existe e funciona. A melhoria é transparente para o usuário e enriquece os dados para a equipe de vendas, que saberá exatamente qual imóvel gerou cada lead.
**Independent Test**: Acessar a página de detalhes de um imóvel (ex.: `/imoveis/apartamento-centro`), preencher e enviar o formulário de contato. Verificar que o lead criado possui `source = "imovel"` e `source_detail` com o título do imóvel.
**Acceptance Scenarios**:
1. **Given** um visitante na página de detalhes de um imóvel, **When** ele preenche e envia o formulário de contato, **Then** o lead é criado com `source = "imovel"` e `source_detail` contendo o título do imóvel.
2. **Given** um lead de imóvel criado com sucesso, **When** o administrador visualiza esse lead na central de leads, **Then** a coluna de origem exibe um badge "Imóvel" e o `source_detail` com o título do imóvel está visível.
3. **Given** um visitante na página de detalhes, **When** o formulário de contato é exibido, **Then** a experiência visual e os campos do formulário permanecem idênticos — nenhuma mudança visível para o usuário.
---
### User Story 4 — Administrador Visualiza e Filtra Leads na Central de Contatos (Priority: P1)
O administrador da imobiliária precisa de uma visão consolidada de todos os contatos recebidos — de qualquer origem — com a possibilidade de filtrar por tipo de origem para priorizar o atendimento.
**Why this priority**: Sem uma central unificada com filtros, a equipe não consegue distinguir leads de imóveis de propostas de parceria ou de proprietários querendo anunciar. O valor do rastreamento de origem só se materializa com uma interface de gestão adequada.
**Independent Test**: Acessar `/admin/leads` como administrador autenticado, verificar que leads das três origens aparecem na listagem. Aplicar filtro por "Cadastro de Residência" e verificar que apenas leads com `source = "cadastro_residencia"` são exibidos.
**Acceptance Scenarios**:
1. **Given** um administrador autenticado acessando `/admin/leads`, **When** a página carrega, **Then** todos os leads são listados em ordem cronológica decrescente com as colunas: origem (badge colorido), nome, e-mail, telefone, prévia da mensagem e data.
2. **Given** a listagem de leads, **When** o administrador seleciona o filtro "Imóvel", **Then** apenas leads com `source = "imovel"` são exibidos e o badge "Imóvel" aparece na coluna de origem de cada linha.
3. **Given** a listagem de leads, **When** o administrador seleciona o filtro "Contato", **Then** apenas leads com `source = "contato"` são exibidos.
4. **Given** a listagem de leads, **When** o administrador seleciona o filtro "Cadastro de Residência", **Then** apenas leads com `source = "cadastro_residencia"` são exibidos.
5. **Given** a listagem de leads com filtro "Imóvel" ativo, **When** há leads com `source_detail` preenchido, **Then** o título do imóvel é exibido como detalhe do badge ou em coluna/tooltip separado.
6. **Given** a listagem de leads, **When** o administrador seleciona "Todos", **Then** todos os leads de todas as origens são exibidos sem filtro.
7. **Given** um não administrador (sem autenticação ou sem permissão), **When** tenta acessar `/admin/leads`, **Then** é redirecionado para a tela de login ou recebe resposta de acesso negado.
8. **Given** a listagem de leads com muitos registros, **When** o limite de exibição por página é atingido, **Then** a navegação entre páginas ou carregamento adicional está disponível para acessar registros anteriores.
---
### User Story 5 — Visitante Navega para `/contato` e `/sobre` via Navbar (Priority: P3)
Um visitante que clica em "Contato" ou "Sobre" na barra de navegação é levado diretamente às páginas internas correspondentes, sem âncoras ou rolagem forçada.
**Why this priority**: Corrige um problema de usabilidade existente. A navbar já tem os links; é apenas necessário atualizar os destinos para rotas internas corretas.
**Independent Test**: Na homepage (ou em qualquer outra página), clicar em "Contato" na navbar e verificar que a rota muda para `/contato`. Clicar em "Sobre" e verificar que a rota muda para `/sobre`.
**Acceptance Scenarios**:
1. **Given** qualquer página do site com a navbar visível, **When** o visitante clica em "Contato", **Then** é navegado para `/contato` sem recarregar a página inteira.
2. **Given** qualquer página do site com a navbar visível, **When** o visitante clica em "Sobre", **Then** é navegado para `/sobre` sem recarregar a página inteira.
3. **Given** o visitante está em `/contato`, **When** a navbar é exibida, **Then** o link "Contato" aparece destacado como item ativo de navegação.
---
### Edge Cases
- O que acontece se o usuário submeter o formulário de `/contato` mais de uma vez seguida (duplo clique acidental)?
- O que acontece se o servidor retornar erro ao salvar o lead — o usuário recebe feedback adequado?
- O que acontece com leads criados antes da adição da coluna `source` — eles aparecem na listagem admin com origem "Desconhecida" ou ficam ocultos?
- Como o sistema se comporta quando `source_detail` está vazio para leads de imóvel (caso de falha na captura do título)?
- O que acontece se o campo "área m²" no formulário `/cadastro-residencia` receber um valor negativo?
- Como a paginação da listagem admin se comporta quando um filtro é aplicado e o número total de resultados muda?
- O que acontece se um visitante acessar `/contato` ou `/cadastro-residencia` em um dispositivo móvel — os formulários são responsivos?
---
## Requirements
### Functional Requirements
#### Grupo 1 — Rastreamento de Origem no Modelo de Lead
- **FR-001**: O modelo de lead DEVE ter um campo `source` que identifica a origem do contato com os valores possíveis: `"contato"`, `"imovel"` e `"cadastro_residencia"`.
- **FR-002**: O modelo de lead DEVE ter um campo `source_detail` opcional para registrar informação complementar da origem (ex.: título do imóvel, tipo + finalidade do imóvel anunciado).
- **FR-003**: Todos os novos leads criados a partir desta feature DEVEM ter o campo `source` preenchido automaticamente de acordo com a origem do formulário que os gerou.
- **FR-004**: Leads existentes criados antes desta feature (campo `source` ausente) NÃO DEVEM ser alterados retroativamente, mas DEVEM continuar acessíveis na listagem admin.
#### Grupo 2 — Página de Contato Geral (`/contato`)
- **FR-005**: O sistema DEVE disponibilizar a rota pública `/contato` com um formulário de contato geral acessível sem autenticação.
- **FR-006**: O formulário de contato DEVE conter os campos: nome (obrigatório), e-mail (obrigatório, formato válido), telefone (obrigatório), assunto (obrigatório, seleção entre: Informações, Anúncio, Parceria, Outro) e mensagem (obrigatória).
- **FR-007**: A submissão bem-sucedida do formulário DEVE criar um lead com `source = "contato"` e exibir uma mensagem de confirmação ao usuário.
- **FR-008**: O formulário DEVE validar todos os campos obrigatórios antes do envio e exibir mensagens de erro inline sem recarregar a página.
- **FR-009**: Após uma submissão bem-sucedida, o botão de envio DEVE ser desabilitado ou o formulário resetado para evitar envio duplicado acidental.
#### Grupo 3 — Página de Cadastro de Residência (`/cadastro-residencia`)
- **FR-010**: O sistema DEVE disponibilizar a rota pública `/cadastro-residencia` com formulário para proprietários interessados em anunciar um imóvel, acessível sem autenticação.
- **FR-011**: O formulário de cadastro DEVE conter os campos: nome (obrigatório), e-mail (obrigatório, formato válido), telefone (obrigatório), endereço (obrigatório), tipo de imóvel (obrigatório, seleção entre: Casa, Apartamento, Comercial), área em m² (opcional, numérico positivo), finalidade (obrigatório, seleção entre: Venda, Aluguel) e observações/mensagem (opcional).
- **FR-012**: A submissão bem-sucedida DEVE criar um lead com `source = "cadastro_residencia"`, com `source_detail` contendo identificação do imóvel (tipo, finalidade e/ou endereço), e exibir confirmação ao usuário.
- **FR-013**: O formulário DEVE validar todos os campos obrigatórios e o formato numérico do campo área antes do envio, com erros inline sem recarregar a página.
#### Grupo 4 — Atualização do Formulário de Contato de Imóvel
- **FR-014**: O formulário de contato existente na página de detalhes de imóvel DEVE passar `source = "imovel"` ao criar o lead.
- **FR-015**: O formulário de contato de imóvel DEVE passar `source_detail` com o título do imóvel ao criar o lead.
- **FR-016**: A aparência e os campos do formulário de contato de imóvel NÃO DEVEM ser alterados — a mudança é exclusivamente no dado enviado ao backend.
#### Grupo 5 — Central de Leads Admin (`/admin/leads`)
- **FR-017**: O sistema DEVE disponibilizar a rota protegida `/admin/leads` acessível apenas por usuários com perfil de administrador.
- **FR-018**: A listagem DEVE exibir todos os leads em ordem cronológica decrescente com as colunas: origem (badge colorido), nome, e-mail, telefone, prévia da mensagem (truncada) e data de criação.
- **FR-019**: Os badges de origem DEVEM ter cores distintas para cada valor de `source`: uma cor para `"contato"`, outra para `"imovel"` e outra para `"cadastro_residencia"`.
- **FR-020**: Para leads com `source = "imovel"` e `source_detail` preenchido, o título do imóvel DEVE ser exibido de forma associada ao badge (ex.: tooltip, sublinha ou coluna adicional).
- **FR-021**: A listagem DEVE oferecer um seletor de filtro por origem com as opções: Todos, Contato, Imóvel e Cadastro de Residência. A seleção de filtro DEVE atualizar a listagem sem recarregar a página.
- **FR-022**: Leads cujo campo `source` está vazio (criados antes desta feature) DEVEM ser exibidos na listagem com um badge ou label indicando origem "Desconhecida" ao selecionar filtro "Todos".
- **FR-023**: A listagem DEVE suportar paginação ou carregamento incremental para listas com grande volume de leads.
#### Grupo 6 — Navbar
- **FR-024**: O link "Contato" na navbar DEVE navegar para a rota interna `/contato`.
- **FR-025**: O link "Sobre" na navbar DEVE navegar para a rota interna `/sobre`.
- **FR-026**: O link ativo na navbar DEVE ser destacado visualmente quando a rota atual corresponder ao destino do link.
### Key Entities
- **Lead de Contato (ContactLead)**: Registro de interesse ou comunicação de um visitante. Atributos relevantes: identificador único, origem (`source`), detalhe de origem (`source_detail`), nome, e-mail, telefone, mensagem, data de criação. Pode ou não estar associado a um imóvel específico.
- **Origem (Source)**: Classificação do canal pelo qual o lead foi gerado. Valores fixos: `"contato"` (formulário geral), `"imovel"` (página de detalhes de imóvel), `"cadastro_residencia"` (formulário de anúncio de proprietário).
- **Detalhe de Origem (Source Detail)**: Informação livre associada à origem do lead. Para `"imovel"`: título do imóvel. Para `"cadastro_residencia"`: tipo, finalidade e/ou endereço do imóvel informado pelo proprietário.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Visitantes conseguem enviar um contato geral através de `/contato` em menos de 2 minutos a partir do primeiro acesso à página.
- **SC-002**: Proprietários conseguem submeter o formulário de cadastro de residência em `/cadastro-residencia` em menos de 3 minutos.
- **SC-003**: 100% dos leads criados a partir das páginas `/contato`, `/cadastro-residencia` e do formulário de imóvel contêm o campo `source` preenchido corretamente.
- **SC-004**: O administrador consegue filtrar leads por origem e visualizar apenas os registros relevantes em menos de 5 segundos após selecionar o filtro.
- **SC-005**: Os links "Contato" e "Sobre" da navbar levam às rotas corretas em 100% das navegações, sem âncoras intermediárias.
- **SC-006**: Zero leads duplicados são criados por submissões acidentais de duplo clique nos formulários de `/contato` e `/cadastro-residencia`.
- **SC-007**: A listagem de leads admin exibe corretamente leads de todas as origens, incluindo leads legados (sem `source`) sem erros ou omissões.
---
## Assumptions
- O modelo `ContactLead` já existe no banco de dados com as colunas `id`, `property_id`, `name`, `email`, `phone`, `message` e `created_at`; as colunas `source` e `source_detail` serão adicionadas via migration Alembic.
- A coluna `source` aceita `NULL` para registros existentes criados antes da migration (compatibilidade retroativa sem valor padrão obrigatório).
- O assunto selecionado no formulário `/contato` é incluído no campo `message` (compondo a mensagem) ou em um campo auxiliar — não requer nova coluna no modelo.
- Os dados do formulário `/cadastro-residencia` são registrados como um único lead usando os campos existentes (`name`, `email`, `phone`, `message`) com detalhes do imóvel concatenados na mensagem e `source_detail` com identificação resumida.
- A autenticação de administrador já existe no sistema; `/admin/leads` usa o mesmo mecanismo de proteção das demais rotas admin.
- A rota `GET /admin/leads` existente será estendida para aceitar o parâmetro de filtro `source` e retornar leads com os novos campos; não é criada uma nova rota separada.
- A paginação da listagem admin utiliza paginação simples por offset/limit ou infinite scroll — a escolha de implementação é deixada para a fase de planejamento.
- Não há envio de e-mail de notificação à equipe da imobiliária como parte desta feature (pode ser adicionado em feature futura).
- As páginas `/contato` e `/cadastro-residencia` são acessíveis publicamente sem autenticação, assim como as demais páginas públicas do site.
- A navbar já possui os itens "Sobre" e "Contato" renderizados; apenas os `href`/destinos de roteamento serão atualizados.

View file

@ -0,0 +1,206 @@
# Tasks: Central de Contatos com Rastreamento de Origem
**Input**: Design documents from `/specs/026-central-contatos/`
**Prerequisites**: spec.md ✅ · contexto técnico do autor ✅
**Branch**: `026-central-contatos`
## 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`, `US4`, `US5`)
- Caminhos exatos incluídos em cada tarefa
---
## Phase 1: Foundational — Modelo e Esquemas de Lead
**Purpose**: Adicionar os campos `source` e `source_detail` ao banco e às camadas Python. Este é o pré-requisito de todas as user stories — nenhuma pode ser implementada sem estes dados disponíveis.
**⚠️ BLOQUEANTE para todas as US**: nenhuma tarefa de US1US5 pode começar até T003 estar completo.
- [ ] T001 Criar migration Alembic para adicionar colunas `source VARCHAR(100) NULL` e `source_detail VARCHAR(255) NULL` à tabela `contact_leads` em `backend/migrations/versions/<timestamp>_add_source_to_contact_leads.py`
- **Detalhes**: usar `op.add_column('contact_leads', sa.Column('source', sa.String(100), nullable=True))` e `op.add_column('contact_leads', sa.Column('source_detail', sa.String(255), nullable=True))`; `downgrade` deve executar `op.drop_column` nas duas colunas; gerar com `alembic revision --autogenerate` e revisar o arquivo gerado.
- **Done**: `alembic upgrade head` executa sem erro; `\d contact_leads` mostra colunas `source` e `source_detail` como nullable; `alembic downgrade -1` remove as colunas sem erro.
- [ ] T002 Adicionar atributos `source` e `source_detail` ao modelo `ContactLead` em `backend/app/models/lead.py`
- **Detalhes**: adicionar `source = db.Column(db.String(100), nullable=True)` e `source_detail = db.Column(db.String(255), nullable=True)` após a coluna `message` existente; sem quebrar campos existentes (`id`, `property_id`, `name`, `email`, `phone`, `message`, `created_at`).
- **Done**: `ContactLead()` instancia sem erro; os dois campos são `None` por default; SQLAlchemy reflete corretamente no ORM após migration aplicada.
- [ ] T003 Atualizar schemas de lead em `backend/app/schemas/lead.py` para aceitar `source` e `source_detail` opcionais
- **Detalhes**: em `ContactLeadIn` (schema de entrada), adicionar `source: Optional[str] = None` e `source_detail: Optional[str] = None`; em `ContactLeadCreatedOut` (schema de saída), adicionar `source: Optional[str] = None` e `source_detail: Optional[str] = None`; importar `Optional` de `typing` se ainda não importado.
- **Done**: `ContactLeadIn(name="X", email="x@x.com", phone="99", message="msg")` valida sem erro (campos opcionais ausentes); `ContactLeadIn(..., source="imovel", source_detail="Apto Centro")` também valida; schema de saída serializa os dois campos.
**Checkpoint Phase 1**: `alembic upgrade head` OK; modelo ORM com os dois novos campos; schemas Pydantic validando opcionalmente. Base para todas as demais fases.
---
## Phase 2: Foundational — Endpoints Backend
**Purpose**: Expor o endpoint público de contato geral (`POST /contact`) e atualizar o roteamento do admin para filtrar por `source`. Estes dois endpoints são pré-requisitos de US1 e US4, respectivamente, e podem ser implementados em paralelo após Phase 1.
- [ ] T004 Criar endpoint `POST /api/v1/contact` (contato geral sem property) em `backend/app/routes/properties.py` ou em novo arquivo `backend/app/routes/contact.py`
- **Detalhes**: aceitar body `ContactLeadIn` via `request.get_json()`; validar com Pydantic; criar `ContactLead(property_id=None, source="contato", source_detail=None, **data.model_dump(exclude={"source","source_detail"}), name=data.name, email=data.email, phone=data.phone, message=data.message)`; persistir com `db.session.add` + `db.session.commit()`; retornar `ContactLeadCreatedOut.model_validate(lead).model_dump()` com status 201; registrar blueprint em `backend/app/__init__.py` se novo arquivo criado.
- **Done**: `POST /api/v1/contact` com payload válido retorna 201 e `id` do lead criado; lead salvo no banco com `source="contato"` e `property_id=NULL`; payload sem `name` retorna 422.
- [ ] T005 [P] Atualizar rota `GET /admin/leads` em `backend/app/routes/admin.py` para filtrar por `?source=`
- **Detalhes**: após o filtro existente `?property_id`, adicionar `source = request.args.get("source")`; se `source` não é None nem string vazia, aplicar `query = query.filter(ContactLead.source == source)`; manter retorno de todos os leads quando `source` não é passado; incluir os campos `source` e `source_detail` na serialização de saída (via schema ou `lead.__dict__`).
- **Done**: `GET /admin/leads` sem parâmetros retorna todos os leads; `GET /admin/leads?source=imovel` retorna somente leads com `source="imovel"`; `GET /admin/leads?source=contato` retorna somente leads com `source="contato"`; leads com `source=NULL` aparecem em "Todos" mas não nos filtros específicos.
**Checkpoint Phase 2**: curl `POST /api/v1/contact` retorna 201 ✓; curl `GET /admin/leads?source=contato` filtra corretamente ✓.
---
## Phase 3: US1 — Página de Contato Geral `/contato` (Priority: P1) 🎯 MVP
**Goal**: Disponibilizar página pública `/contato` com formulário funcional que cria leads com `source = "contato"`.
**Independent Test**: Acessar `/contato` sem autenticação, preencher nome, e-mail, telefone, assunto e mensagem e submeter. Verificar mensagem de confirmação na tela e lead salvo no banco com `source = "contato"`.
**Dependências**: T004 (endpoint `POST /api/v1/contact`) deve estar completo.
- [ ] T006 [US1] Criar função `contactGeneral(data)` em `frontend/src/services/properties.ts`
- **Detalhes**: exportar `async function contactGeneral(data: { name: string; email: string; phone: string; subject: string; message: string }): Promise<void>`; fazer `POST /api/v1/contact` com axios passando `{ ...data, source: "contato" }`; lançar erro em caso de resposta não-2xx.
- **Done**: função exportada e tipada; chama o endpoint correto com `source: "contato"` no payload.
- [ ] T007 [US1] Criar `frontend/src/pages/ContactPage.tsx`
- **Detalhes**: campos controlados: `name` (text, obrigatório), `email` (email, obrigatório, validar formato com regex), `phone` (tel, obrigatório), `subject` (select com opções: "Informações", "Anúncio", "Parceria", "Outro", obrigatório), `message` (textarea, obrigatório); estado `loading: boolean`, `success: boolean`, `errors: Record<string, string>`; validação client-side antes do submit (todos obrigatórios + formato de e-mail); ao submeter, chamar `contactGeneral(data)`; em sucesso, exibir mensagem de confirmação inline e desabilitar/resetar o formulário; em erro do servidor, exibir alerta de erro genérico; estilizar com Tailwind CSS consistente com o restante do projeto.
- **Done**: formulário renderiza; validação destaca campos obrigatórios ausentes sem submeter; e-mail inválido exibe erro inline; submissão bem-sucedida mostra confirmação e bloqueia re-envio; submissão com erro de servidor mostra feedback de erro.
- [ ] T008 [US1] Registrar rota `/contato` em `frontend/src/App.tsx`
- **Detalhes**: importar `ContactPage` de `./pages/ContactPage`; adicionar `<Route path="/contato" element={<ContactPage />} />` dentro do bloco de rotas públicas existente (dentro de `<Routes>`); não alterar nenhuma outra rota.
- **Done**: navegar para `/contato` renderiza `ContactPage`; rotas existentes não quebram.
**Checkpoint US1**: Acessar `/contato` → página renderiza ✓. Submeter com todos os campos → confirmação aparece ✓. Submeter sem nome → erro inline ✓. Lead salvo com `source="contato"` ✓.
---
## Phase 4: US2 — Página de Cadastro de Residência `/cadastro-residencia` (Priority: P1)
**Goal**: Disponibilizar página pública `/cadastro-residencia` para proprietários interessados em anunciar imóvel, criando lead com `source = "cadastro_residencia"`.
**Independent Test**: Acessar `/cadastro-residencia`, preencher todos os campos obrigatórios (nome, e-mail, telefone, endereço, tipo, finalidade) e submeter. Verificar lead criado com `source = "cadastro_residencia"` e `source_detail` contendo identificação do imóvel.
**Dependências**: T004 (endpoint `POST /api/v1/contact`) deve estar completo.
- [ ] T009 [US2] Criar `frontend/src/pages/CadastroResidenciaPage.tsx`
- **Detalhes**: campos controlados: `name` (text, obrigatório), `email` (email, obrigatório, validar formato), `phone` (tel, obrigatório), `address` (text, obrigatório), `propertyType` (select: "Casa", "Apartamento", "Comercial", obrigatório), `area` (number opcional, validar que é positivo quando preenchido), `purpose` (select: "Venda", "Aluguel", obrigatório), `notes` (textarea, opcional); estado `loading`, `success`, `errors`; ao submeter, construir `source_detail` como `"${propertyType} • ${purpose} • ${address}"` e enviar para `POST /api/v1/contact` com `{ name, email, phone, message: notes || "(sem observações)", source: "cadastro_residencia", source_detail }`; exibir confirmação em sucesso; estilizar com Tailwind CSS.
- **Done**: formulário renderiza todos os campos; campos obrigatórios ausentes são destacados; área não-numérica ou negativa exibe erro inline; submissão cria lead com `source="cadastro_residencia"` e `source_detail` identificável; confirmação aparece após sucesso.
- [ ] T010 [US2] Registrar rota `/cadastro-residencia` em `frontend/src/App.tsx`
- **Detalhes**: importar `CadastroResidenciaPage` de `./pages/CadastroResidenciaPage`; adicionar `<Route path="/cadastro-residencia" element={<CadastroResidenciaPage />} />` nas rotas públicas; não alterar outras rotas.
- **Done**: navegar para `/cadastro-residencia` renderiza `CadastroResidenciaPage`; rotas existentes não quebram.
**Checkpoint US2**: Acessar `/cadastro-residencia` → página renderiza ✓. Submeter com todos os campos → lead criado com `source="cadastro_residencia"` ✓. Área negativa → erro inline ✓.
---
## Phase 5: US4 — Central de Leads no Painel Admin (Priority: P1)
**Goal**: Página `/admin/leads` com listagem de todos os leads ordenada por data decrescente e filtro por origem (badge colorido por source).
**Independent Test**: Autenticar como admin, acessar `/admin/leads`, verificar leads das três origens listados. Aplicar filtro "Cadastro de Residência" e confirmar que apenas `source = "cadastro_residencia"` aparece.
**Dependências**: T005 (`GET /admin/leads?source=`) deve estar completo.
- [ ] T011 [US4] Criar `frontend/src/pages/admin/AdminLeadsPage.tsx`
- **Detalhes**: ao montar, buscar `GET /api/v1/admin/leads` (com token de autenticação via axios interceptor existente); estado `leads`, `loading`, `error`, `sourceFilter: string` (default `""`); quando `sourceFilter` não vazio, refazer fetch com `?source=${sourceFilter}`; renderizar barra de filtros com botões/tabs: "Todos", "Contato" (`source=contato`), "Imóvel" (`source=imovel`), "Cadastro de Residência" (`source=cadastro_residencia`); tabela com colunas: Origem (badge colorido por source — contato: azul, imovel: verde, cadastro_residencia: laranja, null/desconhecido: cinza), Nome, E-mail, Telefone, Prévia da mensagem (truncada em 60 chars), Data (formatada `dd/MM/yyyy`); badge "Imóvel" exibe `source_detail` como subtitle ou tooltip; ordenar por `created_at DESC`; exibir estado vazio ("Nenhum lead encontrado") quando lista vazia; suportar paginação básica (se a listagem retornar campo de total, exibir link "carregar mais" ou navegação por páginas).
- **Done**: página renderiza leads de todas as origens; filtro por source recarrega a lista; badge colorido por origem; `source_detail` visível para leads de imóvel; data formatada; estado de loading e erro tratados.
- [ ] T012 [US4] Adicionar item "Leads" ao menu lateral em `frontend/src/layouts/AdminLayout.tsx`
- **Detalhes**: localizar o array/lista de `navLinks` ou itens de menu do `AdminLayout`; adicionar entrada `{ label: "Leads", path: "/admin/leads" }` (ou equivalente JSX `<NavLink to="/admin/leads">Leads</NavLink>`) após o item existente que melhor se encaixa na ordem do menu (ex.: após "Imóveis" ou "Configurações"); não remover nem reordenar itens existentes.
- **Done**: menu lateral exibe item "Leads"; clicar navega para `/admin/leads`; link ativo é destacado pelo mecanismo existente.
- [ ] T013 [US4] Registrar rota `/admin/leads` em `frontend/src/App.tsx`
- **Detalhes**: importar `AdminLeadsPage` de `./pages/admin/AdminLeadsPage`; adicionar `<Route path="/admin/leads" element={<AdminLeadsPage />} />` dentro do bloco de rotas protegidas de admin (dentro do layout de admin existente); não alterar outras rotas.
- **Done**: navegar para `/admin/leads` como admin autenticado renderiza `AdminLeadsPage` dentro do `AdminLayout`; não autenticado redireciona para login.
**Checkpoint US4**: Acessar `/admin/leads` autenticado → listagem completa ✓. Filtrar por "Imóvel" → somente `source="imovel"` ✓. Badge colorido por origem ✓.
---
## Phase 6: US3 — Rastreamento de Origem no Formulário de Imóvel (Priority: P2)
**Goal**: O formulário de contato existente na página de detalhes do imóvel passa automaticamente `source = "imovel"` e `source_detail = property.title` sem alterar a experiência visual do usuário.
**Independent Test**: Acessar página de detalhes de um imóvel, enviar formulário de contato. Verificar no banco que o lead criado tem `source = "imovel"` e `source_detail` com o título do imóvel.
**Dependências**: T003 (schemas atualizados), T001/T002 (migration e modelo), mais T004 não é necessário — a rota de imóvel já existe. As tasks de frontend dependem de T003.
- [ ] T014 [US3] Atualizar rota `POST /properties/<slug>/contact` em `backend/app/routes/properties.py` para salvar `source` e `source_detail`
- **Detalhes**: após criar o objeto `data = ContactLeadIn(**request.get_json())`, ao instanciar `ContactLead`, definir `source = data.source or "imovel"` e `source_detail = data.source_detail`; o campo `property_id` já é definido pela slug da rota; o resto do handler permanece idêntico.
- **Done**: `POST /properties/<slug>/contact` salva `source="imovel"` quando o frontend não passa `source`; quando o frontend passa `source`, usa o valor recebido; `property_id` continua sendo preenchido pela slug.
- [ ] T015 [P] [US3] Atualizar a função `contactProperty` em `frontend/src/services/properties.ts` para aceitar e enviar `source` e `source_detail`
- **Detalhes**: adicionar `source?: string` e `source_detail?: string` ao tipo do parâmetro `data` (ou ao tipo `ContactData` se existir); incluir esses campos no payload da chamada axios existente: `{ ...data, source, source_detail }`.
- **Done**: `contactProperty(slug, { name, email, phone, message, source: "imovel", source_detail: "Título" })` envia o payload completo; chamadas sem `source`/`source_detail` continuam funcionando (campos opcionais).
- [ ] T016 [US3] Atualizar `frontend/src/pages/PropertyDetailPage.tsx` para passar `source` e `source_detail` ao chamar `contactProperty`
- **Detalhes**: localizar a chamada existente a `contactProperty(slug, data)` no handler de submit do formulário; adicionar `source: "imovel"` e `source_detail: property?.title ?? ""` ao objeto `data` passado; nenhuma mudança visual ou nos campos do formulário.
- **Done**: ao submeter o formulário de contato em `/imoveis/<slug>`, o lead criado tem `source="imovel"` e `source_detail` com o título do imóvel; a aparência do formulário é idêntica.
**Checkpoint US3**: Enviar formulário de contato em `/imoveis/<slug>` → lead com `source="imovel"` e `source_detail=título` no banco ✓. Formulário sem mudanças visuais ✓.
---
## Phase 7: US5 — Correção dos Links da Navbar (Priority: P3)
**Goal**: Os links "Contato" e "Sobre" da navbar navegam para rotas internas `/contato` e `/sobre` em vez de âncoras na homepage.
**Independent Test**: Em qualquer página, clicar "Contato" → URL muda para `/contato`. Clicar "Sobre" → URL muda para `/sobre`. Sem reload da página inteira.
**Dependências**: nenhuma — tarefa completamente independente; pode ser executada a qualquer momento após a branch ser criada.
- [ ] T017 [P] [US5] Corrigir links "Sobre" e "Contato" em `frontend/src/components/Navbar.tsx`
- **Detalhes**: localizar o array `navLinks` (ou equivalente); alterar a entrada com label/text "Sobre" de `href="/#sobre"` (ou `to="/#sobre"`) para `to="/sobre"`; alterar a entrada "Contato" de `href="/#contato"` (ou `to="/#contato"`) para `to="/contato"`; se os links usam tag `<a>`, converter para `<Link>` ou `<NavLink>` do react-router-dom para navegação client-side.
- **Done**: clicar "Contato" na navbar navega para `/contato` sem reload; clicar "Sobre" navega para `/sobre` sem reload; o link ativo é destacado pelo mecanismo existente do NavLink.
**Checkpoint US5**: Navbar em qualquer página: "Contato" → `/contato` ✓. "Sobre" → `/sobre` ✓. Navegação client-side (sem reload) ✓.
---
## Polish & Cross-Cutting
**Purpose**: Ajustes finais de UX e consistência que dependem de todas as user stories anteriores estarem completas.
- [ ] T018 Verificar responsividade dos formulários de `/contato` e `/cadastro-residencia` em viewport mobile (< 768px) ajustar classes Tailwind se necessário em `frontend/src/pages/ContactPage.tsx` e `frontend/src/pages/CadastroResidenciaPage.tsx`
- **Done**: formulários renderizam sem overflow horizontal em 375px de largura; inputs e botões têm tamanho mínimo de toque adequado.
- [ ] T019 [P] Garantir que leads com `source = NULL` (criados antes da feature) aparecem na listagem admin sem errors em `frontend/src/pages/admin/AdminLeadsPage.tsx`
- **Detalhes**: o badge de origem deve exibir "Desconhecida" (cinza) quando `source` é `null` ou `undefined`; nenhum crash ou `undefined` visível na UI.
- **Done**: leads sem `source` exibem badge cinza "Desconhecida"; filtros "Contato", "Imóvel" e "Cadastro de Residência" não incluem esses leads; filtro "Todos" os inclui.
---
## Dependency Graph
```
T001 → T002 → T003 ──┬──→ T004 ──→ T006 → T007 → T008 [US1]
│ └──→ T009 → T010 [US2]
├──→ T005 [US4 backend]
├──→ T014 [US3 backend]
└──→ T015 → T016 [US3 frontend]
T005 ──→ T011 → T012 → T013 [US4 frontend]
T017 [US5 — independente]
T018, T019 ── aguardam todas as US [Polish]
```
## Parallel Execution
**Após T003 completo**, as seguintes trilhas podem avançar em paralelo:
| Trilha A (US1) | Trilha B (US2) | Trilha C (US4 backend) | Trilha D (US3) | Trilha E (US5) |
|---|---|---|---|---|
| T004 → T006 → T007 → T008 | T004 → T009 → T010 | T005 | T014 + T015 → T016 | T017 |
> T004 é compartilhado entre US1 e US2 — completar antes de iniciar as duas trilhas.
## Implementation Strategy
**MVP mínimo** (US1 + US4): Phase 1 → Phase 2 → Phase 3 → Phase 5. Resultado: formulário `/contato` funcional + admin pode visualizar e filtrar leads por origem.
**Incremento 2** (+ US2): adicionar Phase 4. Proprietários já podem cadastrar imóveis para anúncio.
**Incremento 3** (+ US3): adicionar Phase 6. Rastreamento completo também para contatos originados de imóveis específicos.
**Incremento 4** (+ US5 + Polish): Phase 7 + Polish. Navbar corrigida e ajustes de responsividade.

View file

@ -0,0 +1,35 @@
# Specification Quality Checklist: Configuração da Página de Contato (Admin)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**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 aprovada sem necessidade de clarificações — todos os campos, escopo, padrão de autenticação e comportamento de fallback foram especificados pelo solicitante.
- Pronta para `/speckit.plan`.

View file

@ -0,0 +1,151 @@
# Feature Specification: Configuração da Página de Contato (Admin)
**Feature Branch**: `027-config-pagina-contato`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
A página `/contato` do site exibe informações institucionais de contato — endereço, telefone, e-mail e horário de atendimento — atualmente fixadas no código-fonte do frontend. Qualquer alteração nessas informações exige um deploy de código, o que cria dependência técnica para uma tarefa puramente operacional.
Esta spec cobre a criação de uma configuração persistida em banco de dados que o administrador pode editar pelo painel admin, tornando o conteúdo da página de contato dinâmico e gerenciável sem necessidade de deploy.
O padrão adotado é o mesmo já utilizado para a `HomepageConfig`: tabela singleton (sempre `id = 1`), endpoint público de leitura e endpoint protegido de escrita acessível apenas por administradores autenticados.
---
## User Scenarios & Testing
### User Story 1 — Administrador Atualiza as Informações de Contato (Priority: P1)
O administrador da imobiliária precisa atualizar o endereço, telefone, e-mail ou horário de atendimento sem depender de um desenvolvedor ou deploy de código.
**Why this priority**: É o núcleo da feature. Sem a capacidade de edição pelo admin, todo o restante não tem valor.
**Independent Test**: Autenticar como administrador, acessar a página de configuração de contato no painel admin (`/admin/contact-config`), alterar o campo de telefone para um novo valor e salvar. Verificar que a resposta da API pública `GET /api/v1/contact-config` retorna o novo valor e que a página `/contato` do site exibe o telefone atualizado após recarregar.
**Acceptance Scenarios**:
1. **Given** um administrador autenticado no painel admin, **When** ele acessa `/admin/contact-config`, **Then** um formulário é exibido com os valores atuais dos campos de endereço, telefone, e-mail e horário de atendimento já preenchidos.
2. **Given** o formulário preenchido com os valores atuais, **When** o admin altera o campo de telefone e clica em "Salvar", **Then** as alterações são persistidas e uma mensagem de sucesso é exibida na tela.
3. **Given** que a configuração foi salva com sucesso, **When** a API pública de configuração de contato é consultada, **Then** ela retorna os novos valores imediatamente, sem necessidade de reiniciar o sistema.
4. **Given** um administrador autenticado, **When** ele tenta salvar o formulário com o campo de e-mail em branco, **Then** o campo é destacado com erro de validação e o envio é bloqueado.
5. **Given** um administrador autenticado, **When** ele tenta salvar com um endereço de e-mail em formato inválido, **Then** o campo é destacado com erro de validação antes do envio ser processado.
---
### User Story 2 — Página de Contato Exibe Informações Dinâmicas (Priority: P1)
Um visitante do site acessa a página `/contato` e vê as informações de contato mais recentes cadastradas pelo administrador, sem nenhuma interação adicional necessária.
**Why this priority**: É o consumidor final da configuração. Sem a integração com a API, a feature não entrega valor ao visitante nem à imobiliária.
**Independent Test**: Com uma configuração de contato salva via painel admin, acessar `/contato` sem autenticação e verificar que o endereço, telefone, e-mail e horário de atendimento exibidos correspondem exatamente aos valores salvos — e não aos dados anteriormente fixados no código.
**Acceptance Scenarios**:
1. **Given** uma configuração de contato salva no sistema, **When** qualquer visitante acessa `/contato`, **Then** a página exibe o endereço (rua, bairro/cidade e CEP), telefone, e-mail e horário de atendimento provenientes da API.
2. **Given** que o administrador atualizou o horário de atendimento, **When** um visitante recarrega `/contato`, **Then** o novo horário é exibido imediatamente.
3. **Given** que a API de configuração de contato está indisponível, **When** um visitante acessa `/contato`, **Then** a página exibe um estado de carregamento ou uma mensagem informativa, sem exibir dados desatualizados ou causar erro de renderização crítico.
4. **Given** um visitante não autenticado, **When** ele acessa a rota pública `GET /api/v1/contact-config` diretamente, **Then** a resposta retorna os dados de configuração sem exigir autenticação.
---
### User Story 3 — Proteção do Endpoint de Edição (Priority: P1)
Apenas administradores autenticados podem alterar a configuração de contato. Tentativas não autorizadas são bloqueadas.
**Why this priority**: Segurança é requisito não-negociável para qualquer endpoint de escrita no painel admin. O impacto de um acesso não autorizado incluiria exibição de informações falsas para todos os visitantes do site.
**Independent Test**: Enviar uma requisição `PUT /admin/contact-config` sem token de autenticação (ou com token de usuário comum) e verificar que a resposta é HTTP 401 ou 403. Verificar também que os dados salvos no banco não foram alterados.
**Acceptance Scenarios**:
1. **Given** uma requisição `PUT /admin/contact-config` sem token de autenticação, **When** a requisição é processada, **Then** o sistema responde com erro de acesso não autorizado (HTTP 401) e não altera nenhum dado.
2. **Given** uma requisição `PUT /admin/contact-config` com um token de usuário comum (não administrador), **When** a requisição é processada, **Then** o sistema responde com erro de permissão insuficiente (HTTP 403) e não altera nenhum dado.
3. **Given** uma requisição `PUT /admin/contact-config` com token de administrador válido, **When** os dados enviados são válidos, **Then** a configuração é atualizada e o sistema retorna os dados atualizados (HTTP 200).
---
### Edge Cases
- O que acontece se a tabela `contact_config` estiver vazia (nenhuma configuração foi salva ainda)? O endpoint público deve retornar valores padrão pré-populados ou um erro?
- Como a página `/contato` se comporta durante o carregamento inicial enquanto aguarda a resposta da API?
- O que acontece se o campo `business_hours` for enviado com um texto excessivamente longo?
- Como o formulário admin lida com falha de rede ao tentar salvar — o usuário perde as alterações não salvas?
- O que acontece se dois administradores tentarem salvar a configuração simultaneamente?
---
## Requirements
### Functional Requirements
#### Grupo 1 — Dados e Persistência
- **FR-001**: O sistema DEVE armazenar as informações de configuração de contato em uma tabela singleton de forma que exista sempre exatamente um registro com `id = 1`, criado automaticamente na primeira leitura ou escrita caso ainda não exista.
- **FR-002**: A configuração DEVE incluir os seguintes campos: logradouro do endereço, complemento de bairro/cidade, CEP, telefone, e-mail e horário de atendimento (texto livre multilinha).
- **FR-003**: Todos os campos DEVEM ser obrigatórios — nenhum pode ser salvo como nulo ou vazio.
- **FR-004**: O campo de e-mail DEVE ser validado quanto ao formato antes de ser persistido.
- **FR-005**: O sistema DEVE registrar automaticamente a data e hora da última atualização da configuração.
#### Grupo 2 — API Pública de Leitura
- **FR-006**: O sistema DEVE disponibilizar um endpoint público de leitura de configuração de contato acessível sem autenticação.
- **FR-007**: O endpoint público DEVE retornar todos os campos da configuração em formato estruturado (um objeto com os campos nomeados).
- **FR-008**: O endpoint público DEVE retornar os valores padrão (correspondentes aos dados atualmente fixados no código) caso nenhuma configuração tenha sido salva ainda, em vez de retornar erro.
#### Grupo 3 — API Protegida de Escrita
- **FR-009**: O sistema DEVE disponibilizar um endpoint protegido de atualização de configuração de contato acessível apenas por administradores autenticados.
- **FR-010**: O endpoint protegido DEVE rejeitar requisições sem token de autenticação válido com HTTP 401.
- **FR-011**: O endpoint protegido DEVE rejeitar tokens de usuários com perfil diferente de administrador com HTTP 403.
- **FR-012**: O endpoint protegido DEVE validar todos os campos recebidos antes de persistir e retornar erros de validação específicos por campo em caso de dados inválidos (HTTP 422).
- **FR-013**: Após persistência bem-sucedida, o endpoint DEVE retornar os dados atualizados incluindo a data de última atualização.
#### Grupo 4 — Interface Admin
- **FR-014**: O painel admin DEVE disponibilizar uma página de edição de configuração de contato (`/admin/contact-config`) acessível apenas a administradores autenticados.
- **FR-015**: A página admin DEVE carregar automaticamente os valores atuais da configuração ao ser aberta e pré-preencher o formulário.
- **FR-016**: O formulário DEVE conter campos de texto para logradouro, bairro/cidade, CEP, telefone, e-mail e uma área de texto para horário de atendimento.
- **FR-017**: O formulário DEVE exibir erros de validação inline por campo antes de tentar salvar no servidor, quando possível (ex.: e-mail com formato inválido, campo obrigatório vazio).
- **FR-018**: O formulário DEVE exibir uma notificação de sucesso após salvar com êxito e uma notificação de erro em caso de falha na requisição ao servidor.
- **FR-019**: O botão de salvar DEVE ser desabilitado enquanto a requisição de salvamento estiver em andamento, para evitar submissões duplicadas.
#### Grupo 5 — Página Pública de Contato
- **FR-020**: A página `/contato` DEVE buscar as informações de contato da API pública em vez de usar valores fixados no código.
- **FR-021**: A página `/contato` DEVE exibir um indicador de carregamento enquanto aguarda a resposta da API.
- **FR-022**: A página `/contato` DEVE continuar renderizando normalmente em caso de falha na API, exibindo uma mensagem informativa no lugar das informações de contato.
- **FR-023**: A estrutura visual e o layout da página `/contato` NÃO DEVEM ser alterados por esta feature — apenas a origem dos dados muda.
### Key Entities
- **ContactConfig**: Registro singleton de configuração de contato da imobiliária. Atributos: logradouro, bairro/cidade, CEP, telefone, e-mail, horário de atendimento (texto multilinha), data de última atualização. Relacionamentos: nenhum — é uma entidade independente de configuração.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: O administrador consegue atualizar qualquer campo de contato em menos de 1 minuto, do acesso à página admin até a confirmação de salvamento.
- **SC-002**: A página `/contato` reflete as alterações feitas pelo admin imediatamente após o recarregamento da página, sem necessidade de nenhuma intervenção técnica.
- **SC-003**: 100% das tentativas de acesso não autenticado ao endpoint de escrita são bloqueadas com resposta de erro apropriada.
- **SC-004**: A página `/contato` permanece funcional e renderizável mesmo quando a API de configuração retorna erro, sem quebrar a experiência do visitante.
- **SC-005**: Nenhum campo de configuração de contato permanece fixado no código-fonte do frontend após a conclusão da feature.
---
## Assumptions
- O padrão singleton (`get_or_create` com `id = 1`) já é conhecido e usado na codebase (`HomepageConfig`); esta feature segue exatamente o mesmo padrão.
- Os valores padrão (usados como fallback quando nenhuma configuração existe) são os atualmente hardcoded na página `/contato`: endereço "Rua das Imobiliárias, 123 / Centro — São Paulo, SP / CEP 01000-000", telefone "(11) 99999-0000", e-mail "contato@imobiliariahub.com.br" e horário conforme texto atual.
- O mecanismo de autenticação e autorização de admin (`require_admin`) já existe e será reutilizado sem modificações.
- Não há necessidade de histórico de versões da configuração — apenas o valor atual importa.
- O campo `business_hours` é texto livre; a formatação de exibição (ex.: quebras de linha) é responsabilidade do componente de apresentação, não da API.
- Esta feature não altera o design, layout ou demais seções da página `/contato` — apenas substitui os dados hardcoded por dados dinâmicos.
- A migração de banco de dados para criar a tabela `contact_config` será gerada via Alembic, seguindo o padrão já adotado no projeto.
- A seed inicial que popula a tabela com os valores padrão é opcional — o comportamento de fallback no endpoint público é suficiente para o primeiro acesso.

View file

@ -0,0 +1,404 @@
# Tasks: Feature 027 — Configuração da Página de Contato (Admin)
**Branch**: `027-config-pagina-contato`
**Spec**: `specs/027-config-pagina-contato/spec.md`
**Última migration**: `g1h2i3j4k5l6_add_source_to_contact_leads.py`
---
## Fase 1 — Foundational: Backend Core (Pré-requisito para todos os user stories)
> **Objetivo**: Criar a tabela `contact_config`, o modelo ORM, os schemas Pydantic e o
> endpoint público de leitura. Nenhum user story pode ser implementado antes desta fase.
>
> **⚠️ CRÍTICO**: Concluir inteiramente antes de iniciar as fases 2 e 3.
- [ ] T001 Gerar migration Alembic para criar tabela `contact_config` com INSERT inicial em `backend/migrations/versions/h2i3j4k5l6m7_add_contact_config.py`
**Comando para gerar a migration** (executar de dentro do container ou com `.venv`):
```bash
flask --app run:app db revision --autogenerate -m "add_contact_config"
```
A migration deve:
1. Criar a tabela com os campos abaixo:
```python
op.create_table(
"contact_config",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("address_street", sa.String(200), nullable=False),
sa.Column("address_neighborhood_city", sa.String(200), nullable=False),
sa.Column("address_zip", sa.String(20), nullable=False),
sa.Column("phone", sa.String(30), nullable=False),
sa.Column("email", sa.String(254), nullable=False),
sa.Column("business_hours", sa.Text, nullable=False),
sa.Column("updated_at", sa.DateTime, nullable=False,
server_default=sa.func.now()),
)
```
2. Inserir o registro singleton com os valores atualmente hardcoded:
```python
op.execute("""
INSERT INTO contact_config
(id, address_street, address_neighborhood_city, address_zip,
phone, email, business_hours, updated_at)
VALUES
(1, 'Rua das Imobiliárias, 123',
'Centro — São Paulo, SP',
'CEP 01000-000',
'(11) 99999-0000',
'contato@imobiliariahub.com.br',
'Segunda a Sexta: 9h às 18h\nSábado: 9h às 13h',
NOW())
""")
```
- [ ] T002 Criar modelo `ContactConfig` em `backend/app/models/contact_config.py` seguindo o padrão de `HomepageConfig`
```python
from app.extensions import db
class ContactConfig(db.Model):
__tablename__ = "contact_config"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
address_street = db.Column(db.String(200), nullable=False)
address_neighborhood_city = db.Column(db.String(200), nullable=False)
address_zip = db.Column(db.String(20), nullable=False)
phone = db.Column(db.String(30), nullable=False)
email = db.Column(db.String(254), nullable=False)
business_hours = db.Column(db.Text, nullable=False)
updated_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
onupdate=db.func.now(),
)
def __repr__(self) -> str:
return f"<ContactConfig id={self.id!r}>"
```
- [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/contact_config.py` seguindo o padrão de `HomepageConfigOut`/`HomepageConfigIn`
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
class ContactConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
address_street: str
address_neighborhood_city: str
address_zip: str
phone: str
email: str
business_hours: str
updated_at: datetime
class ContactConfigIn(BaseModel):
address_street: str
address_neighborhood_city: str
address_zip: str
phone: str
email: EmailStr
business_hours: str
@field_validator("address_street", "address_neighborhood_city", "address_zip",
"phone", "business_hours")
@classmethod
def not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Campo não pode ser vazio")
return v
```
- [ ] T004 Criar rota pública `GET /api/v1/contact-config` em `backend/app/routes/contact_config.py` e registrar o blueprint em `backend/app/__init__.py`
**`backend/app/routes/contact_config.py`**:
```python
from flask import Blueprint, jsonify
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigOut
contact_config_bp = Blueprint("contact_config", __name__, url_prefix="/api/v1")
@contact_config_bp.get("/contact-config")
def get_contact_config():
config = ContactConfig.query.first()
if config is None:
return jsonify({"error": "Contact config not found"}), 404
return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json"))
```
**`backend/app/__init__.py`** — adicionar após o import de `homepage_bp`:
```python
from app.routes.contact_config import contact_config_bp
```
E registrar junto aos demais blueprints:
```python
app.register_blueprint(contact_config_bp)
```
**Checkpoint — Fase 1 concluída**: `GET /api/v1/contact-config` retorna os dados do banco. As fases 2 e 3 podem ser iniciadas em paralelo.
---
## Fase 2 — User Stories 1 + 3: Admin Edita Configuração e Endpoint Protegido (P1)
> **Objetivo**: Administrador acessa `/admin/contact-config`, vê o formulário preenchido
> com os valores atuais, edita e salva. O endpoint PUT rejeita acessos não autorizados.
>
> **Teste independente**: Autenticar como admin, acessar `/admin/contact-config`,
> alterar o telefone, clicar em "Salvar". Verificar `GET /api/v1/contact-config` retorna o
> novo valor. Verificar que `PUT /api/v1/admin/contact-config` sem token retorna HTTP 401.
### Implementação — User Stories 1 + 3
- [ ] T005 [US1] Adicionar rota `PUT /api/v1/admin/contact-config` em `backend/app/routes/admin.py` com `@require_admin`
**Adicionar imports** no topo de `backend/app/routes/admin.py`:
```python
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigIn, ContactConfigOut
```
**Adicionar a rota** (em qualquer ponto lógico do arquivo, ex.: próximo a outras rotas de configuração):
```python
@admin_bp.put("/contact-config")
@require_admin
def update_contact_config():
try:
data = ContactConfigIn.model_validate(request.get_json(force=True) or {})
except ValidationError as exc:
return jsonify({"errors": exc.errors()}), 422
config = ContactConfig.query.first()
if config is None:
config = ContactConfig(id=1, **data.model_dump())
db.session.add(config)
else:
for field, value in data.model_dump().items():
setattr(config, field, value)
db.session.commit()
db.session.refresh(config)
return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json"))
```
> `@require_admin` garante HTTP 401 para não-autenticados e HTTP 403 para não-admins (US3).
- [ ] T006 [P] [US1] Criar `frontend/src/services/contactConfig.ts` com `getContactConfig()` e `updateContactConfig()`
```typescript
import { api } from './api'
export interface ContactConfig {
address_street: string
address_neighborhood_city: string
address_zip: string
phone: string
email: string
business_hours: string
updated_at: string
}
export interface ContactConfigInput {
address_street: string
address_neighborhood_city: string
address_zip: string
phone: string
email: string
business_hours: string
}
export async function getContactConfig(): Promise<ContactConfig> {
const response = await api.get<ContactConfig>('/contact-config')
return response.data
}
export async function updateContactConfig(data: ContactConfigInput): Promise<ContactConfig> {
const response = await api.put<ContactConfig>('/admin/contact-config', data)
return response.data
}
```
- [ ] T007 [US1] Criar `frontend/src/pages/admin/AdminContactConfigPage.tsx` seguindo o padrão visual das demais páginas admin (ex.: `AdminAgentsPage.tsx`)
**Comportamento esperado**:
- `useEffect` faz `GET /api/v1/contact-config` ao montar e pré-preenche o form
- Estado local `form` com os 6 campos editáveis
- `handleSubmit` chama `updateContactConfig(form)`, exibe toast de sucesso ou erro
- Botão "Salvar" desabilitado enquanto `saving === true` (FR-019)
- Validação frontend: e-mail com formato válido, campos não vazios antes de submeter (FR-017)
- Erros de campo exibidos inline; toast global para erros de rede
**Estrutura do componente**:
```tsx
import { useState, useEffect } from 'react'
import { getContactConfig, updateContactConfig } from '../../services/contactConfig'
import type { ContactConfigInput } from '../../services/contactConfig'
const emptyForm: ContactConfigInput = {
address_street: '',
address_neighborhood_city: '',
address_zip: '',
phone: '',
email: '',
business_hours: '',
}
export default function AdminContactConfigPage() {
const [form, setForm] = useState<ContactConfigInput>(emptyForm)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getContactConfig()
.then(data => {
const { updated_at, ...editable } = data
setForm(editable)
})
.finally(() => setLoading(false))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
await updateContactConfig(form)
setSuccess(true)
} catch {
setError('Erro ao salvar. Tente novamente.')
} finally {
setSaving(false)
}
}
// Renderizar: loading skeleton → formulário com 5 inputs + 1 textarea + botão salvar
// Campos: Logradouro, Bairro/Cidade, CEP, Telefone, E-mail, Horário de Atendimento
}
```
- [ ] T008 [US1] Registrar rota `/admin/contact-config` em `frontend/src/App.tsx` e adicionar item `{ to: '/admin/contact-config', label: 'Conf. Contato' }` em `adminNavItems` em `frontend/src/components/Navbar.tsx`
**`frontend/src/App.tsx`** — localizar o trecho de rotas admin e adicionar:
```tsx
import AdminContactConfigPage from './pages/admin/AdminContactConfigPage'
// ...
<Route path="/admin/contact-config" element={<AdminContactConfigPage />} />
```
**`frontend/src/components/Navbar.tsx`** — acrescentar ao array `adminNavItems`:
```typescript
{ to: '/admin/contact-config', label: 'Conf. Contato' },
```
**Checkpoint — Fase 2 concluída**: Admin consegue editar e salvar a configuração de contato. Endpoint PUT retorna 401/403 para acessos não autorizados.
---
## Fase 3 — User Story 2: Página Pública de Contato Exibe Dados Dinâmicos (P1)
> **Objetivo**: A página `/contato` deixa de usar dados hardcoded e passa a consumir
> `GET /api/v1/contact-config`, preservando layout e estrutura visual existentes.
>
> **Teste independente**: Sem autenticação, acessar `/contato` e verificar que os dados
> exibidos correspondem ao banco (alterado via painel admin). A estrutura visual não muda.
### Implementação — User Story 2
- [ ] T009 [US2] Atualizar `frontend/src/pages/ContactPage.tsx` para consumir `getContactConfig()` no lugar dos dados hardcoded
**Comportamento esperado**:
- `useEffect` chama `getContactConfig()` ao montar
- Estado `config` inicializado como `null`; enquanto `loading === true` exibir skeleton ou spinner no lugar dos dados de contato (FR-021)
- Em caso de erro na requisição, exibir mensagem informativa em lugar dos dados — não renderizar valores obsoletos nem lançar erro de renderização (FR-022)
- Layout, classes CSS e demais seções da página NÃO devem ser alterados (FR-023)
**Campos a substituir** (remover literais hardcoded e usar `config.campo`):
- Endereço: `config.address_street`, `config.address_neighborhood_city`, `config.address_zip`
- Telefone: `config.phone`
- E-mail: `config.email`
- Horário de atendimento: `config.business_hours` (renderizar com `white-space: pre-line` ou equivalente para preservar quebras de linha)
**Exemplo de estrutura**:
```tsx
import { useState, useEffect } from 'react'
import { getContactConfig } from '../services/contactConfig'
import type { ContactConfig } from '../services/contactConfig'
export default function ContactPage() {
const [config, setConfig] = useState<ContactConfig | null>(null)
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState(false)
useEffect(() => {
getContactConfig()
.then(setConfig)
.catch(() => setFetchError(true))
.finally(() => setLoading(false))
}, [])
// ...restante do JSX existente — apenas substituir as strings hardcoded
// por {loading ? <Skeleton /> : fetchError ? <p>Informações indisponíveis</p> : config?.campo}
}
```
**Checkpoint — Fase 3 concluída**: `/contato` exibe dados dinâmicos da API. Todos os user stories são funcionais e testáveis de forma independente.
---
## Fase 4 — Polish & Verificações Finais
- [ ] T010 [P] Verificar que `backend/app/models/__init__.py` exporta `ContactConfig` (se o arquivo contiver imports explícitos dos modelos)
Se o arquivo importar modelos explicitamente, adicionar:
```python
from app.models.contact_config import ContactConfig # noqa: F401
```
- [ ] T011 [P] Aplicar a migration no banco de dados e verificar o registro singleton
```bash
# Dentro do container ou com .venv ativo:
flask --app run:app db upgrade
# Verificar:
# SELECT * FROM contact_config; → deve retornar 1 linha com id=1
```
---
## Dependências entre Tasks
```
T001 → T002 → T003 → T004 (blueprint público)
T005 (PUT admin)
T006 → T007 → T008 (rotas frontend)
T006 → T009 (ContactPage)
```
**Execução paralela possível**:
- T003 pode começar em paralelo com T002 (schemas não importam o modelo diretamente)
- T006, T007, T008, T009 podem ser desenvolvidos em paralelo após T001T004
---
## Escopo MVP
O **MVP mínimo** é completar as fases 1, 2 e 3 integralmente — as três user stories têm
prioridade P1 e são interdependentes para entregar valor. A fase 4 é verificação final.

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Página "Trabalhe Conosco"
**Purpose**: Validar completude e qualidade da especificação antes de avançar para o planejamento
**Created**: 2026-04-21
**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
- Upload real de arquivo está explicitamente fora do escopo (Assumption documentada)
- Listagem no painel admin cobre apenas a API; UI React de `/admin/jobs` pode ser entregue em iteração futura
- Spec pronta para `/speckit.plan`

View file

@ -0,0 +1,210 @@
# API Contracts: Trabalhe Conosco
**Feature**: 028-trabalhe-conosco
**Phase**: 1 — Design & Contracts
**Base URL**: `/api/v1`
---
## Endpoints
| Método | Path | Auth | Descrição |
|--------|-------------------------|--------------|------------------------------------------|
| POST | `/jobs/apply` | Nenhuma | Submeter candidatura (público) |
| GET | `/admin/jobs` | `@require_admin` (JWT) | Listar candidaturas paginadas (admin) |
---
## POST /api/v1/jobs/apply
Endpoint público. Recebe os dados textuais da candidatura e persiste na tabela `job_applications`.
### Request
**Headers**
```
Content-Type: application/json
```
**Body** (JSON)
| Campo | Tipo | Obrigatório | Validações |
|-----------------|----------|-------------|-------------------------------------------------------------------------|
| `name` | string | Sim | Não pode ser vazio ou apenas espaços; strip aplicado |
| `email` | string | Sim | Formato de e-mail válido (RFC-5321 via `pydantic.EmailStr`) |
| `phone` | string | Não | Qualquer string; sem validação de formato nesta versão |
| `role_interest` | string | Sim | Deve ser exatamente um de: `"Corretor(a)"`, `"Assistente Administrativo"`, `"Estagiário(a)"`, `"Outro"` |
| `message` | string | Sim | Não pode ser vazio; máximo 5000 caracteres |
| `file_name` | string | Não | Nome do arquivo de currículo; sem conteúdo binário |
**Exemplo de request**
```json
{
"name": "Ana Lima",
"email": "ana.lima@email.com",
"phone": "(11) 98765-4321",
"role_interest": "Corretor(a)",
"message": "Tenho 5 anos de experiência no mercado imobiliário e gostaria de integrar a equipe.",
"file_name": "curriculo-ana-lima.pdf"
}
```
### Responses
#### 201 Created — Candidatura registrada com sucesso
```json
{
"message": "Candidatura recebida com sucesso"
}
```
#### 422 Unprocessable Entity — Dados inválidos
```json
{
"error": "Dados inválidos",
"details": [
{
"type": "value_error",
"loc": ["role_interest"],
"msg": "Value error, role_interest deve ser um de: Assistente Administrativo, Corretor(a), Estagiário(a), Outro",
"input": "Diretor",
"url": "https://errors.pydantic.dev/..."
}
]
}
```
#### 400 Bad Request — Body ausente ou não é JSON válido
```json
{
"error": "Dados inválidos",
"details": [...]
}
```
---
## GET /api/v1/admin/jobs
Endpoint protegido. Retorna listagem paginada de todas as candidaturas em ordem decrescente de `created_at`.
### Request
**Headers**
```
Authorization: Bearer <jwt_token>
Content-Type: application/json
```
**Query Parameters**
| Parâmetro | Tipo | Default | Restrições | Descrição |
|------------|---------|---------|-----------------|------------------------|
| `page` | integer | `1` | ≥ 1 | Número da página |
| `per_page` | integer | `20` | 1 100 | Registros por página |
**Exemplo de request**
```
GET /api/v1/admin/jobs?page=1&per_page=20
```
### Responses
#### 200 OK — Lista retornada com sucesso
```json
{
"items": [
{
"id": 7,
"name": "Ana Lima",
"email": "ana.lima@email.com",
"phone": "(11) 98765-4321",
"role_interest": "Corretor(a)",
"message": "Tenho 5 anos de experiência no mercado imobiliário...",
"file_name": "curriculo-ana-lima.pdf",
"status": "pending",
"created_at": "2026-04-21T14:35:00"
},
{
"id": 6,
"name": "Carlos Souza",
"email": "carlos@email.com",
"phone": null,
"role_interest": "Estagiário(a)",
"message": "Estudante de Administração em busca do primeiro emprego.",
"file_name": null,
"status": "pending",
"created_at": "2026-04-20T09:12:00"
}
],
"total": 42,
"page": 1,
"per_page": 20,
"pages": 3
}
```
**Schema do item** (`JobApplicationOut`)
| Campo | Tipo | Nullable | Descrição |
|-----------------|------------------|----------|----------------------------------|
| `id` | integer | Não | Identificador único |
| `name` | string | Não | Nome completo do candidato |
| `email` | string | Não | E-mail do candidato |
| `phone` | string \| null | Sim | Telefone (opcional) |
| `role_interest` | string | Não | Cargo de interesse selecionado |
| `message` | string | Não | Mensagem/apresentação |
| `file_name` | string \| null | Sim | Nome do arquivo de currículo |
| `status` | string | Não | Estado: `"pending"` (padrão) |
| `created_at` | string (ISO 8601)| Não | Data/hora do envio |
**Schema de paginação**
| Campo | Tipo | Descrição |
|------------|---------|-------------------------------------|
| `total` | integer | Total de candidaturas no sistema |
| `page` | integer | Página atual |
| `per_page` | integer | Registros retornados nesta página |
| `pages` | integer | Total de páginas |
#### 200 OK — Nenhuma candidatura registrada
```json
{
"items": [],
"total": 0,
"page": 1,
"per_page": 20,
"pages": 0
}
```
#### 401 Unauthorized — Token ausente ou inválido
```json
{
"error": "Token inválido ou ausente"
}
```
#### 403 Forbidden — Usuário autenticado sem permissão de admin
```json
{
"error": "Acesso negado"
}
```
---
## Notas de Implementação
- O endpoint `POST /api/v1/jobs/apply` **não** possui autenticação — qualquer cliente pode submeter.
- O endpoint `GET /api/v1/admin/jobs` usa o decorator `@require_admin` já existente no projeto, que valida o JWT e verifica a flag de administrador.
- O campo `created_at` é serializado pelo Pydantic como ISO 8601 sem timezone (`TIMESTAMP WITHOUT TIME ZONE` no PostgreSQL).
- `per_page` deve ser limitado a 100 no backend para evitar queries excessivamente grandes.
- Campos ausentes no body do POST são tratados pelo Pydantic: obrigatórios geram erro 422, opcionais recebem `None`.

View file

@ -0,0 +1,215 @@
# Data Model: Trabalhe Conosco
**Feature**: 028-trabalhe-conosco
**Phase**: 1 — Design & Contracts
**Source**: spec.md
---
## Entidade: JobApplication (Candidatura)
### Tabela: `job_applications`
| 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 |
| `email` | `VARCHAR(254)` | NOT NULL | — | formato e-mail válido (validado no backend)|
| `phone` | `VARCHAR(30)` | NULL | — | opcional conforme spec |
| `role_interest` | `VARCHAR(100)` | NOT NULL | — | enum: Corretor(a), Assistente Administrativo, Estagiário(a), Outro |
| `message` | `TEXT` | NOT NULL | — | apresentação/mensagem do candidato |
| `file_name` | `VARCHAR(255)` | NULL | — | nome do arquivo de currículo (sem upload) |
| `status` | `VARCHAR(50)` | NOT NULL | `'pending'` | estado da candidatura (pending / reviewed) |
| `created_at` | `TIMESTAMP WITHOUT TIME ZONE` | NOT NULL | `now()` (server) | imutável após criação |
### Índices
| Índice | Colunas | Motivo |
|------------------------------------|-----------------|------------------------------------------------|
| `ix_job_applications_created_at` | `created_at` | ordenação DESC na listagem admin |
| `ix_job_applications_status` | `status` | filtragem futura por estado |
### Invariantes
1. `name`, `email`, `role_interest` e `message` nunca são deixados em branco (validação Pydantic).
2. `email` deve ser validado com `pydantic.EmailStr` — formato RFC-5321.
3. `role_interest` deve ser um dos valores permitidos: `"Corretor(a)"`, `"Assistente Administrativo"`, `"Estagiário(a)"`, `"Outro"`.
4. `message` não pode ultrapassar 5000 caracteres (validação frontend + Pydantic `max_length`).
5. `phone` é opcional — sem validação de formato nesta versão.
6. `file_name` armazena apenas o nome do arquivo informado, sem conteúdo binário.
7. Múltiplas candidaturas do mesmo `email` são permitidas (sem deduplicação nesta versão).
8. Nenhum `DELETE` físico é exposto; o campo `status` permite rastreabilidade futura.
### Diagrama ER
```
job_applications
├── id PK SERIAL
├── name VARCHAR(150) NOT NULL
├── email VARCHAR(254) NOT NULL
├── phone VARCHAR(30) NULL
├── role_interest VARCHAR(100) NOT NULL
├── message TEXT NOT NULL
├── file_name VARCHAR(255) NULL
├── status VARCHAR(50) NOT NULL DEFAULT 'pending'
└── created_at TIMESTAMP NOT NULL DEFAULT now()
```
Sem relacionamentos com outras tabelas nesta versão. Entidade standalone.
---
## Modelo SQLAlchemy: `backend/app/models/job_application.py`
```python
from app.extensions import db
ROLE_INTEREST_OPTIONS = [
"Corretor(a)",
"Assistente Administrativo",
"Estagiário(a)",
"Outro",
]
class JobApplication(db.Model):
__tablename__ = "job_applications"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(150), nullable=False)
email = db.Column(db.String(254), nullable=False)
phone = db.Column(db.String(30), nullable=True)
role_interest = db.Column(db.String(100), nullable=False)
message = db.Column(db.Text, nullable=False)
file_name = db.Column(db.String(255), nullable=True)
status = db.Column(db.String(50), nullable=False, default="pending")
created_at = db.Column(
db.DateTime, nullable=False, server_default=db.func.now()
)
def __repr__(self) -> str:
return f"<JobApplication id={self.id} email={self.email!r}>"
```
---
## Migration Alembic: `backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py`
```python
"""add job_applications table
Revision ID: i1j2k3l4m5n6
Revises: h1i2j3k4l5m6
Create Date: 2026-04-21 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "i1j2k3l4m5n6"
down_revision = "h1i2j3k4l5m6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"job_applications",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=150), nullable=False),
sa.Column("email", sa.String(length=254), nullable=False),
sa.Column("phone", sa.String(length=30), nullable=True),
sa.Column("role_interest", sa.String(length=100), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("file_name", sa.String(length=255), nullable=True),
sa.Column(
"status",
sa.String(length=50),
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_job_applications_created_at", "job_applications", ["created_at"]
)
op.create_index(
"ix_job_applications_status", "job_applications", ["status"]
)
def downgrade():
op.drop_index("ix_job_applications_status", table_name="job_applications")
op.drop_index("ix_job_applications_created_at", table_name="job_applications")
op.drop_table("job_applications")
```
---
## Schemas Pydantic: `backend/app/schemas/job_application.py`
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
VALID_ROLES = {"Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"}
class JobApplicationIn(BaseModel):
name: str
email: EmailStr
phone: str | None = None
role_interest: str
message: str
file_name: str | None = None
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name não pode ser vazio")
return v
@field_validator("role_interest")
@classmethod
def role_must_be_valid(cls, v: str) -> str:
if v not in VALID_ROLES:
raise ValueError(f"role_interest deve ser um de: {', '.join(sorted(VALID_ROLES))}")
return v
@field_validator("message")
@classmethod
def message_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("message não pode ser vazia")
if len(v) > 5000:
raise ValueError("message não pode ultrapassar 5000 caracteres")
return v
class JobApplicationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
phone: str | None
role_interest: str
message: str
file_name: str | None
status: str
created_at: datetime
```

View file

@ -0,0 +1,354 @@
# Implementation Plan: Trabalhe Conosco
**Branch**: `028-trabalhe-conosco` | **Date**: 2026-04-21 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/028-trabalhe-conosco/spec.md`
## Summary
Criar a página pública `/trabalhe-conosco` com hero section, seção de benefícios (3 cards estáticos) e formulário de candidatura. O formulário submete via `POST /api/v1/jobs/apply` (endpoint público sem auth). As candidaturas são persistidas na tabela `job_applications` e recuperáveis pelo administrador via `GET /api/v1/admin/jobs` (paginado, protegido por `@require_admin`). Links adicionados no footer (coluna "A Imobiliária") e em `AgentsPage.tsx`. Dois novos blueprints Flask, novo model SQLAlchemy, migration Alembic, schemas Pydantic e uma nova página React com serviço Axios.
---
## 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 `job_applications`
**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-006); listagem admin paginada (20/página)
**Constraints**: sem upload real de arquivo (apenas `file_name` como texto); sem envio de e-mail; sem rate limiting nesta versão; múltiplas candidaturas do mesmo e-mail são permitidas
**Scale/Scope**: volume baixo de candidaturas; paginação padrão 20/página
---
## Constitution Check
| Princípio | Status | Observação |
|-----------|--------|------------|
| **I. Design-First** | ✅ PASS | Hero, cards de benefícios e formulário seguem design tokens dark do `DESIGN.md`; cores `#5e6ad2`, tipografia Inter, cards com `bg-panel border-borderSubtle` |
| **II. Separation of Concerns** | ✅ PASS | Flask retorna JSON puro; React SPA consome via Axios; zero lógica de renderização no backend |
| **III. Spec-Driven** | ✅ PASS | `spec.md` com user stories P1/P2/P3 e acceptance scenarios; plan derivado do spec |
| **IV. Data Integrity** | ✅ PASS | Migration Alembic (`i1j2k3l4m5n6`); Pydantic valida todos os inputs; `email: EmailStr`; sem raw SQL |
| **V. Security** | ✅ PASS | Endpoint admin protegido por `@require_admin` (JWT); endpoint público não expõe dados internos; sem exposição de stack traces em erro 500 |
| **VI. Simplicity First** | ✅ PASS | Sem upload binário (justificado na spec), sem e-mail transacional, sem rate limiting nesta versão; página de admin React adiada para iteração futura (conforme Assumptions da spec) |
**Veredicto**: Sem violações. Pode prosseguir com implementação.
---
## Project Structure
### Documentação (esta feature)
```text
specs/028-trabalhe-conosco/
├── spec.md # Especificação de produto
├── data-model.md # Entidade JobApplication, migration, schemas
├── plan.md # Este arquivo
├── contracts/
│ └── jobs-api.md # Contratos dos 2 endpoints REST
└── tasks.md # (Phase 2 — gerado por /speckit.tasks)
```
### Código-fonte (raiz do repositório)
```text
backend/
├── app/
│ ├── models/
│ │ └── job_application.py # NOVO — modelo SQLAlchemy JobApplication
│ ├── schemas/
│ │ └── job_application.py # NOVO — JobApplicationIn, JobApplicationOut
│ ├── routes/
│ │ └── jobs.py # NOVO — jobs_public_bp + jobs_admin_bp
│ └── __init__.py # MODIFICAR — importar model + registrar blueprints
└── migrations/
└── versions/
└── i1j2k3l4m5n6_add_job_applications.py # NOVO — cria tabela + índices
frontend/
└── src/
├── types/
│ └── jobApplication.ts # NOVO — interface JobApplication
├── services/
│ └── jobs.ts # NOVO — submitApplication(), listApplications()
├── pages/
│ └── JobsPage.tsx # NOVO — página pública /trabalhe-conosco
├── App.tsx # MODIFICAR — adicionar rota /trabalhe-conosco
└── components/
└── Footer.tsx # MODIFICAR — link "Trabalhe Conosco" em "A Imobiliária"
└── pages/
└── AgentsPage.tsx # MODIFICAR — link/botão "Trabalhe Conosco"
```
---
## Backend: Arquitetura Técnica
### Model: `backend/app/models/job_application.py`
Entidade standalone com 9 campos. Ver [data-model.md](data-model.md) para schema completo e invariantes.
Campos principais:
- `id` — PK SERIAL
- `name`, `email`, `role_interest`, `message` — obrigatórios
- `phone`, `file_name` — opcionais
- `status` — default `"pending"` (extensível futuramente)
- `created_at` — server_default `now()`, imutável
### Schemas Pydantic: `backend/app/schemas/job_application.py`
**`JobApplicationIn`** (entrada do endpoint público):
- Valida `name` (não vazio, strip), `email` (EmailStr), `role_interest` (enum de 4 opções), `message` (não vazio, max 5000 chars)
- `phone` e `file_name` opcionais
**`JobApplicationOut`** (saída do endpoint admin):
- Retorna todos os campos incluindo `id`, `status` e `created_at`
- `model_config = ConfigDict(from_attributes=True)` para serialização ORM
### Blueprints: `backend/app/routes/jobs.py`
Dois blueprints no mesmo arquivo, seguindo o padrão de `routes/agents.py`:
```python
jobs_public_bp = Blueprint("jobs_public", __name__, url_prefix="/api/v1")
jobs_admin_bp = Blueprint("jobs_admin", __name__, url_prefix="/api/v1/admin")
```
**`POST /api/v1/jobs/apply`** (público, sem autenticação):
1. `request.get_json(silent=True) or {}`
2. `JobApplicationIn.model_validate(data)` → 422 com `exc.errors()` se inválido
3. Instanciar `JobApplication(...)` e `db.session.add` + `db.session.commit()`
4. Retornar `{"message": "Candidatura recebida com sucesso"}`, HTTP 201
**`GET /api/v1/admin/jobs`** (protegido por `@require_admin`):
1. Query params: `page` (default 1), `per_page` (default 20, max 100)
2. `JobApplication.query.order_by(JobApplication.created_at.desc()).paginate(page, per_page, error_out=False)`
3. Serializar com `JobApplicationOut` e retornar envelope paginado:
```json
{
"items": [...],
"total": 42,
"page": 1,
"per_page": 20,
"pages": 3
}
```
4. `@require_admin` dispara 401/403 automaticamente
### Registro em `backend/app/__init__.py`
Dois patches necessários:
```python
# Importar model (para Flask-Migrate detectar)
from app.models import job_application as _job_application_models # noqa: F401
# Importar e registrar blueprints
from app.routes.jobs import jobs_public_bp, jobs_admin_bp
app.register_blueprint(jobs_public_bp)
app.register_blueprint(jobs_admin_bp)
```
### Migration Alembic
Arquivo: `i1j2k3l4m5n6_add_job_applications.py`
- `down_revision = "h1i2j3k4l5m6"` (migration atual mais recente: `create_contact_config`)
- Cria tabela `job_applications` com 9 colunas
- Cria índices: `ix_job_applications_created_at`, `ix_job_applications_status`
- `downgrade()` desfaz índices e tabela
Ver código completo em [data-model.md](data-model.md).
---
## Frontend: Arquitetura Técnica
### Types: `frontend/src/types/jobApplication.ts`
```typescript
export interface JobApplicationPayload {
name: string
email: string
phone?: string
role_interest: string
message: string
file_name?: string
}
export interface JobApplication {
id: number
name: string
email: string
phone: string | null
role_interest: string
message: string
file_name: string | null
status: string
created_at: string
}
export interface JobApplicationsResponse {
items: JobApplication[]
total: number
page: number
per_page: number
pages: number
}
```
### Service: `frontend/src/services/jobs.ts`
```typescript
import api from './api'
import type { JobApplicationPayload, JobApplicationsResponse } from '../types/jobApplication'
export async function submitApplication(payload: JobApplicationPayload): Promise<void> {
await api.post('/api/v1/jobs/apply', payload)
}
export async function listApplications(
page = 1,
perPage = 20
): Promise<JobApplicationsResponse> {
const { data } = await api.get('/api/v1/admin/jobs', {
params: { page, per_page: perPage },
})
return data
}
```
### Página: `frontend/src/pages/JobsPage.tsx`
Estrutura da página (3 seções, de cima para baixo):
#### 1. Hero Section
```
bg-canvas | max-w-[1200px] mx-auto px-6 pt-16 pb-10
├── eyebrow: "Faça parte do nosso time" (text-[#5e6ad2] uppercase tracking-widest)
├── h1: "Trabalhe Conosco" (text-3xl md:text-4xl font-semibold text-textPrimary)
└── subtítulo: texto descritivo (text-textSecondary)
```
#### 2. Seção "Por que trabalhar conosco?" (3 cards estáticos)
```
max-w-[1200px] mx-auto px-6 py-10
├── h2: "Por que trabalhar conosco?" (text-xl font-semibold text-textPrimary mb-6)
└── grid grid-cols-1 md:grid-cols-3 gap-5
├── Card 1: ícone + "Ambiente Colaborativo" + descrição
├── Card 2: ícone + "Crescimento Profissional" + descrição
└── Card 3: ícone + "Remuneração Competitiva" + descrição
(cada card: bg-panel border border-borderSubtle rounded-2xl p-6)
```
#### 3. Formulário de Candidatura
```
max-w-[640px] mx-auto px-6 pb-20
├── h2: "Envie sua candidatura"
└── <form onSubmit={handleSubmit}>
├── name — input text, obrigatório
├── email — input email, obrigatório, validação RFC
├── phone — input tel, opcional
├── role_interest — select (4 opções), obrigatório
├── message — textarea, obrigatório, max 5000 chars, contador de chars
├── file (currículo) — input file, accept=".pdf", max 2MB (validação frontend only)
│ ao selecionar: setFileName(file.name), não envia binário
└── submit button "Enviar Candidatura"
```
**Gerenciamento de estado** (hooks locais, sem Redux/Context):
- `formData` — estado do formulário
- `fileName` — nome do arquivo selecionado (string | null)
- `errors` — Record<string, string> para mensagens por campo
- `submitting` — boolean, desabilita botão durante requisição
- `submitted` — boolean, exibe mensagem de sucesso e reseta form
- `serverError` — string | null, erro de rede/500
**Validação frontend** (antes de chamar `submitApplication`):
- `name`: obrigatório, trim
- `email`: obrigatório, regex RFC simples
- `role_interest`: obrigatório, não pode ser valor vazio/placeholder
- `message`: obrigatório, max 5000 chars
- `file`: se presente, extensão `.pdf` e tamanho ≤ 2 MB; apenas registra `file_name`
**Fluxo de submit**:
1. Validar campos → exibir erros por campo se inválido
2. `setSubmitting(true)`
3. `submitApplication({ name, email, phone, role_interest, message, file_name: fileName ?? undefined })`
4. Sucesso → `setSubmitted(true)`, resetar `formData`, `setFileName(null)`
5. Erro 422 → parsear `details` e mapear para `errors` por campo
6. Erro ≥ 500 ou rede → `setServerError("Erro ao enviar candidatura. Tente novamente.")`
7. `finally``setSubmitting(false)`
**Design tokens** (seguir padrão do projeto):
- Inputs: `w-full bg-panel border border-borderSubtle rounded-lg px-4 py-2.5 text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/50`
- Labels: `text-sm font-medium text-textSecondary mb-1.5`
- Erros: `text-xs text-red-400 mt-1`
- Botão submit: `w-full bg-[#5e6ad2] hover:bg-[#4f5bbf] text-white font-medium py-2.5 rounded-lg transition-colors duration-150 disabled:opacity-60`
- Mensagem de sucesso: `bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-6 text-center`
### Modificação: `frontend/src/App.tsx`
Adicionar a rota da nova página:
```tsx
import JobsPage from './pages/JobsPage'
// ...
<Route path="/trabalhe-conosco" element={<JobsPage />} />
```
### Modificação: `frontend/src/components/Footer.tsx`
Adicionar link na coluna "A Imobiliária" (após "Política de Privacidade"):
```tsx
<FooterLink to="/trabalhe-conosco">Trabalhe Conosco</FooterLink>
```
### Modificação: `frontend/src/pages/AgentsPage.tsx`
Adicionar banner/botão após o grid de corretores e antes do `<Footer />`:
```tsx
{/* CTA Trabalhe Conosco */}
<div className="max-w-[1200px] mx-auto px-6 pb-20">
<div className="bg-panel border border-borderSubtle rounded-2xl p-8 flex flex-col sm:flex-row items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-textPrimary">Quer fazer parte do time?</h2>
<p className="text-textSecondary text-sm mt-1">Envie sua candidatura e venha crescer conosco.</p>
</div>
<Link
to="/trabalhe-conosco"
className="shrink-0 bg-[#5e6ad2] hover:bg-[#4f5bbf] text-white font-medium px-5 py-2.5 rounded-lg transition-colors duration-150 text-sm"
>
Trabalhe Conosco
</Link>
</div>
</div>
```
---
## Sequência de Implementação
1. **Migration** — criar `i1j2k3l4m5n6_add_job_applications.py` e rodar `flask db upgrade`
2. **Model** — criar `backend/app/models/job_application.py`
3. **Schemas** — criar `backend/app/schemas/job_application.py`
4. **Routes** — criar `backend/app/routes/jobs.py`
5. **Register** — modificar `backend/app/__init__.py` (model import + blueprints)
6. **Types** — criar `frontend/src/types/jobApplication.ts`
7. **Service** — criar `frontend/src/services/jobs.ts`
8. **Page** — criar `frontend/src/pages/JobsPage.tsx`
9. **Route** — modificar `frontend/src/App.tsx`
10. **Footer** — modificar `frontend/src/components/Footer.tsx`
11. **AgentsPage** — modificar `frontend/src/pages/AgentsPage.tsx`
---
## Complexity Tracking
Sem violações de constituição — seção não aplicável.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|--------------------------------------|
| — | — | — |

View file

@ -0,0 +1,150 @@
# Feature Specification: Página "Trabalhe Conosco"
**Feature Branch**: `028-trabalhe-conosco`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
O site imobiliário atualmente não oferece um canal formal para que candidatos manifestem interesse em trabalhar na empresa. Esse contato ocorre de maneira informal — por telefone, e-mail avulso ou presencialmente — sem rastreabilidade e sem uma experiência consistente para o candidato.
Esta spec cobre a criação de uma página pública "/trabalhe-conosco" com formulário de candidatura, armazenamento das submissões em banco de dados e listagem das candidaturas no painel administrativo. A página também deve ser acessível via links no footer e na página de equipe, tornando o recrutamento um ponto de contato organizado e profissional.
---
## User Scenarios & Testing
### User Story 1 — Candidato Envia Formulário de Candidatura (Priority: P1)
Um candidato interessado em trabalhar na imobiliária acessa a página "/trabalhe-conosco", preenche o formulário com seus dados e envia sua candidatura.
**Why this priority**: É o núcleo da feature. Toda a proposta de valor gira em torno dessa ação — sem ela, a página é apenas um conteúdo estático sem utilidade.
**Independent Test**: Acessar `/trabalhe-conosco` sem autenticação, preencher todos os campos obrigatórios (nome, e-mail, telefone, cargo de interesse, mensagem) e submeter. Verificar que uma mensagem de sucesso é exibida e que a candidatura aparece na listagem do painel admin em `GET /api/v1/admin/jobs`.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado na página `/trabalhe-conosco`, **When** ele preenche todos os campos obrigatórios e clica em "Enviar Candidatura", **Then** a candidatura é registrada no sistema e uma mensagem de confirmação é exibida ao candidato.
2. **Given** o formulário preenchido corretamente, **When** o campo de cargo de interesse é "Corretor(a)", **Then** o valor enviado e armazenado reflete exatamente a opção selecionada.
3. **Given** o formulário preenchido corretamente com um arquivo PDF informado, **When** o candidato submete, **Then** o nome do arquivo é registrado junto com a candidatura, mesmo que o conteúdo do arquivo não seja armazenado nesta versão.
4. **Given** o formulário submetido com sucesso, **When** o candidato tenta submeter novamente sem recarregar a página, **Then** o formulário é limpo/resetado após o sucesso, prevenindo envios duplicados acidentais.
5. **Given** falha de rede durante o envio, **When** a requisição não é completada, **Then** uma mensagem de erro informativa é exibida e o candidato pode tentar novamente sem perder os dados preenchidos.
---
### User Story 2 — Visitante Descobre a Oportunidade Pelo Site (Priority: P1)
Um visitante que navega pelo footer ou pela página de equipe (/corretores) encontra o link "Trabalhe Conosco" e acessa a página de candidatura.
**Why this priority**: Sem pontos de entrada adequados, a página não é encontrada organicamente dentro do site, tornando o canal de recrutamento inacessível na prática.
**Independent Test**: Acessar o footer do site e verificar a presença do link "Trabalhe Conosco" na coluna "A Imobiliária". Acessar `/corretores` e verificar a presença do link/botão "Trabalhe Conosco". Clicar em cada link e confirmar que navega para `/trabalhe-conosco`.
**Acceptance Scenarios**:
1. **Given** um visitante em qualquer página do site, **When** ele visualiza o footer, **Then** o link "Trabalhe Conosco" está visível na coluna "A Imobiliária".
2. **Given** um visitante na página `/corretores`, **When** ele visualiza a página de equipe, **Then** existe um elemento (link ou botão) com o texto "Trabalhe Conosco" que leva a `/trabalhe-conosco`.
3. **Given** um visitante clicando no link "Trabalhe Conosco" a partir do footer, **When** a navegação ocorre, **Then** ele é direcionado para `/trabalhe-conosco` com a página completa carregada.
4. **Given** um visitante em dispositivo móvel, **When** ele visualiza o footer ou a página `/corretores`, **Then** o link "Trabalhe Conosco" é igualmente acessível e funcional.
---
### User Story 3 — Administrador Visualiza as Candidaturas Recebidas (Priority: P2)
O administrador da imobiliária acessa o painel admin e visualiza uma listagem paginada de todas as candidaturas enviadas pelos candidatos.
**Why this priority**: Sem visibilidade das candidaturas, o canal de recrutamento não entrega valor operacional. A listagem é o produto final que o administrador consume para iniciar o processo seletivo.
**Independent Test**: Com candidaturas já enviadas via formulário público, autenticar como administrador e consultar `GET /api/v1/admin/jobs`. Verificar que a resposta inclui os dados dos candidatos (nome, e-mail, cargo, data de envio) com paginação funcional.
**Acceptance Scenarios**:
1. **Given** um administrador autenticado, **When** ele acessa o endpoint de listagem de candidaturas, **Then** a resposta inclui uma lista paginada com nome, e-mail, telefone, cargo de interesse, data de envio e nome do arquivo informado para cada candidatura.
2. **Given** mais de 20 candidaturas no sistema, **When** o administrador consulta a segunda página, **Then** os resultados são diferentes da primeira página e o total de candidaturas é informado na resposta.
3. **Given** um usuário não autenticado tentando acessar o endpoint de listagem, **When** a requisição é enviada, **Then** o sistema retorna erro de acesso não autorizado (HTTP 401).
4. **Given** um token de usuário comum (não administrador), **When** ele tenta acessar o endpoint de listagem, **Then** o sistema retorna erro de permissão insuficiente (HTTP 403).
5. **Given** nenhuma candidatura registrada, **When** o administrador consulta a listagem, **Then** o sistema retorna uma lista vazia com o total zerado, sem erro.
---
### User Story 4 — Candidato Vê a Página com Conteúdo Institucional (Priority: P3)
Um candidato acessa `/trabalhe-conosco` e, além do formulário, encontra uma apresentação institucional da imobiliária como empregadora, com destaque para benefícios de trabalhar na empresa.
**Why this priority**: Enriquece a experiência do candidato e posiciona a imobiliária como empregadora, mas não bloqueia o funcionamento do recrutamento em si.
**Independent Test**: Acessar `/trabalhe-conosco` e verificar que a página contém: uma hero section com título e subtítulo, uma seção "Por que trabalhar conosco?" com exatamente 3 cards de benefícios, e o formulário de candidatura.
**Acceptance Scenarios**:
1. **Given** qualquer visitante acessando `/trabalhe-conosco`, **When** a página carrega, **Then** uma hero section com título principal e subtítulo descritivo é exibida no topo.
2. **Given** a página carregada, **When** o visitante rola a tela, **Then** uma seção "Por que trabalhar conosco?" com 3 cards de benefícios é visível antes do formulário.
3. **Given** a página carregada, **When** o visitante acessa em dispositivo móvel, **Then** hero section, cards de benefícios e formulário se adaptam ao layout vertical sem perda de conteúdo ou sobreposição visual.
---
### Edge Cases
- O que acontece se o candidato enviar o formulário com um e-mail em formato inválido? A validação deve ocorrer no frontend antes do envio, e o backend deve rejeitar com HTTP 422 e mensagem descritiva.
- O que acontece se o campo de mensagem ultrapassar o limite de caracteres? O sistema deve validar e informar o candidato antes de enviar.
- O que acontece se o candidato tentar enviar o mesmo e-mail múltiplas vezes? Por padrão, múltiplas candidaturas do mesmo e-mail são permitidas (sem deduplicação nesta versão).
- O que acontece se o candidato selecionar um arquivo que não seja PDF ou que exceda 2 MB? O frontend deve bloquear o envio e exibir mensagem de erro clara. Nesta versão, apenas o nome do arquivo é registrado — não há upload real de arquivo.
- O que acontece se o backend retornar erro 500 durante o envio? O frontend deve exibir mensagem genérica de erro sem expor detalhes técnicos.
- Como o endpoint público `POST /api/v1/jobs/apply` se comporta em caso de sobrecarga? Por padrão, o endpoint não possui rate limiting nesta versão — isso pode ser adicionado futuramente.
---
## Requirements
### Functional Requirements
- **FR-001**: O sistema DEVE disponibilizar a rota pública `/trabalhe-conosco` no frontend, acessível sem autenticação.
- **FR-002**: A página DEVE conter uma hero section com título e subtítulo configurados estaticamente.
- **FR-003**: A página DEVE conter uma seção "Por que trabalhar conosco?" com 3 cards de benefícios (conteúdo estático).
- **FR-004**: A página DEVE conter um formulário de candidatura com os campos: nome completo, e-mail, telefone, cargo de interesse (select), mensagem/apresentação e seleção de arquivo de currículo.
- **FR-005**: O campo de cargo de interesse DEVE oferecer as opções: Corretor(a), Assistente Administrativo, Estagiário(a), Outro.
- **FR-006**: O formulário DEVE validar campos obrigatórios (nome, e-mail, cargo, mensagem) antes do envio, exibindo mensagens de erro por campo.
- **FR-007**: O campo de e-mail DEVE validar formato de e-mail válido no frontend antes do envio.
- **FR-008**: O campo de arquivo DEVE aceitar apenas arquivos PDF e rejeitar arquivos acima de 2 MB, com mensagem de erro clara — a validação ocorre no frontend; nesta versão, apenas o nome do arquivo é enviado ao backend.
- **FR-009**: Após envio bem-sucedido, o formulário DEVE exibir uma mensagem de confirmação ao candidato e limpar os campos.
- **FR-010**: O sistema DEVE disponibilizar o endpoint público `POST /api/v1/jobs/apply` que receba e persista os dados textuais da candidatura (sem autenticação).
- **FR-011**: O backend DEVE validar os dados recebidos no endpoint de candidatura e retornar HTTP 422 com detalhes para dados inválidos.
- **FR-012**: O sistema DEVE armazenar as candidaturas em uma tabela `job_applications` com os campos: nome completo, e-mail, telefone, cargo de interesse, mensagem, nome do arquivo informado e data/hora do envio.
- **FR-013**: O sistema DEVE disponibilizar o endpoint protegido `GET /api/v1/admin/jobs` que retorne uma listagem paginada das candidaturas, acessível apenas por administradores autenticados.
- **FR-014**: O endpoint de listagem DEVE retornar para cada candidatura: nome, e-mail, telefone, cargo de interesse, mensagem, nome do arquivo e data de envio.
- **FR-015**: O endpoint de listagem DEVE suportar paginação com parâmetros `page` e `per_page`, retornando o total de registros.
- **FR-016**: O footer do site DEVE conter o link "Trabalhe Conosco" na coluna "A Imobiliária", navegando para `/trabalhe-conosco`.
- **FR-017**: A página `/corretores` DEVE conter um link ou botão "Trabalhe Conosco" navegando para `/trabalhe-conosco`.
- **FR-018**: O design da página DEVE seguir os design tokens existentes do projeto (cores, tipografia Inter, estilo de cards limpos).
### Key Entities
- **JobApplication**: Registro de candidatura enviada por um candidato. Atributos principais: identificador único, nome completo do candidato, e-mail, telefone, cargo de interesse selecionado, texto de apresentação/mensagem, nome do arquivo de currículo informado (opcional), data e hora do envio. Sem relacionamento com outras entidades nesta versão.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Um candidato consegue localizar e acessar a página "Trabalhe Conosco" a partir do footer ou da página de equipe em no máximo 2 cliques.
- **SC-002**: Um candidato consegue preencher e enviar o formulário de candidatura completo em menos de 3 minutos.
- **SC-003**: 100% das candidaturas enviadas com dados válidos são armazenadas e recuperáveis pelo administrador via painel admin.
- **SC-004**: Tentativas de acesso não autorizado ao endpoint de listagem de candidaturas são bloqueadas em 100% dos casos.
- **SC-005**: O formulário exibe mensagem de erro específica para cada campo inválido sem necessidade de recarregar a página.
- **SC-006**: A página carrega e exibe todo o conteúdo estático (hero, benefícios, formulário) em menos de 2 segundos em conexões de banda larga padrão.
---
## Assumptions
- O upload real do arquivo de currículo (armazenamento binário no servidor ou serviço de storage) está fora do escopo desta versão; apenas o nome do arquivo informado pelo candidato é salvo como texto.
- Não há deduplicação de candidaturas por e-mail nesta versão — múltiplas submissões do mesmo endereço são permitidas.
- Os 3 benefícios exibidos na seção "Por que trabalhar conosco?" são conteúdo estático definido em tempo de desenvolvimento; não há interface de gerenciamento para esse conteúdo.
- Não há envio de e-mail de confirmação ao candidato nem notificação por e-mail ao administrador nesta versão.
- O endpoint público de candidatura não possui rate limiting nesta versão.
- A listagem de candidaturas no painel admin é acessível via API; a interface visual no painel admin (página React de `/admin/jobs`) pode ser entregue em iteração futura, não sendo requisito desta spec.
- O padrão de autenticação de administrador já implementado no projeto (`require_admin`) é suficiente e será reutilizado para proteger o endpoint de listagem.
- O campo de telefone é opcional para o candidato, mas recomendado — a validação de formato não é obrigatória nesta versão.

View file

@ -0,0 +1,217 @@
---
description: "Task list para a feature 028 - Trabalhe Conosco"
---
# Tasks: Trabalhe Conosco (028)
**Input**: Design documents de `specs/028-trabalhe-conosco/`
**Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/jobs-api.md ✅
## Format: `[ID] [P?] [Story?] Description — arquivo`
- **[P]**: Pode executar em paralelo (arquivo diferente, sem bloqueadores incompletos)
- **[Story]**: User story correspondente (US1, US2, US3, US4)
- Arquivo exato indicado em cada task
---
## Phase 1: Foundational — Backend (Bloqueador de tudo)
**Purpose**: Migration, model, schemas e rotas Flask precisam existir antes que qualquer integração frontend possa ser testada contra o servidor real.
**⚠️ CRÍTICO**: Nenhuma fase de user story pode começar até esta fase estar completa.
- [ ] T001 Criar migration Alembic `i1j2k3l4m5n6` em `backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py` com `down_revision = "h1i2j3k4l5m6"` — implementar `upgrade()` criando a tabela `job_applications` (9 colunas conforme data-model.md: id SERIAL PK, name VARCHAR(150) NOT NULL, email VARCHAR(254) NOT NULL, phone VARCHAR(30) NULL, role_interest VARCHAR(100) NOT NULL, message TEXT NOT NULL, file_name VARCHAR(255) NULL, status VARCHAR(50) NOT NULL server_default `'pending'`, created_at TIMESTAMP NOT NULL server_default `now()`) + 2 índices (`ix_job_applications_created_at` em `created_at`, `ix_job_applications_status` em `status`); `downgrade()` remove índices e tabela na ordem inversa
- [ ] T002 Criar modelo SQLAlchemy `JobApplication` em `backend/app/models/job_application.py` — classe com `__tablename__ = "job_applications"`, 9 colunas mapeando o schema da tabela (status com `default="pending"`, created_at com `server_default=db.func.now()`), constante `ROLE_INTEREST_OPTIONS = ["Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"]` e `__repr__` com id + email
- [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/job.py` — definir 3 classes:
- `JobApplicationIn(BaseModel)`: campos `name: str` (strip, não vazio), `email: EmailStr`, `phone: str | None = None`, `role_interest: str` (validado contra `ROLE_INTEREST_OPTIONS` via `@field_validator`), `message: str` (max_length=5000, não vazio), `file_name: str | None = None`
- `JobApplicationOut(BaseModel)`: todos os campos de `JobApplicationIn` + `id: int`, `status: str`, `created_at: datetime`; `model_config = ConfigDict(from_attributes=True)`
- `PaginatedJobApplications(BaseModel)`: `items: list[JobApplicationOut]`, `total: int`, `page: int`, `per_page: int`, `pages: int`
- [ ] T004 Criar rotas em `backend/app/routes/jobs.py` com dois blueprints:
- `jobs_public_bp = Blueprint("jobs_public", __name__)`: endpoint `POST /jobs/apply` público — valida body via `JobApplicationIn` (retorna 422 com `{"error": "Dados inválidos", "details": ...}` em ValidationError), cria e salva `JobApplication` via `db.session`, retorna `{"message": "Candidatura recebida com sucesso"}` com status 201
- `jobs_admin_bp = Blueprint("jobs_admin", __name__)`: endpoint `GET /jobs` decorado com `@require_admin` — lê query params `page` (default 1, ≥ 1) e `per_page` (default 20, clamp 1100), consulta `JobApplication.query.order_by(JobApplication.created_at.desc()).paginate(...)`, serializa via `PaginatedJobApplications` e retorna JSON 200
- [ ] T005 Registrar model e blueprints em `backend/app/__init__.py`:
- Na seção de imports de models, adicionar `from app.models import job_application as _job_application_models`
- Registrar `jobs_public_bp` com `url_prefix="/api/v1"` e `jobs_admin_bp` com `url_prefix="/api/v1/admin"` na função `create_app()`
- [ ] T006 Aplicar migration no container e verificar schema: `docker-compose exec backend flask db upgrade` → confirmar tabela com `docker-compose exec db psql -U postgres -d saas_imobiliaria -c "\d job_applications"`
**Checkpoint**: `curl -X POST http://localhost:5000/api/v1/jobs/apply` com body válido retorna 201. `GET /api/v1/admin/jobs` sem token retorna 401.
---
## Phase 2: User Story 1 — Candidato Envia Formulário (Priority: P1) 🎯 MVP
**Goal**: Página `/trabalhe-conosco` com formulário funcional que submete via `POST /api/v1/jobs/apply`, exibe confirmação de sucesso e mantém dados em caso de erro de rede.
**Independent Test**: Acessar `/trabalhe-conosco` sem autenticação, preencher todos os campos obrigatórios (nome, e-mail, cargo de interesse, mensagem) e clicar em "Enviar Candidatura". Verificar que uma mensagem de confirmação é exibida e que `GET /api/v1/admin/jobs` (com token admin) lista a candidatura recebida.
- [ ] T007 [P] [US1] Criar interface TypeScript em `frontend/src/types/jobApplication.ts`:
```typescript
export interface JobApplicationPayload {
name: string;
email: string;
phone?: string;
role_interest: string;
message: string;
file_name?: string;
}
export const ROLE_INTEREST_OPTIONS = [
"Corretor(a)",
"Assistente Administrativo",
"Estagiário(a)",
"Outro",
] as const;
```
- [ ] T008 [P] [US1] Criar `frontend/src/services/jobsService.ts` com função `submitApplication(data: JobApplicationPayload): Promise<void>` — chama `api.post("/api/v1/jobs/apply", data)` via instância Axios do projeto e relança o erro para tratamento no componente
- [ ] T009 [US1] Criar `frontend/src/pages/JobsPage.tsx` com formulário de candidatura:
- Campos controlados com `useState`: `name`, `email`, `phone`, `role_interest` (select com `ROLE_INTEREST_OPTIONS`), `message` (textarea, contador de caracteres até 5000), `file_name` (input file decorativo — apenas registra `e.target.files?.[0]?.name`)
- Validação frontend antes do submit: e-mail formato válido, campos obrigatórios não vazios, message ≤ 5000 chars, arquivo (se presente) deve ser PDF e ≤ 2 MB
- Estado `submitting: boolean` para desabilitar o botão durante o envio
- Submit: chama `submitApplication()`, em sucesso exibe mensagem de confirmação e limpa o formulário; em erro de rede exibe mensagem genérica sem apagar os dados preenchidos
- Estilo: dark theme do projeto (`bg-panel`, `border-borderSubtle`, accent `#5e6ad2`, tipografia Inter, Tailwind CSS)
- [ ] T010 [US1] Adicionar rota `/trabalhe-conosco` em `frontend/src/App.tsx`: importar `JobsPage` e inserir `<Route path="/trabalhe-conosco" element={<JobsPage />} />` entre as rotas públicas
**Checkpoint**: Formulário em `/trabalhe-conosco` envia candidatura, recebe 201 e exibe confirmação. Erro de rede exibe mensagem sem apagar campos.
---
## Phase 3: User Story 2 — Visitante Descobre a Oportunidade (Priority: P1)
**Goal**: Links "Trabalhe Conosco" no footer (coluna "A Imobiliária") e na página `/corretores` tornam a página de candidatura descobrível organicamente.
**Independent Test**: Acessar qualquer página e verificar que o footer contém o link "Trabalhe Conosco" na coluna "A Imobiliária". Acessar `/corretores` e verificar que existe elemento com texto "Trabalhe Conosco" que navega para `/trabalhe-conosco`.
- [ ] T011 [P] [US2] Adicionar link "Trabalhe Conosco" em `frontend/src/components/Footer.tsx` — localizar a coluna "A Imobiliária" e inserir `<Link to="/trabalhe-conosco">Trabalhe Conosco</Link>` seguindo o mesmo padrão visual dos demais links da coluna
- [ ] T012 [P] [US2] Adicionar link/botão "Trabalhe Conosco" em `frontend/src/pages/AgentsPage.tsx` — inserir elemento (link `<Link>` ou botão secundário) com texto "Trabalhe Conosco" e `href`/`to="/trabalhe-conosco"` em posição visível na página (ex.: ao final da seção de equipe ou como chamada à ação após o grid de corretores)
**Checkpoint**: Footer exibe link em todas as páginas. `/corretores` exibe elemento que navega para `/trabalhe-conosco`.
---
## Phase 4: User Story 3 — Administrador Visualiza Candidaturas (Priority: P2)
**Goal**: Endpoint `GET /api/v1/admin/jobs` (implementado na Phase 1) retorna listagem paginada e corretamente serializada. Serviço Axios disponível para consumo futuro no painel admin.
**Independent Test**: Autenticar como admin e consultar `GET /api/v1/admin/jobs?page=1&per_page=20`. Verificar: resposta 200 com campos `items`, `total`, `page`, `per_page`, `pages`; cada item contém id, name, email, phone, role_interest, message, file_name, status, created_at. Sem token: 401. Token não-admin: 403.
- [ ] T013 [US3] Adicionar função `listApplications(page?: number, perPage?: number): Promise<PaginatedJobApplications>` em `frontend/src/services/jobsService.ts` — chama `api.get("/api/v1/admin/jobs", { params: { page, per_page: perPage } })` com header Authorization via instância autenticada do Axios; adicionar tipo `PaginatedJobApplications` em `frontend/src/types/jobApplication.ts` espelhando o schema do contrato (`items: JobApplicationItem[]`, `total`, `page`, `per_page`, `pages`)
**Checkpoint**: `listApplications()` pode ser chamado do console do browser (após login admin) e retorna dados paginados com a estrutura correta.
---
## Phase 5: User Story 4 — Conteúdo Institucional (Priority: P3)
**Goal**: Página `/trabalhe-conosco` enriquecida com hero section e seção "Por que trabalhar conosco?" com 3 cards de benefícios, posicionados acima do formulário.
**Independent Test**: Acessar `/trabalhe-conosco` e verificar: hero section com título principal e subtítulo no topo; seção "Por que trabalhar conosco?" com exatamente 3 cards de benefícios antes do formulário; layout responsivo sem sobreposição em mobile.
- [ ] T014 [US4] Adicionar hero section no topo de `frontend/src/pages/JobsPage.tsx` — bloco com título principal (ex.: "Faça parte da nossa equipe") e subtítulo descritivo; seguir design tokens dark (`text-primary`, `text-secondary`, fundo com gradiente sutil ou `bg-surface`); posicionar acima dos cards de benefícios e do formulário
- [ ] T015 [US4] Adicionar seção "Por que trabalhar conosco?" em `frontend/src/pages/JobsPage.tsx` com 3 cards de benefícios estáticos — cada card tem ícone SVG, título e descrição; layout em grid responsivo (`grid-cols-1 md:grid-cols-3`); estilo `bg-panel border border-borderSubtle rounded-xl`; posicionar entre o hero e o formulário de candidatura. Sugestão de conteúdo dos cards: "Crescimento Profissional" / "Ambiente Colaborativo" / "Comissões Competitivas"
**Checkpoint**: `/trabalhe-conosco` exibe hero → 3 benefit cards → formulário nessa ordem. Em mobile (375 px) os cards empilham verticalmente sem overflow horizontal.
---
## Phase 6: Polish & Verificação Final
- [ ] T016 Executar verificação end-to-end manualmente:
1. `GET /api/v1/admin/jobs` sem token → 401
2. `POST /api/v1/jobs/apply` com body válido → 201, candidatura registrada
3. `POST /api/v1/jobs/apply` com e-mail inválido → 422 com `details`
4. `GET /api/v1/admin/jobs?page=1` com token admin → 200 com a candidatura enviada
5. Browser: `/trabalhe-conosco` renderiza hero + 3 cards + formulário
6. Browser: footer → link "Trabalhe Conosco" → navega para `/trabalhe-conosco`
7. Browser: `/corretores` → link "Trabalhe Conosco" → navega para `/trabalhe-conosco`
---
## Dependencies & Execution Order
### Dependências entre fases
```
Phase 1 (Foundational Backend)
├──→ Phase 2 (US1 — Formulário) ──→ Phase 4 (US3 — Admin service)
│ │
│ └──→ Phase 3 (US2 — Links de entrada)
└──→ Phase 5 (US4 — Conteúdo institucional, extensão da Phase 2)
└──→ Phase 6 (Polish)
```
- **Phase 1**: Sem dependências — começa imediatamente
- **Phase 2**: T007 e T008 podem começar em paralelo com Phase 1 (sem necessidade do backend para criar os arquivos TS); T009 depende de T007 + T008; T010 depende de T009
- **Phase 3**: T011 e T012 são paralelos entre si e independentes do backend; dependem apenas de T010 (rota já existir no App.tsx)
- **Phase 4**: T013 depende de T007/T008 (padrão do serviço) e do endpoint já implementado em T004
- **Phase 5**: T014 e T015 são modificações em JobsPage.tsx criado em T009 — devem ser feitas sequencialmente em relação a T009
- **Phase 6**: Depende de todas as fases anteriores
### Dependências por task
| Task | Depende de | Pode ir em paralelo com |
|------|----------------|------------------------|
| T001 | — | T003 |
| T002 | T001 | T003 |
| T003 | — | T001, T002 |
| T004 | T002, T003 | — |
| T005 | T002, T004 | — |
| T006 | T005 | — |
| T007 | — | T001T006, T008, T011, T012 |
| T008 | T007 | T011, T012 |
| T009 | T007, T008 | T011, T012 |
| T010 | T009 | T011, T012 |
| T011 | T010 | T012 |
| T012 | T010 | T011 |
| T013 | T007, T008 | T011, T012 |
| T014 | T009 | T013 |
| T015 | T014 | T013 |
| T016 | T006, T015 | — |
---
## Parallel Execution Examples
### Fluxo MVP (US1 apenas — Phase 1 + Phase 2)
```
Stream A (Backend): T001 → T002 → T004 → T005 → T006
Stream B (Schemas): T003 (paralelo a T001-T002)
Stream C (Frontend): T007 → T008 → T009 → T010
```
### Fluxo completo
```
Stream A (Backend): T001 → T002 → T004 → T005 → T006
Stream B (Schemas): T003
Stream C (Frontend): T007 → T008 → T009 → T010 → T014 → T015
Stream D (Links): T011 (paralelo após T010)
Stream E (Links): T012 (paralelo após T010)
Stream F (Admin svc): T013 (paralelo após T008)
```
---
## Implementation Strategy
**MVP Scope** (Phase 1 + Phase 2): Formulário público funcional com persistência — entrega o núcleo da feature (US1 P1).
**Incremento 2** (Phase 3): Links de descoberta — sem novos arquivos backend, apenas modificações pontuais em Footer e AgentsPage (US2 P1).
**Incremento 3** (Phase 4): Serviço admin no frontend — prepara consumo da listagem (US3 P2); página admin React adiada para iteração futura.
**Incremento 4** (Phase 5): Conteúdo institucional (hero + cards) sobre a base já existente de JobsPage (US4 P3).

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Revisão UX/UI — Área do Cliente
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-22
**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 gerada a partir do UX audit `ux-audit.md` existente no mesmo diretório. Todos os itens aprovados na primeira validação.
- Remoção de BoletosPage é apenas frontend; backend/model preservados — documentado em Assumptions.
- Pronto para `/speckit.plan`.

View file

@ -0,0 +1,118 @@
# Implementation Plan: Revisao UX/UI - Area do Cliente
**Branch**: `029-ux-area-do-cliente` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/029-ux-area-do-cliente/spec.md`
## Summary
Esta feature simplifica a area do cliente removendo etapas de baixo valor (dashboard e boletos no frontend), melhora a navegacao contextual (menu com 4 itens e iconografia SVG consistente), aumenta a qualidade de leitura de favoritos (thumbnail, preco e localizacao), adiciona a acao de cancelamento de visitas pendentes e cria a pagina Minha Conta com atualizacao de nome e troca de senha.
## Technical Context
**Language/Version**: Python 3.12 (backend) + TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, bcrypt (backend); React 18, react-router-dom v6, Axios, Tailwind CSS 3.4 (frontend)
**Storage**: PostgreSQL 16 (sem novas tabelas nesta feature)
**Testing**: `pytest` para backend; `npm run build` para frontend; checklist manual de UX
**Target Platform**: SPA web responsiva (desktop/mobile)
**Project Type**: web app full-stack com foco principal em UX frontend
**Performance Goals**: navegacao da area do cliente sem etapa intermediaria e atualizacoes sem reload para acoes principais
**Constraints**: nao alterar schemas do banco; manter auth JWT existente; manter backend de boletos intacto
**Scale/Scope**: rotas e componentes da area do cliente + 3 endpoints PATCH no backend
## Constitution Check
| Principio | Status | Observacao |
|---|---|---|
| Design-First | PASS | Mudancas focadas em fluxo, hierarquia visual e consistencia de icones |
| Separation of Concerns | PASS | Regras de dominio no backend e comportamento de tela no frontend |
| Spec-Driven | PASS | Plano derivado de `spec.md`, `ux-audit.md` e `tasks.md` |
| Data Integrity | PASS | Sem migrations de schema; update em entidades existentes |
| Security | PASS | Endpoints novos protegidos por JWT e ownership checks |
| Simplicity First | PASS | Remove telas redundantes e reduz friccao de navegacao |
## Project Structure
### Documentation (this feature)
```text
specs/029-ux-area-do-cliente/
├── plan.md
├── spec.md
├── tasks.md
├── ux-audit.md
└── checklists/
```
### Source Code (repository root)
```text
backend/
└── app/
├── routes/client_area.py
└── schemas/client_area.py
frontend/
└── src/
├── App.tsx
├── layouts/ClientLayout.tsx
├── contexts/AuthContext.tsx
├── services/clientArea.ts
├── types/clientArea.ts
└── pages/client/
├── FavoritesPage.tsx
├── VisitsPage.tsx
├── ComparisonPage.tsx
└── ProfilePage.tsx
```
**Structure Decision**: manter estrutura web existente, com mudancas localizadas em rotas/schemas backend e paginas/layouts da area do cliente no frontend.
## Implementation Approach
### 1. Roteamento e navegacao
- Redirecionar index da area do cliente para favoritos.
- Remover rota e item visual de boletos no frontend.
- Introduzir rota Minha Conta e manter destaque de item ativo em desktop/mobile.
### 2. Favoritos com contexto visual
- Enriquecer payload de favoritos no backend com dados de card (`price`, `city`, `neighborhood`, `cover_photo_url`).
- Atualizar card de favoritos para exibicao direta desses dados com fallback de imagem.
- Manter remocao sem reload com comportamento otimista.
### 3. Visitas com acao de cancelamento
- Exibir botao de cancelamento apenas para `status=pending`.
- Criar endpoint de cancelamento com validacao de ownership e estado atual.
- Aplicar update otimista no frontend com rollback em erro.
### 4. Minha Conta
- Criar tela `/area-do-cliente/conta` com formulario de nome e formulario de senha.
- Criar endpoints `PATCH /me/profile` e `PATCH /me/password`.
- Expor `updateUser` no `AuthContext` para refletir nome atualizado imediatamente.
### 5. Qualidade visual e consistencia
- Trocar iconografia por SVG Heroicons no layout do cliente.
- Melhorar empty states e feedbacks de erro/sucesso nas principais interacoes.
## Rollout and Validation
### Executavel
- Frontend: `npm run build`
- Backend: `pytest` nos cenarios relevantes de cliente (quando disponiveis)
### Manual
- Verificar redirecionamento `/area-do-cliente -> /area-do-cliente/favoritos`
- Verificar ausencia de menu/rota de boletos no frontend
- Verificar card de favorito com imagem/preco/localizacao
- Verificar cancelamento de visita pendente e bloqueio para estados nao pendentes
- Verificar atualizacao de nome e troca de senha em Minha Conta
## Complexity Tracking
Nenhuma excecao arquitetural requerida para esta feature.

View file

@ -0,0 +1,203 @@
# Feature Specification: Revisão UX/UI — Área do Cliente
**Feature Branch**: `029-ux-area-do-cliente`
**Created**: 2026-04-22
**Status**: Draft
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Navegar direto para Favoritos ao acessar a Área do Cliente (Priority: P1)
O cliente autenticado digita ou clica no link da Área do Cliente e chega imediatamente à lista de imóveis favoritos, sem passar por uma tela de painel intermediária que não agrega valor.
**Why this priority**: Elimina um clique desnecessário presente hoje. É a mudança de maior impacto imediato na jornada do usuário e desbloqueio para todo o restante da área.
**Independent Test**: Acessar `/area-do-cliente` deve redirecionar para `/area-do-cliente/favoritos`. A página de Favoritos deve carregar de forma autônoma e ser completamente utilizável.
**Acceptance Scenarios**:
1. **Given** um cliente autenticado, **When** ele acessa `/area-do-cliente`, **Then** é redirecionado automaticamente para `/area-do-cliente/favoritos`.
2. **Given** um cliente autenticado, **When** ele acessa `/area-do-cliente/boletos`, **Then** recebe uma resposta 404 (rota inexistente).
3. **Given** um cliente autenticado, **When** ele visualiza o menu lateral, **Then** os itens "Painel" e "Boletos" não estão presentes.
---
### User Story 2 — Visualizar favoritos com imagem, preço e localização (Priority: P1)
O cliente vê seus imóveis favoritos exibidos em cards que mostram a thumbnail da propriedade, o preço e a cidade/bairro, permitindo identificar cada imóvel sem precisar clicar para abrir.
**Why this priority**: É a página mais acessada da área do cliente. Sem imagem e preço, o usuário não consegue distinguir os imóveis e a experiência é frustrante.
**Independent Test**: Com ao menos um favorito salvo, a página deve exibir cards com imagem (ou placeholder), preço formatado e localização.
**Acceptance Scenarios**:
1. **Given** um cliente com favoritos salvos, **When** ele acessa `/area-do-cliente/favoritos`, **Then** cada card exibe thumbnail da propriedade (ou placeholder se sem foto), título, preço e cidade/bairro.
2. **Given** um cliente sem favoritos, **When** ele acessa a página, **Then** vê um empty state informativo com link para `/imoveis`.
3. **Given** um cliente visualizando favoritos, **When** ele clica em "Ver imóvel", **Then** é levado à página de detalhes do imóvel.
4. **Given** um cliente visualizando favoritos, **When** ele remove um favorito, **Then** o card some da lista sem recarregar a página.
---
### User Story 3 — Cancelar visita agendada (Priority: P2)
O cliente pode solicitar o cancelamento de uma visita com status pendente diretamente pela Área do Cliente, sem precisar entrar em contato por outro canal.
**Why this priority**: Resolve um ponto de atrito real: hoje o usuário não tem como cancelar uma visita pelo sistema, o que gera contato desnecessário via WhatsApp ou telefone.
**Independent Test**: Com uma visita no status "pendente", o botão "Cancelar" deve ser exibido e, ao clicar, alterar o status para "cancelado".
**Acceptance Scenarios**:
1. **Given** uma visita com `status=pending`, **When** o cliente acessa `/area-do-cliente/visitas`, **Then** o card exibe o botão "Cancelar".
2. **Given** o cliente clica em "Cancelar", **When** a ação é confirmada, **Then** o status do card muda para "cancelado" e o botão desaparece.
3. **Given** uma visita com status diferente de `pending` (ex: confirmada, cancelada), **When** o cliente visualiza o card, **Then** o botão "Cancelar" não está disponível.
4. **Given** uma falha de rede ao cancelar, **When** a requisição retorna erro, **Then** o cliente vê uma mensagem de erro e o status não muda.
---
### User Story 4 — Editar perfil e trocar senha (Priority: P2)
O cliente pode acessar `/area-do-cliente/conta`, visualizar seus dados cadastrais, atualizar o nome e alterar a senha, tudo dentro da Área do Cliente.
**Why this priority**: Hoje não existe nenhuma forma do cliente atualizar seus dados pelo sistema. É uma funcionalidade básica esperada em qualquer área logada.
**Independent Test**: A página `/area-do-cliente/conta` deve existir com formulário funcional de atualização de nome e de troca de senha.
**Acceptance Scenarios**:
1. **Given** o cliente acessa `/area-do-cliente/conta`, **When** a página carrega, **Then** exibe o nome atual preenchido e o e-mail em campo somente leitura.
2. **Given** o cliente altera o nome e clica em "Salvar", **When** o nome é válido (não vazio), **Then** vê confirmação de sucesso e o nome atualizado no menu lateral.
3. **Given** o cliente preenche "Senha atual", "Nova senha" e "Confirmar nova senha", **When** as senhas coincidem e têm pelo menos 8 caracteres, **Then** a senha é alterada e uma mensagem de sucesso é exibida.
4. **Given** o cliente informa uma senha atual incorreta, **When** submete o form, **Then** vê mensagem de erro "Senha atual incorreta" sem alterar nada.
5. **Given** "Nova senha" e "Confirmar nova senha" divergem, **When** o cliente tenta salvar, **Then** vê validação inline "As senhas não coincidem" antes de enviar ao servidor.
---
### User Story 5 — Navegação com ícones consistentes e item "Minha conta" (Priority: P3)
O cliente vê o menu lateral (desktop) e a barra de navegação (mobile) com ícones vetoriais consistentes em qualquer sistema operacional, e um link "Minha conta" que leva à nova página de perfil.
**Why this priority**: Melhoria visual e de consistência; não bloqueia funcionalidades principais, mas impacta a percepção de qualidade do produto.
**Independent Test**: O menu deve renderizar 4 itens (Favoritos, Comparar, Visitas, Minha conta) com ícones SVG visíveis e corretos em temas claro e escuro.
**Acceptance Scenarios**:
1. **Given** o cliente está na Área do Cliente, **When** visualiza o menu lateral ou a barra mobile, **Then** os 4 itens (Favoritos, Comparar, Visitas, Minha conta) estão presentes com ícones SVG.
2. **Given** o cliente está em qualquer página da área do cliente, **When** observa o item de menu correspondente à página atual, **Then** o item está visualmente destacado (ativo).
3. **Given** o cliente está em mobile, **When** acessa a barra de navegação, **Then** os 4 itens estão centralizados sem scroll horizontal desnecessário.
4. **Given** o cliente clica em "Sair", **When** a ação é executada, **Then** o ícone do botão é o ícone de logout (seta saindo de uma porta), não uma seta genérica.
---
### User Story 6 — Empty state explicativo no Comparar (Priority: P3)
O cliente que acessa a página de comparação sem imóveis selecionados recebe uma instrução clara sobre como adicionar imóveis à comparação.
**Why this priority**: Melhoria de usabilidade pontual; sem ela o usuário fica confuso, mas o impacto é menor que os itens anteriores.
**Independent Test**: Acessar `/area-do-cliente/comparar` sem imóveis selecionados deve exibir o empty state com a instrução de uso.
**Acceptance Scenarios**:
1. **Given** o cliente não tem imóveis na comparação, **When** acessa `/area-do-cliente/comparar`, **Then** vê uma mensagem explicando como adicionar imóveis (ex: "Acesse um imóvel e clique em 'Comparar' para adicioná-lo aqui").
2. **Given** o cliente tem imóveis na comparação, **When** acessa a página, **Then** a tabela de comparação é exibida normalmente.
---
### Edge Cases
- O que acontece se a foto de um imóvel favorito for removida após ser salvo? → O card exibe um placeholder de imagem.
- O que acontece se o cliente tentar cancelar uma visita já cancelada por outra aba? → O servidor retorna erro e o frontend exibe mensagem informativa.
- O que acontece se o cliente enviar o form de perfil com nome vazio? → Validação inline impede o envio antes de chegar ao servidor.
- O que acontece se o cliente sem favoritos tentar acessar diretamente `/area-do-cliente`? → Redireciona para `/area-do-cliente/favoritos` e exibe o empty state de favoritos.
- O que acontece se a sessão expirar durante uso da área do cliente? → O cliente é redirecionado para a tela de login com mensagem de sessão expirada.
---
## Requirements *(mandatory)*
### Functional Requirements
**Roteamento e Estrutura**
- **FR-001**: O sistema DEVE redirecionar `/area-do-cliente` para `/area-do-cliente/favoritos` de forma automática.
- **FR-002**: A rota `/area-do-cliente/boletos` DEVE ser removida; acessá-la DEVE retornar 404.
- **FR-003**: O menu de navegação da Área do Cliente DEVE conter exatamente 4 itens: Favoritos, Comparar, Visitas e Minha conta.
- **FR-004**: Os ícones do menu DEVE ser SVG vetoriais (Heroicons 2.0 outline), sem uso de emoji ou caracteres Unicode.
**Favoritos**
- **FR-005**: Cada card de favorito DEVE exibir: thumbnail da propriedade (ou placeholder padronizado), título, preço formatado em reais e cidade/bairro.
- **FR-006**: A thumbnail DEVE ser obtida a partir dos dados da propriedade (campo `cover_photo` ou primeira foto da galeria).
- **FR-007**: O card DEVE oferecer as ações "Ver imóvel" (navega para detalhe) e "Remover dos favoritos" (remove sem recarregar a página).
**Visitas**
- **FR-008**: O card de visita DEVE exibir: título do imóvel em destaque, data agendada/solicitada em destaque, badge de status alinhado à direita e mensagem como texto secundário.
- **FR-009**: Visitas com `status=pending` DEVE exibir botão "Cancelar".
- **FR-010**: Ao confirmar o cancelamento, o sistema DEVE chamar `PATCH /me/visits/:id/cancel` e atualizar o status do card para "cancelado" sem recarregar a página.
- **FR-011**: Visitas com status diferente de `pending` NÃO DEVEM exibir o botão "Cancelar".
**Comparar**
- **FR-012**: Quando não há imóveis selecionados para comparação, a página DEVE exibir um empty state com instrução clara de como adicionar imóveis à comparação.
**Perfil / Minha conta**
- **FR-013**: A rota `/area-do-cliente/conta` DEVE ser criada e acessível pelo menu "Minha conta".
- **FR-014**: A página de conta DEVE exibir o nome atual do cliente em campo editável e o e-mail em campo somente leitura.
- **FR-015**: O cliente DEVE poder atualizar o nome via `PATCH /me/profile`; o campo nome é obrigatório.
- **FR-016**: O cliente DEVE poder trocar a senha informando senha atual, nova senha e confirmação via `PATCH /me/password`.
- **FR-017**: A nova senha DEVE ter no mínimo 8 caracteres; a confirmação DEVE ser idêntica à nova senha; validações DEVEM ocorrer no frontend antes do envio.
- **FR-018**: Se a senha atual informada estiver incorreta, o servidor DEVE retornar erro e o frontend DEVE exibir "Senha atual incorreta".
**Backend — Novos Endpoints**
- **FR-019**: `PATCH /me/profile` — atualiza o nome do cliente autenticado. Requer autenticação JWT. Retorna os dados atualizados.
- **FR-020**: `PATCH /me/password` — altera a senha do cliente autenticado. Valida senha atual antes de persistir. Requer autenticação JWT.
- **FR-021**: `PATCH /me/visits/:id/cancel` — cancela uma visita com `status=pending` pertencente ao cliente autenticado. Retorna a visita atualizada. Requer autenticação JWT.
- **FR-022**: Tentativa de cancelar visita com status diferente de `pending` DEVE retornar erro com mensagem descritiva.
- **FR-023**: Tentativa de cancelar visita de outro cliente DEVE retornar 403 Forbidden.
**Layout e Mobile**
- **FR-024**: O botão "Sair" DEVE usar o ícone ArrowRightOnRectangle (Heroicons) no lugar do caractere `→`.
- **FR-025**: Em viewport mobile, a barra de navegação DEVE centralizar os 4 itens e indicar visualmente o item ativo.
### Key Entities
- **ClientUser**: Usuário da área do cliente. Atributos relevantes: `id`, `name`, `email`, `password_hash`. Possui coleções de favoritos e visitas.
- **SavedProperty (Favorito)**: Associação entre `ClientUser` e `Property`. Atributos relevantes: `id`, `client_user_id`, `property_id`, `created_at`.
- **VisitRequest (Visita)**: Solicitação de visita feita por um `ClientUser` para uma `Property`. Atributos relevantes: `id`, `client_user_id`, `property_id`, `scheduled_date`, `status` (pending | confirmed | cancelled), `message`.
- **Property (Imóvel)**: Imóvel do catálogo. Atributos consumidos nesta feature: `id`, `title`, `price`, `city`, `neighborhood`, `cover_photo` (ou galeria de fotos).
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: O cliente chega ao conteúdo real (Favoritos) com um clique a menos do que hoje — a página de painel não existe mais como etapa intermediária.
- **SC-002**: O cliente consegue identificar visualmente seus imóveis favoritos sem precisar abrir nenhum deles — cada card mostra imagem, preço e localização.
- **SC-003**: O cliente consegue cancelar uma visita pendente em menos de 3 cliques a partir da página de Visitas, sem sair da Área do Cliente.
- **SC-004**: O cliente consegue atualizar nome ou senha em uma única interação com o formulário de conta, sem precisar entrar em contato com suporte.
- **SC-005**: Os ícones do menu são renderizados de forma idêntica em Windows, macOS e Linux, tanto em tema claro quanto escuro.
- **SC-006**: A taxa de abandono da Área do Cliente (saída imediata) deve ser reduzida, dado que o primeiro conteúdo exibido passa a ser imediatamente útil.
- **SC-007**: Todos os 3 novos endpoints do backend respondem corretamente com autenticação válida e retornam erros descritivos para entradas inválidas.
---
## Assumptions
- A tabela `client_users` já existe no banco com as colunas `id`, `name`, `email` e `password_hash`; nenhuma migration de schema é necessária para os endpoints de perfil e senha.
- A tabela `visit_requests` já possui a coluna `status` com os valores `pending`, `confirmed` e `cancelled`; o endpoint de cancelamento apenas atualiza este campo.
- O backend de favoritos já expõe o `property_id`; a foto do imóvel será obtida do campo `cover_photo` da tabela de propriedades ou do primeiro item retornado pela API de fotos já existente.
- O sistema de autenticação JWT para clientes já está operacional; os novos endpoints reutilizarão o mesmo middleware de autenticação.
- A remoção de `BoletosPage` é apenas frontend e de rota; o model e os dados de boletos no banco são mantidos intactos para uso futuro pelo admin.
- O componente de comparação já armazena os imóveis selecionados em estado local ou contexto; esta feature não altera o mecanismo de seleção, apenas o empty state.
- Não há requisito de confirmação por e-mail para troca de senha nesta fase (fluxo simplificado: validar senha atual → salvar nova senha).
- A feature não inclui upload de foto de perfil do cliente.

View file

@ -0,0 +1,994 @@
# Tasks — Feature 029: Revisão UX/UI da Área do Cliente
**Branch**: `029-ux-area-do-cliente`
**Gerado em**: 2026-04-22
**Status**: Ready for implementation
---
## Fase 1 — Backend: Schemas
### T01 — Adicionar `PropertyCard` e schemas de profile/senha em `client_area.py`
**Arquivo**: `backend/app/schemas/client_area.py`
**Contexto**: O schema `PropertyBrief` só carrega `id`, `title`, `slug`. Os cards de favoritos precisam de `price`, `city`, `neighborhood` e `cover_photo_url`. Além disso, os endpoints de perfil e senha precisam de schemas de entrada/saída que não existem.
**Passos**:
1. Abrir `backend/app/schemas/client_area.py`.
2. Adicionar import `from typing import Optional` (já existe) e `from decimal import Decimal` (já existe).
3. Adicionar o schema `PropertyCard` logo após `PropertyBrief`:
```python
class PropertyCard(BaseModel):
id: str
title: str
slug: str
price: Optional[Decimal] = None
city: Optional[str] = None
neighborhood: Optional[str] = None
cover_photo_url: Optional[str] = None
model_config = {"from_attributes": True}
@classmethod
def from_property(cls, prop) -> "PropertyCard":
"""Constrói PropertyCard a partir de um ORM Property."""
cover = prop.photos[0].url if prop.photos else None
city = prop.city.name if prop.city else None
neighborhood = prop.neighborhood.name if prop.neighborhood else None
return cls(
id=str(prop.id),
title=prop.title,
slug=prop.slug,
price=prop.price,
city=city,
neighborhood=neighborhood,
cover_photo_url=cover,
)
```
4. Alterar `SavedPropertyOut` para usar `PropertyCard` em vez de `PropertyBrief`:
```python
class SavedPropertyOut(BaseModel):
id: str
property_id: Optional[str]
property: Optional[PropertyCard] # era PropertyBrief
created_at: datetime
model_config = {"from_attributes": True}
```
5. Adicionar os schemas de profile e senha ao final do arquivo:
```python
class UpdateProfileIn(BaseModel):
name: str
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Nome não pode ser vazio")
return v.strip()
class UpdateProfileOut(BaseModel):
id: str
name: str
email: str
model_config = {"from_attributes": True}
class UpdatePasswordIn(BaseModel):
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("A nova senha deve ter pelo menos 8 caracteres")
return v
```
**Critérios de conclusão**:
- [ ] `PropertyCard` existe em `client_area.py` com os campos: `id`, `title`, `slug`, `price`, `city`, `neighborhood`, `cover_photo_url`.
- [ ] `PropertyCard.from_property()` extrai `photos[0].url` como cover e `city.name` / `neighborhood.name` dos relacionamentos ORM.
- [ ] `SavedPropertyOut.property` usa `PropertyCard` (não mais `PropertyBrief`).
- [ ] `UpdateProfileIn`, `UpdateProfileOut` e `UpdatePasswordIn` existem com as validações descritas.
---
## Fase 2 — Backend: Endpoints
### T02 — Adicionar eager load de fotos na query de favoritos
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: `SavedProperty.property` usa `lazy="joined"`, mas `Property.photos` usa `lazy="select"`. Sem `selectinload`, cada card de favorito dispara uma query extra para buscar as fotos (N+1). Precisa carregar as fotos junto com os favoritos.
**Passos**:
1. Adicionar import no topo do arquivo:
```python
from sqlalchemy.orm import selectinload
from app.models.property import Property as PropertyModel
```
2. Localizar a função `get_favorites` (rota `GET /favorites`).
3. Substituir a query atual:
```python
# Antes:
saved = (
SavedProperty.query.filter_by(user_id=g.current_user_id)
.order_by(SavedProperty.created_at.desc())
.all()
)
```
Por:
```python
# Depois:
saved = (
SavedProperty.query
.filter_by(user_id=g.current_user_id)
.options(selectinload(SavedProperty.property).selectinload(PropertyModel.photos))
.order_by(SavedProperty.created_at.desc())
.all()
)
```
4. Atualizar os imports de schemas no topo do arquivo — `SavedPropertyOut` agora serializa `PropertyCard`; não é necessária mudança de código aqui pois o schema foi alterado em T01, mas verificar que `PropertyCard` está disponível via `SavedPropertyOut`.
**Critérios de conclusão**:
- [ ] A query de favoritos usa `selectinload` para `property``photos`.
- [ ] O endpoint `GET /me/favorites` retorna o campo `property.cover_photo_url` com a URL da primeira foto (ou `null`).
- [ ] O endpoint retorna `property.price`, `property.city`, `property.neighborhood`.
- [ ] Testado manualmente: chamada para `GET /api/me/favorites` retorna JSON com campos `price`, `city`, `neighborhood`, `cover_photo_url` no objeto `property`.
---
### T03 — Implementar `PATCH /me/profile`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve atualizar `ClientUser.name` do usuário autenticado.
**Passos**:
1. Adicionar import:
```python
import bcrypt
from app.models.user import ClientUser
from app.schemas.client_area import UpdateProfileIn, UpdateProfileOut, UpdatePasswordIn
```
2. Adicionar ao final do arquivo (antes de qualquer `if __name__`):
```python
@client_bp.patch("/profile")
@require_auth
def update_profile():
try:
data = UpdateProfileIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
user = db.session.get(ClientUser, g.current_user_id)
if not user:
return jsonify({"error": "Usuário não encontrado"}), 404
user.name = data.name
db.session.commit()
return jsonify(UpdateProfileOut.model_validate(user).model_dump(mode="json")), 200
```
**Critérios de conclusão**:
- [ ] `PATCH /api/me/profile` com `{ "name": "Novo Nome" }` retorna `200` com `{ id, name, email }`.
- [ ] `PATCH /api/me/profile` com `{ "name": "" }` retorna `422`.
- [ ] `PATCH /api/me/profile` sem token retorna `401`.
---
### T04 — Implementar `PATCH /me/password`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve verificar senha atual com bcrypt antes de gravar a nova.
**Passos**:
1. Garantir que `bcrypt` e `ClientUser` estão importados (feito em T03).
2. Adicionar ao final do arquivo:
```python
@client_bp.patch("/password")
@require_auth
def change_password():
try:
data = UpdatePasswordIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
user = db.session.get(ClientUser, g.current_user_id)
if not user:
return jsonify({"error": "Usuário não encontrado"}), 404
# Verifica senha atual
if not bcrypt.checkpw(
data.current_password.encode("utf-8"),
user.password_hash.encode("utf-8"),
):
return jsonify({"error": "Senha atual incorreta"}), 400
user.password_hash = bcrypt.hashpw(
data.new_password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
db.session.commit()
return "", 204
```
**Critérios de conclusão**:
- [ ] `PATCH /api/me/password` com senha atual correta e nova senha ≥ 8 chars retorna `204`.
- [ ] `PATCH /api/me/password` com senha atual incorreta retorna `400` com `"Senha atual incorreta"`.
- [ ] `PATCH /api/me/password` com nova senha < 8 chars retorna `422`.
- [ ] `PATCH /api/me/password` sem token retorna `401`.
---
### T05 — Implementar `PATCH /me/visits/<id>/cancel`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve cancelar visita com `status=pending` do usuário autenticado.
**Passos**:
1. Adicionar ao final do arquivo:
```python
@client_bp.patch("/visits/<visit_id>/cancel")
@require_auth
def cancel_visit(visit_id: str):
visit = db.session.get(VisitRequest, visit_id)
if not visit:
return jsonify({"error": "Visita não encontrada"}), 404
if visit.user_id != g.current_user_id:
return jsonify({"error": "Acesso negado"}), 403
if visit.status != "pending":
return jsonify({"error": "Apenas visitas pendentes podem ser canceladas"}), 400
visit.status = "cancelled"
db.session.commit()
return jsonify(VisitRequestOut.model_validate(visit).model_dump(mode="json")), 200
```
**Critérios de conclusão**:
- [ ] `PATCH /api/me/visits/<id>/cancel` com visita `status=pending` do próprio usuário retorna `200` com `status: "cancelled"`.
- [ ] Cancelar visita com `status=confirmed` retorna `400`.
- [ ] Cancelar visita de outro usuário retorna `403`.
- [ ] Cancelar visita inexistente retorna `404`.
- [ ] `VisitRequestOut` está importado (já estava) e serializa o resultado corretamente.
---
## Fase 3 — Frontend: Tipos e Serviços
### T06 — Atualizar tipos em `clientArea.ts`
**Arquivo**: `frontend/src/types/clientArea.ts`
**Contexto**: `SavedProperty.property` só tem `{ id, title, slug }`. Precisa incluir `price`, `city`, `neighborhood`, `cover_photo_url`. Adicionar também os tipos dos payloads de profile/senha.
**Passos**:
1. Substituir o tipo `SavedProperty`:
```typescript
export interface PropertyCard {
id: string;
title: string;
slug: string;
price: string | null; // Decimal serializado como string pelo backend
city: string | null;
neighborhood: string | null;
cover_photo_url: string | null;
}
export interface SavedProperty {
id: string;
property_id: string | null;
property: PropertyCard | null;
created_at: string;
}
```
2. Adicionar ao final do arquivo:
```typescript
export interface UpdateProfilePayload {
name: string;
}
export interface UpdateProfileResponse {
id: string;
name: string;
email: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
}
```
**Critérios de conclusão**:
- [ ] `SavedProperty.property` é `PropertyCard | null`.
- [ ] `PropertyCard` tem todos os 7 campos listados.
- [ ] `UpdateProfilePayload`, `UpdateProfileResponse` e `ChangePasswordPayload` estão exportados.
- [ ] Sem erros de TypeScript em arquivos que importam `SavedProperty`.
---
### T07 — Adicionar `updateProfile`, `changePassword` e `cancelVisit` em `clientArea.ts`
**Arquivo**: `frontend/src/services/clientArea.ts`
**Contexto**: O serviço atual tem `getFavorites`, `addFavorite`, `removeFavorite`, `getVisits`, `getBoletos`. Faltam os três novos métodos correspondentes aos endpoints criados em T03T05.
**Passos**:
1. Atualizar o import de tipos no topo:
```typescript
import type {
Boleto,
ChangePasswordPayload,
SavedProperty,
UpdateProfilePayload,
UpdateProfileResponse,
VisitRequest,
} from '../types/clientArea';
```
2. Adicionar ao final do arquivo:
```typescript
export async function updateProfile(
data: UpdateProfilePayload,
): Promise<UpdateProfileResponse> {
const response = await api.patch<UpdateProfileResponse>('/me/profile', data);
return response.data;
}
export async function changePassword(data: ChangePasswordPayload): Promise<void> {
await api.patch('/me/password', data);
}
export async function cancelVisit(visitId: string): Promise<VisitRequest> {
const response = await api.patch<VisitRequest>(`/me/visits/${visitId}/cancel`);
return response.data;
}
```
**Critérios de conclusão**:
- [ ] `updateProfile`, `changePassword` e `cancelVisit` são exportados de `clientArea.ts`.
- [ ] Tipos corretos: `updateProfile` retorna `Promise<UpdateProfileResponse>`, `cancelVisit` retorna `Promise<VisitRequest>`.
- [ ] Sem erros TypeScript no arquivo.
---
## Fase 4 — Frontend: AuthContext
### T08 — Expor `updateUser` no `AuthContext`
**Arquivo**: `frontend/src/contexts/AuthContext.tsx`
**Contexto**: `ProfilePage` (T10) precisará atualizar `user.name` no contexto após salvar com sucesso, para que o sidebar reflita imediatamente o novo nome sem reload. Atualmente `setUser` é interno ao Provider.
**Passos**:
1. Adicionar `updateUser` à interface `AuthContextValue`:
```typescript
interface AuthContextValue {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
login: (data: LoginCredentials) => Promise<void>
register: (data: RegisterCredentials) => Promise<void>
logout: () => void
updateUser: (partial: Partial<User>) => void // NOVO
}
```
2. Implementar `updateUser` dentro do `AuthProvider`, após a declaração de `logout`:
```typescript
const updateUser = useCallback((partial: Partial<User>) => {
setUser(prev => (prev ? { ...prev, ...partial } : prev))
}, [])
```
3. Adicionar `updateUser` ao objeto passado para `AuthContext.Provider`:
```typescript
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateUser, // NOVO
}}
```
**Critérios de conclusão**:
- [ ] `useAuth().updateUser({ name: "Novo Nome" })` atualiza `user.name` no contexto.
- [ ] O sidebar (`ClientLayout.tsx`) reflete o novo nome sem recarga de página ao chamar `updateUser`.
- [ ] Sem erros TypeScript no arquivo ou em consumidores de `useAuth`.
---
## Fase 5 — Frontend: Layout
### T09 — Refatorar `ClientLayout.tsx` com ícones SVG e nova navegação
**Arquivo**: `frontend/src/layouts/ClientLayout.tsx`
**Contexto**: O layout atual tem 5 itens de nav com emoji Unicode inconsistentes, inclui "Painel" e "Boletos", e o botão "Sair" usa `→`. A nova nav tem 4 itens com SVG Heroicons.
**Passos**:
1. Substituir o array `navItems` por componentes SVG inline para cada ícone. Criar 4 componentes SVG pequenos no topo do arquivo (ou inline no array), usando Heroicons 2.0 outline (24×24, `stroke="currentColor"`, `strokeWidth={1.5}`):
- **Favoritos**`HeartIcon` (coração vazio)
- **Comparar**`ScaleIcon` (balança/scale)
- **Visitas**`CalendarIcon` (calendário)
- **Minha conta**`UserCircleIcon` (usuário com círculo)
- **Logout**`ArrowRightOnRectangleIcon` (seta saindo de retângulo)
Exemplo de SVG inline para `HeartIcon`:
```tsx
function HeartIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
strokeWidth={1.5} stroke="currentColor" className="size-5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
);
}
```
Criar funções análogas: `ScaleIcon`, `CalendarIcon`, `UserCircleIcon`, `ArrowRightOnRectangleIcon`.
2. Substituir `navItems` por:
```tsx
const navItems = [
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false, Icon: HeartIcon },
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false, Icon: ScaleIcon },
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false, Icon: CalendarIcon },
{ to: '/area-do-cliente/conta', label: 'Minha conta', end: false, Icon: UserCircleIcon },
];
```
Remover `adminNavItems` ou mantê-lo separado se o admin ainda precisar (não alterar funcionalidade admin).
3. No render de cada `NavLink` (sidebar e mobile), substituir `<span className="text-base">{item.icon}</span>` por `<item.Icon />`.
4. Substituir o botão de logout:
```tsx
{/* Antes */}
<span></span>Sair
{/* Depois */}
<ArrowRightOnRectangleIcon />Sair
```
5. **Mobile nav**: No bloco `lg:hidden`, os links agora têm 4 itens. Adicionar ícones SVG também na nav mobile e centralizar:
```tsx
<div className="flex flex-1 justify-center gap-1">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-xs transition ${
isActive
? 'bg-surface text-textPrimary font-medium'
: 'text-textSecondary hover:text-textPrimary'
}`
}
>
<item.Icon />
{item.label}
</NavLink>
))}
</div>
```
**Critérios de conclusão**:
- [ ] Menu lateral tem exatamente 4 itens: Favoritos, Comparar, Visitas, Minha conta.
- [ ] "Painel" e "Boletos" não aparecem no menu.
- [ ] Todos os ícones são SVG `<svg>` inline (sem emoji, sem texto Unicode).
- [ ] Botão "Sair" usa `ArrowRightOnRectangleIcon`.
- [ ] Item ativo está visualmente destacado em ambos: sidebar (desktop) e barra (mobile).
- [ ] Mobile: 4 itens centralizados sem scroll horizontal.
- [ ] Sem erros TypeScript/JSX no arquivo.
---
## Fase 6 — Frontend: Páginas
### T10 — Criar `ProfilePage.tsx`
**Arquivo**: `frontend/src/pages/client/ProfilePage.tsx` *(criar)*
**Contexto**: Página inexistente. Deve exibir dois formulários independentes: edição de nome e troca de senha.
**Passos**:
1. Criar o arquivo com a seguinte estrutura:
```tsx
import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { changePassword, updateProfile } from '../../services/clientArea';
export default function ProfilePage() {
const { user, updateUser } = useAuth();
// — Form de perfil —
const [name, setName] = useState(user?.name ?? '');
const [nameError, setNameError] = useState('');
const [nameSaving, setNameSaving] = useState(false);
const [nameSuccess, setNameSuccess] = useState(false);
// — Form de senha —
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordSuccess, setPasswordSuccess] = useState(false);
async function handleSaveName(e: React.FormEvent) {
e.preventDefault();
setNameError('');
setNameSuccess(false);
if (!name.trim()) {
setNameError('O nome não pode ser vazio.');
return;
}
setNameSaving(true);
try {
const updated = await updateProfile({ name: name.trim() });
updateUser({ name: updated.name });
setNameSuccess(true);
} catch {
setNameError('Erro ao salvar. Tente novamente.');
} finally {
setNameSaving(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordError('');
setPasswordSuccess(false);
if (newPassword.length < 8) {
setPasswordError('A nova senha deve ter pelo menos 8 caracteres.');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('As senhas não coincidem.');
return;
}
setPasswordSaving(true);
try {
await changePassword({ current_password: currentPassword, new_password: newPassword });
setPasswordSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
const msg = err?.response?.data?.error ?? 'Erro ao alterar senha.';
setPasswordError(msg);
} finally {
setPasswordSaving(false);
}
}
return (
<div className="p-6 max-w-lg space-y-8">
<h1 className="text-xl font-semibold text-textPrimary">Minha conta</h1>
{/* Formulário: dados pessoais */}
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
<h2 className="text-sm font-semibold text-textPrimary">Dados pessoais</h2>
<form onSubmit={handleSaveName} className="space-y-4">
<div>
<label className="block text-xs text-textSecondary mb-1">Nome</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">E-mail</label>
<input
type="email"
value={user?.email ?? ''}
readOnly
className="w-full rounded-lg border border-borderSubtle bg-surface px-3 py-2 text-sm text-textTertiary cursor-not-allowed"
/>
</div>
{nameError && <p className="text-xs text-red-400">{nameError}</p>}
{nameSuccess && <p className="text-xs text-green-400">Nome atualizado com sucesso!</p>}
<button
type="submit"
disabled={nameSaving}
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition"
>
{nameSaving ? 'Salvando…' : 'Salvar alterações'}
</button>
</form>
</section>
{/* Formulário: trocar senha */}
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
<h2 className="text-sm font-semibold text-textPrimary">Alterar senha</h2>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-xs text-textSecondary mb-1">Senha atual</label>
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">Nova senha</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">Confirmar nova senha</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
{passwordError && <p className="text-xs text-red-400">{passwordError}</p>}
{passwordSuccess && <p className="text-xs text-green-400">Senha alterada com sucesso!</p>}
<button
type="submit"
disabled={passwordSaving}
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition"
>
{passwordSaving ? 'Salvando…' : 'Alterar senha'}
</button>
</form>
</section>
</div>
);
}
```
**Critérios de conclusão**:
- [ ] Página renderiza sem erros.
- [ ] Nome atual do usuário aparece pré-preenchido no campo "Nome".
- [ ] E-mail aparece em campo `readOnly` (não editável).
- [ ] Submit com nome vazio exibe erro inline sem chamar o servidor.
- [ ] Submit bem-sucedido de nome exibe "Nome atualizado com sucesso!" e o sidebar reflete o novo nome.
- [ ] Submit com `newPassword !== confirmPassword` exibe "As senhas não coincidem." sem chamar o servidor.
- [ ] Submit com `newPassword.length < 8` exibe erro inline.
- [ ] Senha atual incorreta → backend retorna `400` → frontend exibe "Senha atual incorreta".
- [ ] Após troca de senha bem-sucedida, campos de senha são limpos.
---
### T11 — Melhorar `FavoritesPage.tsx` com cards enriquecidos
**Arquivo**: `frontend/src/pages/client/FavoritesPage.tsx`
**Contexto**: Cards atuais mostram só título e link. Com T01/T02, o backend já entrega `cover_photo_url`, `price`, `city`, `neighborhood`. Precisa exibir essas informações e melhorar o empty state.
**Passos**:
1. Atualizar o import de tipos:
```typescript
import type { SavedProperty } from '../../types/clientArea';
```
2. Corrigir o tipo do estado: `useState<SavedProperty[]>([])`.
3. Remover o componente `HeartButton` deste contexto — substituir pela ação "Remover dos favoritos" usando `removeFavorite` do serviço:
```typescript
import { getFavorites, removeFavorite } from '../../services/clientArea';
```
4. Implementar a função de remoção (optimistic update):
```typescript
async function handleRemove(savedId: string, propertyId: string | null) {
if (!propertyId) return;
setFavorites(prev => prev.filter(f => f.id !== savedId));
try {
await removeFavorite(propertyId);
} catch {
// Recarregar em caso de erro
getFavorites().then(data => setFavorites(Array.isArray(data) ? data : []));
}
}
```
5. Substituir os cards no retorno JSX por:
```tsx
{favorites.map((item) => {
const prop = item.property;
const price = prop?.price
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })
.format(parseFloat(prop.price))
: null;
const location = [prop?.neighborhood, prop?.city].filter(Boolean).join(', ');
return (
<div key={item.id} className="rounded-xl border border-borderSubtle bg-panel overflow-hidden hover:border-borderStandard transition">
{/* Thumbnail */}
<div className="relative h-40 bg-surface">
{prop?.cover_photo_url ? (
<img
src={prop.cover_photo_url}
alt={prop.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-textQuaternary text-xs">
Sem foto
</div>
)}
</div>
{/* Info */}
<div className="p-4">
<p className="text-sm font-medium text-textPrimary line-clamp-2">{prop?.title ?? 'Imóvel'}</p>
{price && <p className="mt-1 text-sm font-semibold text-brand">{price}</p>}
{location && <p className="mt-0.5 text-xs text-textTertiary">{location}</p>}
{/* Ações */}
<div className="mt-3 flex items-center gap-2">
<a
href={prop?.slug ? `/imoveis/${prop.slug}` : '#'}
className="flex-1 rounded-lg border border-borderSubtle px-3 py-1.5 text-center text-xs text-textSecondary hover:text-textPrimary hover:border-borderStandard transition"
>
Ver imóvel
</a>
<button
onClick={() => handleRemove(item.id, item.property_id)}
className="rounded-lg border border-borderSubtle px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 hover:border-red-500/20 transition"
>
Remover
</button>
</div>
</div>
</div>
);
})}
```
**Critérios de conclusão**:
- [ ] Cada card exibe: thumbnail (ou placeholder "Sem foto"), título, preço formatado em BRL, cidade/bairro.
- [ ] Botão "Remover" remove o card da lista imediatamente (optimistic update) e chama `removeFavorite`.
- [ ] Botão "Ver imóvel" navega para `/imoveis/<slug>`.
- [ ] Empty state exibe link para `/imoveis`.
- [ ] Sem erros TypeScript.
---
### T12 — Melhorar `VisitsPage.tsx` com cancelamento de visita
**Arquivo**: `frontend/src/pages/client/VisitsPage.tsx`
**Contexto**: Página só exibe visitas. Precisa adicionar botão "Cancelar" para `status=pending`, com confirmação simples e optimistic update.
**Passos**:
1. Adicionar import do serviço:
```typescript
import { cancelVisit, getVisits } from '../../services/clientArea';
```
2. Adicionar estado para controle de cancelamento:
```typescript
const [cancelling, setCancelling] = useState<string | null>(null); // visitId em progresso
const [cancelError, setCancelError] = useState<string | null>(null);
```
3. Implementar `handleCancel`:
```typescript
async function handleCancel(visitId: string) {
if (!window.confirm('Confirmar cancelamento desta visita?')) return;
setCancelling(visitId);
setCancelError(null);
// Optimistic update
setVisits(prev =>
prev.map(v => (v.id === visitId ? { ...v, status: 'cancelled' as const } : v))
);
try {
const updated = await cancelVisit(visitId);
setVisits(prev => prev.map(v => (v.id === visitId ? updated : v)));
} catch (err: any) {
// Reverter
setCancelError('Não foi possível cancelar. Tente novamente.');
getVisits().then(setVisits).catch(() => {});
} finally {
setCancelling(null);
}
}
```
4. Dentro do map de visitas, após o badge de status, adicionar o botão condicional:
```tsx
{visit.status === 'pending' && (
<button
onClick={() => handleCancel(visit.id)}
disabled={cancelling === visit.id}
className="mt-3 rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition"
>
{cancelling === visit.id ? 'Cancelando…' : 'Cancelar visita'}
</button>
)}
```
5. Exibir `cancelError` se presente, logo após o bloco de visitas:
```tsx
{cancelError && (
<p className="mt-2 text-xs text-red-400">{cancelError}</p>
)}
```
**Critérios de conclusão**:
- [ ] Visita com `status=pending` exibe botão "Cancelar visita".
- [ ] Ao clicar, `window.confirm` é exibido; se cancelado pelo usuário, nenhuma ação.
- [ ] Ao confirmar, o badge de status muda imediatamente para "Cancelada" (optimistic).
- [ ] O botão "Cancelar visita" desaparece após status mudar.
- [ ] Visitas com status diferente de `pending` não exibem o botão.
- [ ] Em caso de erro de rede, mensagem de erro é exibida e lista é recarregada.
---
### T13 — Melhorar empty state de `ComparisonPage.tsx`
**Arquivo**: `frontend/src/pages/client/ComparisonPage.tsx`
**Contexto**: O empty state atual diz "Nenhum imóvel selecionado para comparação" com link para `/imoveis`. Falta instrução clara de como usar a feature.
**Passos**:
1. Localizar o bloco do empty state (quando `properties.length === 0`).
2. Substituir o conteúdo interno do `div` de empty state por:
```tsx
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center max-w-sm mx-auto">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface">
{/* ScaleIcon SVG inline */}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
strokeWidth={1.5} stroke="currentColor" className="size-6 text-textTertiary">
<path strokeLinecap="round" strokeLinejoin="round"
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 15.95M5.25 4.97l-2.62 15.95m0 0a48.959 48.959 0 0 0 3.32.65M5.63 20.92a48.958 48.958 0 0 0 3.32-.65m9.63.65a48.952 48.952 0 0 0 3.32-.65" />
</svg>
</div>
<p className="text-sm font-medium text-textPrimary mb-2">Nenhum imóvel para comparar</p>
<p className="text-xs text-textTertiary mb-4">
Acesse um imóvel no catálogo e clique em{' '}
<span className="font-medium text-textSecondary">"Comparar"</span>{' '}
para adicioná-lo aqui. Você pode comparar até 3 imóveis lado a lado.
</p>
<Link
to="/imoveis"
className="inline-block rounded-lg bg-brand px-4 py-2 text-xs font-medium text-white hover:bg-brandHover transition"
>
Explorar imóveis
</Link>
</div>
```
**Critérios de conclusão**:
- [ ] Empty state exibe instrução explicando como adicionar imóveis à comparação.
- [ ] Instrução menciona o botão "Comparar" nos cards de imóvel.
- [ ] Link "Explorar imóveis" navega para `/imoveis`.
- [ ] Quando há imóveis na comparação, a tabela é exibida normalmente (sem regressão).
---
## Fase 7 — Frontend: Roteamento
### T14 — Atualizar rotas em `App.tsx`
**Arquivo**: `frontend/src/App.tsx`
**Contexto**: Precisa: (a) redirecionar `/area-do-cliente` para `/area-do-cliente/favoritos`; (b) remover a rota `/boletos`; (c) adicionar a rota `/conta` apontando para `ProfilePage`.
**Passos**:
1. Adicionar import de `Navigate` e `ProfilePage`:
```typescript
import { Navigate } from 'react-router-dom'; // adicionar ao import existente de react-router-dom
import ProfilePage from './pages/client/ProfilePage';
```
2. Remover os imports de `BoletosPage` e `ClientDashboardPage`:
```typescript
// Remover estas linhas:
import BoletosPage from './pages/client/BoletosPage';
import ClientDashboardPage from './pages/client/ClientDashboardPage';
```
3. Localizar o bloco de rotas da área do cliente e substituir:
```tsx
{/* Antes */}
<Route index element={<ClientDashboardPage />} />
<Route path="favoritos" element={<FavoritesPage />} />
<Route path="comparar" element={<ComparisonPage />} />
<Route path="visitas" element={<VisitsPage />} />
<Route path="boletos" element={<BoletosPage />} />
{/* Depois */}
<Route index element={<Navigate to="favoritos" replace />} />
<Route path="favoritos" element={<FavoritesPage />} />
<Route path="comparar" element={<ComparisonPage />} />
<Route path="visitas" element={<VisitsPage />} />
<Route path="conta" element={<ProfilePage />} />
```
**Critérios de conclusão**:
- [ ] Acessar `/area-do-cliente` redireciona para `/area-do-cliente/favoritos` (Replace — sem entrada no histórico).
- [ ] A rota `/area-do-cliente/boletos` não existe mais (retorna 404 do React Router).
- [ ] A rota `/area-do-cliente/conta` renderiza `ProfilePage`.
- [ ] Sem erros TypeScript/lint no arquivo.
---
## Fase 8 — Remoção de Arquivos Obsoletos
### T15 — Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`
**Arquivos a deletar**:
- `frontend/src/pages/client/BoletosPage.tsx`
- `frontend/src/pages/client/ClientDashboardPage.tsx`
**Contexto**: Após T14, nenhum import desses componentes existe mais no projeto. Removê-los evita confusão futura.
**Passos**:
1. Verificar que nenhum arquivo do projeto importa `BoletosPage` ou `ClientDashboardPage`:
```powershell
Select-String -Path "frontend/src/**" -Pattern "BoletosPage|ClientDashboardPage" -Recurse
```
2. Se o resultado for vazio (apenas os próprios arquivos), deletar:
```powershell
Remove-Item "frontend/src/pages/client/BoletosPage.tsx"
Remove-Item "frontend/src/pages/client/ClientDashboardPage.tsx"
```
**Critérios de conclusão**:
- [ ] Os dois arquivos não existem mais no projeto.
- [ ] Nenhum arquivo do projeto os importa.
- [ ] Build do frontend (ou `tsc --noEmit`) passa sem erros.
---
## Grafo de Dependências
```
T01 (Schemas backend)
└─ T02 (Eager load GET /favorites) → T11 (FavoritesPage)
└─ T03 (PATCH /profile) → T07 (cancelVisit/updateProfile) → T10 (ProfilePage)
└─ T04 (PATCH /password) ↗
└─ T05 (PATCH /visits/cancel) → T07 (cancelVisit) → T12 (VisitsPage)
T06 (Tipos TS) → T07 (Serviços) → T10, T11, T12
T08 (AuthContext.updateUser) → T10 (ProfilePage usa updateUser)
T09 (ClientLayout) — independente, pode ser feito em paralelo com T01T08
T13 (ComparisonPage empty state) — independente
T14 (App.tsx rotas) → depende de T10 (ProfilePage deve existir)
T15 (Deletar arquivos) → depende de T14
```
## Execução em Paralelo
| Grupo paralelo | Tasks |
|---|---|
| 1 (backend) | T01 → (T02, T03, T04, T05) em paralelo após T01 |
| 2 (frontend infra) | T06, T08, T09, T13 podem ser feitos em paralelo entre si |
| 3 (serviços) | T07 após T06 |
| 4 (páginas) | T10 após T03+T04+T08+T07; T11 após T02+T06+T07; T12 após T05+T06+T07 |
| 5 (finalização) | T14 após T10; T15 após T14 |
## Checklist Resumida
- [X] T01 [P] Schemas backend: `PropertyCard`, `UpdateProfileIn/Out`, `UpdatePasswordIn` em `backend/app/schemas/client_area.py`
- [X] T12 [US3] Cancelamento em `frontend/src/pages/client/VisitsPage.tsx`
- [X] T13 [P] [US6] Empty state em `frontend/src/pages/client/ComparisonPage.tsx`
- [X] T14 Rotas em `frontend/src/App.tsx`
- [X] T15 Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`

View file

@ -0,0 +1,173 @@
# UX/UI Audit — Área do Cliente (`/area-do-cliente`)
**Data:** 2026-04-22
**Auditor:** GitHub Copilot (UX/UI Review)
**Escopo:** Todas as páginas e componentes sob `/area-do-cliente`
---
## 1. Inventário de Telas Atuais
| Rota | Componente | Status |
|------|-----------|--------|
| `/area-do-cliente` | `ClientDashboardPage` | ❌ Remover |
| `/area-do-cliente/favoritos` | `FavoritesPage` | 🔧 Melhorar |
| `/area-do-cliente/comparar` | `ComparisonPage` | 🔧 Melhorar |
| `/area-do-cliente/visitas` | `VisitsPage` | 🔧 Melhorar |
| `/area-do-cliente/boletos` | `BoletosPage` | ❌ Remover |
---
## 2. Problemas Identificados
### 2.1 Dashboard (Painel) — REMOVER
- **Problema:** 3 cards com números (favoritos, visitas, boletos). Não agrega valor real ao usuário — ele precisa clicar de qualquer forma para ver os detalhes.
- **Impacto:** UX: Alto. Adiciona um clique desnecessário para chegar ao conteúdo real.
- **Decisão:** Remover o dashboard. Redirecionar `/area-do-cliente``/area-do-cliente/favoritos`.
### 2.2 Boletos — REMOVER
- **Problema:** Funcionalidade de boletos é uma feature de gestão imobiliária avançada raramente usada. A tabela existe mas os dados dependem de inserção manual pelo admin. Gera confusão para usuários sem boletos.
- **Impacto:** UX: Médio. Ruído visual no menu, expectativa não cumprida.
- **Decisão:** Remover a rota, o componente e o item do menu. Manter o backend (não excluir o model/API).
### 2.3 Navegação (Sidebar) — Ícones
- **Problema:** Ícones usam caracteres Unicode/emoji (`⊞`, `♡`, `⇄`, `📅`, `📄`) que rendem de forma inconsistente entre sistemas operacionais e navegadores. Look não-profissional em temas dark/light.
- **Impacto:** Visual: Alto. Inconsistência de rendering cross-platform.
- **Decisão:** Substituir por SVG inline (Heroicons 2.0 outline) para cada item de nav.
### 2.4 FavoritesPage — Cards sem imagem/preço
- **Problema:** Cards de favoritos mostram apenas título e link "Ver detalhes →". O usuário não consegue lembrar qual imóvel é qual sem clicar.
- **Impacto:** UX: Alto. Experiência frustrante para quem tem múltiplos favoritos.
- **Decisão:** Mostrar thumbnail da primeira foto, preço e cidade/bairro no card.
### 2.5 FavoritesPage — Layout de grid muito esparso
- **Problema:** Grid `1 → 2 → 3 colunas` com cards muito altos (h-48 skeleton) para conteúdo mínimo.
- **Decisão:** Cards com imagem real + info resumida, altura proporcional.
### 2.6 VisitsPage — Sem ação do usuário
- **Problema:** O usuário só visualiza visitas. Não há como cancelar, nem há indicação clara de próximos passos.
- **Impacto:** UX: Médio. Usuário precisa entrar em contato por outro canal para cancelar.
- **Decisão:** Adicionar botão "Solicitar cancelamento" para visitas com status `pending`.
### 2.7 VisitsPage — Layout de lista plana
- **Problema:** Cards de visita não têm hierarquia visual clara. Data e status competem com o título.
- **Decisão:** Refatorar card de visita com: título em destaque, data em destaque, status badge alinhado à direita, mensagem como texto secundário colapsável.
### 2.8 ComparisonPage — Empty state sem call-to-action útil
- **Problema:** Empty state tem apenas "Nenhum imóvel selecionado" e link para `/imoveis`. Usuário não sabe como adicionar imóveis à comparação.
- **Decisão:** Empty state explicativo com instrução de como usar a feature (tooltip/hint).
### 2.9 ClientLayout — Sidebar sem perfil editável
- **Problema:** Sidebar mostra avatar + nome + email mas sem link para perfil/configurações.
- **Impacto:** UX: Médio. Usuário não consegue alterar dados cadastrais pela área do cliente.
- **Decisão:** Adicionar link "Minha conta" apontando para nova rota `/area-do-cliente/conta`.
### 2.10 ClientLayout — Botão "Sair" sem ícone próprio
- **Problema:** Usa `→` como ícone de logout. Semântica errada (parece "ir para").
- **Decisão:** Substituir por ícone ArrowRightOnRectangle (Heroicons logout).
### 2.11 Mobile Nav — Scroll horizontal fraco
- **Problema:** Em mobile, a navegação é uma barra horizontal com scroll. Após remover boletos e painel, teremos 3 itens — cabe bem. Mas ainda carece de indicador visual claro de ativo.
- **Decisão:** Melhorar o indicador ativo com underline/pill e alinhar os 3 itens centralizados.
### 2.12 Ausência de página de perfil/conta
- **Problema:** Não existe `/area-do-cliente/conta`. Usuário não pode ver/editar nome, email ou senha.
- **Decisão:** Criar `ProfilePage` com form de atualização de nome e alteração de senha.
---
## 3. Resumo das Decisões
### ❌ Remover
- `ClientDashboardPage` (`/area-do-cliente`) — redirecionar para `/area-do-cliente/favoritos`
- `BoletosPage` (`/area-do-cliente/boletos`) — remover rota, nav item e componente
- Item "Boletos" e item "Painel" do nav
### ✅ Manter (com melhorias)
- `FavoritesPage` — melhorar cards com thumbnail + preço + localização
- `VisitsPage` — adicionar cancelamento + melhorar layout do card
- `ComparisonPage` — melhorar empty state
- `ClientLayout` — trocar ícones por SVG, adicionar link "Minha conta", melhorar logout
### Criar
- `ProfilePage` (`/area-do-cliente/conta`) — formulário de edição de perfil
---
## 4. Nova Estrutura de Navegação
**Antes (5 itens + Painel):**
```
⊞ Painel
♡ Favoritos
⇄ Comparar
📅 Visitas
📄 Boletos
```
**Depois (4 itens, sem Painel como item separado — é o redirect):**
```
[heart-icon] Favoritos
[scale-icon] Comparar
[calendar-icon] Visitas
[user-icon] Minha conta
```
---
## 5. Fluxo UX Revisado
```
/area-do-cliente → redirect 302 → /area-do-cliente/favoritos
/area-do-cliente/favoritos
Cards: thumbnail | título | preço | cidade
Ação: [Remover dos favoritos] [Ver imóvel →]
/area-do-cliente/visitas
Cards: título imóvel | data | status badge
Ação: [Solicitar cancelamento] (só para status=pending)
/area-do-cliente/comparar
Tabela: até 3 imóveis lado a lado
Empty state: instrução de como adicionar imóvel à comparação
/area-do-cliente/conta
Form: Nome | Email (readonly) | Senha atual | Nova senha | Confirmar senha
Ação: [Salvar alterações]
```
---
## 6. Componentes Afetados
| Arquivo | Ação |
|---------|------|
| `frontend/src/layouts/ClientLayout.tsx` | Refatorar nav (SVG icons, remover Boletos/Painel, adicionar Conta) |
| `frontend/src/pages/client/ClientDashboardPage.tsx` | Deletar ou converter em redirect |
| `frontend/src/pages/client/BoletosPage.tsx` | Deletar |
| `frontend/src/pages/client/FavoritesPage.tsx` | Melhorar cards |
| `frontend/src/pages/client/VisitsPage.tsx` | Melhorar card + adicionar cancelamento |
| `frontend/src/pages/client/ComparisonPage.tsx` | Melhorar empty state |
| `frontend/src/pages/client/ProfilePage.tsx` | Criar (novo) |
| `frontend/src/services/clientArea.ts` | Adicionar `updateProfile`, `changePassword`, `cancelVisit` |
| `frontend/src/App.tsx` | Atualizar rotas (remover boletos, adicionar conta, redirect) |
| `backend/app/routes/client.py` | Adicionar PATCH `/me/profile`, PATCH `/me/password`, PATCH `/me/visits/:id/cancel` |
| `backend/app/schemas/client.py` | Adicionar schemas de update |
---
## 7. Critérios de Aceite
- [ ] `/area-do-cliente` redireciona para `/area-do-cliente/favoritos`
- [ ] "Boletos" não aparece no menu e a rota 404
- [ ] "Painel" não aparece no menu
- [ ] Ícones do menu são SVG Heroicons consistentes em dark e light
- [ ] Card de favorito mostra: imagem (ou placeholder), título, preço, cidade
- [ ] Card de visita mostra: título, data agendada (ou solicitada), status badge
- [ ] Visita com `status=pending` exibe botão "Cancelar" que muda para `cancelled`
- [ ] Empty state do comparar explica como usar a feature
- [ ] `/area-do-cliente/conta` exibe form com nome e troca de senha
- [ ] Form de conta valida: nome obrigatório, senhas com mínimo 8 chars, confirmação igual
- [ ] Mobile nav centraliza os 4 itens sem scroll (ou scroll suave se necessário)
- [ ] Botão "Sair" usa ícone de logout correto

View file

@ -0,0 +1,191 @@
# UI Contract — Navbar do Topo
## Objetivo
Definir o comportamento observável da navbar por perfil de usuário, breakpoint e estado interativo, sem introduzir mudanças de API.
---
## 1. Perfis suportados
### `visitor`
Condição:
- `isAuthenticated === false`
Elementos obrigatórios:
- Logo com navegação para `/`
- Links públicos principais
- CTA `Anunciar imóvel` para `/cadastro-residencia`
- Ação `Entrar` para `/login`
- Ação de favoritos públicos quando aplicável ao estado atual do produto
Elementos proibidos:
- Menu `Admin`
- Seção `Minha Conta`
### `client`
Condição:
- `isAuthenticated === true`
- `user.role !== 'admin'`
Elementos obrigatórios:
- Tudo que continua relevante da navegação pública
- Gatilho de conta com inicial/avatar e primeiro nome truncado
- Entradas contextuais: `Favoritos`, `Comparar`, `Visitas`, `Minha conta`
- Ação `Sair` separada visualmente
Elementos proibidos:
- Menu `Admin`
- Botão `Entrar`
### `admin`
Condição:
- `isAuthenticated === true`
- `user.role === 'admin'`
Elementos obrigatórios:
- Tudo que continua relevante da navegação pública
- Gatilho contextual `Admin`
- Atalhos admin prioritários para módulos existentes
- Ação `Sair`
Elementos proibidos:
- Menu padrão `Minha Conta` de cliente
- Botão `Entrar`
---
## 2. Contrato desktop
### Estrutura mínima
```text
[Logo]
[Navegação pública principal]
[Ações contextuais por perfil]
[Theme toggle]
[CTA principal]
[Ação de autenticação ou logout]
```
### Regras
- Devem existir no máximo 5 links públicos visíveis simultaneamente.
- O CTA principal deve ter destaque visual acima dos links secundários.
- Menus contextuais (`Admin` e `Conta`) não podem deslocar ou truncar a navegação principal.
- Nomes longos de usuário devem truncar sem quebrar altura ou alinhamento.
---
## 3. Contrato mobile
### Gatilho hambúrguer
Obrigatório:
- `aria-label` dinâmico entre abrir/fechar
- `aria-expanded` coerente com o estado
- `aria-controls` apontando para o painel do menu
### Conteúdo do menu
Obrigatório:
- Mesmos destinos públicos principais em ordem lógica
- CTA `Anunciar imóvel`
- Seção `Minha Conta` apenas para cliente autenticado
- Seção `Admin` apenas para admin
- Logout apenas para usuário autenticado
- `Entrar` apenas para visitante
Regras:
- Alvos tocáveis com área mínima de 44x44 px.
- Ao navegar por qualquer item, o menu deve fechar.
- O menu mobile não pode coexistir com dropdown contextual desktop.
---
## 4. Contrato de estado interativo
Estados possíveis:
```text
closed
mobile-open
client-open
admin-open
```
Invariantes:
- Apenas um estado aberto por vez.
- Abrir um contexto fecha qualquer outro.
- Clique fora fecha o contexto aberto.
- Mudança de rota fecha qualquer contexto aberto.
- Logout fecha qualquer contexto aberto e restaura a visualização de visitante.
---
## 5. Contrato de acessibilidade
Obrigatório:
- `nav` com `aria-label` descritivo
- foco visível em links e botões
- acionamento por teclado em gatilhos interativos
- contraste adequado em texto, hover, active e estados abertos nos temas suportados
Não aceitável:
- gatilhos sem nome acessível
- estados visuais dependentes apenas de cor sem contraste suficiente
- foco invisível ou escondido pelo backdrop da navbar
---
## 6. Destinos atualmente suportados
### Navegação pública
| Rótulo | Destino atual |
|---|---|
| Comprar | `/imoveis?listing_type=venda` |
| Alugar | `/imoveis?listing_type=aluguel` |
| Equipe | `/corretores` |
| Sobre | `/sobre` |
| Contato | `/contato` |
| Anunciar imóvel | `/cadastro-residencia` |
| Entrar | `/login` |
### Navegação do cliente
| Rótulo | Destino atual |
|---|---|
| Favoritos | `/area-do-cliente/favoritos` |
| Comparar | `/area-do-cliente/comparar` |
| Visitas | `/area-do-cliente/visitas` |
| Minha conta | `/area-do-cliente/conta` |
### Navegação admin
| Rótulo | Destino atual |
|---|---|
| Imóveis | `/admin/properties` |
| Corretores | `/admin/corretores` |
| Clientes | `/admin/clientes` |
| Boletos | `/admin/boletos` |
| Visitas | `/admin/visitas` |
| Favoritos | `/admin/favoritos` |
| Cidades | `/admin/cidades` |
| Amenidades | `/admin/amenidades` |
| Analytics | `/admin/analytics` |
| Leads | `/admin/leads` |
| Candidaturas | `/admin/candidaturas` |
| Conf. Contato | `/admin/contato-config` |
---
## 7. Fora de escopo
- Alterar políticas de autorização backend
- Criar novas rotas ou módulos administrativos
- Persistir preferências de navegação em banco
- Transformar a navbar em configuração dinâmica vinda da API

View file

@ -0,0 +1,165 @@
# Data Model — 030-navbar-topo-ux
> Esta feature não cria entidades de banco nem migrations. O modelo abaixo descreve dados de sessão já existentes e o estado de UI necessário para a navbar.
---
## 1. Entidades Existentes Consumidas
### `AuthSession`
Fonte: `frontend/src/contexts/AuthContext.tsx`
| Campo | Tipo | Origem | Uso na navbar |
|---|---|---|---|
| `user` | `User | null` | contexto de autenticação | Decide exibição de avatar, primeiro nome e papel do usuário |
| `token` | `string | null` | `localStorage` + contexto | Não renderiza UI diretamente; sustenta estado autenticado |
| `isAuthenticated` | `boolean` | derivado do contexto | Liga/desliga ações de visitante vs cliente/admin |
| `isLoading` | `boolean` | bootstrap da sessão | Evita flicker de ações incorretas durante hidratação |
| `logout()` | `() => void` | contexto | Encerra sessão e força retorno visual ao estado visitante |
### `UserProfile`
Fonte: tipo `User` retornado pelo fluxo de auth atual.
| Campo | Tipo | Regra | Uso na navbar |
|---|---|---|---|
| `name` | `string` | obrigatório quando `user != null` | Exibe inicial e primeiro nome truncado |
| `role` | `string` | valor relevante nesta feature: `admin` ou não-admin | Controla presença do menu Admin e do menu Minha Conta |
**Invariantes**:
- `user === null` implica navbar de visitante.
- `user.role === 'admin'` implica ausência do menu de cliente padrão.
- Nome longo nunca deve quebrar layout; deve ser truncado no gatilho.
---
## 2. Entidades de Navegação
### `NavItem`
Representa um item navegável exibido em um dos grupos da navbar.
```ts
interface NavItem {
label: string
to: string
end?: boolean
visibility: 'public' | 'client' | 'admin'
group: 'primary' | 'contextual' | 'cta'
}
```
**Regras**:
- Itens `public` aparecem para todos, salvo ajustes de prioridade visual.
- Itens `client` aparecem apenas quando `isAuthenticated === true` e `role !== 'admin'`.
- Itens `admin` aparecem apenas quando `role === 'admin'`.
- `group='cta'` não substitui o grupo público; ele complementa a hierarquia do topo.
### `ProfileSection`
Agrupa ações contextuais por perfil no mobile e no desktop.
```ts
interface ProfileSection {
id: 'admin' | 'client'
title: string
items: NavItem[]
includesLogout: boolean
}
```
**Regras**:
- No mobile, cada seção deve aparecer com cabeçalho próprio.
- Logout deve permanecer visualmente separado das ações de navegação.
---
## 3. Estado Transitório de UI
### `NavUIState`
Modelo recomendado para governar interações mutuamente exclusivas.
```ts
type ActiveOverlay = 'closed' | 'mobile' | 'admin' | 'client'
interface NavUIState {
activeOverlay: ActiveOverlay
isDesktop: boolean
}
```
**Regras de transição**:
| Evento | Estado atual | Próximo estado | Observação |
|---|---|---|---|
| Clique no hambúrguer | `closed` | `mobile` | Só em mobile |
| Clique no hambúrguer | `mobile` | `closed` | Toggle padrão |
| Clique no gatilho Admin | `closed` ou `client` | `admin` | Fecha qualquer outro overlay |
| Clique no gatilho Cliente | `closed` ou `admin` | `client` | Fecha qualquer outro overlay |
| Clique fora | `mobile` / `admin` / `client` | `closed` | Requisito FR-012 |
| Mudança de rota | qualquer | `closed` | Requisito FR-013 |
| Logout confirmado | qualquer | `closed` | Navbar volta ao estado visitante |
| Escape | `mobile` / `admin` / `client` | `closed` | Recomendado para previsibilidade |
**Invariantes**:
- Apenas um contexto pode permanecer aberto por vez.
- `mobile` não pode coexistir com `admin` ou `client`.
- `admin` e `client` são mutuamente exclusivos.
---
## 4. Estados Derivados de Exibição
### `NavbarVariant`
```ts
type NavbarVariant = 'visitor' | 'client' | 'admin'
```
Derivação:
- `visitor`: `!isLoading && !isAuthenticated`
- `client`: `isAuthenticated && user?.role !== 'admin'`
- `admin`: `isAuthenticated && user?.role === 'admin'`
### `ActiveLinkState`
Estado derivado de `NavLink`/rota atual.
**Regras**:
- Links públicos devem refletir estado ativo em desktop e mobile.
- Rotas com query string, como `/imoveis?listing_type=venda`, devem manter coerência visual com a intenção do link.
- Itens contextuais devem fechar o menu após navegação bem-sucedida.
---
## 5. Regras de Validação de UX/A11y
| Regra | Aplicação |
|---|---|
| `aria-expanded` coerente | gatilhos do menu mobile e dropdowns |
| `aria-controls` presente | menu hambúrguer e, se aplicável, painéis contextuais |
| foco visível | todos os links e botões interativos |
| alvo mínimo `44x44` | hambúrguer, CTA, itens tocáveis em mobile |
| truncamento elegante | nome do usuário e gatilhos de perfil |
---
## 6. Relações Entre Entidades
```text
AuthSession
└── UserProfile
├── role ──────────────┐
└── name ───────┐ │
│ │
NavbarVariant <─────────┘ │
NavItem.visibility ────────────┘
NavUIState.activeOverlay
├── controls mobile menu visibility
├── controls admin dropdown visibility
└── controls client dropdown visibility
```

View file

@ -0,0 +1,123 @@
# Implementation Plan: Navbar Topo UX
**Branch**: `030-navbar-topo-ux` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/030-navbar-topo-ux/spec.md`
## Summary
Revisar e refinar a navbar fixa do topo, concentrando a implementação no frontend React para separar melhor navegação pública e contextual por perfil, melhorar a hierarquia visual em desktop/mobile, consolidar o controle de estados transitórios (menu mobile, dropdown admin, dropdown cliente) e elevar acessibilidade sem alterar contratos backend. A principal superfície técnica atual é `frontend/src/components/Navbar.tsx`, apoiada por `AuthContext`, `FavoritesContext`, rotas já existentes no SPA e tokens visuais já definidos em `index.css`.
---
## Technical Context
**Language/Version**: TypeScript 5.5 (frontend principal) / Python 3.12 existente sem mudança funcional
**Primary Dependencies**: React 18, react-router-dom v6, Tailwind CSS 3.4, Axios (indireto via autenticação), contexto próprio `useAuth`, `useFavorites`, `ThemeToggle`
**Storage**: N/A para persistência nova; sessão e token continuam em `localStorage` via `AuthContext`
**Testing**: `npm run build` no frontend + validação manual responsiva/a11y; `pytest` backend não deve ser impactado
**Target Platform**: SPA React em navegadores desktop e mobile, servida por Vite/Nginx no ambiente atual
**Project Type**: aplicação web full-stack com foco nesta feature em SPA frontend/UX
**Performance Goals**: navegação e abertura/fechamento de menus sem jank perceptível; interações do topo percebidas em < 100 ms; zero quebra visual entre 320 px e 1440 px+
**Constraints**: sem alterar autorização backend; preservar rotas existentes de admin e área do cliente; respeitar `DESIGN.md`, tokens do projeto e suporte atual a tema claro/escuro; garantir foco visível, `aria-*` coerente e alvo mínimo de 44x44 px em mobile
**Scale/Scope**: um componente navbar compartilhado entre páginas públicas, área do cliente e área admin; 3 perfis de usuário; 3 contextos interativos mutuamente exclusivos
---
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Princípio | Status | Observação |
|-----------|--------|------------|
| **I. Design-First** | ✅ PASS | A revisão mantém a navbar alinhada ao sistema visual existente, reutilizando tokens e variáveis globais em vez de introduzir paleta ad hoc. O tema claro/escuro já existe no produto e será respeitado sem romper o padrão visual. |
| **II. Separation of Concerns** | ✅ PASS | A feature altera navegação e estado visual no React; não exige renderização no backend nem novo acoplamento entre Flask e SPA. |
| **III. Spec-Driven** | ✅ PASS | O plano deriva diretamente de `spec.md`, com user stories por perfil e critérios de sucesso claros antes da implementação. |
| **IV. Data Integrity** | ✅ PASS | Não há mudança de schema, payload ou persistência. A feature consome apenas dados já existentes de sessão (`user`, `role`, `isAuthenticated`). |
| **V. Security** | ✅ PASS | O menu admin continua condicionado ao papel `admin` no frontend; nenhuma credencial nova será exposta e nenhuma rota protegida será aberta a outros perfis. |
| **VI. Simplicity First** | ✅ PASS | O caminho proposto evita novos pacotes e favorece simplificação do estado atual da navbar, priorizando um único controlador de overlay/menu em vez de abstrações extras. |
**Veredicto Pré-Design**: Sem violações. A feature pode prosseguir para pesquisa e desenho técnico.
**Revalidação Pós-Design**: Mantida como ✅ PASS após a definição do contrato de UI, do modelo de estado e do quickstart. Não surgiram dependências novas nem necessidade de mudança backend.
---
## Project Structure
### Documentação (esta feature)
```text
specs/030-navbar-topo-ux/
├── spec.md # Especificação do produto
├── plan.md # Este arquivo
├── research.md # Decisões e tradeoffs de UX/UI e arquitetura local
├── data-model.md # Modelo de estado e entidades da navbar
├── quickstart.md # Fluxo recomendado de implementação e validação
├── contracts/
│ └── navbar-ui-contract.md # Contrato de comportamento por perfil/breakpoint
└── tasks.md # Phase 2 — gerado por /speckit.tasks
```
### Código-fonte (raiz do repositório)
```text
frontend/
└── src/
├── components/
│ ├── Navbar.tsx # SUPERFÍCIE PRINCIPAL — links, dropdowns e menu mobile
│ └── ThemeToggle.tsx # Reutilizado no topo desktop/mobile
├── contexts/
│ ├── AuthContext.tsx # Fonte de verdade da sessão e logout
│ └── FavoritesContext.tsx # Badge/link de favoritos para visitante
├── layouts/
│ ├── ClientLayout.tsx # Consome Navbar na área do cliente
│ └── AdminLayout.tsx # Consome Navbar na área admin
├── pages/
│ ├── HomePage.tsx
│ ├── PropertiesPage.tsx
│ ├── ContactPage.tsx
│ └── ... # Demais páginas públicas que já renderizam Navbar
├── App.tsx # Rotas já existentes usadas pelo contrato de navegação
└── index.css # Variáveis/tokens e acabamento visual do topo
backend/
└── app/
└── ... # Sem mudanças previstas para esta feature
```
**Structure Decision**: Manter a arquitetura web existente e concentrar a implementação no frontend, com alterações localizadas em `Navbar.tsx`, possíveis ajustes pequenos em `AuthContext.tsx` para fluxo de logout/navegação e refinamentos visuais em `index.css`. Não há necessidade de novos módulos backend ou novos serviços HTTP.
---
## Implementation Approach
### 1. Arquitetura de informação da navbar
- Manter até 5 links públicos primários no desktop, com prioridade para descoberta de imóveis e contato.
- Separar claramente três blocos visuais: marca/logo, navegação pública e ações contextuais de perfil/CTA.
- Tratar o menu Admin como navegação contextual especializada, não como parte da navegação pública.
### 2. Modelo de estado local
- Consolidar o controle de menus para garantir exclusão mútua entre `mobile`, `admin` e `client`.
- Fechar qualquer overlay ao trocar rota, ao clicar fora e ao iniciar logout.
- Derivar a renderização por perfil a partir do estado já disponível em `useAuth()`.
### 3. Acessibilidade e previsibilidade
- Garantir rótulos e estados ARIA coerentes para gatilhos de dropdown e hambúrguer.
- Tornar foco e navegação por teclado parte do contrato da feature, inclusive nos gatilhos contextuais.
- Padronizar feedbacks de hover/active/open com transições curtas e consistentes.
### 4. Escopo de backend
- Nenhuma API nova.
- Nenhuma mudança em autorização.
- Nenhuma migration.
---
## Complexity Tracking
Nenhuma violação constitucional identificada. Não há complexidade excepcional que exija justificativa adicional nesta fase.

View file

@ -0,0 +1,105 @@
# Quickstart — 030-navbar-topo-ux
Guia curto para implementar e validar a revisão UX/UI da navbar do topo.
---
## Pré-requisitos
- Ambiente do projeto iniciado via `./start.ps1` na raiz.
- Frontend disponível em `http://localhost:5174`.
- Branch de trabalho: `030-navbar-topo-ux`.
Credenciais úteis encontradas no frontend atual:
- Admin: `admin@demo.com` / `admin1234`
- Usuário: `usuario@demo.com` / `demo1234`
---
## Superfícies de implementação
Arquivos com maior probabilidade de edição:
- `frontend/src/components/Navbar.tsx`
- `frontend/src/contexts/AuthContext.tsx`
- `frontend/src/index.css`
- Opcionalmente `frontend/src/layouts/ClientLayout.tsx` e `frontend/src/layouts/AdminLayout.tsx` se houver ajuste fino de offset/spacing
---
## Ordem recomendada de trabalho
### 1. Revisar a arquitetura local da navbar
- Mapear os grupos de links públicos, cliente e admin.
- Definir o modelo de estado único para overlays/contextos abertos.
- Garantir fechamento em clique fora, troca de rota e logout.
### 2. Ajustar a hierarquia visual desktop
- Reequilibrar logo, links primários, CTA e ações de conta.
- Limitar ruído visual e reforçar separação entre navegação pública e contextual.
- Validar truncamento do nome e destaque do CTA em ambos os temas.
### 3. Ajustar o comportamento mobile
- Garantir ordem lógica dos destinos no menu hambúrguer.
- Exibir seções `Minha Conta` e `Admin` apenas para os perfis corretos.
- Validar alvo de toque, foco e estados abertos/fechados.
### 4. Refinar logout e previsibilidade
- Conferir se logout fecha menus e retorna imediatamente ao estado visual de visitante.
- Se necessário, ajustar o comportamento atual de redirecionamento em `AuthContext.tsx` para evitar fricção visual desnecessária.
---
## Validação executável
Na pasta `frontend/`:
```bash
npm run build
```
Esse é o guardrail mínimo obrigatório antes de concluir a implementação.
---
## Checklist manual por persona
### Visitante
- Desktop: links principais legíveis, CTA `Anunciar imóvel` destacado e ação `Entrar` visível.
- Mobile: hambúrguer abre e fecha corretamente, com os mesmos destinos públicos em ordem lógica.
- Tema claro/escuro: contraste suficiente em texto, hover e estado ativo.
### Usuário autenticado
- Desktop: avatar/inicial, primeiro nome truncado e dropdown `Minha conta` funcionam.
- Dropdown: `Favoritos`, `Comparar`, `Visitas`, `Minha conta` e `Sair` fecham menu após clique.
- Mobile: seção `Minha Conta` aparece apenas quando autenticado não-admin.
### Admin
- Desktop: gatilho `Admin` visível e separado da navegação pública.
- Dropdown: atalhos admin navegam corretamente e fecham o menu.
- Mobile: seção `Admin` aparece e não conflita com outros contextos.
---
## Checklist de comportamento global
- Abrir dropdown admin fecha dropdown cliente.
- Abrir dropdown cliente fecha dropdown admin.
- Trocar de rota fecha menu mobile e dropdowns.
- Clique fora fecha o contexto aberto.
- `Tab`, `Enter` e `Espaço` funcionam nos gatilhos relevantes.
- Em 320 px, 768 px, 1024 px e 1280 px não há quebra ou sobreposição.
---
## Risco conhecido para implementação
O comportamento atual de logout em `AuthContext.tsx` usa redirecionamento forçado para `/login`. Isso é funcional, mas pode entrar em tensão com o objetivo da spec de “retornar à navbar de visitante” com mínima fricção visual. A implementação deve decidir se mantém esse fluxo por regra de produto ou se o suaviza no frontend sem alterar segurança.

View file

@ -0,0 +1,71 @@
# Research — 030-navbar-topo-ux
## Decision 1: Manter uma única Navbar compartilhada por todo o produto
**Decision**: Evoluir o componente compartilhado `frontend/src/components/Navbar.tsx` em vez de criar navbars separadas para visitante, cliente e admin.
**Rationale**: A navbar já é consumida por páginas públicas e layouts protegidos. Um único ponto de evolução reduz divergência visual, evita duplicação de links/rotas e facilita garantir consistência de comportamento entre desktop e mobile.
**Alternatives considered**:
- Criar três variantes independentes de navbar: rejeitado por elevar custo de manutenção e risco de regressões cruzadas.
- Extrair uma navbar diferente para admin: rejeitado nesta fase porque a spec pede eficiência sem poluir a experiência global, não uma shell totalmente nova.
---
## Decision 2: Consolidar o estado transitório em um único controlador de overlay
**Decision**: Planejar a navbar com um estado mutuamente exclusivo para `closed`, `mobile`, `client` e `admin`, mesmo que a implementação inicial parta do componente atual com múltiplos booleans.
**Rationale**: Os requisitos FR-011, FR-012 e FR-013 pedem previsibilidade forte. Um controlador único reduz a chance de estados simultâneos, simplifica o fechamento em mudança de rota e deixa o comportamento mais testável.
**Alternatives considered**:
- Manter três `useState` independentes: rejeitado como forma final porque exige disciplina manual em todos os handlers.
- Levar o estado para contexto global: rejeitado por excesso de complexidade para uma concern local de UI.
---
## Decision 3: Não introduzir novos contratos de API nem mudanças backend
**Decision**: Tratar a feature como frontend/UX puro, apoiada apenas pelos dados já disponíveis em `AuthContext` e nas rotas existentes em `App.tsx`.
**Rationale**: A spec descreve comportamento e hierarquia visual, não novos fluxos de domínio. `user`, `role`, `isAuthenticated` e `logout()` já cobrem as condições necessárias para as três personas.
**Alternatives considered**:
- Criar endpoint específico para navegação por perfil: rejeitado por desnecessário e contrário ao princípio de simplicidade.
- Mover a configuração de menu para backend: rejeitado nesta fase por não haver requisito de CMS/configuração dinâmica.
---
## Decision 4: Derivar destinos do menu a partir das rotas reais existentes
**Decision**: Basear o contrato de navegação nos destinos já disponíveis em `frontend/src/App.tsx` e na lista atual de módulos administrativos/cliente.
**Rationale**: Isso evita planejar links inexistentes e mantém a feature ancorada no sistema real. Também permite priorizar atalhos admin sem inventar módulos novos.
**Alternatives considered**:
- Redesenhar a informação da navbar a partir de destinos hipotéticos: rejeitado por abrir escopo além da spec.
- Remover rotas admin do topo nesta fase: rejeitado porque a spec exige acesso rápido para administradores.
---
## Decision 5: Formalizar um contrato de UI da navbar
**Decision**: Criar um documento em `contracts/navbar-ui-contract.md` descrevendo comportamento por perfil, breakpoint e estado interativo.
**Rationale**: Embora não exista API nova, a aplicação expõe uma interface de navegação para o usuário final. O contrato de UI serve como referência objetiva para implementação, QA e futura geração de tarefas.
**Alternatives considered**:
- Não criar contrato algum: rejeitado porque a feature é centrada em interação e estados, e isso precisa de definição explícita.
- Modelar o contrato só dentro do plan: rejeitado para manter separação clara entre abordagem técnica e comportamento esperado.
---
## Decision 6: Validar principalmente com build e checklist manual responsivo
**Decision**: Adotar `npm run build` como validação executável mínima e complementar com checklist manual por persona e breakpoint.
**Rationale**: O frontend atual não expõe uma suíte automatizada de testes de componentes/RTL. A natureza visual/interativa da navbar exige inspeção manual em desktop/mobile além da checagem de compilação.
**Alternatives considered**:
- Introduzir Vitest/RTL apenas para esta feature: rejeitado nesta fase de planning por ampliar escopo e dependências.
- Confiar só em validação visual manual: rejeitado porque o build ainda é o guardrail executável mínimo disponível.

View file

@ -0,0 +1,195 @@
# Feature Specification: Revisão UX/UI da Navbar do Topo
**Feature Branch**: `030-navbar-topo-ux`
**Created**: 2026-04-22
**Status**: Draft
---
## Objetivo
Melhorar a navbar fixa do topo para oferecer uma navegação mais clara, consistente e eficiente em desktop e mobile, com comportamento específico por perfil:
- Visitante (não autenticado)
- Usuário autenticado (cliente)
- Usuário admin
A feature deve equilibrar descoberta de funcionalidades, redução de ruído visual e eficiência de navegação para tarefas críticas.
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Navegação principal clara para visitantes (Priority: P1)
Como visitante, quero entender rapidamente os caminhos principais do site para encontrar imóveis, equipe e contato sem confusão.
**Why this priority**: A navbar é o principal ponto de orientação global da aplicação e impacta diretamente descoberta, conversão e retenção.
**Independent Test**: Em páginas públicas, o visitante visualiza links principais, CTA e autenticação com hierarquia visual clara.
**Acceptance Scenarios**:
1. **Given** um visitante em viewport desktop, **When** a navbar é exibida, **Then** os itens principais de navegação estão visíveis e legíveis, sem truncamento.
2. **Given** um visitante, **When** clica em um item principal (ex: Comprar, Alugar, Contato), **Then** é redirecionado ao destino correto e o estado ativo é refletido quando aplicável.
3. **Given** um visitante, **When** interage com ações de topo, **Then** o CTA “Anunciar imóvel” e a ação “Entrar” aparecem com destaque adequado e consistente.
4. **Given** um visitante em mobile, **When** abre o menu hambúrguer, **Then** encontra os mesmos destinos principais em ordem lógica e com alvo de toque adequado.
---
### User Story 2 — Menu de usuário autenticado orientado a tarefas (Priority: P1)
Como usuário autenticado, quero acessar rapidamente minhas ações pessoais pela navbar para continuar minha jornada sem fricção.
**Why this priority**: Usuário logado tem intenção clara; reduzir cliques e ambiguidade melhora eficiência e percepção de produto.
**Independent Test**: Ao autenticar como cliente, o menu do usuário é exibido com ações pessoais e fluxo de logout confiável.
**Acceptance Scenarios**:
1. **Given** um cliente autenticado (não admin), **When** visualiza a navbar desktop, **Then** vê avatar/inicial, primeiro nome e um dropdown de conta.
2. **Given** o dropdown do usuário aberto, **When** o cliente seleciona uma opção, **Then** navega para a rota correspondente e o dropdown fecha.
3. **Given** o cliente autenticado, **When** escolhe “Sair”, **Then** a sessão é encerrada e a navbar retorna ao estado de visitante.
4. **Given** o cliente autenticado em mobile, **When** abre o menu, **Then** encontra uma seção “Minha Conta” contendo links pessoais e logout.
---
### User Story 3 — Menu admin com acesso rápido e controle visual (Priority: P1)
Como administrador, quero acessar módulos administrativos pela navbar sem poluir a experiência dos demais usuários.
**Why this priority**: Admin precisa de eficiência operacional, mas o sistema também deve preservar clareza para perfis não-admin.
**Independent Test**: Ao autenticar como admin, o menu Admin aparece; para outros perfis ele não aparece.
**Acceptance Scenarios**:
1. **Given** um usuário com role admin, **When** acessa o sistema, **Then** o gatilho do dropdown “Admin” é exibido na navbar desktop.
2. **Given** o dropdown “Admin” aberto, **When** o admin escolhe um módulo, **Then** navega para o destino correto e o menu fecha.
3. **Given** um usuário não admin, **When** navega no sistema, **Then** não vê o menu Admin em nenhum breakpoint.
4. **Given** admin em mobile, **When** abre o menu, **Then** encontra uma seção “Admin” com os mesmos módulos prioritários da navegação desktop.
---
### User Story 4 — Navbar previsível, acessível e sem conflitos de estado (Priority: P2)
Como usuário, quero que os menus da navbar respondam de forma previsível para não perder contexto durante a navegação.
**Why this priority**: A navbar concentra múltiplos estados (menu mobile, dropdown usuário, dropdown admin), aumentando risco de comportamento inconsistente.
**Independent Test**: Abrir/fechar menus mantém estados consistentes com clique externo, teclado e mudança de rota.
**Acceptance Scenarios**:
1. **Given** dropdown admin aberto, **When** o usuário abre dropdown do cliente, **Then** o dropdown admin fecha automaticamente.
2. **Given** qualquer dropdown aberto, **When** ocorre clique fora, **Then** o dropdown fecha.
3. **Given** menu mobile aberto, **When** o usuário navega para outra rota, **Then** o menu fecha automaticamente.
4. **Given** navegação por teclado, **When** foco percorre a navbar, **Then** botões e links têm foco visível e acionamento por Enter/Espaço quando aplicável.
---
### User Story 5 — Hierarquia visual e responsividade premium (Priority: P2)
Como usuário em desktop e mobile, quero uma navbar com equilíbrio visual e legibilidade para navegar com confiança em qualquer tamanho de tela.
**Why this priority**: Qualidade visual e consistência de comportamento influenciam diretamente confiança e percepção de profissionalismo.
**Independent Test**: Em breakpoints principais, navbar mantém legibilidade, contraste e espaçamento sem sobreposição de elementos.
**Acceptance Scenarios**:
1. **Given** telas entre 320px e 1440px+, **When** a navbar é renderizada, **Then** não há quebra de layout ou sobreposição de elementos.
2. **Given** conteúdo de nome longo do usuário, **When** exibido no gatilho da conta, **Then** é truncado de forma elegante sem quebrar alinhamento.
3. **Given** tema claro/escuro, **When** a navbar é exibida, **Then** contraste de texto e estados hover/active permanecem acessíveis.
4. **Given** navegação prolongada, **When** o usuário rola e interage repetidamente, **Then** a navbar fixa mantém performance fluida sem jank perceptível.
---
## Edge Cases
- Sessão expira com dropdown aberto: navbar deve invalidar estado autenticado e retornar ao estado visitante sem erro visual.
- Usuário admin com nome muito longo: gatilho de conta/admin não deve deslocar links primários fora da área visível.
- Rotas com query string (ex: listagem filtrada): estado ativo da navegação deve permanecer coerente.
- Abertura simultânea de menu mobile e dropdown contextual: apenas um contexto de navegação deve permanecer aberto por vez.
- Falha ao executar logout: deve mostrar feedback de erro e manter usuário autenticado até confirmação.
---
## Requirements *(mandatory)*
### Functional Requirements
**Arquitetura da navegação**
- **FR-001**: A navbar DEVE manter uma área de navegação principal comum para todos os perfis (links públicos).
- **FR-002**: O sistema DEVE exibir estados de navbar distintos para visitante, cliente autenticado e admin autenticado.
- **FR-003**: O menu Admin DEVE ser exibido somente para usuários com `role=admin`.
- **FR-004**: O menu do usuário (cliente) DEVE ser exibido somente para usuários autenticados não-admin.
**Menu do usuário (cliente)**
- **FR-005**: O dropdown do cliente DEVE incluir entradas para `Favoritos`, `Comparar`, `Visitas` e `Minha conta`.
- **FR-006**: O dropdown do cliente DEVE incluir ação de `Sair` separada visualmente das ações de navegação.
- **FR-007**: Em mobile, as ações do cliente DEVEM aparecer agrupadas sob uma seção `Minha Conta` dentro do menu principal.
**Menu Admin**
- **FR-008**: O dropdown Admin DEVE incluir atalhos para os módulos administrativos vigentes no sistema.
- **FR-009**: Em mobile, os atalhos admin DEVEM ser exibidos em seção dedicada `Admin`.
- **FR-010**: O menu Admin NÃO DEVE estar presente para visitantes nem clientes não-admin.
**Comportamento de estados**
- **FR-011**: Abrir um dropdown (Admin ou Cliente) DEVE fechar automaticamente o outro dropdown.
- **FR-012**: Clique fora DEVE fechar dropdowns abertos.
- **FR-013**: Mudança de rota DEVE fechar menu mobile e dropdowns abertos.
- **FR-014**: O botão hambúrguer DEVE expor `aria-expanded` e `aria-controls` coerentes com o estado atual.
**Acessibilidade e usabilidade**
- **FR-015**: Todos os gatilhos interativos da navbar DEVEM possuir rótulos acessíveis e foco visível.
- **FR-016**: Alvos de toque em mobile DEVEM respeitar área mínima de interação (44x44 CSS px).
- **FR-017**: O estado ativo dos links DEVE ser visualmente distinguível em desktop e mobile.
**Hierarquia visual e consistência**
- **FR-018**: A navbar DEVE apresentar hierarquia clara entre links primários, menus contextuais (Admin/Conta) e CTAs.
- **FR-019**: O CTA principal DEVE manter contraste e legibilidade adequados em tema claro e escuro.
- **FR-020**: Nomes longos de usuário DEVEM ser truncados sem quebrar layout.
### UX/UI Recommendations (Design Direction)
- **UX-001**: Reduzir densidade cognitiva no topo priorizando no máximo 5 links primários visíveis no desktop.
- **UX-002**: Reforçar separação visual entre navegação pública e navegação contextual de perfil (Admin/Conta).
- **UX-003**: Priorizar consistência de iconografia entre desktop e mobile para ações de conta e logout.
- **UX-004**: Padronizar microinterações (hover, active, open/close) com durações curtas e previsíveis.
- **UX-005**: Incluir indicadores sutis de contexto de perfil (ex: badge/label admin) sem competir com CTA principal.
### Key Entities
- **AuthSession**: Estado de autenticação consumido pela navbar (`isAuthenticated`, `isLoading`, `user`).
- **UserProfile**: Dados mínimos para renderização contextual (`name`, `role`).
- **NavItem**: Item de navegação com destino e rótulo para links públicos, cliente e admin.
- **NavUIState**: Estado transitório da navbar (`menuOpen`, `adminOpen`, `clientOpen`).
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% dos cenários de visibilidade por perfil (visitante, cliente, admin) são respeitados em desktop e mobile.
- **SC-002**: 100% dos links de menu contextual (cliente/admin) navegam para os destinos corretos e fecham o menu após clique.
- **SC-003**: 0 ocorrências de sobreposição/quebra da navbar nos breakpoints suportados (320px, 768px, 1024px, 1280px).
- **SC-004**: Interações básicas de acessibilidade (foco visível, rótulos ARIA essenciais, navegação por teclado) funcionam sem bloqueios.
- **SC-005**: Logout retorna estado visual de visitante em até 1 interação, sem necessidade de refresh manual.
---
## Assumptions
- O fluxo de autenticação atual e o contexto de usuário (`useAuth`) permanecerão como fonte única de verdade para perfil e sessão.
- Os módulos administrativos já existentes continuam válidos como destinos no menu Admin.
- A navegação da Área do Cliente continuará disponível por links no menu do usuário autenticado.
- Esta feature não altera regras de autorização backend; foca em UX/UI e comportamento de navegação no frontend.
- Ajustes de conteúdo textual fino (copywriting) podem ser refinados durante implementação, sem alterar requisitos funcionais.

View file

@ -0,0 +1,261 @@
# Tasks: Navbar Topo UX (030)
**Input**: Design documents de `specs/030-navbar-topo-ux/`
**Prerequisites**: plan.md ✅ · spec.md ✅ · research.md ✅ · data-model.md ✅ · quickstart.md ✅ · contracts/navbar-ui-contract.md ✅
**Branch**: `030-navbar-topo-ux`
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: pode ser executada em paralelo quando tocar arquivos distintos e sem dependência de tarefa incompleta
- **[Story]**: user story correspondente (`US1`, `US2`, `US3`, `US4`, `US5`)
- Caminhos exatos incluídos em cada tarefa
**Tests**: a spec não pede suíte automatizada nova para esta feature. O guardrail executável mínimo é `npm run build` em `frontend/`, complementado pelo checklist manual de `quickstart.md`.
---
## Phase 1: Setup — Preparação da Navbar Compartilhada
**Purpose**: estabelecer a base visual e estrutural da navbar compartilhada antes da refatoração de estados e das variações por perfil.
- [X] T001 Consolidar a configuração de navegação compartilhada em `frontend/src/components/Navbar.tsx` com arrays tipados para links públicos, ações do cliente e atalhos admin, alinhados aos destinos definidos em `specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md`
- [X] T002 [P] Preparar tokens utilitários da navbar fixa em `frontend/src/index.css` para backdrop, foco visível, estados hover/active/open, alvo mínimo de toque e contraste consistente em tema claro/escuro
**Checkpoint**: a base de navegação e os tokens visuais da navbar estão definidos sem introduzir novas dependências externas.
---
## Phase 2: Foundational — Estado e Shell Compartilhados
**Purpose**: implementar a infraestrutura de estado e shell que bloqueia todas as user stories da navbar.
**⚠️ CRÍTICO**: nenhuma user story deve avançar antes desta fase estar concluída.
- [X] T003 Implementar em `frontend/src/components/Navbar.tsx` a derivação explícita de variante `visitor` / `client` / `admin` a partir de `useAuth()` e centralizar a decisão de visibilidade dos blocos públicos, contextuais e de autenticação
- [X] T004 Implementar em `frontend/src/components/Navbar.tsx` um controlador único de overlay com os estados `closed`, `mobile`, `client` e `admin`, substituindo booleans soltos por handlers de abertura/fechamento mutuamente exclusivos
- [X] T005 [P] Integrar em `frontend/src/components/Navbar.tsx` e `frontend/src/contexts/AuthContext.tsx` o fechamento global por mudança de rota, clique fora e logout, garantindo retorno imediato ao estado visual de visitante sem menus órfãos
- [X] T006 [P] Ajustar `frontend/src/layouts/ClientLayout.tsx` e `frontend/src/layouts/AdminLayout.tsx` para respeitar a altura e o empilhamento da navbar fixa sem sobrepor o conteúdo principal após a refatoração
**Checkpoint**: existe uma única fonte de verdade para os overlays da navbar, e os shells compartilham offset compatível com a barra fixa.
---
## Phase 3: User Story 1 — Navegação Principal Clara para Visitantes (Priority: P1) 🎯 MVP
**Goal**: entregar uma navbar pública clara, legível e consistente para visitantes em desktop e mobile.
**Independent Test**: em páginas públicas, o visitante vê até 5 links principais com estado ativo coerente, CTA `Anunciar imóvel`, ação `Entrar` e menu mobile com os mesmos destinos em ordem lógica.
- [X] T007 [US1] Reorganizar a estrutura desktop de visitante em `frontend/src/components/Navbar.tsx` para exibir logo, navegação pública principal, favoritos públicos quando aplicável, CTA `Anunciar imóvel` e ação `Entrar` com hierarquia visual clara
- [X] T008 [P] [US1] Implementar em `frontend/src/components/Navbar.tsx` a lógica de estado ativo para links públicos, incluindo correspondência coerente para rotas com query string como `/imoveis?listing_type=venda` e `/imoveis?listing_type=aluguel`
- [X] T009 [US1] Implementar o menu mobile de visitante em `frontend/src/components/Navbar.tsx` com gatilho hambúrguer, mesma ordem de destinos públicos do desktop, CTA destacado e fechamento automático ao navegar
- [X] T010 [P] [US1] Refinar em `frontend/src/index.css` a aparência da navegação pública desktop/mobile, do CTA principal e dos estados hover/active para manter legibilidade entre 320 px e 1440 px+
**Checkpoint**: US1 fica utilizável de forma independente para visitante em desktop e mobile, com destinos públicos claros e sem truncamento indevido.
---
## Phase 4: User Story 2 — Menu de Usuário Autenticado Orientado a Tarefas (Priority: P1)
**Goal**: permitir que o cliente autenticado acesse rapidamente ações pessoais e logout pela navbar.
**Independent Test**: ao autenticar como cliente não-admin, a navbar exibe gatilho de conta com nome truncado, dropdown com `Favoritos`, `Comparar`, `Visitas`, `Minha conta` e `Sair`, além da seção `Minha Conta` no mobile.
- [X] T011 [US2] Implementar em `frontend/src/components/Navbar.tsx` o gatilho de conta do cliente com inicial/avatar, primeiro nome truncado e dropdown desktop contendo `Favoritos`, `Comparar`, `Visitas`, `Minha conta` e `Sair` com separação visual do logout
- [X] T012 [P] [US2] Ajustar em `frontend/src/contexts/AuthContext.tsx` a superfície consumida pela navbar para suportar renderização confiável de nome, role e logout sem flicker durante hidratação ou encerramento de sessão
- [X] T013 [US2] Integrar em `frontend/src/components/Navbar.tsx` os links de `Favoritos` e `Comparar` com os contextos existentes `frontend/src/contexts/FavoritesContext.tsx` e `frontend/src/contexts/ComparisonContext.tsx` sem quebrar a navegação contextual do cliente
- [X] T014 [US2] Implementar em `frontend/src/components/Navbar.tsx` a seção mobile `Minha Conta` com os mesmos destinos do dropdown desktop, logout separado visualmente e fechamento automático após clique em qualquer ação
**Checkpoint**: US2 fica testável de forma independente com login de cliente, incluindo fluxo confiável de logout e paridade desktop/mobile.
---
## Phase 5: User Story 3 — Menu Admin com Acesso Rápido e Controle Visual (Priority: P1)
**Goal**: expor atalhos administrativos apenas para admins, sem poluir a experiência de visitantes e clientes.
**Independent Test**: ao autenticar como admin, a navbar exibe gatilho `Admin` com atalhos prioritários no desktop e seção `Admin` no mobile; para não-admin, nada disso aparece.
- [X] T015 [US3] Implementar em `frontend/src/components/Navbar.tsx` o gatilho e dropdown desktop `Admin`, exibindo apenas para `role === 'admin'` e listando os módulos existentes definidos em `specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md`
- [X] T016 [US3] Implementar em `frontend/src/components/Navbar.tsx` a seção mobile `Admin` com os mesmos atalhos prioritários do desktop, mantendo exclusão do menu padrão `Minha Conta` para admins
- [X] T017 [P] [US3] Validar e ajustar em `frontend/src/App.tsx` os destinos usados pela navbar admin para garantir que todos os atalhos planejados apontem para rotas já existentes no SPA sem criar rotas novas fora do escopo
**Checkpoint**: US3 fica utilizável por admin sem regressão de visibilidade para visitante ou cliente autenticado.
---
## Phase 6: User Story 4 — Navbar Previsível, Acessível e Sem Conflitos de Estado (Priority: P2)
**Goal**: garantir previsibilidade de interação, acessibilidade básica e exclusão mútua robusta entre menu mobile, dropdown do cliente e dropdown admin.
**Independent Test**: abrir qualquer contexto e verificar fechamento por clique fora, Escape, mudança de rota e abertura de outro contexto; foco, labels e ARIA permanecem coerentes durante navegação por teclado.
- [X] T018 [US4] Aplicar em `frontend/src/components/Navbar.tsx` `aria-label`, `aria-expanded`, `aria-controls`, ids estáveis e suporte a `Enter`, `Espaço` e `Escape` para hambúrguer, dropdown do cliente e dropdown admin
- [X] T019 [P] [US4] Garantir em `frontend/src/index.css` foco visível, contraste acessível e alvo mínimo de 44x44 px para todos os gatilhos e itens clicáveis da navbar em desktop e mobile
- [X] T020 [US4] Consolidar em `frontend/src/components/Navbar.tsx` o fechamento mútuo entre `mobile`, `client` e `admin`, incluindo transições corretas ao trocar rota, clicar fora e executar logout com sucesso ou erro
**Checkpoint**: US4 fica validável com teclado e clique externo, sem estados simultâneos nem overlays presos.
---
## Phase 7: User Story 5 — Hierarquia Visual e Responsividade Premium (Priority: P2)
**Goal**: elevar a qualidade visual e a responsividade da navbar compartilhada em todos os breakpoints suportados.
**Independent Test**: a navbar permanece legível, sem sobreposição e com truncamento elegante de nomes longos em 320 px, 768 px, 1024 px e 1280 px+, nos dois temas.
- [X] T021 [US5] Reequilibrar em `frontend/src/components/Navbar.tsx` a distribuição entre logo, links públicos, ações contextuais, `ThemeToggle` e CTA para evitar sobreposição e excesso de densidade visual nos breakpoints principais
- [X] T022 [P] [US5] Ajustar em `frontend/src/index.css` truncamento elegante de nomes longos, espaçamento responsivo, microinterações curtas e separação visual entre navegação pública e contextual nos temas claro e escuro
- [X] T023 [US5] Revisar em `frontend/src/layouts/ClientLayout.tsx` e `frontend/src/layouts/AdminLayout.tsx` o comportamento do conteúdo após scroll para manter a navbar fixa estável e sem jank perceptível nas áreas autenticadas
**Checkpoint**: US5 fica estável visualmente nos breakpoints e temas suportados, com densidade e truncamento sob controle.
---
## Phase Final: Polish & Validação
**Purpose**: executar o guardrail mínimo e o checklist manual completo da feature antes do merge.
- [X] T024 Executar `npm run build` no diretório `frontend/` e corrigir qualquer erro de TypeScript ou compilação relacionado à navbar compartilhada
- [X] T025 [P] Executar os cenários de validação por persona, tema e breakpoint descritos em `specs/030-navbar-topo-ux/quickstart.md`, cobrindo visitante, cliente, admin e os breakpoints 320 px, 768 px, 1024 px e 1280 px
- [X] T026 [P] Fazer limpeza final em `frontend/src/components/Navbar.tsx` e `frontend/src/index.css`, removendo handlers, classes e estados legados substituídos pela refatoração de overlay único
---
## Dependencies & Execution Order
### Phase Dependencies
```text
Phase 1 (Setup)
└──→ Phase 2 (Foundational)
├──→ Phase 3 (US1 — Visitante)
├──→ Phase 4 (US2 — Cliente)
├──→ Phase 5 (US3 — Admin)
├──→ Phase 6 (US4 — Acessibilidade e estado)
└──→ Phase 7 (US5 — Responsividade premium)
└──→ Phase Final (Build + validação manual)
```
- **Phase 1**: sem dependências; prepara a configuração e os tokens visuais da navbar
- **Phase 2**: depende da conclusão de Phase 1; bloqueia todas as user stories
- **Phase 3 a Phase 7**: dependem de Phase 2; podem avançar em paralelo apenas quando não houver conflito de arquivo
- **Phase Final**: depende das user stories desejadas concluídas
### User Story Dependencies
- **US1 (P1)**: pode começar após Phase 2; independente de US2 e US3 no comportamento de perfil
- **US2 (P1)**: depende de Phase 2 e compartilha `Navbar.tsx` com US1, então a execução prática tende a ser sequencial no mesmo componente
- **US3 (P1)**: depende de Phase 2; pode ocorrer em paralelo apenas com T017, que toca `App.tsx`
- **US4 (P2)**: depende de T004 e T005 porque a base de overlay único e fechamento global precisa existir primeiro
- **US5 (P2)**: depende da estrutura renderizada por US1, US2 e US3 para calibrar responsividade final com dados reais
### Task-Level Notes
- T002 pode rodar em paralelo com T001
- T005 e T006 podem rodar em paralelo após T003 e T004
- T008 e T010 podem rodar em paralelo dentro de US1
- T012 pode rodar em paralelo com T011; T013 pode iniciar após T011
- T017 pode rodar em paralelo com T015 e T016
- T019 pode rodar em paralelo com T018; T020 depende da instrumentação criada em T018
- T022 pode rodar em paralelo com T021; T023 depende do ajuste de altura/layout consolidado
---
## Parallel Execution Examples
### User Story 1
```text
T007 → T009
T008 || T010
```
### User Story 2
```text
T011 → T013 → T014
T012 pode ocorrer em paralelo a T011
```
### User Story 3
```text
T015 → T016
T017 pode ocorrer em paralelo a T015
```
### User Story 4
```text
T018 → T020
T019 pode ocorrer em paralelo a T018
```
### User Story 5
```text
T021 → T023
T022 pode ocorrer em paralelo a T021
```
---
## Implementation Strategy
### MVP First
1. Concluir Phase 1 e Phase 2
2. Entregar US1 para visitante como primeiro incremento navegável
3. Adicionar US2 e US3 para fechar a matriz de perfis da navbar
4. Validar build e checklist manual antes de partir para polish visual fino
### Incremental Delivery
1. **Incremento 1**: Setup + Foundational
2. **Incremento 2**: US1 — navegação pública clara em desktop/mobile
3. **Incremento 3**: US2 + US3 — menus contextuais por perfil autenticado
4. **Incremento 4**: US4 — previsibilidade, teclado e ARIA
5. **Incremento 5**: US5 — acabamento responsivo premium
### Suggested MVP Scope
O menor recorte demonstrável é **US1** após a fase Foundational. O menor recorte funcional completo para a matriz de perfis é **US1 + US2 + US3**.
---
## Summary
| Fase | Tarefas | Escopo |
|------|---------|--------|
| Phase 1 | T001T002 | Setup da navbar compartilhada |
| Phase 2 | T003T006 | Estado e shell bloqueadores |
| Phase 3 | T007T010 | US1 — visitante |
| Phase 4 | T011T014 | US2 — cliente autenticado |
| Phase 5 | T015T017 | US3 — admin |
| Phase 6 | T018T020 | US4 — acessibilidade e previsibilidade |
| Phase 7 | T021T023 | US5 — responsividade premium |
| Phase Final | T024T026 | Build, validação manual e limpeza |
| **Total** | **26 tarefas** | **5 user stories + setup/foundational/polish** |

View file

@ -0,0 +1,28 @@
# Implementation Plan: Home Hero Light/Dark
**Branch**: `031-home-hero-light-dark` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
## Summary
Adicionar suporte de imagem hero por tema (light/dark) na configuração da home, com CRUD administrativo focado em edição de URLs e fallback para o campo legado.
## Technical Context
- Backend: Flask 3.x, SQLAlchemy, Alembic, Pydantic v2
- Frontend: React 18, TypeScript 5.5, Tailwind
- Persistência: tabela `homepage_config` (novas colunas)
## Scope
1. Migration para adicionar colunas `hero_image_light_url` e `hero_image_dark_url`.
2. Atualização de model/schemas/endpoint público de homepage.
3. Novo endpoint admin para atualizar homepage config.
4. Nova página admin para edição da configuração da home.
5. Atualizar navbar/admin routes para acesso à página.
6. Ajustar HomePage para escolher imagem por tema.
7. Atualizar seed padrão com valores light/dark.
## Validation
- `npm run build` em `frontend/`
- checagem de erros nos arquivos backend alterados

View file

@ -0,0 +1,56 @@
# Feature Specification: Configuração de Background da Home por Tema
**Feature Branch**: `031-home-hero-light-dark`
**Created**: 2026-04-22
**Status**: Draft
## Objetivo
Permitir que administradores configurem separadamente a imagem de fundo da seção hero da página inicial para tema claro (light) e tema escuro (dark), com fallback seguro para a imagem atual.
## User Scenarios & Testing
### User Story 1 — Admin edita imagens light/dark (P1)
Como admin, quero informar URLs diferentes para o background da home em light e dark para manter legibilidade e identidade visual em cada tema.
**Independent Test**: Admin consegue abrir tela de configuração da home, editar os campos de imagem light/dark e salvar com sucesso.
**Acceptance Scenarios**:
1. Dado um admin autenticado, quando acessa a configuração da home, então visualiza os campos de imagem light e dark.
2. Dado um admin, quando salva URLs válidas, então as mudanças persistem no backend.
3. Dado falha de API, quando tenta salvar, então recebe feedback de erro sem perder o formulário.
### User Story 2 — Home usa imagem correta por tema (P1)
Como visitante/cliente, quero ver uma imagem de fundo adequada ao tema atual para manter contraste e experiência visual.
**Independent Test**: Alterar tema entre light e dark muda a imagem de fundo da home sem quebrar fallback.
**Acceptance Scenarios**:
1. Dado tema light, quando carrega a home, então usa `hero_image_light_url`.
2. Dado tema dark, quando carrega a home, então usa `hero_image_dark_url`.
3. Dado campo de tema ausente, quando renderiza a home, então usa fallback (`hero_image_url` e depois gradiente padrão).
## Edge Cases
- Apenas uma das imagens (light ou dark) cadastrada: sistema deve usar fallback sem erro.
- Registro de `homepage_config` inexistente: endpoint admin deve criar registro base no primeiro save.
- URL vazia: deve ser tratada como `null`.
## Requirements
- **FR-001**: Backend deve armazenar `hero_image_light_url` e `hero_image_dark_url` em `homepage_config`.
- **FR-002**: Endpoint público `/api/v1/homepage-config` deve retornar os novos campos.
- **FR-003**: Backend deve expor endpoint admin autenticado para atualizar configuração da home.
- **FR-004**: Frontend admin deve ter tela para editar e salvar imagens light/dark.
- **FR-005**: Navbar/menu admin deve permitir acesso à nova tela de configuração da home.
- **FR-006**: Home pública deve escolher imagem com base no tema resolvido (`resolvedTheme`).
- **FR-007**: Seed deve popular valores iniciais de imagem light/dark.
## Success Criteria
- **SC-001**: Admin salva configuração com HTTP 200 e dados persistidos.
- **SC-002**: Troca de tema na home altera o background sem erro de renderização.
- **SC-003**: Build frontend e backend sem erros após mudança.
- **SC-004**: Seed padrão cria `HomepageConfig` com URLs light/dark preenchidas.

View file

@ -0,0 +1,12 @@
# Tasks: Home Hero Light/Dark
- [ ] T001 Criar migration Alembic para colunas `hero_image_light_url` e `hero_image_dark_url`.
- [ ] T002 Atualizar model `HomepageConfig` com os novos campos.
- [ ] T003 Atualizar schemas `HomepageConfigOut` e `HomepageConfigIn`.
- [ ] T004 Criar endpoint admin de update para homepage config.
- [ ] T005 Atualizar tipos/serviços frontend para novos campos e update admin.
- [ ] T006 Criar página admin de configuração da home (imagens light/dark e conteúdo hero).
- [ ] T007 Registrar rota da nova página no App e adicionar atalho no menu admin.
- [ ] T008 Atualizar HomePage para escolher imagem por tema (`resolvedTheme`).
- [ ] T009 Atualizar seed com URLs light/dark padrão.
- [ ] T010 Validar build frontend e consistência backend.

View file

@ -0,0 +1,302 @@
# Auditoria de Performance — Página Inicial (Home)
> Versão: 1.0 · Data: 2026-04-22
> Escopo: `HomePage.tsx`, `HomeScrollScene.tsx`, `AgentsCarousel.tsx`, `PropertyRowCard.tsx`, `Navbar.tsx`
---
## 1. Resumo Executivo
A página inicial é a mais crítica em termos de first impression e conversão. A análise identificou **5 categorias de problemas** que impactam diretamente o Core Web Vitals (LCP, CLS, INP) e a experiência percebida do usuário:
| Categoria | Severidade | Impacto estimado no LCP |
|---|---|---|
| Requests em cascata (waterfall) | 🔴 Alta | +6001200 ms |
| Imagem hero sem preload / sem dimensões | 🔴 Alta | +8001500 ms |
| Scroll scene: `useTheme` + re-renders desnecessários | 🟡 Média | +60150 ms INP |
| AgentsCarousel: autoplay sem `requestAnimationFrame` | 🟡 Média | jank visual |
| `RiseCard`: IntersectionObserver por instância | 🟢 Baixa | +n×2 ms compositing |
---
## 2. Problemas Identificados
### 2.1 Waterfall de Requests (Crítico)
**Arquivo:** `HomePage.tsx` + `HomeScrollScene.tsx`
**Problema atual:**
```
Render HomePage
└─ useEffect: getHomepageConfig() ← request 1 (bloqueia backgroundImage)
└─ setState(config) → re-render
└─ HomeScrollScene recebe props
└─ useEffect: getFeaturedProperties() ← request 2 só começa AQUI
└─ useEffect: getAgents() ← request 3 só começa APÓS mount
```
Os três requests são **seriais por dependência de render**: `getAgents` e `getFeaturedProperties` só disparam após `HomeScrollScene` montar, que depende do render de `HomePage`, que depende de `getHomepageConfig`.
**Impacto:** Em conexões 3G, o usuário espera 3× o RTT antes de ver qualquer conteúdo real. LCP pode ultrapassar 4 s.
**Solução:** Paralelizar os três fetches no topo de `HomePage` com `Promise.all` e passar dados via props.
---
### 2.2 Hero Image sem `<link rel="preload">` e sem width/height (Crítico)
**Arquivo:** `HomeScrollScene.tsx`, linha da tag `<img>`
**Problema atual:**
```tsx
<img
src={backgroundImage}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover"
/>
```
- Sem `width`/`height` → o browser não reserva espaço → **CLS** quando a imagem carrega
- Sem `<link rel="preload">` no `<head>` → a imagem começa a baixar somente quando o React renderiza o componente, não durante o parse do HTML
- Sem `fetchpriority="high"` → browser não prioriza sobre outros assets
- Sem `loading="eager"` explícito → comportamento depende do browser
**Impacto:** LCP degradado. Em telas de 1080p com imagem de 1 MB, o atraso pode ser de 12 s adicionais.
**Solução:**
1. Adicionar `fetchpriority="high"` e `loading="eager"` na tag `<img>`
2. Injetar `<link rel="preload">` dinamicamente via `useEffect` assim que `backgroundImage` estiver disponível (ou via `<Helmet>`)
---
### 2.3 `useTheme` sendo chamado duas vezes para o mesmo dado
**Arquivos:** `HomePage.tsx` e `HomeScrollScene.tsx`
**Problema atual:**
```tsx
// HomePage.tsx
const { resolvedTheme } = useTheme()
const themedBackgroundImage = resolvedTheme === 'dark' ? ... : ...
// HomeScrollScene.tsx (recebe backgroundImage, mas chama useTheme DE NOVO)
const { resolvedTheme } = useTheme()
const isLight = resolvedTheme === 'light'
```
`resolvedTheme` é derivado duas vezes. Qualquer mudança de tema causa re-render em dois componentes separados com lógica duplicada. Além disso, `HomeScrollScene` não precisa conhecer o tema se receber `backgroundImage` já resolvida pelo pai — mas ainda usa `isLight` para estilos internos, o que é legítimo. O problema é a derivação `themedBackgroundImage` estar fora do componente que a consome.
**Solução:** Manter `isLight` em `HomeScrollScene` (necessário para estilos), mas mover `themedBackgroundImage` para dentro de `HomeScrollScene` eliminando a prop intermediária e a chamada dupla ao context em `HomePage`.
---
### 2.4 `AgentsCarousel`: `setInterval` sem cleanup confiável + CSS transform sem `will-change`
**Arquivo:** `AgentsCarousel.tsx`
**Problema atual:**
```tsx
const autoplayRef = useRef<ReturnType<typeof setInterval> | null>(null)
// ...sem uso de requestAnimationFrame
// CSS translate via inline style, sem will-change: transform
```
- `setInterval` para animação de scroll não é sincronizado com o frame rate do browser → pode causar jank em displays 120 Hz
- Ausência de `will-change: transform` no track → o browser não promove a camada para GPU antes da primeira animação → primeiro frame pode ser janky
- Duplicação de slides (`[...agents, ...agents]`) sem `key` estável baseada em `agent.id` único (usa índice implicitamente)
---
### 2.5 `RiseCard`: IntersectionObserver por instância sem threshold otimizado
**Arquivo:** `HomeScrollScene.tsx`
**Problema atual:**
```tsx
// Cria 1 IntersectionObserver por card (até 6+ na home)
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setVisible(true)
observer.disconnect()
}
}, { threshold: 0.05 })
```
Cada `RiseCard` instancia seu próprio `IntersectionObserver`. Com 6 cards, são 6 observers ativos simultaneamente. O ideal é um único observer compartilhado (padrão singleton/context) que observa todos os elementos.
**Impacto:** Pequeno em volume baixo, mas escala mal. 6 observers × scroll events = trabalho desnecessário na main thread.
---
### 2.6 `<style>` inline com `@keyframes` renderizado dentro do componente
**Arquivo:** `HomeScrollScene.tsx`
**Problema atual:**
```tsx
<style>{`
@keyframes fadeDown {
0%, 100% { opacity: 0; transform: translateY(-4px); }
50% { opacity: 1; transform: translateY(4px); }
}
`}</style>
```
Isso insere uma tag `<style>` no DOM a cada render. O browser re-parseia o CSS. O correto é mover para `index.css` ou um módulo CSS estático.
---
### 2.7 `PropertyRowCard` dentro da Home: fotos sem `loading="lazy"`
**Arquivo:** `PropertyRowCard.tsx``SlideImage`
**Problema atual:**
```tsx
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
className={...}
draggable={false}
/>
```
Sem `loading="lazy"`, o browser baixa todas as imagens dos cards de destaque imediatamente, competindo com a imagem hero pelo bandwidth no carregamento inicial.
---
## 3. Core Web Vitals — Estado Atual vs. Meta
| Métrica | Estado estimado atual | Meta (Good) | Gap |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ~3.55 s | < 2.5 s | ~1.5 s |
| CLS (Cumulative Layout Shift) | ~0.120.18 | < 0.1 | ~0.05 |
| INP (Interaction to Next Paint) | ~150250 ms | < 200 ms | borderline |
| FCP (First Contentful Paint) | ~1.21.8 s | < 1.8 s | borderline |
| TTFB (Time to First Byte) | ~200400 ms | < 800 ms | OK |
---
## 4. Plano de Implementação
### Fase 1 — Quick Wins (alto impacto, baixo risco) · Estimativa: 1 dia
| # | Ação | Arquivo | Impacto |
|---|---|---|---|
| 1.1 | Adicionar `fetchpriority="high"` + `loading="eager"` na hero image | `HomeScrollScene.tsx` | LCP 400 ms |
| 1.2 | Mover `@keyframes fadeDown` para `index.css` | `HomeScrollScene.tsx` + `index.css` | Elimina re-parse CSS |
| 1.3 | Adicionar `loading="lazy"` nos `SlideImage` dos cards | `PropertyRowCard.tsx` | Bandwidth LCP |
| 1.4 | Adicionar `will-change: transform` no track do carousel | `AgentsCarousel.tsx` | Elimina jank GPU |
### Fase 2 — Paralelização de Requests (crítico) · Estimativa: 0.5 dia
| # | Ação | Arquivo | Impacto |
|---|---|---|---|
| 2.1 | `Promise.all([getHomepageConfig(), getFeaturedProperties(), getAgents()])` em `HomePage` | `HomePage.tsx` | LCP 600 ms |
| 2.2 | `HomeScrollScene` e `AgentsCarousel` passam a receber dados via props | Ambos | Elimina waterfalls |
| 2.3 | Preload dinâmico da hero image via `<link rel="preload">` | `HomePage.tsx` | LCP 300 ms |
### Fase 3 — Refatoração de Observers (qualidade) · Estimativa: 0.5 dia
| # | Ação | Arquivo | Impacto |
|---|---|---|---|
| 3.1 | Criar hook `useIntersectionObserver` compartilhado | `hooks/useIntersectionObserver.ts` | Reduz observers de N→1 |
| 3.2 | Refatorar `RiseCard` para usar o hook compartilhado | `HomeScrollScene.tsx` | Main thread reduzida |
| 3.3 | Consolidar lógica de `resolvedTheme` / `isLight` | `HomeScrollScene.tsx` | Elimina re-render duplo |
### Fase 4 — Optimistic UI e Caching (avançado) · Estimativa: 1 dia
| # | Ação | Impacto |
|---|---|---|
| 4.1 | Cache de `homepageConfig` em `sessionStorage` (TTL 5 min) | FCP sem spinner na navegação de volta |
| 4.2 | Skeleton de altura fixa na hero para evitar CLS | CLS 0.08 |
| 4.3 | `React.memo` em `PropertyRowCard` e `AgentSlide` | INP nos re-renders de tema |
---
## 5. Referências de Código — Soluções Concretas
### 5.1 Paralelização de requests (Fase 2.1)
```tsx
// HomePage.tsx — ANTES
useEffect(() => {
getHomepageConfig().then(setConfig).finally(() => setIsLoading(false))
}, [])
// HomePage.tsx — DEPOIS
useEffect(() => {
Promise.all([getHomepageConfig(), getFeaturedProperties(), getAgents()])
.then(([cfg, props, agts]) => {
setConfig(cfg)
setFeaturedProperties(props)
setAgents(agts)
})
.catch(() => {})
.finally(() => setIsLoading(false))
}, [])
```
### 5.2 Preload dinâmico da hero (Fase 2.3)
```tsx
// HomePage.tsx
useEffect(() => {
if (!themedBackgroundImage) return
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'image'
link.href = themedBackgroundImage
document.head.appendChild(link)
return () => { document.head.removeChild(link) }
}, [themedBackgroundImage])
```
### 5.3 Hero image com prioridade correta (Fase 1.1)
```tsx
<img
src={backgroundImage}
alt=""
aria-hidden="true"
fetchPriority="high"
loading="eager"
decoding="async"
className="absolute inset-0 w-full h-full object-cover"
/>
```
### 5.4 Hook compartilhado de IntersectionObserver (Fase 3.1)
```ts
// hooks/useInView.ts
export function useInView(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setInView(true)
observer.disconnect()
}
}, options)
observer.observe(el)
return () => observer.disconnect()
}, [])
return { ref, inView }
}
```
---
## 6. Métricas Esperadas Após Implementação Completa
| Métrica | Atual (estimado) | Após Fase 1+2 | Após Fase 1+2+3+4 |
|---|---|---|---|
| LCP | ~4 s | ~2.2 s | ~1.8 s |
| CLS | ~0.15 | ~0.08 | ~0.04 |
| INP | ~200 ms | ~150 ms | ~80 ms |
| Requests em paralelo | 3 seriais | 3 paralelos | 3 paralelos + cache |

View file

@ -0,0 +1,136 @@
# Plan — 032: Performance Homepage
## Visão Técnica
A estratégia central é **inverter o fluxo de dados**: em vez de cada componente buscar seus próprios dados (model-per-component), o `HomePage` orquestra todos os fetches em paralelo e distribui via props. Isso elimina o waterfall e permite paralelizar corretamente.
```
ANTES (serial):
HomePage mount → fetch config → render HomeScrollScene → fetch properties
→ fetch agents (AgentsCarousel)
DEPOIS (paralelo):
HomePage mount → Promise.all([config, properties, agents]) → render com dados prontos
```
---
## Arquitetura das Mudanças
### 1. `HomePage.tsx` — Orchestrator
- Gerencia 3 estados: `config`, `featuredProperties`, `agents`
- `Promise.all` no único `useEffect`
- Injeta `<link rel="preload">` via efeito secundário quando `backgroundImage` resolve
- Cache de `config` em `sessionStorage` com TTL 5 min
- Passa `properties`, `loadingProperties`, `agents`, `loadingAgents` como props para baixo
### 2. `HomeScrollScene.tsx` — Apresentação
- Remove `useEffect` de `getFeaturedProperties` e estado interno de `properties`
- Recebe `properties: Property[]` e `loadingProperties: boolean` via props
- Mantém `isLight` (necessário para estilos internos)
- Remove `<style>` inline com `@keyframes` (migra para `index.css`)
- Adiciona `fetchPriority="high"` na hero `<img>`
- `RiseCard` passa a usar hook `useInView`
### 3. `AgentsCarousel.tsx` — Apresentação
- Remove `useEffect` de `getAgents` e estado interno de `agents`/`loading`
- Recebe `agents: Agent[]` e `loading: boolean` via props
- Adiciona `will-change: transform` no track CSS
### 4. `PropertyRowCard.tsx` — Folha
- `SlideImage`: adiciona `loading="lazy"` e `decoding="async"`
### 5. `hooks/useInView.ts` — Utilitário
- Encapsula `IntersectionObserver` com `disconnect` no `isIntersecting`
- Aceita `options?: IntersectionObserverInit`
- Retorna `{ ref, inView }`
### 6. `index.css`
- Adiciona bloco `@keyframes fadeDown` que estava inline em `HomeScrollScene`
---
## Interface das Props Alteradas
```ts
// HomeScrollScene — novas props
interface HomeScrollSceneProps {
headline: string
subheadline: string | null
ctaLabel: string
ctaUrl: string
backgroundImage?: string | null
isLoading?: boolean
properties: Property[] // NOVO — antes buscado internamente
loadingProperties: boolean // NOVO
}
// AgentsCarousel — novas props
interface AgentsCarouselProps {
agents: Agent[] // NOVO — antes buscado internamente
loading: boolean // NOVO
}
```
---
## Estratégia de Cache — `sessionStorage`
```ts
const CACHE_KEY = 'homepage_config'
const CACHE_TTL = 5 * 60 * 1000 // 5 min
function getCachedConfig(): HomepageConfig | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
if (!raw) return null
const { data, ts } = JSON.parse(raw)
if (Date.now() - ts > CACHE_TTL) return null
return data
} catch { return null }
}
function setCachedConfig(data: HomepageConfig) {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ data, ts: Date.now() }))
} catch {}
}
```
---
## Tratamento de CLS na Hero
O `HomeScrollScene` receberá `isLoading` do pai. Enquanto `isLoading=true`, o container sticky terá `min-height: 100svh` preservado via className, garantindo que o browser reserve o espaço mesmo antes da imagem carregar.
O skeleton inline já existente está correto — o problema atual é que o `backgroundImage` resolve mais tarde que o skeleton desaparece, causando re-layout. Com o preload dinâmico, a imagem chega antes.
---
## Decisões de Design
| Decisão | Alternativa rejeitada | Motivo |
|---|---|---|
| `sessionStorage` para cache | `localStorage` | sessionStorage expira ao fechar a aba — adequado para conteúdo editorial |
| Preload via `document.createElement` | `react-helmet` | Evita dependência extra para um caso simples |
| Props drilling em vez de Context | Context global de dados da home | Overkill para componentes de folha; props é suficiente e mais rastreável |
| `useInView` hook simples | Biblioteca externa (react-intersection-observer) | Sem dependência extra, controle total |
---
## Sequência de Implementação
```
1. hooks/useInView.ts (sem dependências)
2. index.css (keyframes) (sem dependências)
3. PropertyRowCard.tsx (loading=lazy) (sem dependências)
4. AgentsCarousel.tsx (props) (depende de tipos Agent)
5. HomeScrollScene.tsx (props + refat) (depende de useInView)
6. HomePage.tsx (orchestrator) (depende de todos acima)
```

View file

@ -0,0 +1,97 @@
# Spec — 032: Performance Homepage
## Identificação
- **ID:** 032
- **Nome:** performance-homepage
- **Prioridade:** Alta
- **Tipo:** Refatoração / Otimização
- **Dependências:** Nenhuma nova tabela ou endpoint. Apenas frontend.
---
## Problema
A página inicial apresenta waterfall de requests, imagem hero sem priorização correta e re-renders desnecessários que degradam os Core Web Vitals (LCP, CLS, INP). Detalhes completos em [`performance-audit.md`](./performance-audit.md).
---
## Objetivo
Atingir **LCP < 2.5 s**, **CLS < 0.1** e **INP < 150 ms** na página inicial sem alterar a aparência visual ou quebrar funcionalidades existentes.
---
## Escopo
### Incluído
- Paralelização dos 3 fetches da home (`homepageConfig`, `featuredProperties`, `agents`)
- Preload dinâmico da imagem hero quando URL estiver disponível
- `fetchPriority="high"` + `loading="eager"` + `decoding="async"` na hero image
- `loading="lazy"` nas imagens dos `PropertyRowCard` dentro da home
- Mover `@keyframes fadeDown` de inline para `index.css`
- `will-change: transform` no track do `AgentsCarousel`
- Hook compartilhado `useInView` para `RiseCard`
- Cache de `homepageConfig` em `sessionStorage` com TTL de 5 minutos
- Skeleton de altura fixa na área hero para evitar CLS
- `React.memo` em `PropertyRowCard` e `AgentSlide`
- Consolidação de `resolvedTheme` / `isLight` para evitar chamada dupla ao `ThemeContext`
### Excluído
- Mudanças no backend
- Mudanças em outras páginas que não sejam dependências diretas
- SSR / SSG
- CDN ou otimização de infraestrutura
- Bundle splitting (separado, escopo de build)
---
## Requisitos Funcionais
| ID | Requisito |
|---|---|
| RF-01 | Todos os dados da home devem ser buscados em paralelo no mount de `HomePage` |
| RF-02 | A imagem hero deve ter `fetchPriority="high"` quando `backgroundImage` estiver disponível |
| RF-03 | Um `<link rel="preload">` deve ser inserido no `<head>` assim que `backgroundImage` for resolvida |
| RF-04 | As imagens dos cards de propriedades devem ter `loading="lazy"` |
| RF-05 | `AgentsCarousel` deve receber `agents` e `loading` via props em vez de fazer fetch próprio |
| RF-06 | `HomeScrollScene` deve receber `properties` e `loadingProperties` via props |
| RF-07 | `homepageConfig` deve ser cacheado em `sessionStorage` por 5 minutos |
| RF-08 | O skeleton da hero deve ter `min-height: 100svh` antes do config carregar para evitar CLS |
---
## Requisitos Não Funcionais
| ID | Requisito |
|---|---|
| RNF-01 | LCP medido via Lighthouse deve ser < 2.5 s em conexão Fast 3G emulada |
| RNF-02 | CLS deve ser < 0.1 |
| RNF-03 | INP deve ser < 150 ms |
| RNF-04 | Nenhuma regressão visual nos temas light e dark |
| RNF-05 | Build TypeScript sem erros após todas as mudanças |
| RNF-06 | Nenhuma mudança na API pública dos componentes exceto as necessárias para passar dados via props |
---
## Impacto em Componentes
| Componente | Tipo de mudança |
|---|---|
| `HomePage.tsx` | Paralelizar fetches, preload, cache, passar props |
| `HomeScrollScene.tsx` | Receber props de dados, mover keyframes, consolidar theme |
| `AgentsCarousel.tsx` | Receber props em vez de fetch interno, will-change |
| `PropertyRowCard.tsx` | `loading="lazy"` no `SlideImage` |
| `index.css` | Adicionar `@keyframes fadeDown` |
| `hooks/useInView.ts` | Criar hook novo |
---
## Critérios de Aceite
- [ ] Lighthouse (Mobile, Fast 3G) reporta LCP < 2.5 s
- [ ] Lighthouse reporta CLS < 0.1
- [ ] DevTools Network mostra os 3 fetches disparando simultaneamente
- [ ] Segunda visita à home não faz request a `/homepage-config` dentro do TTL
- [ ] Nenhum erro TypeScript (`npm run build` passa)
- [ ] Tema light e dark funcionam visualmente como antes
- [ ] `AgentsCarousel` e cards de destaque renderizam normalmente

View file

@ -0,0 +1,203 @@
# Tasks — 032: Performance Homepage
> Ordem de execução respeita dependências. Cada task é atômica e pode ser validada individualmente.
---
## FASE 1 — Fundação (sem quebra de interface)
### TASK-01: Criar hook `useInView`
- **Arquivo:** `frontend/src/hooks/useInView.ts` (criar)
- **Ação:**
```ts
import { useEffect, useRef, useState } from 'react'
export function useInView(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setInView(true)
observer.disconnect()
}
}, options)
observer.observe(el)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return { ref, inView }
}
```
- **Validação:** `get_errors` sem erros
---
### TASK-02: Mover `@keyframes fadeDown` para `index.css`
- **Arquivo:** `frontend/src/index.css` (editar — adicionar ao final)
- **Conteúdo a adicionar:**
```css
@keyframes fadeDown {
0%, 100% { opacity: 0; transform: translateY(-4px); }
50% { opacity: 1; transform: translateY(4px); }
}
```
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx` (editar — remover o bloco `<style>` inline)
- **Validação:** Build passa, setas do scroll hint continuam animadas
---
### TASK-03: `loading="lazy"` + `decoding="async"` em `SlideImage`
- **Arquivo:** `frontend/src/components/PropertyRowCard.tsx`
- **Alvo:** função `SlideImage`, tag `<img>`
- **Adicionar atributos:** `loading="lazy"` e `decoding="async"`
- **Cuidado:** Não adicionar `loading="lazy"` na imagem hero de `HomeScrollScene` (ela deve ser eager)
- **Validação:** `get_errors` sem erros
---
### TASK-04: `will-change: transform` no track do `AgentsCarousel`
- **Arquivo:** `frontend/src/components/AgentsCarousel.tsx`
- **Alvo:** elemento `div` com `ref={trackRef}` que recebe `transform` no estilo inline
- **Ação:** Adicionar `willChange: 'transform'` no objeto de style do track
- **Validação:** `get_errors` sem erros
---
## FASE 2 — Refatoração de `AgentsCarousel` para receber props
### TASK-05: Adicionar props de dados em `AgentsCarousel`
- **Arquivo:** `frontend/src/components/AgentsCarousel.tsx`
- **Ação:**
1. Definir `interface AgentsCarouselProps { agents: Agent[]; loading: boolean }`
2. Receber `{ agents, loading }` como props do componente
3. Remover `useEffect` que chama `getAgents()` e os estados `agents` / `loading`
4. Remover import de `getAgents`
5. Manter toda a lógica de carrossel (autoplay, prev/next, etc.) inalterada
- **Validação:** `get_errors` sem erros (vai reportar erro de prop em `HomePage.tsx` — resolver na TASK-08)
---
## FASE 3 — Refatoração de `HomeScrollScene` para receber props
### TASK-06: Adicionar props de dados em `HomeScrollScene`
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx`
- **Ação:**
1. Adicionar ao `HomeScrollSceneProps`: `properties: Property[]` e `loadingProperties: boolean`
2. Remover `const [properties, setProperties] = useState<Property[]>([])`
3. Remover `const [loading, setLoading] = useState(true)`
4. Remover `useEffect(() => { getFeaturedProperties()... })`
5. Remover import de `getFeaturedProperties`
6. No JSX, substituir uso de `loading` por `loadingProperties`
- **Validação:** `get_errors` (vai ter erro em uso — resolver na TASK-08)
---
### TASK-07: Refatorar `RiseCard` para usar `useInView`
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx`
- **Ação:**
1. Importar `useInView` de `'../hooks/useInView'`
2. Substituir o corpo de `RiseCard`: remover `useRef`, `useState(false)`, `useEffect` com `IntersectionObserver`
3. Usar `const { ref, inView } = useInView({ threshold: 0.05 })`
4. Manter a div com `ref={ref}` e classes condicionais em `inView`
- **Validação:** `get_errors` sem erros
---
## FASE 4 — `HomePage` como orchestrator
### TASK-08: Paralelizar fetches + cache + preload em `HomePage`
- **Arquivo:** `frontend/src/pages/HomePage.tsx`
- **Ação:**
1. Adicionar imports: `getFeaturedProperties` de `'../services/properties'`, `getAgents` de `'../services/agents'`, `Agent` de `'../types/agent'`, `Property` de `'../types/property'`
2. Adicionar estados: `featuredProperties: Property[]`, `agents: Agent[]`, `loadingProperties: boolean`, `loadingAgents: boolean`
3. Criar helpers de cache:
```ts
const CFG_CACHE_KEY = 'homepage_config_v1'
const CFG_CACHE_TTL = 5 * 60 * 1000
function getCachedConfig(): HomepageConfig | null { ... }
function setCachedConfig(data: HomepageConfig): void { ... }
```
4. Substituir `useEffect` de `getHomepageConfig` por `Promise.all`:
```ts
useEffect(() => {
const cached = getCachedConfig()
const configFetch = cached
? Promise.resolve(cached)
: getHomepageConfig().then(d => { setCachedConfig(d); return d })
Promise.all([configFetch, getFeaturedProperties(), getAgents()])
.then(([cfg, props, agts]) => {
setConfig(cfg)
setFeaturedProperties(props)
setAgents(agts)
})
.catch(() => {})
.finally(() => {
setIsLoading(false)
setLoadingProperties(false)
setLoadingAgents(false)
})
}, [])
```
5. Adicionar `useEffect` de preload:
```ts
useEffect(() => {
if (!themedBackgroundImage) return
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'image'
link.href = themedBackgroundImage
document.head.appendChild(link)
return () => { document.head.removeChild(link) }
}, [themedBackgroundImage])
```
6. Passar `properties={featuredProperties}` e `loadingProperties={loadingProperties}` para `<HomeScrollScene>`
7. Passar `agents={agents}` e `loading={loadingAgents}` para `<AgentsCarousel>`
- **Validação:** `get_errors` sem erros; `npm run build` passa
---
### TASK-09: `fetchPriority="high"` + `loading="eager"` na hero image
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx`
- **Alvo:** tag `<img>` do `backgroundImage` (dentro do bloco `{backgroundImage ? (`)
- **Adicionar:** `fetchPriority="high"` e `loading="eager"` e `decoding="async"`
- **Nota:** `fetchPriority` é atributo HTML5 — TypeScript pode reclamar; usar `{...{ fetchpriority: 'high' } as React.ImgHTMLAttributes<HTMLImageElement>}` se necessário, ou verificar suporte em `@types/react`
- **Validação:** `get_errors` sem erros
---
## FASE 5 — Validação Final
### TASK-10: Build e checklist final
- **Ação:**
1. Executar `npm run build` no diretório `frontend/`
2. Verificar zero erros TypeScript
3. Testar manualmente:
- [ ] Home carrega no tema dark sem erro visual
- [ ] Home carrega no tema light sem erro visual
- [ ] Cards de destaque aparecem com animação rise
- [ ] Carousel de corretores funciona (autoplay, prev/next)
- [ ] Troca de tema reage corretamente no gradiente hero
- [ ] DevTools Network: 3 requests paralelos no load inicial
- [ ] Segunda visita (< 5 min): `/homepage-config` não é chamado
---
## Checklist de Qualidade
- [X] TASK-01 concluída — `hooks/useInView.ts` criado
- [X] TASK-02 concluída — `@keyframes` em `index.css`, sem `<style>` inline
- [X] TASK-03 concluída — `loading="lazy"` em `SlideImage`
- [X] TASK-04 concluída — `will-change: transform` no carrossel
- [X] TASK-05 concluída — `AgentsCarousel` recebe props
- [X] TASK-06 concluída — `HomeScrollScene` recebe props
- [X] TASK-07 concluída — `RiseCard` usa `useInView`
- [X] TASK-08 concluída — `HomePage` paraleliza fetches + cache + preload
- [X] TASK-09 concluída — hero image com prioridade correta
- [X] TASK-10 concluída — build verde, smoke test manual OK