feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- 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:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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)}`.
|
||||
|
|
|
|||
36
specs/025-favoritos-locais/checklists/requirements.md
Normal file
36
specs/025-favoritos-locais/checklists/requirements.md
Normal 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.
|
||||
255
specs/025-favoritos-locais/plan.md
Normal file
255
specs/025-favoritos-locais/plan.md
Normal 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
|
||||
166
specs/025-favoritos-locais/spec.md
Normal file
166
specs/025-favoritos-locais/spec.md
Normal 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.
|
||||
265
specs/025-favoritos-locais/tasks.md
Normal file
265
specs/025-favoritos-locais/tasks.md
Normal 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 (T001–T010): visitante favorita imóveis localmente e acessa a página `/favoritos`.
|
||||
|
||||
Fases 4–5 (T011–T012) entregam as histórias P2 (banner + merge no login).
|
||||
|
||||
---
|
||||
|
||||
## Contagem de Tasks
|
||||
|
||||
| Fase | Tasks | User Story |
|
||||
|------|-------|-----------|
|
||||
| Foundational | T001, T002 | — |
|
||||
| US1 | T003–T008 | P1 |
|
||||
| US2 | T009–T010 | P1 |
|
||||
| US3 | T011 | P2 |
|
||||
| US4 | T012 | P2 |
|
||||
| Polish | T013–T014 | — |
|
||||
| **Total** | **14 tasks** | |
|
||||
36
specs/026-central-contatos/checklists/requirements.md
Normal file
36
specs/026-central-contatos/checklists/requirements.md
Normal 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.
|
||||
201
specs/026-central-contatos/spec.md
Normal file
201
specs/026-central-contatos/spec.md
Normal 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.
|
||||
206
specs/026-central-contatos/tasks.md
Normal file
206
specs/026-central-contatos/tasks.md
Normal 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 US1–US5 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.
|
||||
35
specs/027-config-pagina-contato/checklists/requirements.md
Normal file
35
specs/027-config-pagina-contato/checklists/requirements.md
Normal 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`.
|
||||
151
specs/027-config-pagina-contato/spec.md
Normal file
151
specs/027-config-pagina-contato/spec.md
Normal 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.
|
||||
404
specs/027-config-pagina-contato/tasks.md
Normal file
404
specs/027-config-pagina-contato/tasks.md
Normal 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 T001–T004
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
36
specs/028-trabalhe-conosco/checklists/requirements.md
Normal file
36
specs/028-trabalhe-conosco/checklists/requirements.md
Normal 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`
|
||||
210
specs/028-trabalhe-conosco/contracts/jobs-api.md
Normal file
210
specs/028-trabalhe-conosco/contracts/jobs-api.md
Normal 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`.
|
||||
215
specs/028-trabalhe-conosco/data-model.md
Normal file
215
specs/028-trabalhe-conosco/data-model.md
Normal 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
|
||||
```
|
||||
354
specs/028-trabalhe-conosco/plan.md
Normal file
354
specs/028-trabalhe-conosco/plan.md
Normal 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 |
|
||||
|-----------|------------|--------------------------------------|
|
||||
| — | — | — |
|
||||
150
specs/028-trabalhe-conosco/spec.md
Normal file
150
specs/028-trabalhe-conosco/spec.md
Normal 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.
|
||||
217
specs/028-trabalhe-conosco/tasks.md
Normal file
217
specs/028-trabalhe-conosco/tasks.md
Normal 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 1–100), 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 | — | T001–T006, 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).
|
||||
36
specs/029-ux-area-do-cliente/checklists/requirements.md
Normal file
36
specs/029-ux-area-do-cliente/checklists/requirements.md
Normal 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`.
|
||||
118
specs/029-ux-area-do-cliente/plan.md
Normal file
118
specs/029-ux-area-do-cliente/plan.md
Normal 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.
|
||||
203
specs/029-ux-area-do-cliente/spec.md
Normal file
203
specs/029-ux-area-do-cliente/spec.md
Normal 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.
|
||||
994
specs/029-ux-area-do-cliente/tasks.md
Normal file
994
specs/029-ux-area-do-cliente/tasks.md
Normal 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 T03–T05.
|
||||
|
||||
**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 T01–T08
|
||||
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`
|
||||
173
specs/029-ux-area-do-cliente/ux-audit.md
Normal file
173
specs/029-ux-area-do-cliente/ux-audit.md
Normal 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
|
||||
191
specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md
Normal file
191
specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md
Normal 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
|
||||
165
specs/030-navbar-topo-ux/data-model.md
Normal file
165
specs/030-navbar-topo-ux/data-model.md
Normal 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
|
||||
```
|
||||
123
specs/030-navbar-topo-ux/plan.md
Normal file
123
specs/030-navbar-topo-ux/plan.md
Normal 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.
|
||||
|
||||
105
specs/030-navbar-topo-ux/quickstart.md
Normal file
105
specs/030-navbar-topo-ux/quickstart.md
Normal 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.
|
||||
71
specs/030-navbar-topo-ux/research.md
Normal file
71
specs/030-navbar-topo-ux/research.md
Normal 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.
|
||||
195
specs/030-navbar-topo-ux/spec.md
Normal file
195
specs/030-navbar-topo-ux/spec.md
Normal 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.
|
||||
261
specs/030-navbar-topo-ux/tasks.md
Normal file
261
specs/030-navbar-topo-ux/tasks.md
Normal 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 | T001–T002 | Setup da navbar compartilhada |
|
||||
| Phase 2 | T003–T006 | Estado e shell bloqueadores |
|
||||
| Phase 3 | T007–T010 | US1 — visitante |
|
||||
| Phase 4 | T011–T014 | US2 — cliente autenticado |
|
||||
| Phase 5 | T015–T017 | US3 — admin |
|
||||
| Phase 6 | T018–T020 | US4 — acessibilidade e previsibilidade |
|
||||
| Phase 7 | T021–T023 | US5 — responsividade premium |
|
||||
| Phase Final | T024–T026 | Build, validação manual e limpeza |
|
||||
| **Total** | **26 tarefas** | **5 user stories + setup/foundational/polish** |
|
||||
|
||||
28
specs/031-home-hero-light-dark/plan.md
Normal file
28
specs/031-home-hero-light-dark/plan.md
Normal 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
|
||||
56
specs/031-home-hero-light-dark/spec.md
Normal file
56
specs/031-home-hero-light-dark/spec.md
Normal 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.
|
||||
12
specs/031-home-hero-light-dark/tasks.md
Normal file
12
specs/031-home-hero-light-dark/tasks.md
Normal 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.
|
||||
302
specs/032-performance-homepage/performance-audit.md
Normal file
302
specs/032-performance-homepage/performance-audit.md
Normal 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 | +600–1200 ms |
|
||||
| Imagem hero sem preload / sem dimensões | 🔴 Alta | +800–1500 ms |
|
||||
| Scroll scene: `useTheme` + re-renders desnecessários | 🟡 Média | +60–150 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 1–2 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.5–5 s | < 2.5 s | ~1.5 s |
|
||||
| CLS (Cumulative Layout Shift) | ~0.12–0.18 | < 0.1 | ~0.05 |
|
||||
| INP (Interaction to Next Paint) | ~150–250 ms | < 200 ms | borderline |
|
||||
| FCP (First Contentful Paint) | ~1.2–1.8 s | < 1.8 s | borderline |
|
||||
| TTFB (Time to First Byte) | ~200–400 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 |
|
||||
136
specs/032-performance-homepage/plan.md
Normal file
136
specs/032-performance-homepage/plan.md
Normal 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)
|
||||
```
|
||||
97
specs/032-performance-homepage/spec.md
Normal file
97
specs/032-performance-homepage/spec.md
Normal 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
|
||||
203
specs/032-performance-homepage/tasks.md
Normal file
203
specs/032-performance-homepage/tasks.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue