feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
37
.specify/features/001-homepage/checklists/requirements.md
Normal file
37
.specify/features/001-homepage/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Specification Quality Checklist: Homepage (Página Inicial)
|
||||
|
||||
**Purpose**: Validar a completude e qualidade da especificação antes de prosseguir para o planejamento
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- A spec está pronta para prosseguir para `/speckit.plan`.
|
||||
- User Story 4 (Admin Configura Conteúdo) depende da feature de autenticação do admin — esse pré-requisito está documentado nas Assumptions.
|
||||
- As seções Sobre, CTA e Rodapé têm conteúdo estático nesta versão; configurabilidade via admin é explicitamente Out of Scope.
|
||||
- O contrato de API inclui endpoints admin por completude, mas sua especificação completa de autenticação/autorização pertence à feature spec do Admin Panel.
|
||||
1148
.specify/features/001-homepage/plan.md
Normal file
1148
.specify/features/001-homepage/plan.md
Normal file
File diff suppressed because it is too large
Load diff
391
.specify/features/001-homepage/spec.md
Normal file
391
.specify/features/001-homepage/spec.md
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
# Feature Specification: Homepage (Página Inicial)
|
||||
|
||||
**Feature Branch**: `001-homepage`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 — Visitante Experimenta o Hero e a Navegação (Priority: P1)
|
||||
|
||||
Um visitante chega ao site da imobiliária e é imediatamente recebido por uma barra de navegação clara e uma seção hero visualmente impactante. O headline comunica a proposta de valor da agência, o subheadline fornece contexto de suporte, e um botão CTA proeminente convida o visitante a explorar os imóveis disponíveis.
|
||||
|
||||
**Why this priority**: O hero é a primeira impressão do site. Sem ele renderizar corretamente com conteúdo atualizado, nenhuma outra seção entrega valor significativo. Ele ancora a identidade visual e orienta o engajamento do visitante.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente carregando a URL da homepage em qualquer navegador — a barra de navegação deve exibir o logotipo e os links, o hero deve mostrar o headline e subheadline configurados pelo admin, e o botão CTA deve estar visível e navegável para a listagem de imóveis.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante abre a URL da homepage, **when** a página carrega, **then** a barra de navegação é visível com o logotipo da agência à esquerda e os links "Imóveis", "Sobre" e "Contato" à direita.
|
||||
2. **Given** a página está carregada, **when** o visitante visualiza a seção hero, **then** ela exibe o headline atual, o subheadline (quando configurado), e um botão CTA com o rótulo configurado pelo admin (padrão: "Ver Imóveis").
|
||||
3. **Given** o visitante clica no botão CTA do hero, **when** o clique é processado, **then** o navegador redireciona para a página de listagem de imóveis (`/imoveis`).
|
||||
4. **Given** um admin atualizou o headline via painel administrativo, **when** o visitante carrega a homepage, **then** a seção hero exibe o novo headline sem necessidade de redeploy.
|
||||
5. **Given** a homepage é acessada em dispositivo móvel (viewport 320px–428px), **when** a página renderiza a seção hero, **then** todo o texto é legível, o botão CTA é tocável, e não há overflow horizontal.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Visitante Explora Imóveis em Destaque (Priority: P1)
|
||||
|
||||
Um visitante rola além da seção hero e encontra uma grade de imóveis curados em destaque. Cada card de imóvel fornece informações suficientes (foto, preço, tipo, estatísticas principais) para o visitante avaliar rapidamente o interesse e decidir clicar para ver mais detalhes.
|
||||
|
||||
**Why this priority**: Os imóveis em destaque são o conteúdo comercial primário da homepage. Esta seção conduz diretamente as consultas de imóveis e a conversão de visitante para lead. É a função de negócio central da homepage.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente carregando a homepage e verificando que pelo menos um card de imóvel aparece na grade em destaque, com todos os campos obrigatórios visíveis (foto, título, preço, badge de tipo, quartos/banheiros/área). O teste pode ser executado contra um banco de dados com imóveis pré-configurados como destaque.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** existem imóveis no sistema com o flag "featured" habilitado, **when** o visitante carrega a homepage, **then** a seção de imóveis em destaque exibe uma grade mostrando até N imóveis (configurável pelo admin), cada card contendo: foto principal, título do imóvel, preço formatado (R$), badge de tipo (Venda ou Aluguel), contagem de quartos, banheiros e área total em m².
|
||||
2. **Given** nenhum imóvel está configurado como destaque, **when** o visitante carrega a homepage, **then** a seção de imóveis em destaque é ocultada ou exibe a mensagem "Nenhum imóvel em destaque no momento" — a grade não é renderizada vazia ou quebrada.
|
||||
3. **Given** um card de imóvel está visível, **when** o visitante clica no card, **then** o navegador navega para a URL de detalhe daquele imóvel (`/imoveis/{slug}`).
|
||||
4. **Given** um imóvel em destaque não possui foto cadastrada, **when** o card é renderizado, **then** ele exibe uma imagem placeholder em vez de um elemento de imagem quebrado.
|
||||
5. **Given** a grade de imóveis em destaque é visualizada em tablet (768px–1023px), **when** o layout renderiza, **then** a grade se adapta para 2 colunas sem overflow ou quebra de alinhamento.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Visitante Descobre a Agência e Inicia Contato (Priority: P2)
|
||||
|
||||
Um visitante que está interessado mas ainda não está pronto para navegar pelos imóveis rola pela seção Sobre para conhecer o background da agência, e em seguida encontra a seção CTA com informações de contato claras ou um convite para entrar em contato.
|
||||
|
||||
**Why this priority**: As seções Sobre e CTA suportam a construção de confiança e a geração de leads para visitantes que precisam de mais contexto antes de engajar com as listagens. São valiosas, mas não bloqueiam a funcionalidade MVP — a homepage funciona sem elas, embora as taxas de conversão se beneficiem significativamente de sua presença.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente rolando a homepage além da seção de imóveis em destaque e verificando que (a) uma seção Sobre com nome e descrição da agência aparece, e (b) uma seção CTA com um convite de contato aparece antes do rodapé.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante rola além da seção de imóveis em destaque, **when** a seção Sobre entra na viewport, **then** ela exibe o nome da agência e pelo menos um parágrafo de descrição.
|
||||
2. **Given** o visitante continua rolando, **when** a seção CTA entra na viewport, **then** ela exibe um convite claro para contato com ao menos um elemento acionável (número de telefone, link de e-mail ou botão "Fale Conosco").
|
||||
3. **Given** a página é rolada até o final, **when** o visitante chega ao rodapé, **then** o rodapé exibe as informações de contato da agência (ao mínimo: e-mail ou telefone) e links de navegação (Imóveis, Sobre, Contato).
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Admin Configura o Conteúdo da Homepage (Priority: P1)
|
||||
|
||||
Um administrador faz login no painel administrativo e atualiza o headline e subheadline do hero da homepage. O admin também seleciona quais imóveis aparecem na grade em destaque habilitando ou desabilitando o flag "featured" nos imóveis individuais.
|
||||
|
||||
**Why this priority**: A capacidade de atualizar o conteúdo da homepage sem alterações de código é um requisito central do painel SaaS. Sem essa capacidade, a homepage é estática e o painel administrativo não entrega valor para o gerenciamento da homepage.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma independente em dois cenários: (1) atualizar o headline via formulário de configurações da homepage no painel e verificar que a mudança aparece na homepage pública; (2) alternar o flag "featured" de um imóvel e verificar que a grade de destaque é atualizada. Ambas as ações podem ser testadas contra o painel com chamadas reais de API.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin está autenticado e na página de configurações da homepage, **when** o admin atualiza o headline e o subheadline e salva, **then** a homepage pública reflete o novo texto no próximo carregamento sem necessidade de redeploy.
|
||||
2. **Given** o admin navega para a lista de imóveis no painel, **when** o admin habilita ou desabilita o flag "featured" em um imóvel e salva, **then** aquele imóvel aparece ou é removido da grade em destaque na homepage pública.
|
||||
3. **Given** o admin tenta salvar a configuração da homepage com headline vazio, **when** o formulário é submetido, **then** o sistema rejeita o salvamento e exibe uma mensagem de validação — headline vazio não é permitido.
|
||||
4. **Given** o admin seleciona imóveis em destaque, **when** mais imóveis que o máximo configurável são marcados como destaque, **then** o sistema exibe apenas até o limite definido na homepage (padrão: 6), priorizando por data de marcação.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Nenhum imóvel em destaque**: Se nenhum imóvel está marcado como featured, a seção deve ser oculta ou exibir "Nenhum imóvel em destaque no momento" — a página não deve lançar erro de renderização.
|
||||
- **Imóvel sem foto**: Se um imóvel em destaque não possui foto, um placeholder genérico é exibido — o card não deve renderizar um elemento de imagem quebrado.
|
||||
- **Headline muito longo**: Se o admin inserir um headline muito longo (120+ caracteres), a seção hero deve adaptar-se sem overflow de texto ou quebra de layout.
|
||||
- **Estado de carregamento / rede lenta**: Enquanto a resposta da API de imóveis em destaque está pendente, a seção deve exibir skeleton loaders — sem flash de conteúdo ou espaço em branco.
|
||||
- **Subheadline vazio**: O subheadline é opcional. Um subheadline vazio deve resultar na renderização do hero sem o elemento subheadline — não uma string vazia visível.
|
||||
- **API indisponível**: Se o endpoint de configuração da homepage ou de imóveis falhar, a página deve renderizar uma versão degradada (conteúdo de fallback estático) em vez de uma tela de erro completa.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE renderizar uma barra de navegação contendo o logotipo da agência e links para as seções/páginas Imóveis, Sobre e Contato.
|
||||
- **FR-002**: A barra de navegação DEVE ser sticky (visível durante a rolagem) em viewports desktop (≥768px).
|
||||
- **FR-003**: A barra de navegação DEVE colapsar para um menu hamburger em viewports abaixo de 768px.
|
||||
- **FR-004**: O sistema DEVE renderizar uma seção hero exibindo um headline, um subheadline opcional, e um botão CTA que navega para a listagem de imóveis.
|
||||
- **FR-005**: O headline e o subheadline do hero DEVEM ser configuráveis por um admin autenticado via painel administrativo sem alterações de código ou redeploy.
|
||||
- **FR-006**: O sistema DEVE renderizar uma seção de Imóveis em Destaque exibindo uma grade de imóveis marcados como "featured" por um admin.
|
||||
- **FR-007**: Cada card de imóvel na grade DEVE exibir: uma foto principal, título do imóvel, preço formatado em R$ (pt-BR), badge de tipo (Venda ou Aluguel), contagem de quartos, contagem de banheiros e área total em m².
|
||||
- **FR-008**: A grade de imóveis em destaque DEVE ser populada dinamicamente via API de backend — a lista NÃO DEVE exigir redeploy para ser atualizada.
|
||||
- **FR-009**: O sistema DEVE limitar a grade em destaque a um número máximo configurável de entradas (padrão: 6, máximo suportado: 12).
|
||||
- **FR-010**: Se nenhum imóvel estiver configurado como destaque, o sistema DEVE tratar esse estado graciosamente sem erros de renderização, ocultando a seção ou exibindo uma mensagem adequada.
|
||||
- **FR-011**: Cards de imóvel DEVEM exibir uma imagem placeholder quando nenhuma foto estiver disponível.
|
||||
- **FR-012**: O sistema DEVE renderizar uma seção Sobre/Empresa contendo ao mínimo o nome da agência e uma descrição breve.
|
||||
- **FR-013**: O sistema DEVE renderizar uma seção Call-to-Action contendo um convite para contato com ao mínimo um elemento acionável.
|
||||
- **FR-014**: O sistema DEVE renderizar um rodapé contendo informações de contato e links de navegação.
|
||||
- **FR-015**: O painel administrativo DEVE fornecer uma interface para selecionar quais imóveis são exibidos na grade em destaque.
|
||||
- **FR-016**: O painel administrativo DEVE rejeitar salvamentos de configuração de homepage com headline vazio, exibindo mensagem de validação ao admin.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
#### Performance
|
||||
|
||||
- **NFR-001**: A homepage DEVE atingir um Largest Contentful Paint (LCP) abaixo de 2,5 segundos em conexão banda larga padrão (>10 Mbps).
|
||||
- **NFR-002**: Os endpoints de API que suprem dados da homepage DEVEM responder em até 500ms sob carga normal (até 100 requisições concorrentes).
|
||||
- **NFR-003**: Imagens de imóveis em destaque DEVEM ser servidas em formato web-otimizado — nenhuma imagem de thumbnail de card deve exceder 300 KB.
|
||||
|
||||
#### Responsiveness
|
||||
|
||||
- **NFR-004**: A homepage DEVE ser completamente funcional e visualmente íntegra nos seguintes breakpoints: 320px (mobile S), 375px (mobile M), 768px (tablet), 1024px (laptop), 1280px (desktop), 1440px (wide).
|
||||
- **NFR-005**: A grade de imóveis em destaque DEVE se adaptar de 1 coluna em mobile para 2 colunas em tablet e 3 colunas em desktop (≥1024px).
|
||||
- **NFR-006**: A tipografia do hero DEVE escalar responsivamente: 72px em desktop, 48px em tablet, 40px em mobile.
|
||||
|
||||
#### Accessibility
|
||||
|
||||
- **NFR-007**: Todas as imagens DEVEM ter texto `alt` descritivo (título do imóvel para fotos de imóveis; descrição para o logotipo).
|
||||
- **NFR-008**: Todos os elementos interativos (links, botões) DEVEM ser navegáveis por teclado e ter estados de foco visíveis.
|
||||
- **NFR-009**: Relações de contraste de cor DEVEM atender aos padrões WCAG 2.1 AA: mínimo 4,5:1 para texto de corpo, 3:1 para texto grande e componentes de UI.
|
||||
- **NFR-010**: A página DEVE usar elementos HTML5 semânticos apropriados e papéis ARIA landmark (`<nav>`, `<main>`, `<footer>`, `<header>`) em toda a estrutura.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **HomepageConfig**: Representa o conteúdo configurável pelo admin para a homepage. Atributos principais: `hero_headline` (obrigatório, máx. 120 caracteres), `hero_subheadline` (opcional, máx. 240 caracteres), `hero_cta_label` (opcional, máx. 40 caracteres, padrão: "Ver Imóveis"), `hero_cta_url` (opcional, caminho relativo, padrão: "/imoveis"), `featured_properties_limit` (inteiro, padrão: 6, máximo: 12).
|
||||
- **Property**: Representa um anúncio imobiliário. Atributos relevantes para a homepage: `id` (UUID), `title` (string), `type` (enum: `venda` | `aluguel`), `price` (decimal), `area_m2` (inteiro), `bedrooms` (inteiro), `bathrooms` (inteiro), `is_featured` (boolean), `slug` (string única). A entidade Property é compartilhada com a feature de listagem de imóveis.
|
||||
- **PropertyPhoto**: Representa uma foto associada a um imóvel. A homepage usa apenas a foto principal (primeira por ordem de display). Atributos: `url` (string), `alt_text` (string), `display_order` (inteiro).
|
||||
|
||||
---
|
||||
|
||||
## Design Specifications
|
||||
|
||||
Toda a especificação visual segue o tema dark inspirado no Linear documentado em `DESIGN.md`.
|
||||
|
||||
### Color Application
|
||||
|
||||
| Element | Token | Value |
|
||||
|---------|-------|-------|
|
||||
| Page background | Marketing Black | `#08090a` |
|
||||
| Navigation background (sticky) | Marketing Black semi-transparente | `rgba(8,9,10,0.85)` + backdrop-blur |
|
||||
| Hero background (gradiente padrão) | Radial brand indigo fade | `radial-gradient(ellipse at center, rgba(94,106,210,0.08) 0%, #08090a 70%)` |
|
||||
| Card/container background | Level 2 Surface | `rgba(255,255,255,0.03)` |
|
||||
| Card border | Border Standard | `1px solid rgba(255,255,255,0.08)` |
|
||||
| Card border hover | Border Standard brightened | `1px solid rgba(255,255,255,0.12)` |
|
||||
| Primary text | Near-white | `#f7f8f8` |
|
||||
| Secondary text | Silver-gray | `#d0d6e0` |
|
||||
| Tertiary text / metadata | Muted gray | `#8a8f98` |
|
||||
| CTA button background | Brand Indigo | `#5e6ad2` |
|
||||
| CTA button hover | Accent Hover | `#828fff` |
|
||||
| Badge tipo Venda | Brand Indigo pill | `rgba(94,106,210,0.15)` + borda `rgba(94,106,210,0.3)` |
|
||||
| Badge tipo Aluguel | Neutral pill | `rgba(255,255,255,0.05)` + borda `rgba(255,255,255,0.1)` |
|
||||
| Section dividers | Line Tint | sem divisor visível — espaçamento separa as seções |
|
||||
| Footer background | Panel Dark | `#0f1011` |
|
||||
|
||||
### Typography Application
|
||||
|
||||
| Element | Style from DESIGN.md | Size | Weight | Letter Spacing |
|
||||
|---------|----------------------|------|--------|----------------|
|
||||
| Hero headline (desktop) | Display XL | 72px | 510 | -1.584px |
|
||||
| Hero headline (tablet) | Display | 48px | 510 | -1.056px |
|
||||
| Hero headline (mobile) | Heading 1 | 40px | 510 | -0.704px |
|
||||
| Hero subheadline | Body Large | 18px | 400 | -0.165px |
|
||||
| CTA button label | Label | 14px | 590 | normal |
|
||||
| Section headings (ex: "Imóveis em Destaque") | Display | 48px (desktop) / 32px (mobile) | 510 | -1.056px / -0.704px |
|
||||
| Property card title | Heading 3 | 20px | 590 | -0.24px |
|
||||
| Property price | Body Semibold | 16px | 590 | normal |
|
||||
| Property stats (quartos, banheiros, área) | Caption Large | 14px | 510 | -0.182px |
|
||||
| Navigation links | Link Small | 14px | 510 | -0.182px |
|
||||
| Footer links | Link Caption | 13px | 510 | -0.13px |
|
||||
| About / CTA body text | Body | 16px | 400 | normal |
|
||||
|
||||
Toda a tipografia usa `Inter Variable` com OpenType features `"cv01", "ss03"` habilitadas globalmente via `font-feature-settings: "cv01", "ss03"`.
|
||||
|
||||
### Layout
|
||||
|
||||
- **Largura máxima do conteúdo**: 1200px, centralizado horizontalmente com margens `auto`.
|
||||
- **Hero section**: mínimo 100vh de altura, layout de coluna única centralizado, padding: 120px superior / 80px inferior em desktop; 80px / 60px em mobile.
|
||||
- **Grade de Imóveis em Destaque**: 3 colunas em desktop (≥1024px), 2 colunas em tablet (768px–1023px), 1 coluna em mobile (<768px). Gap: 24px.
|
||||
- **Espaçamento vertical entre seções**: 80px padding-top e padding-bottom em desktop; 60px em mobile.
|
||||
- **Border radius dos cards**: 12px (Panel radius conforme DESIGN.md).
|
||||
- **Fotos dos cards**: border-radius `12px 12px 0 0` (arredondado só no topo), aspect-ratio 16:9.
|
||||
|
||||
### Component Notes
|
||||
|
||||
- **Navigation bar**: fundo `rgba(8,9,10,0.85)` com `backdrop-filter: blur(12px)`, `border-bottom: 1px solid rgba(255,255,255,0.05)`. Posição: sticky, `z-index` elevado.
|
||||
- **Property card**: fundo `rgba(255,255,255,0.03)`, borda `1px solid rgba(255,255,255,0.08)`, radius 12px, com transição de hover aumentando levemente a opacidade do fundo e a borda.
|
||||
- **CTA button (Primary)**: Background `#5e6ad2`, padding `10px 20px`, radius 6px, texto branco weight 590. Hover: `#828fff`.
|
||||
- **Type badges**: pills com radius `9999px`, padding `2px 10px`, fonte 12px weight 510.
|
||||
|
||||
---
|
||||
|
||||
## API Contract
|
||||
|
||||
Os seguintes endpoints devem ser implementados pelo backend Flask para suportar a homepage.
|
||||
|
||||
### `GET /api/v1/homepage-config`
|
||||
|
||||
Retorna o conteúdo configurável pelo admin para a seção hero da homepage.
|
||||
|
||||
**Autenticação**: Nenhuma (endpoint público)
|
||||
**Cache**: As respostas podem ser cacheadas por até 60 segundos (header `Cache-Control: public, max-age=60`).
|
||||
|
||||
**Response `200 OK`**:
|
||||
```json
|
||||
{
|
||||
"hero": {
|
||||
"headline": "Encontre o imóvel dos seus sonhos",
|
||||
"subheadline": "Imóveis para comprar e alugar com a melhor assessoria da região.",
|
||||
"cta_label": "Ver Imóveis",
|
||||
"cta_url": "/imoveis"
|
||||
},
|
||||
"featured_properties_limit": 6
|
||||
}
|
||||
```
|
||||
|
||||
**Response `500 Internal Server Error`**:
|
||||
```json
|
||||
{
|
||||
"error": "internal_server_error",
|
||||
"message": "An unexpected error occurred."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/v1/properties?featured=true`
|
||||
|
||||
Retorna a lista de imóveis marcados como destaque, ordenados por prioridade de exibição.
|
||||
|
||||
**Autenticação**: Nenhuma (endpoint público)
|
||||
|
||||
**Query Parameters**:
|
||||
|
||||
| Parâmetro | Tipo | Obrigatório | Descrição |
|
||||
|-----------|------|-------------|-----------|
|
||||
| `featured` | boolean | sim (para este use case) | `true` filtra apenas imóveis em destaque |
|
||||
| `limit` | integer | não | Máximo de resultados. Padrão: valor de `featured_properties_limit` da config. Máximo: 12. |
|
||||
|
||||
**Response `200 OK`**:
|
||||
```json
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"title": "Apartamento Moderno no Centro",
|
||||
"type": "venda",
|
||||
"price": 450000.00,
|
||||
"price_formatted": "R$ 450.000,00",
|
||||
"area_m2": 85,
|
||||
"bedrooms": 3,
|
||||
"bathrooms": 2,
|
||||
"primary_photo_url": "https://cdn.example.com/properties/abc123/main.jpg",
|
||||
"primary_photo_alt": "Apartamento Moderno no Centro — foto principal",
|
||||
"slug": "apartamento-moderno-no-centro",
|
||||
"detail_url": "/imoveis/apartamento-moderno-no-centro"
|
||||
}
|
||||
],
|
||||
"total": 4
|
||||
}
|
||||
```
|
||||
|
||||
**Response `400 Bad Request`** (parâmetros inválidos):
|
||||
```json
|
||||
{
|
||||
"error": "invalid_parameter",
|
||||
"message": "Parameter 'limit' must be an integer between 1 and 12."
|
||||
}
|
||||
```
|
||||
|
||||
**Response `500 Internal Server Error`**:
|
||||
```json
|
||||
{
|
||||
"error": "internal_server_error",
|
||||
"message": "An unexpected error occurred."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/v1/admin/homepage-config`
|
||||
|
||||
Atualiza a configuração da homepage. Reservado para o painel administrativo (autenticação de admin obrigatória).
|
||||
|
||||
**Autenticação**: Bearer token (JWT), role `admin` obrigatória.
|
||||
**Nota**: Este endpoint é referenciado para completude do contrato; sua especificação completa (autenticação, sessões, permissões) é definida na feature spec do Admin Panel.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"hero": {
|
||||
"headline": "string (obrigatório, máx. 120 caracteres)",
|
||||
"subheadline": "string (opcional, máx. 240 caracteres)",
|
||||
"cta_label": "string (opcional, máx. 40 caracteres)",
|
||||
"cta_url": "string (opcional, caminho relativo válido)"
|
||||
},
|
||||
"featured_properties_limit": "integer (opcional, 1–12)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200 OK`**: Retorna o objeto `HomepageConfig` atualizado com o mesmo schema de `GET /api/v1/homepage-config`.
|
||||
|
||||
**Response `400 Bad Request`** (validação falhou):
|
||||
```json
|
||||
{
|
||||
"error": "validation_error",
|
||||
"fields": {
|
||||
"hero.headline": "This field is required and cannot be empty."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response `401 Unauthorized`** / **`403 Forbidden`**: Retornado quando o token está ausente, expirado ou sem a role `admin`.
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/v1/admin/properties/{id}/featured`
|
||||
|
||||
Habilita ou desabilita o flag de destaque de um imóvel específico.
|
||||
|
||||
**Autenticação**: Bearer token (JWT), role `admin` obrigatória.
|
||||
**Nota**: Endpoint referenciado para completude. Especificação completa na feature spec de gerenciamento de imóveis do Admin Panel.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"is_featured": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200 OK`**:
|
||||
```json
|
||||
{
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"is_featured": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Os itens a seguir estão explicitamente excluídos desta especificação de feature e serão tratados em specs separadas:
|
||||
|
||||
- **Página de detalhe do imóvel**: Clicar em um card de imóvel navega para a URL de detalhe, mas o layout, conteúdo e API da página de detalhe são uma feature separada.
|
||||
- **Busca e filtros de imóveis**: A funcionalidade de busca (filtrar por faixa de preço, localização, tipo, quartos, etc.) é uma feature separada. O botão CTA do hero linka para a listagem; qualquer interface de busca na homepage está fora de escopo.
|
||||
- **Autenticação e UI do Painel Administrativo**: O fluxo de login do admin, layout do painel, navegação e gerenciamento de sessão são features separadas. Esta spec define apenas a view pública da homepage e o contrato de API que o painel consumirá.
|
||||
- **Página de Listagem de Imóveis**: A listagem completa em `/imoveis` com paginação, filtros e ordenação é uma feature separada.
|
||||
- **Backend de Formulário de Contato**: O processamento de submissão de formulário de contato, entrega de e-mail e armazenamento de leads são features separadas. O CTA da homepage pode linkar para uma página de contato ou fornecer e-mail/telefone estático.
|
||||
- **Suporte multi-idioma (i18n)**: Português (pt-BR) é o único idioma suportado. Internacionalização está fora de escopo.
|
||||
- **SEO / Meta Tags avançadas**: Open Graph tags, dados estruturados (JSON-LD para `RealEstateListing`) e geração de sitemap são concerns separados.
|
||||
- **Integração de Analytics**: Eventos de rastreamento (GA4, Hotjar, etc.) estão fora de escopo para esta feature.
|
||||
- **Conteúdo editável via admin das seções Sobre e Rodapé**: Na versão inicial, o conteúdo das seções Sobre, CTA e Rodapé é estático. Uma spec futura pode adicionar gerenciamento dessas seções pelo admin.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Um visitante pode chegar à homepage e clicar para ver um imóvel em destaque em menos de 30 segundos sem nenhuma orientação — taxa de conclusão da tarefa primária ≥ 90% em testes de usabilidade.
|
||||
- **SC-002**: A homepage carrega e exibe o conteúdo acima da dobra (hero + navegação) em até 2,5 segundos em conexão banda larga padrão.
|
||||
- **SC-003**: Um admin pode atualizar o headline do hero e vê-lo refletido na homepage pública em até 60 segundos, sem intervenção de desenvolvedor.
|
||||
- **SC-004**: Um admin pode alterar quais imóveis estão em destaque e ver a grade da homepage ser atualizada em até 60 segundos, sem redeploy.
|
||||
- **SC-005**: A homepage renderiza sem regressões visuais em todos os 6 breakpoints definidos (320px, 375px, 768px, 1024px, 1280px, 1440px).
|
||||
- **SC-006**: Todos os requisitos de contraste de cor WCAG 2.1 AA são atendidos em toda a homepage — verificado por auditoria de acessibilidade automatizada com zero violações críticas.
|
||||
- **SC-007**: A seção de imóveis em destaque renderiza corretamente com 0, 1 ou 6 imóveis marcados como featured — sem erros de JavaScript ou layouts quebrados em nenhum desses estados.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A agência opera exclusivamente no Brasil; toda formatação de moeda usa o locale pt-BR (prefixo R$, ponto como separador de milhar, vírgula como separador decimal).
|
||||
- A homepage é renderizada como React SPA com client-side data fetching; esta spec não prescreve a estratégia de renderização (CSR/SSR/SSG).
|
||||
- O sistema de autenticação do painel admin (login, sessão, JWT) é implementado como feature separada e é um pré-requisito para a User Story 4 (Admin Configura Conteúdo).
|
||||
- Fotos de imóveis são armazenadas externamente (object storage / CDN) e referenciadas por URL na resposta da API. A homepage não realiza upload de arquivos.
|
||||
- O conteúdo das seções Sobre e CTA (descrição da agência, informações de contato) é considerado estático para a versão inicial e não requer configurabilidade via admin nesta spec.
|
||||
- O conteúdo do rodapé (links e informações de contato) é HTML estático para a versão inicial.
|
||||
- O número máximo de imóveis em destaque exibidos na homepage tem padrão 6, configurável pelo admin até no máximo 12.
|
||||
- Links de cards de imóveis apontam para `/imoveis/{slug}` mesmo que a página de detalhe ainda não esteja implementada. Links temporariamente quebrados são aceitáveis durante o desenvolvimento em fases.
|
||||
- A fonte Inter Variable é carregada via fonte auto-hospedada ou CDN com OpenType features `"cv01", "ss03"` habilitadas globalmente via `font-feature-settings`.
|
||||
- O painel admin está hospedado na mesma origem ou com CORS configurado adequadamente para os endpoints `/api/v1/admin/*`.
|
||||
449
.specify/features/001-homepage/tasks.md
Normal file
449
.specify/features/001-homepage/tasks.md
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
# Tasks: Homepage (Página Inicial)
|
||||
|
||||
**Feature**: `001-homepage`
|
||||
**Branch**: `001-homepage`
|
||||
**Input**: `spec.md`, `plan.md`, `DESIGN.md`, `.specify/memory/constitution.md`
|
||||
**Generated**: 2026-04-13
|
||||
**Status**: Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
- [ ] T[NNN] [P?] [USN?] Description — path/to/file.ext
|
||||
```
|
||||
|
||||
- **[P]** — Paralelizável (arquivo diferente, sem dependência de tarefa incompleta)
|
||||
- **[USN]** — User Story associada (US1–US4)
|
||||
- IDs sequenciais na ordem de execução recomendada
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup — Scaffolding do Projeto
|
||||
|
||||
**Objetivo**: Criar a estrutura de pastas, dependências e arquivos de configuração. Nenhuma lógica de negócio ainda.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T001 | S | — | plan.md §1.1 |
|
||||
| T002 | S | T001 | plan.md §1.1 |
|
||||
| T003 | S | T001 | plan.md §1.1 |
|
||||
| T004 | S | T001 | plan.md §1.1 |
|
||||
| T005 | S | T001 | plan.md §1.1 |
|
||||
| T006 | M | — | plan.md §1.3 |
|
||||
| T007 | S | T006 | plan.md §1.3 |
|
||||
| T008 | M | T007 | plan.md §3.1, DESIGN.md |
|
||||
| T009 | S | T007 | plan.md §3.1 |
|
||||
| T010 | S | T006 | plan.md §1.3 |
|
||||
|
||||
- [X] T001 Criar estrutura de diretórios do backend — `backend/app/models/`, `backend/app/schemas/`, `backend/app/routes/`, `backend/seeds/`, `backend/tests/`, `backend/migrations/`
|
||||
- **Done when**: Todos os diretórios existem conforme `plan.md` project structure; `__init__.py` vazio em cada subpacote Python.
|
||||
|
||||
- [X] T002 Criar `backend/pyproject.toml` com dependências Flask, SQLAlchemy, Flask-Migrate, Flask-CORS, Pydantic v2, psycopg2-binary, python-dotenv, pytest, pytest-flask — `backend/pyproject.toml`
|
||||
- **Done when**: `uv sync` executa sem erro; `uv run python -c "import flask; import pydantic"` passa.
|
||||
|
||||
- [X] T003 Criar `backend/app/config.py` com `DevelopmentConfig`, `ProductionConfig`, `TestingConfig` lendo `DATABASE_URL`, `SECRET_KEY`, `CORS_ORIGINS` de variáveis de ambiente — `backend/app/config.py`
|
||||
- **Done when**: `from app.config import config` importa sem erro; chave ausente levanta `KeyError` explícito.
|
||||
|
||||
- [X] T004 Criar `backend/app/extensions.py` com instâncias únicas `db = SQLAlchemy()`, `migrate = Migrate()`, `cors = CORS()` — `backend/app/extensions.py`
|
||||
- **Done when**: `from app.extensions import db, migrate, cors` importa sem erro; nenhuma extensão é inicializada neste arquivo (apenas instanciada).
|
||||
|
||||
- [X] T005 Criar `backend/.env.example` com `DATABASE_URL`, `SECRET_KEY`, `CORS_ORIGINS`, `FLASK_ENV` — `backend/.env.example`
|
||||
- **Done when**: Arquivo presente sem valores reais; todos os campos obrigatórios do `config.py` cobertos.
|
||||
|
||||
- [X] T006 [P] Criar projeto frontend com `npm create vite@latest frontend -- --template react-ts` — `frontend/`
|
||||
- **Done when**: `cd frontend && npm run dev` sobe em `localhost:5173` sem erros.
|
||||
|
||||
- [X] T007 Instalar dependências do frontend: `tailwindcss`, `postcss`, `autoprefixer`, `axios`, `react-router-dom`, `@types/react-router-dom` — `frontend/package.json`
|
||||
- **Done when**: `npm install` completa; `node_modules/tailwindcss` e `node_modules/axios` existem; `npm run build` passa.
|
||||
|
||||
- [X] T008 Criar `frontend/tailwind.config.ts` com todos os tokens de `DESIGN.md`: cores `mkt-black`, `panel-dark`, `surface-elevated`, `brand-indigo`, `accent-violet`, `accent-hover`, pesos `medium: 510`, `semibold: 590`, letter-spacing para display sizes, fontFamily Inter Variable — `frontend/tailwind.config.ts`
|
||||
- **Done when**: `npx tailwindcss --input src/index.css --output /dev/null` compila sem aviso; classe `bg-mkt-black` e `text-brand-indigo` existem no output CSS gerado.
|
||||
|
||||
- [X] T009 Configurar `frontend/src/index.css`: importar Tailwind (`@tailwind base/components/utilities`), `@import` Inter Variable via Google Fonts, `@layer base` com `font-feature-settings: "cv01", "ss03"` e `body { @apply bg-mkt-black text-text-primary font-sans antialiased }` — `frontend/src/index.css`
|
||||
- **Done when**: Página abre com fundo `#08090a` e fonte Inter Variable sem inline style.
|
||||
|
||||
- [X] T010 [P] Configurar `frontend/vite.config.ts` com proxy `/api` → `http://localhost:5000` e `changeOrigin: true` — `frontend/vite.config.ts`
|
||||
- **Done when**: `npm run build` passa sem erros TypeScript; proxy configurado no bloco `server.proxy`.
|
||||
|
||||
**Checkpoint Phase 1**: `uv sync` e `npm run dev` funcionam; estrutura de pastas completa conforme `plan.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational — Infraestrutura Flask + PostgreSQL
|
||||
|
||||
**Objetivo**: Flask app factory funcional, banco de dados conectado, Flask-Migrate inicializado. Bloqueia todas as fases de user story.
|
||||
|
||||
**⚠️ CRÍTICO**: Nenhuma User Story pode ser implementada antes desta fase estar completa.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T011 | S | T005 | plan.md §1.2 |
|
||||
| T012 | M | T002, T003, T004 | plan.md §1.1 |
|
||||
| T013 | S | T011, T012 | plan.md §1.2 |
|
||||
| T014 | S | T013 | plan.md §1.2 |
|
||||
|
||||
- [X] T011 Iniciar container PostgreSQL local: `docker run -d --name saas_imob_db -e POSTGRES_USER=imob -e POSTGRES_PASSWORD=imob_dev -e POSTGRES_DB=saas_imobiliaria -p 5432:5432 postgres:16-alpine`; copiar `backend/.env.example` para `backend/.env` e preencher com valores locais — `backend/.env`
|
||||
- **Done when**: `docker ps` mostra container `saas_imob_db` Running; `psql postgresql://imob:imob_dev@localhost:5432/saas_imobiliaria -c "\l"` lista o banco.
|
||||
|
||||
- [X] T012 Criar `backend/app/__init__.py` com `create_app(config_name="default")` que inicializa `db`, `migrate`, `cors` e registra blueprints de `app.routes` — `backend/app/__init__.py`
|
||||
- **Done when**: `uv run flask --app app shell` abre sem traceback; `db.engine.connect()` no shell retorna sem erro.
|
||||
|
||||
- [X] T013 Inicializar Flask-Migrate: `uv run flask --app app db init` dentro de `backend/` — `backend/migrations/`
|
||||
- **Done when**: Pasta `backend/migrations/` criada com `env.py` e `versions/` pelo Alembic.
|
||||
|
||||
- [X] T014 Confirmar conexão DB no shell Flask: `db.engine.connect()` — nenhum arquivo criado
|
||||
- **Done when**: Comando retorna `Connection` sem exceção; PostgreSQL aceita a conexão com `DATABASE_URL` do `.env`.
|
||||
|
||||
**Checkpoint Phase 2**: `uv run flask --app app db init` ok; `uv run pytest` passa (0 testes, setup ok).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 4 — Admin Configura Conteúdo da Homepage (Priority: P1)
|
||||
|
||||
**Goal**: API backend completamente funcional: `GET /api/v1/homepage-config` e `GET /api/v1/properties?featured=true` retornando JSON válido com dados do seeder.
|
||||
|
||||
**Independent Test**: `curl http://localhost:5000/api/v1/homepage-config` retorna `200` com JSON contendo `hero_headline`; `curl "http://localhost:5000/api/v1/properties?featured=true"` retorna array de até 6 imóveis; `uv run pytest` passa nos dois arquivos de teste.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T015 | M | T012 | plan.md §2.1, spec.md US4, FR-006, FR-008 |
|
||||
| T016 | S | T012 | plan.md §2.1, spec.md US4, FR-005 |
|
||||
| T017 | M | T015 | plan.md §2.2, spec.md FR-007 |
|
||||
| T018 | M | T016 | plan.md §2.2, spec.md FR-005, FR-016 |
|
||||
| T019 | S | T015, T016 | plan.md §2.4 |
|
||||
| T020 | S | T019 | plan.md §2.4 |
|
||||
| T021 | M | T018, T020 | plan.md §2.3, spec.md US4 scenario 1 |
|
||||
| T022 | M | T017, T020 | plan.md §2.3, spec.md US2, FR-006–009 |
|
||||
| T023 | S | T021, T022 | plan.md §1.1 |
|
||||
| T024 | M | T023 | plan.md §2.5, spec.md US4 scenario 2 |
|
||||
| T025 | S | T012 | plan.md §2 |
|
||||
| T026 | M | T025, T021 | plan.md §2, spec.md US4 scenarios 1–3 |
|
||||
| T027 | M | T025, T022 | plan.md §2, spec.md US2 scenarios 1–2, FR-010 |
|
||||
|
||||
- [X] T015 [P] [US4] Criar `backend/app/models/property.py` com classes `Property` (UUID pk, title, slug, address, price Numeric(12,2), type enum `venda|aluguel`, bedrooms, bathrooms, area_m2, is_featured, is_active, created_at) e `PropertyPhoto` (id, property_id FK CASCADE, url, alt_text, display_order) com relationship order_by display_order — `backend/app/models/property.py`
|
||||
- **Done when**: `from app.models.property import Property, PropertyPhoto` importa sem erro; campos declarados exatamente conforme `plan.md §2.1`; `price` usa `Numeric(12,2)`, nunca `Float`.
|
||||
|
||||
- [X] T016 [P] [US4] Criar `backend/app/models/homepage.py` com classe `HomepageConfig` (id Integer PK, hero_headline String(120) NOT NULL, hero_subheadline String(240) nullable, hero_cta_label String(40) default "Ver Imóveis", hero_cta_url String(200) default "/imoveis", featured_properties_limit Integer default 6, updated_at DateTime server_default+onupdate) — `backend/app/models/homepage.py`
|
||||
- **Done when**: `from app.models.homepage import HomepageConfig` importa sem erro; `hero_headline` é NOT NULL no modelo; `featured_properties_limit` tem default 6.
|
||||
|
||||
- [X] T017 [US4] Criar `backend/app/schemas/property.py` com `PropertyPhotoOut` e `PropertyOut` (Pydantic v2, `model_config = ConfigDict(from_attributes=True)`, `price: Decimal`, `type: Literal["venda", "aluguel"]`, `photos: list[PropertyPhotoOut]`) — `backend/app/schemas/property.py`
|
||||
- **Done when**: `PropertyOut.model_validate(property_instance)` funciona em teste manual; `price` serializa como `Decimal` (não float).
|
||||
|
||||
- [X] T018 [US4] Criar `backend/app/schemas/homepage.py` com `HomepageConfigOut` e `HomepageConfigIn` (Pydantic v2); `HomepageConfigIn` com `@field_validator("hero_headline")` rejeitando string vazia e `@field_validator("featured_properties_limit")` rejeitando valores fora de 1–12 — `backend/app/schemas/homepage.py`
|
||||
- **Done when**: `HomepageConfigIn(hero_headline="")` levanta `ValidationError`; `HomepageConfigIn(featured_properties_limit=13)` levanta `ValidationError`; instância válida passa.
|
||||
|
||||
- [X] T019 [US4] Gerar migração inicial com Flask-Migrate: `uv run flask --app app db migrate -m "initial schema: properties, property_photos, homepage_config"` — `backend/migrations/versions/`
|
||||
- **Done when**: Arquivo de migração criado em `backend/migrations/versions/`; revisão manual confirma tabelas `properties`, `property_photos`, `homepage_config` e enum `property_type` presentes.
|
||||
|
||||
- [X] T020 [US4] Aplicar migração e testar ciclo upgrade/downgrade: `flask db upgrade`, `flask db downgrade base`, `flask db upgrade` — banco de dados
|
||||
- **Done when**: Ambos os comandos executam sem erro; tabelas existem no banco após upgrade final; `\dt` no psql lista as três tabelas.
|
||||
|
||||
- [X] T021 [US4] Criar `backend/app/routes/homepage.py` com Blueprint `homepage_bp` e rota `GET /api/v1/homepage-config` retornando `HomepageConfigOut.model_validate(config).model_dump()` ou `404` se nenhum registro — `backend/app/routes/homepage.py`
|
||||
- **Done when**: `curl http://localhost:5000/api/v1/homepage-config` retorna `200` com JSON contendo `hero_headline` após seeder; retorna `404` se tabela vazia.
|
||||
|
||||
- [X] T022 [US4] Criar `backend/app/routes/properties.py` com Blueprint `properties_bp` e rota `GET /api/v1/properties` que filtra por `is_active=True`; quando `featured=true`, filtra por `is_featured=True`, ordena por `created_at DESC`, aplica `limit` de `HomepageConfig.featured_properties_limit` (fallback 6) — `backend/app/routes/properties.py`
|
||||
- **Done when**: `curl "http://localhost:5000/api/v1/properties?featured=true"` retorna array JSON; quando nenhum imóvel featured, retorna `[]` (não 500); limite máximo respeitado conforme config.
|
||||
|
||||
- [X] T023 [US4] Registrar `homepage_bp` e `properties_bp` no `create_app()` de `backend/app/__init__.py`; importar models de `property` e `homepage` para garantir que Flask-Migrate os detecte — `backend/app/__init__.py`
|
||||
- **Done when**: `flask routes` lista `/api/v1/homepage-config` e `/api/v1/properties`; CORS permite `http://localhost:5173`.
|
||||
|
||||
- [X] T024 [US4] Criar `backend/seeds/seed.py` que apaga e recria: 1 `HomepageConfig` com headline/subheadline/cta configurados e 6 `Property` com `is_featured=True`, tipos variados (venda/aluguel), e pelo menos 1 `PropertyPhoto` por imóvel usando URLs do `picsum.photos` — `backend/seeds/seed.py`
|
||||
- **Done when**: `uv run python seeds/seed.py` executa sem erro; `GET /api/v1/properties?featured=true` retorna exatamente 6 imóveis; cada imóvel tem `photos` com pelo menos 1 entrada.
|
||||
|
||||
- [X] T025 [US4] Criar `backend/tests/conftest.py` com fixture `app` (usando `TestingConfig` com SQLite em memória ou PostgreSQL de teste) e fixture `client` (`app.test_client()`) — `backend/tests/conftest.py`
|
||||
- **Done when**: `uv run pytest --collect-only` descobre conftest sem error; fixture `client` disponível nos testes.
|
||||
|
||||
- [X] T026 [P] [US4] Criar `backend/tests/test_homepage.py` com testes: (1) `GET /api/v1/homepage-config` → `200` com campos obrigatórios; (2) `GET /api/v1/homepage-config` sem registro → `404`; (3) `HomepageConfigIn(hero_headline="")` → `ValidationError` — `backend/tests/test_homepage.py`
|
||||
- **Done when**: `uv run pytest tests/test_homepage.py -v` passa com 3 testes verdes.
|
||||
|
||||
- [X] T027 [P] [US4] Criar `backend/tests/test_properties.py` com testes: (1) `GET /api/v1/properties?featured=true` → `200` com array; (2) resultado contém campos `id`, `title`, `slug`, `price`, `type`, `bedrooms`, `bathrooms`, `area_m2`, `photos`; (3) sem imóveis featured → `200` com `[]` (não 500) — `backend/tests/test_properties.py`
|
||||
- **Done when**: `uv run pytest tests/test_properties.py -v` passa com 3 testes verdes.
|
||||
|
||||
**Checkpoint Phase 3 (US4)**: `uv run pytest` passa; ambos os endpoints retornam JSON válido; seeder popula 6 imóveis.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 1 — Visitante Experimenta o Hero e a Navegação (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Navbar sticky e HeroSection renderizando com conteúdo real da API; fallback silencioso quando API falha; responsivo em todos os breakpoints.
|
||||
|
||||
**Independent Test**: Abrir `http://localhost:5173` com backend rodando — Navbar exibe logo + links; Hero exibe headline da API; CTA redireciona para `/imoveis`; sem backend, FALLBACK_CONFIG é exibido silenciosamente.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T028 | S | T010 | plan.md §3.2, spec.md US1 |
|
||||
| T029 | S | T010 | plan.md §3.2, spec.md US2 |
|
||||
| T030 | S | T028 | plan.md §3.3 |
|
||||
| T031 | S | T030 | plan.md §3.3 |
|
||||
| T032 | S | T030 | plan.md §3.3 |
|
||||
| T033 | M | T008, T009 | plan.md §3.4, spec.md FR-001–003, NFR-004 |
|
||||
| T034 | L | T031, T033 | plan.md §3.4, spec.md FR-004, US1 scenarios 1–5, NFR-006 |
|
||||
| T035 | M | T031, T034 | plan.md §3.5, spec.md US1 scenario 4, edge-cases |
|
||||
| T036 | S | T035 | plan.md §3.6 |
|
||||
| T037 | S | T036 | plan.md §3.6 |
|
||||
| T038 | S | T007 | plan.md §3.4, spec.md FR-011 |
|
||||
|
||||
- [X] T028 [P] [US1] Criar `frontend/src/types/homepage.ts` com interface `HomepageConfig` (hero_headline, hero_subheadline `string | null`, hero_cta_label, hero_cta_url, featured_properties_limit) — `frontend/src/types/homepage.ts`
|
||||
- **Done when**: Arquivo exporta interface sem erro TypeScript; todos os campos opcionais/nullable corretamente tipados.
|
||||
|
||||
- [X] T029 [P] [US1] Criar `frontend/src/types/property.ts` com interfaces `PropertyPhoto` (url, alt_text, display_order) e `Property` (id, title, slug, price `string`, type `'venda' | 'aluguel'`, bedrooms, bathrooms, area_m2, is_featured, photos) — `frontend/src/types/property.ts`
|
||||
- **Done when**: Arquivo exporta ambas as interfaces sem erro TypeScript; `price` é `string` (Decimal serializado do backend).
|
||||
|
||||
- [X] T030 [US1] Criar `frontend/src/services/api.ts` com instância Axios (`baseURL: '/api/v1'`, `timeout: 8000`, header `Content-Type: application/json`) — `frontend/src/services/api.ts`
|
||||
- **Done when**: `import { api } from './api'` compila sem erro; baseURL aponta para `/api/v1`.
|
||||
|
||||
- [X] T031 [US1] Criar `frontend/src/services/homepage.ts` com `getHomepageConfig(): Promise<HomepageConfig>` chamando `api.get<HomepageConfig>('/homepage-config')` — `frontend/src/services/homepage.ts`
|
||||
- **Done when**: Função exportada compila sem erro TypeScript; retorna tipo `Promise<HomepageConfig>`.
|
||||
|
||||
- [X] T032 [US1] Criar `frontend/src/services/properties.ts` com `getFeaturedProperties(): Promise<Property[]>` chamando `api.get<Property[]>('/properties', { params: { featured: 'true' } })` — `frontend/src/services/properties.ts`
|
||||
- **Done when**: Função exportada compila sem erro TypeScript; retorna tipo `Promise<Property[]>`.
|
||||
|
||||
- [X] T033 [US1] Criar `frontend/src/components/Navbar.tsx`: header semântico (`<header role="banner"><nav aria-label="Navegação principal">`), fundo `rgba(8,9,10,0.85)` + `backdrop-blur-navbar` (classe Tailwind), sticky `z-50`, border-bottom `border-white/5`, logo à esquerda, links "Imóveis"/"Sobre"/"Contato" à direita (`text-sm text-text-secondary hover:text-text-primary`), hamburger menu em mobile (<768px) com toggle de estado — `frontend/src/components/Navbar.tsx`
|
||||
- **Done when**: Navbar visível sticky ao scroll; hamburger aparece em `<768px`; links navegáveis por teclado com foco visível (NFR-008); fundo com blur confirmado visualmente.
|
||||
|
||||
- [X] T034 [US1] Criar `frontend/src/components/HeroSection.tsx` com props `{ headline, subheadline, ctaLabel, ctaUrl }`: fundo `#08090a` + gradiente radial `rgba(94,106,210,0.08)`, headline com `text-[72px] tracking-display-xl font-medium` em desktop / `text-[48px]` tablet / `text-[40px]` mobile, subheadline `text-xl text-text-secondary font-light` (não renderizado quando `null`), botão CTA `bg-brand-indigo hover:bg-accent-hover rounded text-white font-semibold transition-colors duration-200` com focus ring, skeleton de 3 linhas animadas quando `isLoading=true` — `frontend/src/components/HeroSection.tsx`
|
||||
- **Done when**: Hero renderiza headline + subheadline da API; `subheadline=null` não insere elemento vazio; tipografia escala nos 3 breakpoints; CTA navega para `ctaUrl`; foco visível no botão (NFR-008); skeleton exibido quando `isLoading`.
|
||||
|
||||
- [X] T035 [US1] Criar `frontend/src/pages/HomePage.tsx` com `FALLBACK_CONFIG` estático, `useState<HomepageConfig>(FALLBACK_CONFIG)`, `useEffect(() => getHomepageConfig().then(setConfig).catch(() => {}), [])`, e renderização de `<Navbar>` + `<HeroSection>` com props do config — `frontend/src/pages/HomePage.tsx`
|
||||
- **Done when**: Página carrega com conteúdo da API após 1 request; com API indisponível, exibe `FALLBACK_CONFIG` silenciosamente (sem erro visível); `isLoading` passado para HeroSection durante fetch.
|
||||
|
||||
- [X] T036 [US1] Criar `frontend/src/App.tsx` com `<BrowserRouter><Routes><Route path="/" element={<HomePage />} /></Routes></BrowserRouter>` — `frontend/src/App.tsx`
|
||||
- **Done when**: `npm run build` compila sem erro; `http://localhost:5173/` renderiza `HomePage`.
|
||||
|
||||
- [X] T037 [US1] Atualizar `frontend/src/main.tsx` para usar `<React.StrictMode><App /></React.StrictMode>` com importação de `./index.css` — `frontend/src/main.tsx`
|
||||
- **Done when**: `npm run dev` não lança erro de StrictMode; CSS global carregado.
|
||||
|
||||
- [X] T038 [US1] Adicionar imagem placeholder `frontend/public/placeholder-property.jpg` (imagem minimalista ≤ 100 KB, 16:9, tema imobiliário ou cinza neutro) — `frontend/public/placeholder-property.jpg`
|
||||
- **Done when**: Arquivo presente em `public/`; acessível em `http://localhost:5173/placeholder-property.jpg`; ≤ 100 KB.
|
||||
|
||||
**Checkpoint Phase 4 (US1)**: Homepage abre com Navbar + Hero; hero exibe dados reais; fallback silencioso funciona; responsivo mobile/tablet/desktop.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 2 — Visitante Explora Imóveis em Destaque (Priority: P1)
|
||||
|
||||
**Goal**: Grade de PropertyCards responsiva (1→2→3 colunas) renderizando dados reais da API, com skeleton durante loading, fallback para grade vazia e placeholder para imóveis sem foto.
|
||||
|
||||
**Independent Test**: Abrir homepage com seeder rodado — grade exibe 6 cards com foto, título, preço formatado em BRL, badge de tipo, quartos/banheiros/área; durante load exibe 3 skeletons; com array vazio exibe mensagem; imóvel sem foto exibe placeholder.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T039 | L | T029, T008 | plan.md §3.4, spec.md FR-007, US2 scenarios 1–4, NFR-007 |
|
||||
| T040 | S | T039 | plan.md §3.4, spec.md edge-cases |
|
||||
| T041 | M | T032, T039, T040 | plan.md §3.4, spec.md FR-006–011, US2, NFR-005 |
|
||||
| T042 | S | T041, T035 | plan.md §3.5 |
|
||||
|
||||
- [X] T039 [US2] Criar `frontend/src/components/PropertyCard.tsx` com props `{ property: Property }`: container `rounded-xl border border-white/5 bg-surface-elevated overflow-hidden cursor-pointer hover:border-white/[0.08] transition-all duration-200`, foto `aspect-[16/9] w-full object-cover rounded-t-xl` (usa `placeholder-property.jpg` quando `photos.length === 0`, `alt={property.title}` NFR-007), badge de tipo pill (`rounded-full text-xs font-medium px-2.5 py-1`, Venda: `bg-brand-indigo/20 text-accent-violet`, Aluguel: `bg-white/5 text-text-secondary border border-white/10`), preço formatado com `Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })` em `text-lg font-medium text-text-primary`, stats (quartos/banheiros/área) com ícone SVG + `text-sm text-text-secondary`, clique navega para `/imoveis/{property.slug}` — `frontend/src/components/PropertyCard.tsx`
|
||||
- **Done when**: Card renderiza todos os campos obrigatórios (FR-007); placeholder exibido quando sem foto (FR-011); preço formatado como `R$ 750.000,00`; navegação para `/imoveis/slug` funciona; alt text presente em todas as imagens (NFR-007).
|
||||
|
||||
- [X] T040 [US2] Criar `frontend/src/components/PropertyCardSkeleton.tsx` com estrutura idêntica ao PropertyCard usando `animate-pulse`, blocos `bg-surface-secondary rounded` no lugar de foto, badge, preço e stats — `frontend/src/components/PropertyCardSkeleton.tsx`
|
||||
- **Done when**: Componente renderiza sem props; altura/largura compatíveis com PropertyCard; animação pulso visível.
|
||||
|
||||
- [X] T041 [US2] Criar `frontend/src/components/FeaturedProperties.tsx` com estados `loading | success | error | empty` gerenciados via `useEffect(() => getFeaturedProperties()...)`: loading → 3 `<PropertyCardSkeleton>`, success com dados → `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6` de `<PropertyCard>`, success sem dados (`[]`) → mensagem "Nenhum imóvel em destaque no momento" centralizada, error → mensagem de fallback sem stack trace — `frontend/src/components/FeaturedProperties.tsx`
|
||||
- **Done when**: 3 skeletons exibidos durante fetch; grid 1→2→3 colunas conforme breakpoints (NFR-005); estado vazio não quebra layout; estado error mostra mensagem amigável (spec edge-case: API indisponível).
|
||||
|
||||
- [X] T042 [US2] Integrar `<FeaturedProperties />` em `frontend/src/pages/HomePage.tsx` após `<HeroSection>` — `frontend/src/pages/HomePage.tsx`
|
||||
- **Done when**: Seção de imóveis em destaque visível abaixo do hero; título da seção "Imóveis em Destaque" presente; dados reais exibidos após fetch.
|
||||
|
||||
**Checkpoint Phase 5 (US2)**: Grade responsiva funcionando com dados reais; skeleton durante loading; placeholder para fotos ausentes; vazio sem erro.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 3 — Visitante Descobre a Agência e Inicia Contato (Priority: P2)
|
||||
|
||||
**Goal**: Seções About, CTA e Footer implementadas com conteúdo estático, visualmente alinhadas ao DESIGN.md.
|
||||
|
||||
**Independent Test**: Rolar a homepage além da grade de imóveis — AboutSection exibe nome + descrição da agência; CTASection exibe convite ao contato com elemento acionável; Footer exibe informações de contato e links de navegação.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T043 | S | T008 | plan.md §3.4, spec.md FR-012, US3 scenario 1 |
|
||||
| T044 | S | T008 | plan.md §3.4, spec.md FR-013, US3 scenario 2 |
|
||||
| T045 | S | T008 | plan.md §3.4, spec.md FR-014, US3 scenario 3, NFR-010 |
|
||||
| T046 | S | T042 | plan.md §3.5 |
|
||||
|
||||
- [ ] T043 [US3] Criar `frontend/src/components/AboutSection.tsx` com fundo `bg-panel-dark`, título "Sobre Nós" `text-3xl font-medium tracking-h1 text-text-primary`, parágrafo de descrição da agência `text-base text-text-secondary leading-relaxed`, padding vertical `py-20 md:py-[80px]`, max-width 1200px centralizado — `frontend/src/components/AboutSection.tsx`
|
||||
- **Done when**: Seção visível ao rolar; nome e descrição presentes; background `#0f1011` confirmado; padding conforme DESIGN.md.
|
||||
|
||||
- [ ] T044 [US3] Criar `frontend/src/components/CTASection.tsx` com fundo `bg-surface-elevated border-t border-white/5`, título de convite ao contato, telefone ou e-mail como elemento acionável (`<a href="tel:..."` ou `<a href="mailto:..."`) estilizado como botão outline ou link proeminente, padding vertical `py-20` — `frontend/src/components/CTASection.tsx`
|
||||
- **Done when**: Elemento acionável presente e clicável; navegável por teclado com foco visível (NFR-008); seção visível antes do rodapé.
|
||||
|
||||
- [ ] T045 [US3] Criar `frontend/src/components/Footer.tsx` com `<footer role="contentinfo">` (NFR-010), fundo `bg-panel-dark border-t border-white/5`, informações de contato (e-mail e/ou telefone), links de navegação "Imóveis"/"Sobre"/"Contato" `text-xs text-text-tertiary hover:text-text-secondary`, copyright com ano atual — `frontend/src/components/Footer.tsx`
|
||||
- **Done when**: Tag semântica `<footer role="contentinfo">` presente; informações de contato exibidas; links navegáveis por teclado; copyright visível.
|
||||
|
||||
- [ ] T046 [US3] Integrar `<AboutSection />`, `<CTASection />` e `<Footer />` em `frontend/src/pages/HomePage.tsx` após `<FeaturedProperties>` — `frontend/src/pages/HomePage.tsx`
|
||||
- **Done when**: Todas as seções visíveis ao rolar a página completa; ordem: Navbar → Hero → FeaturedProperties → AboutSection → CTASection → Footer.
|
||||
|
||||
**Checkpoint Phase 6 (US3)**: Homepage completa visualmente; todas as seções visíveis; estrutura semântica HTML5 correta em toda a página.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Objetivo**: Integração E2E verificada, edge cases cobertos, conformidade visual com DESIGN.md, acessibilidade WCAG 2.1 AA, performance.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T047 | M | T046 | plan.md §4.1, spec.md US4 scenarios 1–2 |
|
||||
| T048 | S | T047 | plan.md §4.2, spec.md edge-cases |
|
||||
| T049 | M | T047 | plan.md §4.2, spec.md edge-cases |
|
||||
| T050 | M | T046 | plan.md §4.3, spec.md NFR-004–006 |
|
||||
| T051 | M | T046 | plan.md §4.3, spec.md NFR-007–010 |
|
||||
| T052 | S | T046 | plan.md §4.3, spec.md NFR-009 |
|
||||
| T053 | M | T047 | plan.md §4.4, spec.md NFR-001–003 |
|
||||
|
||||
- [ ] T047 Verificar integração E2E completa com backend e frontend rodando: hero exibe headline real da API, grade exibe 6 imóveis do seeder, atualizar headline via `PATCH /api/v1/homepage-config` no shell e confirmar que reload da homepage reflete novo texto (FR-005, US4 scenario 1); confirmar que CORS aceita apenas `http://localhost:5173` — nenhum arquivo criado
|
||||
- **Done when**: Todos os dados dinâmicos vêm da API; headline atualizado via API reflete no próximo reload; CORS não aceita origens não configuradas.
|
||||
|
||||
- [ ] T048 [P] Verificar fallback e estados de erro: (1) parar backend e confirmar que FALLBACK_CONFIG é exibido silenciosamente; (2) `FeaturedProperties` com API indisponível exibe mensagem de fallback (não 500); (3) nenhum stack trace visível ao usuário — nenhum arquivo criado
|
||||
- **Done when**: Página renderiza versão degradada sem crash visível; console do browser não lança erros não tratados.
|
||||
|
||||
- [ ] T049 [P] Verificar edge cases: (a) subheadline `null` → HeroSection não renderiza elemento vazio; (b) headline com 120 caracteres → sem overflow no hero; (c) imóvel sem photo → placeholder exibido; (d) featured vazio (`[]`) → mensagem "Nenhum imóvel em destaque" sem colapso de seção — nenhum arquivo criado
|
||||
- **Done when**: Todos os 4 casos verificados visualmente; nenhum layout quebrado.
|
||||
|
||||
- [ ] T050 [P] Verificar responsividade nos breakpoints 320px, 375px, 768px, 1024px, 1280px, 1440px via DevTools: tipografia do hero escala (40px/48px/72px conforme NFR-006), grade 1→2→3 colunas (NFR-005), Navbar hamburger em <768px, nenhum overflow horizontal — nenhum arquivo criado
|
||||
- **Done when**: Todos os 6 breakpoints verificados sem overflow; tipografia e grade conforme NFR-004–006.
|
||||
|
||||
- [ ] T051 [P] Verificar acessibilidade: (a) todas as imagens têm `alt` descritivos (NFR-007); (b) tab order lógico por toda a página; (c) botões/links com foco visível (NFR-008); (d) elementos semânticos `<header>`, `<nav>`, `<main>`, `<footer>` presentes (NFR-010); (e) roles ARIA corretos — nenhum arquivo criado
|
||||
- **Done when**: Axe DevTools (ou similar) sem violações críticas; tab navigation cobre todos os elementos interativos.
|
||||
|
||||
- [ ] T052 Verificar contraste WCAG 2.1 AA: (a) `#f7f8f8` sobre `#08090a` ≥ 4,5:1 ✅; (b) `#d0d6e0` sobre `#08090a` ≥ 4,5:1 ✅; (c) branco sobre `#5e6ad2` (botão CTA) — verificar com tool; (d) cor do badge Aluguel sobre fundo do card — verificar com tool; corrigir para `#828fff` se necessário (plan.md §4.3) — possível ajuste em `frontend/src/components/PropertyCard.tsx`
|
||||
- **Done when**: Todos os pares de cor críticos ≥ 4,5:1 para texto de corpo; ≥ 3:1 para componentes UI (NFR-009).
|
||||
|
||||
- [ ] T053 Executar Lighthouse no modo "Mobile" com throttling 4G: LCP < 2,5s (NFR-001); verificar no DevTools Network que `GET /api/v1/properties?featured=true` < 500ms (NFR-002); confirmar nenhuma imagem do seeder > 300 KB (NFR-003) — possível ajuste em `backend/seeds/seed.py` para usar URLs de imagens otimizadas
|
||||
- **Done when**: Lighthouse LCP < 2,5s; API < 500ms em máquina local; imagens do seeder de `picsum.photos` com dimensões adequadas (max 300 KB cada).
|
||||
|
||||
**Checkpoint Phase 7**: Integração E2E completa; edge cases tratados; WCAG 2.1 AA ok; responsividade verificada; performance dentro das metas.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
```
|
||||
Phase 1 (Setup)
|
||||
└── Phase 2 (Foundational — BLOQUEIA tudo)
|
||||
├── Phase 3 (US4 — Backend API)
|
||||
│ └── Phase 4 (US1 — Hero/Nav) *requires T031
|
||||
│ └── Phase 5 (US2 — Featured Properties) *requires T032
|
||||
└── Phase 4 pode iniciar em paralelo com Phase 3
|
||||
(frontend não depende do backend, só de tipos e services)
|
||||
|
||||
Phase 4 ── completa ──┐
|
||||
Phase 5 ── completa ──┼── Phase 6 (US3 — About/CTA/Footer)
|
||||
└── Phase 7 (Polish — requer Phases 3–6)
|
||||
```
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US4 (Phase 3)**: Pode iniciar após Phase 2. Independente dos outros US.
|
||||
- **US1 (Phase 4)**: Pode iniciar após Phase 2 (frontend não precisa do backend rodando para ser codado). Requer backend para teste E2E.
|
||||
- **US2 (Phase 5)**: Pode iniciar após Phase 4 (depende de `PropertyCard` e `FeaturedProperties`). Independente de US3.
|
||||
- **US3 (Phase 6)**: Pode iniciar após Phase 5 ou em paralelo (componentes independentes). Independente de US1/US2 no código.
|
||||
- **Polish (Phase 7)**: Requer todas as fases anteriores completas.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Types/interfaces → Services → Components → Page integration
|
||||
- Models → Schemas → Routes → seed/tests (backend)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Paralelo possível na Phase 1
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Backend
|
||||
cd backend
|
||||
uv sync
|
||||
uv run flask --app app shell
|
||||
|
||||
# Terminal 2 — Frontend (independente)
|
||||
npm create vite@latest frontend -- --template react-ts
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Paralelo possível na Phase 3 (US4)
|
||||
|
||||
```bash
|
||||
# T015 e T016 podem ser escritos por pessoas diferentes simultaneamente
|
||||
# (arquivos diferentes, sem dependência entre si)
|
||||
# T017 depende de T015; T018 depende de T016
|
||||
# T026 e T027 podem ser escritos em paralelo após T025
|
||||
```
|
||||
|
||||
### Paralelo possível nas Phases 4–6
|
||||
|
||||
```bash
|
||||
# Após Phase 2 concluída:
|
||||
# Phase 4 (US1) e Phase 3 (US4) podem rodar em paralelo
|
||||
# (frontend e backend são totalmente desacoplados até o teste E2E)
|
||||
|
||||
# Dentro da Phase 4:
|
||||
# T028 (types/homepage) e T029 (types/property) são paralelos
|
||||
# T033 (Navbar) e T034 (HeroSection) são paralelos após T030/T031
|
||||
|
||||
# Dentro da Phase 6:
|
||||
# T043, T044, T045 são paralelos (arquivos independentes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP Scope (entregável mínimo para validação)
|
||||
|
||||
Completar apenas as fases de **P1** para ter uma homepage funcional:
|
||||
|
||||
1. ✅ Phase 1 (Setup)
|
||||
2. ✅ Phase 2 (Foundational)
|
||||
3. ✅ Phase 3 (US4 — API backend)
|
||||
4. ✅ Phase 4 (US1 — Hero + Nav)
|
||||
5. ✅ Phase 5 (US2 — Featured Properties)
|
||||
6. ⏭️ Phase 6 (US3 — P2, pode ser adicionado depois)
|
||||
7. ⏭️ Phase 7 (Polish — verificação final antes de deploy)
|
||||
|
||||
O MVP entrega: homepage com hero dinâmico, grade de imóveis em destaque, responsividade, fallback silencioso.
|
||||
|
||||
### Incremental Delivery Order
|
||||
|
||||
```
|
||||
Sprint 1: T001–T014 (Setup + Foundational)
|
||||
Sprint 2: T015–T027 (Backend completo com testes)
|
||||
Sprint 3: T028–T038 (Frontend: tipos, services, Navbar, Hero)
|
||||
Sprint 4: T039–T042 (Frontend: PropertyCard, FeaturedProperties)
|
||||
Sprint 5: T043–T046 (Frontend: About, CTA, Footer)
|
||||
Sprint 6: T047–T053 (Polish, integração, QA)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Métrica | Valor |
|
||||
|---------|-------|
|
||||
| **Total de tarefas** | 53 |
|
||||
| **US1 (Hero/Nav)** | 11 tarefas (T028–T038) |
|
||||
| **US2 (Featured Properties)** | 4 tarefas (T039–T042) |
|
||||
| **US3 (About/CTA/Footer)** | 4 tarefas (T043–T046) |
|
||||
| **US4 (Backend API)** | 13 tarefas (T015–T027) |
|
||||
| **Setup + Foundational** | 14 tarefas (T001–T014) |
|
||||
| **Polish** | 7 tarefas (T047–T053) |
|
||||
| **Tarefas paralelizáveis [P]** | 22 |
|
||||
| **Complexidade S** | 26 |
|
||||
| **Complexidade M** | 19 |
|
||||
| **Complexidade L** | 2 (T034, T039) |
|
||||
|
||||
### Format Validation
|
||||
|
||||
Todas as 53 tarefas seguem o formato obrigatório:
|
||||
- ✅ Checkbox `- [ ]`
|
||||
- ✅ Task ID sequencial (`T001`–`T053`)
|
||||
- ✅ Marker `[P]` onde aplicável
|
||||
- ✅ Label `[USN]` em todas as tarefas de user story
|
||||
- ✅ Caminhos de arquivo explícitos em cada tarefa
|
||||
- ✅ Critério "Done when" em cada tarefa
|
||||
114
.specify/features/002-docker-setup/plan.md
Normal file
114
.specify/features/002-docker-setup/plan.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Implementation Plan: Docker Setup
|
||||
|
||||
**Branch**: `002-docker-setup`
|
||||
**Spec**: `.specify/features/002-docker-setup/spec.md`
|
||||
**Created**: 2026-04-13
|
||||
|
||||
## Constitution Check
|
||||
- ✅ Design-First: não se aplica (infra)
|
||||
- ✅ Separation of Concerns: Flask e React permanecem desacoplados, Docker apenas orquestra
|
||||
- ✅ Spec-Driven: este plano deriva da spec aprovada
|
||||
- ✅ Security: nenhuma secret hardcoded; `.env` no gitignore
|
||||
- ✅ Simplicity: sem multi-stage build, sem nginx — foco em dev environment
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend Dockerfile
|
||||
|
||||
**Objetivo**: Containerizar o Flask app usando python:3.12-slim e uv.
|
||||
|
||||
**Arquivos**:
|
||||
- `backend/Dockerfile`
|
||||
|
||||
**Detalhes**:
|
||||
- Base: `python:3.12-slim`
|
||||
- Instalar `uv` via pip
|
||||
- Copiar `pyproject.toml` → `uv sync` → copiar código
|
||||
- Entrypoint: script shell que executa `flask db upgrade && python seeds/seed.py && python run.py`
|
||||
- Porta exposta: 5000
|
||||
|
||||
**Critério de conclusão**: `docker build -t backend ./backend` conclui sem erro.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Frontend Dockerfile
|
||||
|
||||
**Objetivo**: Containerizar o servidor de desenvolvimento Vite com hot-reload.
|
||||
|
||||
**Arquivos**:
|
||||
- `frontend/Dockerfile`
|
||||
|
||||
**Detalhes**:
|
||||
- Base: `node:20-alpine`
|
||||
- Copiar `package*.json` → `npm ci` → copiar código
|
||||
- CMD: `npm run dev -- --host 0.0.0.0` (necessário para expor dentro do container)
|
||||
- Porta exposta: 5173
|
||||
- O `vite.config.ts` já tem proxy `/api → http://localhost:5000`; atualizar para `http://backend:5000` (nome do serviço Docker)
|
||||
|
||||
**Critério de conclusão**: `docker build -t frontend ./frontend` conclui sem erro.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — docker-compose.yml
|
||||
|
||||
**Objetivo**: Orquestrar db + backend + frontend com dependências e healthchecks.
|
||||
|
||||
**Arquivos**:
|
||||
- `docker-compose.yml` (raiz)
|
||||
- `.env.example` (atualizar/criar na raiz)
|
||||
|
||||
**Serviços**:
|
||||
```yaml
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
healthcheck: pg_isready -U ${POSTGRES_USER}
|
||||
depends_on: (nenhum)
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
depends_on: [backend]
|
||||
volumes:
|
||||
- ./frontend/src:/app/src ← hot-reload
|
||||
- ./frontend/public:/app/public
|
||||
```
|
||||
|
||||
**Critério de conclusão**: `docker-compose config` valida sem erro; `docker-compose up` sobe os 3 serviços.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — start.ps1
|
||||
|
||||
**Objetivo**: Script PowerShell de conveniência na raiz.
|
||||
|
||||
**Arquivos**:
|
||||
- `start.ps1` (raiz)
|
||||
|
||||
**Lógica**:
|
||||
1. Verificar se `docker` está disponível no PATH
|
||||
2. Se não: exibir mensagem com link para Docker Desktop e sair
|
||||
3. Se sim: executar `docker-compose up --build`
|
||||
|
||||
**Critério de conclusão**: `.\start.ps1` inicia o ambiente ou exibe erro informativo.
|
||||
|
||||
---
|
||||
|
||||
## Sequência de execução
|
||||
|
||||
```
|
||||
Phase 1 (backend/Dockerfile)
|
||||
↓
|
||||
Phase 2 (frontend/Dockerfile + vite.config proxy update)
|
||||
↓
|
||||
Phase 3 (docker-compose.yml + .env.example)
|
||||
↓
|
||||
Phase 4 (start.ps1)
|
||||
↓
|
||||
Validação: docker-compose up --build → testar endpoints
|
||||
```
|
||||
93
.specify/features/002-docker-setup/spec.md
Normal file
93
.specify/features/002-docker-setup/spec.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Feature Specification: Docker Setup
|
||||
|
||||
**Feature Branch**: `002-docker-setup`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Approved
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 — Desenvolvedor sobe todo o ambiente com um comando (Priority: P1)
|
||||
|
||||
Um desenvolvedor que acabou de clonar o repositório deve conseguir subir PostgreSQL, backend Flask e frontend React rodando juntos com um único comando, sem instalar Python ou Node localmente.
|
||||
|
||||
**Why this priority**: É o bloqueio zero de onboarding. Sem isso, cada dev configura manualmente, gerando ambientes inconsistentes.
|
||||
|
||||
**Independent Test**: Executar `docker-compose up --build` na raiz e acessar `http://localhost:5173` deve exibir a homepage carregando dados do backend.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o repositório recém-clonado com Docker instalado, **When** o dev roda `docker-compose up --build`, **Then** os três serviços (db, backend, frontend) sobem sem erro e ficam acessíveis.
|
||||
2. **Given** o ambiente rodando, **When** o dev acessa `http://localhost:5173`, **Then** a homepage é renderizada com dados reais do banco.
|
||||
3. **Given** o ambiente rodando, **When** o dev acessa `http://localhost:5000/api/v1/homepage-config`, **Then** a API retorna JSON 200 com os dados seedados.
|
||||
4. **Given** o ambiente rodando, **When** o dev faz uma alteração num arquivo `.tsx`, **Then** o Vite hot-reload atualiza o browser sem reiniciar o container.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Script de conveniência na raiz (Priority: P1)
|
||||
|
||||
Um desenvolvedor Windows deve poder rodar `.\start.ps1` na raiz para ter o ambiente de um clique, sem memorizar comandos Docker.
|
||||
|
||||
**Why this priority**: Reduz fricção para devs Windows que são o público principal.
|
||||
|
||||
**Independent Test**: `.\start.ps1` executa `docker-compose up --build` e exibe mensagens de status claras.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** Docker Desktop instalado, **When** o dev roda `.\start.ps1`, **Then** o script valida que Docker está rodando e executa o compose.
|
||||
2. **Given** Docker não instalado, **When** o dev roda `.\start.ps1`, **Then** o script exibe mensagem clara pedindo para instalar Docker Desktop.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece se a porta 5432/5000/5173 já estiver em uso? → `docker-compose` retorna erro; o script deve informar.
|
||||
- O que acontece se o banco ainda não estiver pronto quando o backend inicializar? → `depends_on` com `healthcheck`; backend deve aguardar ou fazer retry.
|
||||
- Como rodar as migrations no primeiro boot? → Entrypoint do backend executa `flask db upgrade` antes de `python run.py`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- FR1: `docker-compose up --build` na raiz sobe 3 serviços: `db` (PostgreSQL 16), `backend` (Flask), `frontend` (React/Vite)
|
||||
- FR2: Backend aguarda PostgreSQL estar saudável antes de inicializar (healthcheck)
|
||||
- FR3: Backend executa `flask db upgrade` + seeder automaticamente no primeiro boot
|
||||
- FR4: Frontend tem hot-reload via bind mount do código-fonte
|
||||
- FR5: Variáveis de ambiente devem estar em `.env` na raiz (não hardcoded no compose)
|
||||
- FR6: `.\start.ps1` na raiz é o ponto de entrada único para Windows
|
||||
|
||||
### Non-Functional
|
||||
- NFR1: `docker-compose up --build` deve completar em menos de 5 min com cache
|
||||
- NFR2: Imagens devem usar variantes slim/alpine para minimizar tamanho
|
||||
- NFR3: Nenhuma secret hardcoded nas imagens — tudo via env vars
|
||||
- NFR4: `.env` não deve ser commitado; `.env.example` deve existir
|
||||
|
||||
## Design / Architecture
|
||||
|
||||
```
|
||||
raiz/
|
||||
├── docker-compose.yml ← orchestração dos 3 serviços
|
||||
├── .env.example ← template de variáveis (db, secret key)
|
||||
├── .env ← valores locais (gitignored)
|
||||
├── start.ps1 ← script Windows de conveniência
|
||||
├── backend/
|
||||
│ └── Dockerfile ← python:3.12-slim, uv, flask
|
||||
└── frontend/
|
||||
└── Dockerfile ← node:20-alpine, vite dev server
|
||||
```
|
||||
|
||||
**Serviços docker-compose:**
|
||||
- `db`: postgres:16-alpine, volume persistente, healthcheck
|
||||
- `backend`: build ./backend, porta 5000, DATABASE_URL apontando para `db`
|
||||
- `frontend`: build ./frontend, porta 5173, bind mount src/ para hot-reload, proxy vite → backend
|
||||
|
||||
## Out of Scope
|
||||
- Build de produção (multi-stage, nginx servindo static) — feature futura
|
||||
- CI/CD pipeline
|
||||
- Deploy em cloud
|
||||
- HTTPS/TLS local
|
||||
|
||||
## Success Criteria
|
||||
1. `docker-compose up --build` funciona do zero em máquina limpa
|
||||
2. `http://localhost:5173` carrega homepage com dados reais
|
||||
3. `http://localhost:5000/api/v1/properties?featured=true` retorna array JSON com imóveis seedados
|
||||
4. Alteração em `.tsx` reflete no browser sem restart do container
|
||||
5. `.env` está no `.gitignore`; `.env.example` documenta todas as variáveis necessárias
|
||||
109
.specify/features/002-docker-setup/tasks.md
Normal file
109
.specify/features/002-docker-setup/tasks.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Tasks: Docker Setup
|
||||
|
||||
**Branch**: `002-docker-setup`
|
||||
**Plan**: `.specify/features/002-docker-setup/plan.md`
|
||||
**Created**: 2026-04-13
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend Dockerfile
|
||||
|
||||
### T001 — Criar backend/Dockerfile
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: nenhuma
|
||||
- **Spec ref**: FR1, FR3
|
||||
- **Files**: `backend/Dockerfile`, `backend/entrypoint.sh`
|
||||
- **Done when**: `docker build -t imob-backend ./backend` conclui sem erro
|
||||
|
||||
### T002 — Criar entrypoint.sh com migrations + seed + server
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: T001
|
||||
- **Spec ref**: FR3
|
||||
- **Files**: `backend/entrypoint.sh`
|
||||
- **Done when**: Script executa `flask db upgrade`, seeder, e sobe Flask na porta 5000
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Frontend Dockerfile
|
||||
|
||||
### T003 — Criar frontend/Dockerfile
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: nenhuma [P] paralelo com T001
|
||||
- **Spec ref**: FR1, FR4
|
||||
- **Files**: `frontend/Dockerfile`
|
||||
- **Done when**: `docker build -t imob-frontend ./frontend` conclui sem erro
|
||||
|
||||
### T004 — Atualizar vite.config.ts para proxy Docker
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: T003
|
||||
- **Spec ref**: FR1
|
||||
- **Files**: `frontend/vite.config.ts`
|
||||
- **Done when**: Proxy aponta para `http://backend:5000` (nome do serviço Docker); local dev ainda funciona via variável de ambiente
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Orquestração
|
||||
|
||||
### T005 — Criar docker-compose.yml
|
||||
- **Complexity**: M
|
||||
- **Dependencies**: T001, T003
|
||||
- **Spec ref**: FR1, FR2, FR4, FR5
|
||||
- **Files**: `docker-compose.yml`
|
||||
- **Done when**: `docker-compose config` valida; 3 serviços definidos com healthcheck no db
|
||||
|
||||
### T006 — Criar .env.example na raiz
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: T005
|
||||
- **Spec ref**: FR5, NFR4
|
||||
- **Files**: `.env.example` (raiz)
|
||||
- **Done when**: Todas as variáveis usadas no compose estão documentadas com valores de exemplo
|
||||
|
||||
### T007 — Verificar .gitignore cobre .env
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: T006
|
||||
- **Spec ref**: NFR3, NFR4
|
||||
- **Files**: `.gitignore`
|
||||
- **Done when**: `.env` está no .gitignore e não aparece em `git status`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Script de conveniência
|
||||
|
||||
### T008 — Criar start.ps1 na raiz
|
||||
- **Complexity**: S
|
||||
- **Dependencies**: T005
|
||||
- **Spec ref**: FR6
|
||||
- **Files**: `start.ps1`
|
||||
- **Done when**: `.\start.ps1` valida Docker e executa compose; exibe erro claro se Docker não estiver instalado
|
||||
|
||||
---
|
||||
|
||||
## Validação
|
||||
|
||||
### T009 — Testar docker-compose up --build end-to-end
|
||||
- **Complexity**: M
|
||||
- **Dependencies**: T005, T008
|
||||
- **Spec ref**: Todos os Acceptance Scenarios
|
||||
- **Done when**:
|
||||
- `http://localhost:5173` carrega homepage
|
||||
- `http://localhost:5000/api/v1/homepage-config` retorna 200
|
||||
- `http://localhost:5000/api/v1/properties?featured=true` retorna array
|
||||
- Editar um `.tsx` reflete no browser via hot-reload
|
||||
|
||||
---
|
||||
|
||||
## Resumo
|
||||
|
||||
| Task | Fase | Complexidade | Paralelo? |
|
||||
|------|------|-------------|-----------|
|
||||
| T001 | Backend Dockerfile | S | - |
|
||||
| T002 | entrypoint.sh | S | - |
|
||||
| T003 | Frontend Dockerfile | S | ✅ com T001 |
|
||||
| T004 | vite proxy Docker | S | - |
|
||||
| T005 | docker-compose.yml | M | - |
|
||||
| T006 | .env.example raiz | S | - |
|
||||
| T007 | .gitignore check | S | - |
|
||||
| T008 | start.ps1 | S | - |
|
||||
| T009 | Validação E2E | M | - |
|
||||
|
||||
**MVP**: T001–T008 = ambiente Docker funcional.
|
||||
108
.specify/features/003-property-listing/spec.md
Normal file
108
.specify/features/003-property-listing/spec.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Feature Specification: Listagem de Imóveis com Filtros
|
||||
|
||||
**Feature Branch**: `003-property-listing`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Approved
|
||||
|
||||
## Contexto
|
||||
|
||||
Página pública `/imoveis` onde visitantes navegam e filtram o catálogo completo de imóveis. Os filtros utilizam dados dinâmicos cadastrados pela área administrativa (tipos, características, lazer, condomínio, segurança).
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Visitante navega no catálogo (P1)
|
||||
**Given** o visitante acessa `/imoveis`, **When** a página carrega, **Then** vê a grade de imóveis ativos com paginação, foto, preço, tipo, dormitórios, banheiros, área e vagas.
|
||||
|
||||
### US2 — Visitante filtra por tipo de imóvel (P1)
|
||||
**Given** o painel de filtros está visível, **When** seleciona categoria "Residencial" e subtipo "Apartamento", **Then** a lista atualiza mostrando apenas apartamentos residenciais.
|
||||
|
||||
### US3 — Visitante filtra por preço (P1)
|
||||
**Given** o filtro de preço está disponível, **When** define min R$ 500.000 e max R$ 1.500.000, **Then** apenas imóveis nesse intervalo aparecem. Com a opção "incluir condomínio" marcada, o backend soma `price + condo_fee` antes de comparar.
|
||||
|
||||
### US4 — Visitante filtra por quartos/banheiros/vagas (P1)
|
||||
**Given** os sliders numéricos estão disponíveis, **When** define mínimo 2 quartos, **Then** apenas imóveis com ≥ 2 quartos aparecem.
|
||||
|
||||
### US5 — Visitante filtra por área (P1)
|
||||
**Given** o filtro de área em m² está disponível, **When** define 50–150 m², **Then** apenas imóveis nesse intervalo aparecem.
|
||||
|
||||
### US6 — Visitante filtra por características (P2)
|
||||
**Given** a lista de características está visível (Aceita animais, Ar-condicionado, etc.), **When** seleciona múltiplas, **Then** apenas imóveis que possuem TODAS as características selecionadas aparecem.
|
||||
|
||||
### US7 — Visitante filtra lazer, condomínio e segurança (P2)
|
||||
Mesmo comportamento do US6 para os grupos adicionais.
|
||||
|
||||
## API Contract
|
||||
|
||||
### Endpoints necessários
|
||||
|
||||
**GET /api/v1/property-types** → lista hierárquica de categorias e subtipos
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1, "name": "Residencial", "slug": "residencial",
|
||||
"subtypes": [{"id": 10, "name": "Apartamento", "slug": "apartamento"}, ...]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**GET /api/v1/amenities** → lista agrupada de amenidades
|
||||
```json
|
||||
[
|
||||
{"id": 1, "name": "Aceita animais", "group": "caracteristica", "count": 37},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**GET /api/v1/properties** (atualizar) — aceitar query params:
|
||||
| Param | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| `subtype_id` | int | ID do subtipo |
|
||||
| `listing_type` | venda\|aluguel | tipo da transação |
|
||||
| `price_min` | number | preço mínimo |
|
||||
| `price_max` | number | preço máximo |
|
||||
| `include_condo` | bool | somar condomínio ao preço |
|
||||
| `bedrooms_min` | int | quartos mínimo |
|
||||
| `bedrooms_max` | int | quartos máximo |
|
||||
| `bathrooms_min` | int | banheiros mínimo |
|
||||
| `bathrooms_max` | int | banheiros máximo |
|
||||
| `parking_min` | int | vagas mínimo |
|
||||
| `parking_max` | int | vagas máximo |
|
||||
| `area_min` | int | área m² mínimo |
|
||||
| `area_max` | int | área m² máximo |
|
||||
| `amenity_ids` | int[] (CSV) | IDs de amenidades (AND) |
|
||||
| `page` | int | página (default 1) |
|
||||
| `per_page` | int | itens por página (default 24, max 48) |
|
||||
| `featured` | bool | retorna destaques (comportamento existente) |
|
||||
|
||||
Resposta paginada:
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"total": 120,
|
||||
"page": 1,
|
||||
"per_page": 24,
|
||||
"pages": 5
|
||||
}
|
||||
```
|
||||
|
||||
## Modelos necessários
|
||||
|
||||
### PropertyType (novo)
|
||||
- `id`, `name`, `slug`, `parent_id` (nullable → hierarquia)
|
||||
|
||||
### Amenity (novo)
|
||||
- `id`, `name`, `slug`, `group` (enum: caracteristica | lazer | condominio | seguranca), `count` (denormalizado)
|
||||
|
||||
### PropertyAmenity (nova tabela N:N)
|
||||
- `property_id`, `amenity_id`
|
||||
|
||||
### Atualizar Property (novo campo)
|
||||
- `subtype_id` FK → PropertyType
|
||||
- `parking_spots` INT
|
||||
- `condo_fee` NUMERIC(10,2) nullable
|
||||
|
||||
## Out of Scope
|
||||
- Detalhe do imóvel (feature 004)
|
||||
- Mapa/geolocalização
|
||||
- Ordenação customizada (entregue com default created_at desc)
|
||||
- Favoritos / comparador
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Specification Quality Checklist: Página de Detalhe do Imóvel
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Spec validated in a single pass — all items passed without requiring iteration.
|
||||
- API contract section included for developer reference; kept technology-agnostic in requirement framing.
|
||||
- IPTU explicitly out of scope (field not modeled in Property).
|
||||
- Rate limiting and e-mail notifications deferred to future features per Simplicity First principle (Constitution §VI).
|
||||
189
.specify/features/004-property-detail-page/contracts/rest.md
Normal file
189
.specify/features/004-property-detail-page/contracts/rest.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# REST API Contracts: Property Detail Page (004)
|
||||
|
||||
**Feature**: `004-property-detail-page`
|
||||
**Date**: 2026-04-13
|
||||
**Base URL**: `/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## GET /properties/{slug}
|
||||
|
||||
Retorna o detalhe completo de um imóvel ativo pelo slug.
|
||||
|
||||
### Request
|
||||
|
||||
```
|
||||
GET /api/v1/properties/{slug}
|
||||
Content-Type: application/json
|
||||
Auth: não requerida
|
||||
```
|
||||
|
||||
**Path parameters**:
|
||||
| Param | Tipo | Descrição |
|
||||
|-------|------|-----------|
|
||||
| `slug` | string `[a-z0-9-]+` | Slug único do imóvel |
|
||||
|
||||
### Response 200 OK
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Apartamento 3 quartos no Centro",
|
||||
"slug": "apartamento-3-quartos-centro-123",
|
||||
"code": "AP-00042",
|
||||
"description": "Excelente apartamento com vista para o jardim...",
|
||||
"address": "Rua das Flores, 100, Centro",
|
||||
"price": "850000.00",
|
||||
"condo_fee": "650.00",
|
||||
"type": "venda",
|
||||
"bedrooms": 3,
|
||||
"bathrooms": 2,
|
||||
"parking_spots": 2,
|
||||
"area_m2": 95,
|
||||
"is_featured": true,
|
||||
"subtype": { "id": 10, "name": "Apartamento", "slug": "apartamento" },
|
||||
"city": { "id": 5, "name": "Franca", "slug": "franca", "state": "SP" },
|
||||
"neighborhood": { "id": 12, "name": "Jardim São Luiz", "slug": "jardim-sao-luiz" },
|
||||
"photos": [
|
||||
{ "url": "https://...", "alt_text": "Sala de estar", "display_order": 0 },
|
||||
{ "url": "https://...", "alt_text": "Quarto principal", "display_order": 1 }
|
||||
],
|
||||
"amenities": [
|
||||
{ "id": 3, "name": "Aceita animais", "slug": "aceita-animais", "group": "caracteristica" },
|
||||
{ "id": 7, "name": "Piscina", "slug": "piscina", "group": "lazer" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Notas do schema**:
|
||||
- `price` e `condo_fee` são strings de decimal (ex: `"850000.00"`) — use `parseFloat()` no frontend
|
||||
- `type` é o campo canônico (equivale a `listing_type` na documentação da spec)
|
||||
- `photos` ordenadas por `display_order` ASC (índice 0 = foto principal do carrossel)
|
||||
- `code` e `description` são `null` se não preenchidos no cadastro
|
||||
- `address` é `null` se não preenchido
|
||||
|
||||
### Response 404 Not Found
|
||||
|
||||
Imóvel inexistente **ou** `is_active = false`.
|
||||
|
||||
```json
|
||||
{ "error": "Imóvel não encontrado" }
|
||||
```
|
||||
|
||||
> Retornar 404 (não 403) para imóveis inativos evita vazar informação sobre existência.
|
||||
|
||||
---
|
||||
|
||||
## POST /properties/{slug}/contact
|
||||
|
||||
Registra um lead de contato vinculado ao imóvel identificado pelo slug.
|
||||
|
||||
### Request
|
||||
|
||||
```
|
||||
POST /api/v1/properties/{slug}/contact
|
||||
Content-Type: application/json
|
||||
Auth: não requerida
|
||||
```
|
||||
|
||||
**Path parameters**:
|
||||
| Param | Tipo | Descrição |
|
||||
|-------|------|-----------|
|
||||
| `slug` | string | Slug do imóvel para o qual o contato é enviado |
|
||||
|
||||
**Request body**:
|
||||
```json
|
||||
{
|
||||
"name": "João Silva",
|
||||
"email": "joao@email.com",
|
||||
"phone": "(16) 99999-0000",
|
||||
"message": "Tenho interesse no imóvel, gostaria de agendar uma visita."
|
||||
}
|
||||
```
|
||||
|
||||
**Campo** | **Tipo** | **Obrigatório** | **Restrições**
|
||||
----------|----------|-----------------|---------------
|
||||
`name` | string | sim | 2–150 caracteres
|
||||
`email` | string | sim | formato e-mail válido (RFC 5322)
|
||||
`phone` | string | não | máximo 20 caracteres
|
||||
`message` | string | sim | 10–2000 caracteres
|
||||
|
||||
### Response 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 88,
|
||||
"message": "Mensagem enviada com sucesso!"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 404 Not Found
|
||||
|
||||
Slug não encontrado ou imóvel inativo.
|
||||
|
||||
```json
|
||||
{ "error": "Imóvel não encontrado" }
|
||||
```
|
||||
|
||||
### Response 422 Unprocessable Entity
|
||||
|
||||
Falha de validação Pydantic.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos",
|
||||
"details": {
|
||||
"email": ["E-mail inválido"],
|
||||
"message": ["Campo obrigatório"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamentos Implícitos
|
||||
|
||||
| Situação | Comportamento |
|
||||
|----------|--------------|
|
||||
| `slug` com caracteres inválidos (maiúsculas, /, etc.) | 404 (SQLAlchemy não encontra correspondência) |
|
||||
| `property_id` na tabela `contact_leads` quando imóvel é deletado | SET NULL (lead preservado) |
|
||||
| Duplo envio de formulário | Dois registros criados (UI bloqueia durante `submitting`) |
|
||||
| `photos` vazia | Array `[]` retornado; frontend exibe placeholder |
|
||||
| `amenities` vazia | Array `[]` retornado; frontend omite seção de amenidades |
|
||||
|
||||
---
|
||||
|
||||
## Integração Frontend
|
||||
|
||||
### `getProperty(slug: string): Promise<PropertyDetail>`
|
||||
|
||||
```typescript
|
||||
// services/properties.ts
|
||||
export async function getProperty(slug: string): Promise<PropertyDetail> {
|
||||
const response = await api.get<PropertyDetail>(`/properties/${slug}`)
|
||||
return response.data
|
||||
}
|
||||
```
|
||||
|
||||
- `404` → Axios lança `AxiosError` com `status 404` → frontend exibe estado "não encontrado"
|
||||
- Outros erros → propagar para tratamento genérico
|
||||
|
||||
### `submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number; message: string }>`
|
||||
|
||||
```typescript
|
||||
// services/properties.ts
|
||||
export async function submitContactForm(
|
||||
slug: string,
|
||||
data: ContactFormData,
|
||||
): Promise<{ id: number; message: string }> {
|
||||
const response = await api.post<{ id: number; message: string }>(
|
||||
`/properties/${slug}/contact`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
```
|
||||
|
||||
- `201` → retorna `{ id, message }`
|
||||
- `422` → `AxiosError` com `response.data.details` disponível para mapear erros por campo
|
||||
- `5xx` → exibir mensagem genérica sem apagar dados do formulário
|
||||
181
.specify/features/004-property-detail-page/data-model.md
Normal file
181
.specify/features/004-property-detail-page/data-model.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# Data Model: Property Detail Page (004)
|
||||
|
||||
**Feature**: `004-property-detail-page`
|
||||
**Date**: 2026-04-13
|
||||
|
||||
---
|
||||
|
||||
## Entidades Modificadas
|
||||
|
||||
### Property (existente — colunas adicionadas)
|
||||
|
||||
| Campo | Tipo SQL | Tipo Python | Nullable | Notas |
|
||||
|-------|----------|-------------|----------|-------|
|
||||
| `id` | UUID PK | `UUID` | não | existente |
|
||||
| `title` | VARCHAR(200) | `str` | não | existente |
|
||||
| `slug` | VARCHAR(220) UNIQUE | `str` | não | existente |
|
||||
| **`code`** | **VARCHAR(30) UNIQUE** | **`str \| None`** | **sim** | **novo** — ex: `"AP-00042"` |
|
||||
| **`description`** | **TEXT** | **`str \| None`** | **sim** | **novo** — descrição narrativa |
|
||||
| `address` | VARCHAR(300) | `str \| None` | sim | existente |
|
||||
| `price` | NUMERIC(12,2) | `Decimal` | não | existente |
|
||||
| `condo_fee` | NUMERIC(10,2) | `Decimal \| None` | sim | existente |
|
||||
| `type` | ENUM(venda,aluguel) | `Literal["venda","aluguel"]` | não | existente |
|
||||
| `subtype_id` | INT FK → property_types | `int \| None` | sim | existente |
|
||||
| `bedrooms` | INT | `int` | não | existente |
|
||||
| `bathrooms` | INT | `int` | não | existente |
|
||||
| `parking_spots` | INT | `int` | não | existente |
|
||||
| `area_m2` | INT | `int` | não | existente |
|
||||
| `city_id` | INT FK → cities | `int \| None` | sim | existente |
|
||||
| `neighborhood_id` | INT FK → neighborhoods | `int \| None` | sim | existente |
|
||||
| `is_featured` | BOOLEAN | `bool` | não | existente |
|
||||
| `is_active` | BOOLEAN | `bool` | não | existente |
|
||||
| `created_at` | TIMESTAMP | `datetime` | não | existente |
|
||||
|
||||
**Relacionamentos existentes** (sem mudança):
|
||||
- `photos`: 1:M → `PropertyPhoto` (cascade delete-orphan, order by `display_order`)
|
||||
- `subtype`: M:1 → `PropertyType` (joined)
|
||||
- `city`: M:1 → `City` (joined)
|
||||
- `neighborhood`: M:1 → `Neighborhood` (joined)
|
||||
- `amenities`: M:M via `property_amenity`
|
||||
|
||||
---
|
||||
|
||||
## Entidades Novas
|
||||
|
||||
### ContactLead (novo)
|
||||
|
||||
Registra cada solicitação de contato feita por um visitante em relação a um imóvel.
|
||||
|
||||
| Campo | Tipo SQL | Tipo Python | Nullable | Restrições |
|
||||
|-------|----------|-------------|----------|------------|
|
||||
| `id` | SERIAL PK | `int` | não | autoincrement |
|
||||
| `property_id` | UUID FK → properties | `UUID \| None` | **sim** | ON DELETE SET NULL |
|
||||
| `name` | VARCHAR(150) | `str` | não | |
|
||||
| `email` | VARCHAR(254) | `str` | não | |
|
||||
| `phone` | VARCHAR(20) | `str \| None` | sim | |
|
||||
| `message` | TEXT | `str` | não | |
|
||||
| `created_at` | TIMESTAMP WITH TIME ZONE | `datetime` | não | server_default=NOW() |
|
||||
|
||||
**Índices**:
|
||||
- `ix_contact_leads_property_id` em `property_id` (consultas futuras de admin)
|
||||
- `ix_contact_leads_created_at` em `created_at` (ordenação)
|
||||
|
||||
**Relacionamento**:
|
||||
- `ContactLead.property` → `Property` (lazy="select", nullable via FK SET NULL)
|
||||
|
||||
**Sem cascade delete**: leads são preservados mesmo se o imóvel for deletado (histórico de negócio).
|
||||
|
||||
---
|
||||
|
||||
## Schemas Pydantic (backend)
|
||||
|
||||
### PropertyDetailOut (novo — herda de PropertyOut)
|
||||
|
||||
```python
|
||||
class PropertyDetailOut(PropertyOut):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
address: str | None
|
||||
code: str | None
|
||||
description: str | None
|
||||
```
|
||||
|
||||
Usado exclusivamente pelo endpoint `GET /api/v1/properties/<slug>`.
|
||||
|
||||
### ContactLeadIn (novo — input de validação)
|
||||
|
||||
```python
|
||||
class ContactLeadIn(BaseModel):
|
||||
name: Annotated[str, Field(min_length=2, max_length=150)]
|
||||
email: EmailStr
|
||||
phone: Annotated[str | None, Field(max_length=20)] = None
|
||||
message: Annotated[str, Field(min_length=10, max_length=2000)]
|
||||
```
|
||||
|
||||
### ContactLeadCreatedOut (novo — resposta 201)
|
||||
|
||||
```python
|
||||
class ContactLeadCreatedOut(BaseModel):
|
||||
id: int
|
||||
message: str
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types TypeScript (frontend)
|
||||
|
||||
### PropertyDetail (novo — herda de Property)
|
||||
|
||||
```typescript
|
||||
export interface PropertyDetail extends Property {
|
||||
address: string | null
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
```
|
||||
|
||||
### ContactFormData (novo)
|
||||
|
||||
```typescript
|
||||
export interface ContactFormData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estado do React — PropertyDetailPage
|
||||
|
||||
```
|
||||
PropertyDetailPage
|
||||
├── property: PropertyDetail | null (fetch por slug)
|
||||
├── notFound: boolean (true se 404)
|
||||
├── loading: boolean
|
||||
└── ContactSection
|
||||
├── formData: ContactFormData
|
||||
├── submitting: boolean
|
||||
├── submitStatus: 'idle' | 'success' | 'error'
|
||||
└── fieldErrors: Partial<Record<keyof ContactFormData, string>>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validações de Estado
|
||||
|
||||
| Regra | Onde aplicada |
|
||||
|-------|---------------|
|
||||
| `is_active = false` → 404 | Backend (GET /properties/\<slug\>) |
|
||||
| slug inexistente → 404 | Backend |
|
||||
| `name` obrigatório, 2–150 chars | Backend (Pydantic) + Frontend (HTML validation) |
|
||||
| `email` formato válido | Backend (EmailStr) + Frontend (type="email") |
|
||||
| `phone` max 20 chars, opcional | Backend (Pydantic) |
|
||||
| `message` obrigatório, 10–2000 chars | Backend (Pydantic) + Frontend |
|
||||
| `property_id` via slug (nunca do cliente) | Backend |
|
||||
|
||||
---
|
||||
|
||||
## Transições de Estado do Formulário de Contato
|
||||
|
||||
```
|
||||
idle → [usuário clica Enviar]
|
||||
↓ validação frontend falha → exibe erros de campo → idle
|
||||
↓ validação ok → submitting (botão desabilitado)
|
||||
↓ 201 Created → success (mensagem de confirmação, formulário limpo)
|
||||
↓ 4xx/5xx → error (mensagem de erro, dados preservados)
|
||||
error → [usuário edita] → idle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agrupamento de Amenidades
|
||||
|
||||
| Valor `group` no BD | Label exibido |
|
||||
|--------------------|---------------|
|
||||
| `caracteristica` | Características |
|
||||
| `lazer` | Lazer |
|
||||
| `condominio` | Condomínio |
|
||||
| `seguranca` | Segurança |
|
||||
|
||||
Grupos sem amenidade associada ao imóvel **não são renderizados** (FR-F07).
|
||||
90
.specify/features/004-property-detail-page/plan.md
Normal file
90
.specify/features/004-property-detail-page/plan.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Implementation Plan: Property Detail Page
|
||||
|
||||
**Branch**: `004-property-detail-page` | **Date**: 2026-04-13 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `.specify/features/004-property-detail-page/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Página pública `/imoveis/<slug>` que exibe todas as informações de um imóvel em detalhe: galeria de fotos em carrossel (teclado + swipe), estatísticas, caixa de preço sticky, description, amenidades agrupadas e seção de contato (formulário + WhatsApp). O backend adiciona dois novos endpoints — `GET /api/v1/properties/<slug>` e `POST /api/v1/properties/<slug>/contact` — além de uma nova tabela `contact_leads` e os campos `code` / `description` no modelo `Property` exigidos pelo contrato da spec.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12 / TypeScript 5.5
|
||||
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
|
||||
**Storage**: PostgreSQL 16 via Docker
|
||||
**Testing**: pytest (backend) · Vite build + verificação visual (frontend)
|
||||
**Target Platform**: Container Linux Docker (backend) / browser SPA (frontend)
|
||||
**Project Type**: REST web-service + single-page application
|
||||
**Performance Goals**: Página renderizada em < 3 s em conexão padrão (SC-001)
|
||||
**Constraints**: Rotas públicas sem autenticação; CORS explícito via Flask-CORS; sem rate limiting no MVP (assumption doc spec); nenhum secret no bundle frontend
|
||||
**Scale/Scope**: MVP — única imobiliária, único número WhatsApp via env
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Evidência |
|
||||
|-----------|--------|-----------|
|
||||
| I. Design-First | ✅ PASS | Todos os componentes usam tokens do DESIGN.md: canvas `#08090a`, panel `#0f1011`, accent `#7170ff`, border `rgba(255,255,255,0.05–0.08)`. Nenhum inline style fora do sistema. |
|
||||
| II. Separation of Concerns | ✅ PASS | Flask retorna JSON puro; React consome a API. Nenhum SSR. CORS explícito configurado. |
|
||||
| III. Spec-Driven | ✅ PASS | `spec.md` finalizado; plan → tasks → implement. |
|
||||
| IV. Data Integrity | ✅ PASS | `ContactLeadIn` valida com Pydantic + `EmailStr`; migration Alembic para `contact_leads` e novos campos de `Property`; ORM exclusivo; `property_id` resolvido via `slug` no backend (FR-B04). |
|
||||
| V. Security | ✅ PASS | Rotas públicas sem auth (especificado na spec). `VITE_WHATSAPP_NUMBER` via env (nunca hardcoded). `property_id` nunca aceito do cliente. |
|
||||
| VI. Simplicity First | ✅ PASS | Carousel implementado com React state + event handlers nativos (sem nova lib). Google Maps Embed como iframe simples (US3 P3). Zero novas dependências npm. |
|
||||
|
||||
**Re-check pós-design**: confirmar que `PropertyDetailOut` herda de `PropertyOut` sem duplicação e que a migration é um único arquivo cobrindo contact_leads + colunas novas.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
.specify/features/004-property-detail-page/
|
||||
├── plan.md ← este arquivo
|
||||
├── research.md ← Phase 0 output
|
||||
├── data-model.md ← Phase 1 output
|
||||
├── quickstart.md ← Phase 1 output
|
||||
├── contracts/
|
||||
│ └── rest.md ← Phase 1 output
|
||||
└── tasks.md ← Phase 2 output (/speckit.tasks — NOT criado aqui)
|
||||
```
|
||||
|
||||
### Source Code
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── models/
|
||||
│ │ └── property.py ← adicionar ContactLead; adicionar code/description em Property
|
||||
│ ├── schemas/
|
||||
│ │ └── property.py ← PropertyDetailOut(PropertyOut), ContactLeadIn, ContactLeadCreatedOut
|
||||
│ └── routes/
|
||||
│ └── properties.py ← GET /properties/<slug>, POST /properties/<slug>/contact
|
||||
└── migrations/versions/
|
||||
└── <hash>_add_contact_leads_and_property_detail_fields.py ← migration única
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── property.ts ← PropertyDetail (extends Property), ContactFormData
|
||||
├── services/
|
||||
│ └── properties.ts ← getProperty(slug), submitContactForm(slug, data)
|
||||
├── components/
|
||||
│ ├── PropertyCarousel.tsx ← novo
|
||||
│ ├── PropertyStatsStrip.tsx ← novo
|
||||
│ ├── Breadcrumb.tsx ← novo
|
||||
│ ├── PriceBox.tsx ← novo
|
||||
│ ├── AmenitiesSection.tsx ← novo
|
||||
│ ├── ContactSection.tsx ← novo
|
||||
│ ├── PropertyDetailSkeleton.tsx ← novo
|
||||
│ └── PropertyCard.tsx ← wrap com Link para /imoveis/<slug>
|
||||
├── pages/
|
||||
│ └── PropertyDetailPage.tsx ← novo
|
||||
└── App.tsx ← adicionar rota /imoveis/:slug
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (backend + frontend). Sem novo diretório top-level; componentes separados por responsabilidade seguindo o padrão já estabelecido no projeto.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> Nenhuma violação identificada. Todos os princípios passam sem justificativa de exceção.
|
||||
370
.specify/features/004-property-detail-page/quickstart.md
Normal file
370
.specify/features/004-property-detail-page/quickstart.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# Quickstart: Property Detail Page (004)
|
||||
|
||||
**Feature**: `004-property-detail-page`
|
||||
**Date**: 2026-04-13
|
||||
|
||||
Guia de implementação sequencial para desenvolvedores. Cada passo é independente; siga a ordem para evitar erros de dependência.
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Docker em execução (`docker compose ps` mostra backend + frontend + db up)
|
||||
- Python env ativo: `$env:DATABASE_URL = "postgresql://imob_user:imob_password_dev@localhost:5432/saas_imobiliaria"`
|
||||
- Frontend deps instaladas: `cd frontend && npm install`
|
||||
|
||||
---
|
||||
|
||||
## Passo 1 — Backend: atualizar modelo `Property` e criar `ContactLead`
|
||||
|
||||
**Arquivo**: `backend/app/models/property.py`
|
||||
|
||||
Adicionar ao modelo `Property`:
|
||||
```python
|
||||
code = db.Column(db.String(30), unique=True, nullable=True, index=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
```
|
||||
|
||||
Adicionar classe `ContactLead` no mesmo arquivo:
|
||||
```python
|
||||
class ContactLead(db.Model):
|
||||
__tablename__ = "contact_leads"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
property_id = db.Column(
|
||||
db.UUID(as_uuid=True),
|
||||
db.ForeignKey("properties.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(
|
||||
db.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
)
|
||||
|
||||
property = db.relationship("Property", foreign_keys=[property_id], lazy="select")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 2 — Backend: registrar o modelo em `__init__.py`
|
||||
|
||||
**Arquivo**: `backend/app/__init__.py`
|
||||
|
||||
Adicionar import para que Flask-Migrate detecte o modelo:
|
||||
```python
|
||||
from app.models import property as _property_models # já existe — ContactLead está no mesmo arquivo
|
||||
```
|
||||
|
||||
> `ContactLead` está em `property.py` — nenhuma linha nova necessária se já importa `property`.
|
||||
|
||||
---
|
||||
|
||||
## Passo 3 — Backend: gerar e aplicar migration
|
||||
|
||||
```powershell
|
||||
# Na raiz do backend com DATABASE_URL setado
|
||||
cd backend
|
||||
uv run flask db migrate -m "add contact_leads and property detail fields"
|
||||
uv run flask db upgrade
|
||||
```
|
||||
|
||||
**Verificar**: o arquivo gerado em `migrations/versions/` deve conter:
|
||||
- `op.create_table('contact_leads', ...)`
|
||||
- `op.add_column('properties', sa.Column('code', ...))`
|
||||
- `op.add_column('properties', sa.Column('description', ...))`
|
||||
|
||||
**Testar downgrade**:
|
||||
```powershell
|
||||
uv run flask db downgrade
|
||||
uv run flask db upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 4 — Backend: criar schemas Pydantic
|
||||
|
||||
**Arquivo**: `backend/app/schemas/property.py`
|
||||
|
||||
Adicionar ao final do arquivo:
|
||||
```python
|
||||
from pydantic import EmailStr
|
||||
from typing import Annotated
|
||||
from pydantic import Field
|
||||
|
||||
class PropertyDetailOut(PropertyOut):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
address: str | None = None
|
||||
code: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ContactLeadIn(BaseModel):
|
||||
name: Annotated[str, Field(min_length=2, max_length=150)]
|
||||
email: EmailStr
|
||||
phone: Annotated[str | None, Field(max_length=20)] = None
|
||||
message: Annotated[str, Field(min_length=10, max_length=2000)]
|
||||
|
||||
|
||||
class ContactLeadCreatedOut(BaseModel):
|
||||
id: int
|
||||
message: str
|
||||
```
|
||||
|
||||
> **Verificar**: `email-validator` está em `pyproject.toml`. Se não: `uv add email-validator`.
|
||||
|
||||
---
|
||||
|
||||
## Passo 5 — Backend: adicionar rotas ao `properties_bp`
|
||||
|
||||
**Arquivo**: `backend/app/routes/properties.py`
|
||||
|
||||
Adicionar as duas rotas ao blueprint existente:
|
||||
|
||||
```python
|
||||
from app.models.property import ContactLead
|
||||
from app.schemas.property import PropertyDetailOut, ContactLeadIn, ContactLeadCreatedOut
|
||||
from pydantic import ValidationError
|
||||
|
||||
@properties_bp.get("/properties/<string:slug>")
|
||||
def get_property(slug: str):
|
||||
prop = Property.query.filter_by(slug=slug, is_active=True).first()
|
||||
if prop is None:
|
||||
return jsonify({"error": "Imóvel não encontrado"}), 404
|
||||
return jsonify(PropertyDetailOut.model_validate(prop).model_dump(mode="json"))
|
||||
|
||||
|
||||
@properties_bp.post("/properties/<string:slug>/contact")
|
||||
def contact_property(slug: str):
|
||||
prop = Property.query.filter_by(slug=slug, is_active=True).first()
|
||||
if prop is None:
|
||||
return jsonify({"error": "Imóvel não encontrado"}), 404
|
||||
|
||||
try:
|
||||
payload = ContactLeadIn.model_validate(request.get_json(force=True) or {})
|
||||
except ValidationError as exc:
|
||||
details = {
|
||||
e["loc"][0]: [e["msg"]] for e in exc.errors() if e["loc"]
|
||||
}
|
||||
return jsonify({"error": "Dados inválidos", "details": details}), 422
|
||||
|
||||
lead = ContactLead(
|
||||
property_id=prop.id,
|
||||
name=payload.name,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
message=payload.message,
|
||||
)
|
||||
from app.extensions import db as _db
|
||||
_db.session.add(lead)
|
||||
_db.session.commit()
|
||||
|
||||
return jsonify(ContactLeadCreatedOut(id=lead.id, message="Mensagem enviada com sucesso!").model_dump()), 201
|
||||
```
|
||||
|
||||
> `properties_bp` já existe e está registrado em `__init__.py` — nenhuma alteração no factory necessária.
|
||||
|
||||
---
|
||||
|
||||
## Passo 6 — Backend: verificar rotas
|
||||
|
||||
```powershell
|
||||
# Com o docker compose rodando (ou DATABASE_URL local)
|
||||
Invoke-WebRequest "http://localhost:5173/api/v1/properties/apartamento-3-quartos-centro-123" -UseBasicParsing | Select-Object StatusCode
|
||||
# Esperado: 200
|
||||
Invoke-WebRequest "http://localhost:5173/api/v1/properties/slug-inexistente" -UseBasicParsing | Select-Object StatusCode
|
||||
# Esperado: 404
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 7 — Frontend: tipos TypeScript
|
||||
|
||||
**Arquivo**: `frontend/src/types/property.ts`
|
||||
|
||||
Adicionar ao final:
|
||||
```typescript
|
||||
export interface PropertyDetail extends Property {
|
||||
address: string | null
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface ContactFormData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 8 — Frontend: services
|
||||
|
||||
**Arquivo**: `frontend/src/services/properties.ts`
|
||||
|
||||
Adicionar ao final:
|
||||
```typescript
|
||||
import type { PropertyDetail, ContactFormData } from '../types/property'
|
||||
|
||||
export async function getProperty(slug: string): Promise<PropertyDetail> {
|
||||
const response = await api.get<PropertyDetail>(`/properties/${slug}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function submitContactForm(
|
||||
slug: string,
|
||||
data: ContactFormData,
|
||||
): Promise<{ id: number; message: string }> {
|
||||
const response = await api.post<{ id: number; message: string }>(
|
||||
`/properties/${slug}/contact`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 9 — Frontend: componentes novos
|
||||
|
||||
Criar os componentes nesta ordem (cada um é independente):
|
||||
|
||||
### 9a. `PropertyCarousel.tsx`
|
||||
- Props: `photos: PropertyPhoto[]`
|
||||
- State: `activeIndex: number`
|
||||
- Keyboard: `onKeyDown` no container com `tabIndex={0}`; teclas `ArrowLeft`/`ArrowRight`
|
||||
- Touch: `onTouchStart` salva `touchStartX`; `onTouchEnd` detecta delta > 50px → muda índice
|
||||
- Sem fotos: exibe placeholder `<div className="bg-panel ...">Sem fotos disponíveis</div>`
|
||||
- Uma foto: sem strip de miniaturas, sem botões de navegação
|
||||
|
||||
### 9b. `PropertyStatsStrip.tsx`
|
||||
- Props: `bedrooms, bathrooms, parking_spots, area_m2: number`
|
||||
- Layout: `flex gap-6 bg-panel border border-white/5 rounded-lg p-4`
|
||||
- Cada stat: ícone SVG + número + label em `text-text-quaternary`
|
||||
|
||||
### 9c. `Breadcrumb.tsx`
|
||||
- Props: `items: { label: string; href?: string }[]`
|
||||
- Último item: `text-text-primary`; demais: `text-text-quaternary`
|
||||
- Separador: `>` em `text-text-quaternary/50`
|
||||
|
||||
### 9d. `PriceBox.tsx`
|
||||
- Props: `price: string, condo_fee: string | null, type: "venda" | "aluguel"`
|
||||
- Sticky: `sticky top-24` em desktop (lg:)
|
||||
- Badge tipo: fundo `#5e6ad2/20` texto `#7170ff`
|
||||
- Preço em `text-3xl font-[510]`
|
||||
- Linha de condomínio: condicional se `condo_fee != null`
|
||||
|
||||
### 9e. `AmenitiesSection.tsx`
|
||||
- Props: `amenities: Amenity[]`
|
||||
- Agrupar por `group` → `Object.groupBy` ou `reduce`
|
||||
- Grid de checkmarks: `✓` em `text-[#7170ff]` + nome em `text-text-secondary`
|
||||
- Não renderizar se `amenities.length === 0`
|
||||
|
||||
### 9f. `ContactSection.tsx`
|
||||
- Props: `slug: string, propertyTitle: string, propertyCode: string | null`
|
||||
- State: `ContactFormData`, `submitting`, `submitStatus`, `fieldErrors`
|
||||
- WhatsApp button: `href=https://wa.me/${VITE_WHATSAPP_NUMBER}?text=...` em nova aba; fundo `#25D366`
|
||||
- `VITE_WHATSAPP_NUMBER` via `import.meta.env.VITE_WHATSAPP_NUMBER`
|
||||
- Inputs: `bg-[#111213] border border-white/[0.07] rounded-md`
|
||||
|
||||
### 9g. `PropertyDetailSkeleton.tsx`
|
||||
- Pulso com `animate-pulse bg-white/5`
|
||||
- Blocos: foto grande (h-96), stats strip, price box, linhas de texto
|
||||
|
||||
---
|
||||
|
||||
## Passo 10 — Frontend: `PropertyDetailPage.tsx`
|
||||
|
||||
```
|
||||
/imoveis/:slug
|
||||
└── loading → <PropertyDetailSkeleton />
|
||||
└── notFound → estado "Imóvel não encontrado" + Link para /imoveis
|
||||
└── loaded →
|
||||
<Navbar />
|
||||
<Breadcrumb items={[...]} />
|
||||
<main className="max-w-7xl mx-auto px-4">
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<PropertyCarousel photos={property.photos} />
|
||||
<h1>{property.title}</h1>
|
||||
<PropertyStatsStrip ... />
|
||||
{property.description && <p>{property.description}</p>}
|
||||
<AmenitiesSection amenities={property.amenities} />
|
||||
<ContactSection slug={slug} ... />
|
||||
</div>
|
||||
<aside>
|
||||
<PriceBox ... />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 11 — Frontend: `App.tsx`
|
||||
|
||||
Adicionar rota:
|
||||
```tsx
|
||||
import PropertyDetailPage from './pages/PropertyDetailPage'
|
||||
|
||||
// dentro de <Routes>
|
||||
<Route path="/imoveis/:slug" element={<PropertyDetailPage />} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 12 — Frontend: `PropertyCard.tsx`
|
||||
|
||||
Envolver o conteúdo do card com `Link to={/imoveis/${property.slug}}`:
|
||||
```tsx
|
||||
import { Link } from 'react-router-dom'
|
||||
// ...
|
||||
return (
|
||||
<Link to={`/imoveis/${property.slug}`} className="block ...">
|
||||
{/* conteúdo existente do card */}
|
||||
</Link>
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 13 — Verificação Final
|
||||
|
||||
```powershell
|
||||
# Backend: rodar testes
|
||||
cd backend
|
||||
uv run pytest -v
|
||||
|
||||
# Frontend: build sem erros de tipo
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# Smoke test manual
|
||||
# 1. Acessar /imoveis → cards linkam para /imoveis/<slug>
|
||||
# 2. Acessar /imoveis/<slug-valido> → página renderiza com todos os blocos
|
||||
# 3. Acessar /imoveis/slug-invalido → estado "não encontrado"
|
||||
# 4. Preencher e enviar formulário → 201, mensagem de sucesso, form limpo
|
||||
# 5. Enviar sem nome/email → erros de campo exibidos
|
||||
# 6. Navegar carrossel com ← →
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Design (Princípio I)
|
||||
|
||||
- [ ] Canvas: `#08090a` (`bg-canvas`) em todos os backgrounds de página
|
||||
- [ ] Panels: `bg-panel` (`#0f1011`) com `border border-white/5`
|
||||
- [ ] Accent: `#7170ff` para checkmarks de amenidades e badge de tipo
|
||||
- [ ] WhatsApp button: `bg-[#25D366]` (não usar accent color)
|
||||
- [ ] Inputs de contato: `bg-[#111213] border border-white/[0.07]`
|
||||
- [ ] Tipografia: `font-inter` com `text-[510]` para preço e títulos principais
|
||||
- [ ] Skeleton: `animate-pulse bg-white/5` (mesma classe do `PropertyCardSkeleton`)
|
||||
- [ ] Breadcrumb: `text-text-quaternary` com último item `text-text-primary`
|
||||
150
.specify/features/004-property-detail-page/research.md
Normal file
150
.specify/features/004-property-detail-page/research.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Research: Property Detail Page (004)
|
||||
|
||||
**Feature**: `004-property-detail-page`
|
||||
**Date**: 2026-04-13
|
||||
**Status**: Complete — todos os NEEDS CLARIFICATION resolvidos
|
||||
|
||||
---
|
||||
|
||||
## R-01 — Campos `code` e `description` ausentes no modelo `Property`
|
||||
|
||||
**Pergunta**: Os campos `code` e `description` devem ser adicionados nesta feature ou numa feature separada?
|
||||
|
||||
**Evidência**: O contrato da API em `spec.md` inclui explicitamente `"code": "AP-00042"` e `"description": "..."` na resposta de `GET /api/v1/properties/<slug>`. A Constituição (Princípio III) define que a spec é a fonte de verdade.
|
||||
|
||||
**Decisão**: Adicionar ambos os campos ao modelo `Property` nesta feature. A migration que cria `contact_leads` também incluirá as novas colunas.
|
||||
|
||||
**Campos a adicionar**:
|
||||
- `code`: `VARCHAR(30)`, `UNIQUE`, `nullable=True` (nullable para não quebrar registros existentes sem migration data)
|
||||
- `description`: `TEXT`, `nullable=True`
|
||||
|
||||
**Rationale**: Agrupar numa única migration evita fragmentação de DDL. Os campos são necessários para o contrato da API desta feature.
|
||||
|
||||
**Alternativa rejeitada**: Feature separada só para `code`/`description` — overhead desnecessário para dois campos simples.
|
||||
|
||||
---
|
||||
|
||||
## R-02 — Campo `address` ausente em `PropertyOut`
|
||||
|
||||
**Pergunta**: O campo `address` existe no modelo mas não está em `PropertyOut`. Como tratar?
|
||||
|
||||
**Evidência**: `Property.address = db.Column(db.String(300), nullable=True)` existe no modelo. `PropertyOut` não inclui `address`. O contrato da spec exige `address` na resposta de detalhe.
|
||||
|
||||
**Decisão**: Adicionar `address: str | None` ao `PropertyOut` existente. É um campo geral do imóvel (não exclusivo da tela de detalhe) e sua ausência no schema era uma omissão.
|
||||
|
||||
**Impacto nos consumers existentes**: O endpoint `GET /api/v1/properties` (list) passará a incluir `address` na resposta. Isso é retrocompatível — campos adicionais em JSON não quebram consumers que não os leem.
|
||||
|
||||
**Alternativa rejeitada**: `PropertyDetailOut` separado apenas para `address` — over-engineering para um campo que logicamente pertence ao schema base.
|
||||
|
||||
---
|
||||
|
||||
## R-03 — `type` vs `listing_type` no contrato da API
|
||||
|
||||
**Pergunta**: O contrato da spec documenta `listing_type` mas o schema e o modelo usam `type`. O que usar na implementação?
|
||||
|
||||
**Evidência**:
|
||||
- Model: `type = db.Column(db.Enum("venda", "aluguel", name="property_type"))`
|
||||
- `PropertyOut`: `type: Literal["venda", "aluguel"]`
|
||||
- Spec API contract: `"listing_type": "venda"`
|
||||
|
||||
**Decisão**: Manter `type` no schema e na serialização JSON. O contrato da spec usa `listing_type` como nome descritivo na documentação, mas o campo JSON emitido pelo backend será `type` (consistente com o endpoint de listagem já em produção). A spec documenta o _significado_ do campo, não exige renaming.
|
||||
|
||||
**Rationale**: Renomear para `listing_type` quebraria o endpoint de listagem que já retorna `type`. Backward compatibility supera a preferência de nomenclatura da spec, especialmente porque o frontend já consome `type`.
|
||||
|
||||
**Alternativa rejeitada**: Alias Pydantic `listing_type` via `Field(alias="type")` — introduziria inconsistência entre list e detail sem benefício real no MVP.
|
||||
|
||||
---
|
||||
|
||||
## R-04 — Carousel: biblioteca externa vs handlers nativos
|
||||
|
||||
**Pergunta**: Usar Embla Carousel, Swiper.js ou implementar com React state + handlers nativos?
|
||||
|
||||
**Análise**:
|
||||
| Opção | Tamanho bundle | Complexidade | Justificativa |
|
||||
|-------|---------------|--------------|---------------|
|
||||
| Embla Carousel | ~7 KB gzip | baixa (API simples) | overkill para carousel básico |
|
||||
| Swiper.js | ~35 KB gzip | média-alta | excesso de features desnecessárias |
|
||||
| React state nativo | 0 KB extra | baixa-média | suficiente para os requisitos da spec |
|
||||
|
||||
**Requisitos da spec**: navegação por teclado (←/→), swipe touch, thumbnail strip com estado ativo. Tudo implementável com:
|
||||
- `useState` para índice ativo
|
||||
- `onKeyDown` no container (tabIndex=0)
|
||||
- `onTouchStart`/`onTouchEnd` para detectar swipe horizontal
|
||||
- CSS `transition` para animação suave
|
||||
|
||||
**Decisão**: Implementar com React state + handlers nativos. Zero nova dependência npm (alinhamento com Princípio VI).
|
||||
|
||||
**Rationale**: Os requisitos são exatos e limitados. Uma lib traz overhead de API para aprender, bundle weight extra e potencial conflito com o design system customizado.
|
||||
|
||||
---
|
||||
|
||||
## R-05 — Mapa (US3 P3): Google Maps Embed
|
||||
|
||||
**Pergunta**: Qual serviço de mapa usar para US3? Chave de API necessária?
|
||||
|
||||
**Análise**:
|
||||
- US3 é P3 (prioridade mais baixa) — não bloqueia o MVP funcional
|
||||
- Google Maps Embed API: iframe simples, sem SDK JS, sem package npm
|
||||
- URL: `https://www.google.com/maps/embed/v1/place?key=API_KEY&q=ENDEREÇO_CODIFICADO`
|
||||
- Requer chave de API com "Maps Embed API" habilitada
|
||||
- OpenStreetMap via `iframe` Nominatim: gratuito, sem chave, mas qualidade variável
|
||||
|
||||
**Decisão**: Google Maps Embed via `<iframe>` simples quando `VITE_GOOGLE_MAPS_API_KEY` estiver definido. Se a variável não existir ou o endereço for nulo, a seção de mapa é silenciosamente omitida `(null render)`.
|
||||
|
||||
**Configuração necessária**:
|
||||
- Env var frontend: `VITE_GOOGLE_MAPS_API_KEY` (opcional)
|
||||
- Sem nova dependência npm
|
||||
|
||||
**Alternativa rejeitada**: Leaflet + react-leaflet — adiciona ~40 KB ao bundle para uma feature P3 que pode ser omitida no MVP.
|
||||
|
||||
---
|
||||
|
||||
## R-06 — Schema de detalhe: `PropertyDetailOut` vs extensão de `PropertyOut`
|
||||
|
||||
**Pergunta**: Criar `PropertyDetailOut(PropertyOut)` ou adicionar campos diretamente a `PropertyOut`?
|
||||
|
||||
**Evidência e raciocínio**:
|
||||
- `code` e `description` são campos de detalhe narrativo — não fazem sentido no card da listagem (espaço limitado)
|
||||
- `PropertyOut` é usado por dois endpoints: list (`GET /api/v1/properties`) e futuramente featured
|
||||
- Adicionar `code`/`description` a `PropertyOut` polui a resposta da listagem
|
||||
|
||||
**Decisão**: Criar `PropertyDetailOut(PropertyOut)` com os campos adicionais:
|
||||
```python
|
||||
class PropertyDetailOut(PropertyOut):
|
||||
address: str | None
|
||||
code: str | None
|
||||
description: str | None
|
||||
```
|
||||
`address` vai em `PropertyDetailOut` — em `PropertyOut` base o campo não é adicionado para não expor informação de endereço completo na listagem paginada.
|
||||
|
||||
**Wait — revisão**: O R-02 decidiu adicionar `address` a `PropertyOut`. Reconsiderando com R-06: manter `address` apenas em `PropertyDetailOut` é mais conservador e evita expor endereços na listagem. **Decisão final**: `address`, `code`, `description` em `PropertyDetailOut` somente.
|
||||
|
||||
---
|
||||
|
||||
## R-07 — Validação de `ContactLeadIn`: comprimentos de campo
|
||||
|
||||
**Pergunta**: Quais validações Pydantic usar em `ContactLeadIn`?
|
||||
|
||||
**Spec definiu**:
|
||||
- `name`: obrigatório, 2–150 chars
|
||||
- `email`: obrigatório, EmailStr
|
||||
- `phone`: opcional, max 20 chars
|
||||
- `message`: obrigatório, 10–2000 chars
|
||||
|
||||
**Decisão**: Usar `pydantic.EmailStr` (requer `email-validator` no pyproject.toml — já presente como dependência do projeto). Usar `Annotated[str, Field(min_length=..., max_length=...)]` para os demais.
|
||||
|
||||
**Verificar**: `email-validator` já está em `pyproject.toml` antes de implementar. Se não estiver, adicionar com `uv add email-validator`.
|
||||
|
||||
---
|
||||
|
||||
## Resumo das Decisões
|
||||
|
||||
| ID | Decisão |
|
||||
|----|---------|
|
||||
| R-01 | `code` e `description` adicionados em `Property` + na migration desta feature |
|
||||
| R-02 | `address` vai em `PropertyDetailOut` (não em `PropertyOut` base) |
|
||||
| R-03 | Manter `type` no JSON (não renomear para `listing_type`) |
|
||||
| R-04 | Carousel: React state + handlers nativos, zero nova lib |
|
||||
| R-05 | Google Maps Embed via iframe, env var opcional, omissão silenciosa se ausente |
|
||||
| R-06 | `PropertyDetailOut(PropertyOut)` com `address`, `code`, `description` |
|
||||
| R-07 | Pydantic `EmailStr` + `Field(min_length, max_length)` para ContactLeadIn |
|
||||
266
.specify/features/004-property-detail-page/spec.md
Normal file
266
.specify/features/004-property-detail-page/spec.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# Feature Specification: Página de Detalhe do Imóvel
|
||||
|
||||
**Feature Branch**: `004-property-detail-page`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
|
||||
## Contexto
|
||||
|
||||
Página pública `/imoveis/<slug>` que exibe todas as informações de um imóvel específico: galeria de fotos em carrossel, dados técnicos, preço, diferenciais e formas de contato. É o ponto de conversão do funil — o visitante que chegou via listagem ou link direto deve encontrar tudo que precisa para solicitar uma visita ou mais informações.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Visitante visualiza o imóvel em detalhe (P1)
|
||||
|
||||
**Given** o visitante acessa `/imoveis/apartamento-3-quartos-centro-123`, **When** a página carrega com sucesso, **Then** vê o carrossel de fotos, título, código do imóvel, breadcrumb de localização, estatísticas-chave (quartos, banheiros, vagas, área), caixa de preço com label "Venda" ou "Aluguel", e a descrição completa do imóvel.
|
||||
|
||||
**Why this priority**: É o núcleo da feature — sem visualização nenhum outro story faz sentido.
|
||||
|
||||
**Independent Test**: Acessar `/imoveis/<slug>` de um imóvel ativo com fotos e verificar que todos os blocos de informação são renderizados corretamente.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um imóvel ativo com 5 fotos e todos os campos preenchidos, **When** o visitante acessa a URL do imóvel, **Then** a foto de índice 0 (`display_order` mais baixo) aparece como principal, as demais aparecem como miniaturas, e todas as informações do imóvel são exibidas.
|
||||
2. **Given** o carrossel está exibindo a primeira foto, **When** o visitante clica na miniatura da terceira foto, **Then** a foto principal muda para a terceira foto e a miniatura ativa recebe destaque visual.
|
||||
3. **Given** o carrossel está em foco, **When** o visitante pressiona a tecla `←` ou `→`, **Then** a foto principal avança ou recua junto às miniaturas.
|
||||
4. **Given** o visitante está em um dispositivo móvel, **When** faz swipe horizontal no carrossel, **Then** a foto principal muda na direção do gesto.
|
||||
5. **Given** um imóvel do tipo `aluguel`, **When** a página carrega, **Then** a caixa de preço exibe o label "Aluguel", o valor principal, e — se `condo_fee` não for nulo — o valor de condomínio separado abaixo.
|
||||
6. **Given** um imóvel do tipo `venda`, **When** a página carrega, **Then** o label exibe "Venda" e não há linha de condomínio se `condo_fee` for nulo.
|
||||
7. **Given** o visitante está em desktop, **When** rola a página para baixo, **Then** a caixa de preço permanece visível (sticky) ao lado do conteúdo principal.
|
||||
8. **Given** a página está carregando, **When** os dados ainda não chegaram, **Then** esqueletos de carregamento ocupam as áreas de foto, estatísticas e preço (sem layout shift).
|
||||
|
||||
---
|
||||
|
||||
### US2 — Visitante solicita contato pelo formulário ou WhatsApp (P2)
|
||||
|
||||
**Given** o visitante está na página de detalhe de um imóvel, **When** rola até a seção de contato, **Then** vê dois caminhos: botão de WhatsApp com número da imobiliária e formulário de contato (nome, e-mail, telefone, mensagem).
|
||||
|
||||
**Why this priority**: Conversão é o objetivo do negócio. O formulário captura leads que não usam WhatsApp.
|
||||
|
||||
**Independent Test**: Preencher e enviar o formulário; verificar que o lead aparece no banco de dados com `property_id` correto. Clicar no botão de WhatsApp e verificar que o link `wa.me` abre com texto pré-preenchido referenciando o imóvel.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o formulário de contato está visível, **When** o visitante preenche nome, e-mail válido, telefone e mensagem e clica em "Enviar", **Then** o sistema registra o lead na tabela `contact_leads`, exibe mensagem de confirmação "Mensagem enviada com sucesso!", e o formulário é limpo.
|
||||
2. **Given** o formulário está exibido, **When** o visitante tenta enviar sem preencher nome ou e-mail, **Then** os campos obrigatórios são destacados com mensagem de erro e o envio é bloqueado no próprio frontend.
|
||||
3. **Given** o e-mail informado tem formato inválido (ex: "joao@"), **When** o visitante tenta enviar, **Then** o campo de e-mail exibe mensagem "E-mail inválido" e o envio é bloqueado.
|
||||
4. **Given** o botão de WhatsApp está visível, **When** o visitante clica nele, **Then** uma nova aba abre com `https://wa.me/<numero>?text=...`, onde o texto pré-preenchido menciona o código e o título do imóvel.
|
||||
5. **Given** o backend retorna erro 5xx ao tentar salvar o lead, **When** o envio falha, **Then** o formulário exibe mensagem "Erro ao enviar. Tente novamente mais tarde." sem apagar os dados já digitados.
|
||||
6. **Given** o formulário está sendo enviado, **When** a requisição está em andamento, **Then** o botão de envio fica desabilitado com indicador de carregamento para evitar duplo envio.
|
||||
|
||||
---
|
||||
|
||||
### US3 — Visitante consulta diferenciais e localização (P3)
|
||||
|
||||
**Given** o visitante está na página de detalhe, **When** rola até as seções de diferenciais e mapa, **Then** vê a lista de amenidades agrupadas por categoria (características, lazer, condomínio, segurança) e um mapa com marcador na localização do imóvel.
|
||||
|
||||
**Why this priority**: Informações de apoio à decisão — importantes mas não bloqueantes para o MVP mínimo funcional.
|
||||
|
||||
**Independent Test**: Acessar a página de um imóvel com amenidades em múltiplos grupos e verificar que cada grupo tem seu título e checklist. Verificar mapa embutido com o endereço correto.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um imóvel possui amenidades nos grupos "caracteristica", "lazer" e "segurança", **When** a página carrega, **Then** cada grupo é exibido como seção distinta com título ("Características", "Lazer", "Segurança") e a lista de amenidades correspondente.
|
||||
2. **Given** um imóvel não possui nenhuma amenidade cadastrada, **When** a página carrega, **Then** a seção de diferenciais não é renderizada (sem seção vazia).
|
||||
3. **Given** o imóvel possui endereço cadastrado, **When** a seção de localização é exibida, **Then** um mapa embutido mostra o pin na localização aproximada do imóvel.
|
||||
4. **Given** o breadcrumb está exibido, **When** o visitante clica em "Imóveis", **Then** é redirecionado para `/imoveis`. Clicar na cidade aplica o filtro de cidade na listagem. Clicar no bairro aplica o filtro de bairro.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Imóvel não encontrado**: Se o `slug` não corresponder a nenhum imóvel na base, o backend retorna `404` e o frontend exibe página de "Imóvel não encontrado" com link de volta para `/imoveis`.
|
||||
- **Imóvel inativo**: Se `is_active = false`, o backend retorna `404` na rota pública (o imóvel não existe para visitantes). Não retornar `403` para não vazar informação sobre existência.
|
||||
- **Sem fotos**: Se o imóvel não tiver nenhuma `PropertyPhoto`, o carrossel exibe um placeholder visual (imagem genérica de imóvel) sem quebrar o layout.
|
||||
- **Uma única foto**: O carrossel exibe a foto principal sem strip de miniaturas e sem os botões de navegação.
|
||||
- **Campo `condo_fee` nulo em aluguel**: A linha de condomínio não é renderizada na caixa de preço.
|
||||
- **Endereço sem coordenadas precisas**: O mapa pode usar geocoding por endereço completo; se falhar, a seção de mapa é omitida silenciosamente.
|
||||
- **Envio duplicado de lead**: O backend não deduplica — cada envio gera um novo `ContactLead`. O bloqueio de UI durante envio (US2, cenário 6) é suficiente para o MVP.
|
||||
- **Slug com caracteres especiais**: A rota aceita slugs no formato `[a-z0-9-]+` apenas; outros formatos retornam `404`.
|
||||
|
||||
## API Contract
|
||||
|
||||
### Endpoint: GET /api/v1/properties/<slug>
|
||||
|
||||
Retorna o detalhe completo de um imóvel ativo pelo slug.
|
||||
|
||||
**Resposta 200 OK**:
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Apartamento 3 quartos no Centro",
|
||||
"slug": "apartamento-3-quartos-centro-123",
|
||||
"code": "AP-00042",
|
||||
"description": "Excelente apartamento com vista para o jardim...",
|
||||
"address": "Rua das Flores, 100, Centro",
|
||||
"price": "850000.00",
|
||||
"condo_fee": "650.00",
|
||||
"listing_type": "venda",
|
||||
"bedrooms": 3,
|
||||
"bathrooms": 2,
|
||||
"parking_spots": 2,
|
||||
"area_m2": 95.0,
|
||||
"is_featured": true,
|
||||
"subtype": { "id": 10, "name": "Apartamento", "slug": "apartamento" },
|
||||
"city": { "id": 5, "name": "Franca", "slug": "franca", "state": "SP" },
|
||||
"neighborhood": { "id": 12, "name": "Jardim São Luiz", "slug": "jardim-sao-luiz" },
|
||||
"photos": [
|
||||
{ "id": 1, "url": "https://...", "alt_text": "Sala de estar", "display_order": 0 },
|
||||
{ "id": 2, "url": "https://...", "alt_text": "Quarto principal", "display_order": 1 }
|
||||
],
|
||||
"amenities": [
|
||||
{ "id": 3, "name": "Aceita animais", "slug": "aceita-animais", "group": "caracteristica" },
|
||||
{ "id": 7, "name": "Piscina", "slug": "piscina", "group": "lazer" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Resposta 404 Not Found** (imóvel inexistente ou inativo):
|
||||
```json
|
||||
{ "error": "Imóvel não encontrado" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Endpoint: POST /api/v1/properties/<slug>/contact
|
||||
|
||||
Registra um lead de contato vinculado ao imóvel.
|
||||
|
||||
**Request body**:
|
||||
```json
|
||||
{
|
||||
"name": "João Silva",
|
||||
"email": "joao@email.com",
|
||||
"phone": "(16) 99999-0000",
|
||||
"message": "Tenho interesse no imóvel, gostaria de agendar uma visita."
|
||||
}
|
||||
```
|
||||
|
||||
**Validações no backend**:
|
||||
- `name`: obrigatório, string, 2–150 caracteres
|
||||
- `email`: obrigatório, formato de e-mail válido
|
||||
- `phone`: opcional, string, máximo 20 caracteres
|
||||
- `message`: obrigatório, string, 10–2000 caracteres
|
||||
|
||||
**Resposta 201 Created**:
|
||||
```json
|
||||
{
|
||||
"id": 88,
|
||||
"message": "Mensagem enviada com sucesso!"
|
||||
}
|
||||
```
|
||||
|
||||
**Resposta 404 Not Found** (slug não encontrado ou imóvel inativo):
|
||||
```json
|
||||
{ "error": "Imóvel não encontrado" }
|
||||
```
|
||||
|
||||
**Resposta 422 Unprocessable Entity** (validação falhou):
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos",
|
||||
"details": {
|
||||
"email": ["E-mail inválido"],
|
||||
"message": ["Campo obrigatório"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modelos necessários
|
||||
|
||||
### ContactLead (novo)
|
||||
|
||||
Registra cada solicitação de contato feita por um visitante em relação a um imóvel.
|
||||
|
||||
| Campo | Tipo | Restrições |
|
||||
|---|---|---|
|
||||
| `id` | SERIAL PK | — |
|
||||
| `property_id` | FK → Property | NOT NULL |
|
||||
| `name` | VARCHAR(150) | NOT NULL |
|
||||
| `email` | VARCHAR(254) | NOT NULL |
|
||||
| `phone` | VARCHAR(20) | nullable |
|
||||
| `message` | TEXT | NOT NULL |
|
||||
| `created_at` | TIMESTAMP WITH TIME ZONE | NOT NULL, default NOW() |
|
||||
|
||||
> Não há relação de exclusão em cascata com `Property` — leads são preservados mesmo se o imóvel for deletado (para histórico de negócio). A FK deve ser SET NULL ou restrita por política — para o MVP: ON DELETE SET NULL é suficiente.
|
||||
|
||||
---
|
||||
|
||||
## Requisitos Funcionais
|
||||
|
||||
### Backend
|
||||
|
||||
- **FR-B01**: O sistema DEVE expor `GET /api/v1/properties/<slug>` retornando o imóvel ativo com fotos e amenidades aninhadas.
|
||||
- **FR-B02**: A rota `GET /api/v1/properties/<slug>` DEVE retornar `404` para imóveis com `is_active = false` ou slug inexistente.
|
||||
- **FR-B03**: O sistema DEVE expor `POST /api/v1/properties/<slug>/contact` que valida o payload com Pydantic e persiste um `ContactLead` no banco.
|
||||
- **FR-B04**: A criação de `ContactLead` DEVE usar `property_id` resolvido via `slug`; nunca aceitar `property_id` diretamente do cliente.
|
||||
- **FR-B05**: As rotas públicas de detalhe e contato NÃO requerem autenticação.
|
||||
- **FR-B06**: A migração Alembic para a tabela `contact_leads` DEVE ser criada antes de qualquer deploy.
|
||||
- **FR-B07**: O `PropertyCard` na listagem `/imoveis` DEVE tornarse clicável, linkando para `/imoveis/<slug>`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **FR-F01**: A aplicação DEVE renderizar a rota `/imoveis/:slug` como `PropertyDetailPage`.
|
||||
- **FR-F02**: O carrossel DEVE exibir a foto ativa em tamanho grande e um strip de miniaturas abaixo (ou lateral); a foto ativa é destacada no strip.
|
||||
- **FR-F03**: O carrossel DEVE suportar navegação por teclado (teclas `←` e `→`) quando em foco.
|
||||
- **FR-F04**: O carrossel DEVE suportar swipe touchscreen (dispositivos móveis).
|
||||
- **FR-F05**: Em telas desktop (≥ 1024px), a caixa de preço DEVE ser sticky durante o scroll da página.
|
||||
- **FR-F06**: O breadcrumb DEVE exibir: "Imóveis > [Cidade] > [Bairro] > [Título do imóvel]", onde "Imóveis" linka para `/imoveis`, cidade e bairro linkam para `/imoveis` com filtros pré-aplicados.
|
||||
- **FR-F07**: As amenidades DEVEM ser agrupadas pelas categorias: "Características" (group=caracteristica), "Lazer" (group=lazer), "Condomínio" (group=condominio), "Segurança" (group=seguranca). Grupos sem amenidade NÃO são renderizados.
|
||||
- **FR-F08**: O formulário de contato DEVE validar `name` e `email` como obrigatórios e `email` como formato válido antes de enviar a requisição.
|
||||
- **FR-F09**: O botão de WhatsApp DEVE abrir `https://wa.me/<NUMERO>?text=<texto_codificado>` em nova aba, onde o texto menciona o código e título do imóvel.
|
||||
- **FR-F10**: O número de WhatsApp DEVE ser configurável via variável de ambiente no frontend (`VITE_WHATSAPP_NUMBER`).
|
||||
- **FR-F11**: A página DEVE exibir skeleton loaders durante o carregamento dos dados.
|
||||
- **FR-F12**: Ao receber `404` do backend, o frontend DEVE renderizar um estado de "Imóvel não encontrado" com CTA para `/imoveis`.
|
||||
- **FR-F13**: Todos os componentes DEVEM seguir o design system Linear dark definido em `DESIGN.md` (canvas `#08090a`, panel `#0f1011`, accent `#7170ff`, tipografia Inter Variable).
|
||||
|
||||
---
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **Property** (existente): imóvel com fotos (1:M) e amenidades (M:M). Nenhum campo novo necessário.
|
||||
- **PropertyPhoto** (existente): foto vinculada ao imóvel com `display_order` para ordenação do carrossel.
|
||||
- **Amenity** (existente): diferencial com `group` para agrupamento visual.
|
||||
- **ContactLead** (novo): registro de interesse de um visitante por um imóvel específico.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- **SC-001**: Um visitante consegue acessar a página de detalhe a partir de um card na listagem e visualizar todas as informações em menos de 3 segundos em conexão padrão.
|
||||
- **SC-002**: 100% dos campos do imóvel (fotos, preço, estatísticas, descrição) são exibidos sem erros de layout para imóveis com dados completos.
|
||||
- **SC-003**: O visitante consegue preencher e enviar o formulário de contato em menos de 2 minutos; a confirmação de envio é visível imediatamente após o sucesso.
|
||||
- **SC-004**: Os leads enviados pelo formulário ficam registrados no banco de dados com `property_id`, `name`, `email` e `created_at` corretos.
|
||||
- **SC-005**: Acessar o slug de um imóvel inexistente ou inativo nunca resulta em página em branco ou erro 500 — sempre exibe estado de "não encontrado".
|
||||
- **SC-006**: O carrossel de fotos é navegável por teclado e por swipe em 100% dos testes de interação.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O campo `listing_type` em `Property` usa os valores `"venda"` e `"aluguel"` (já implementado).
|
||||
- O slug é único por imóvel e imutável após criação (não há redirecionamento de slugs antigos no MVP).
|
||||
- O número de WhatsApp da imobiliária é único e configurado por variável de ambiente (`VITE_WHATSAPP_NUMBER`); não há múltiplos corretores no MVP.
|
||||
- O mapa embutido usa o serviço de mapas via endereço textual (geocoding pelo Google Maps Embed ou similar); coordenadas geográficas não são armazenadas no modelo `Property` no MVP.
|
||||
- Não há sistema de autenticação de visitante — o formulário de contato é anônimo, e o campo `phone` é opcional.
|
||||
- `PropertyCard` na listagem (`/imoveis`) já renderiza `slug` nos dados retornados pela API existente `GET /api/v1/properties`.
|
||||
- Rate limiting no endpoint de contato está fora do escopo do MVP (será tratado em feature de segurança dedicada).
|
||||
- Notificação por e-mail para a imobiliária ao receber um lead está fora do escopo do MVP (apenas persistência em DB).
|
||||
- O IPTU não está modelado em `Property` atualmente; a exibição de IPTU está fora do escopo desta feature.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Painel administrativo para visualizar leads recebidos (feature futura)
|
||||
- Notificação por e-mail ou push ao receber novo lead
|
||||
- Imóveis similares / "Veja também"
|
||||
- Compartilhamento em redes sociais
|
||||
- Favoritar imóvel (requer autenticação de visitante)
|
||||
- Comparador de imóveis
|
||||
- Tour virtual / vídeo embutido
|
||||
- IPTU na caixa de preço (campo não modelado em `Property`)
|
||||
- Múltiplos corretores com contato individualizado
|
||||
- Slug redirect (slugs antigos não são preservados)
|
||||
217
.specify/features/004-property-detail-page/tasks.md
Normal file
217
.specify/features/004-property-detail-page/tasks.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Tasks: Property Detail Page (Página de Detalhe do Imóvel)
|
||||
|
||||
**Feature**: `004-property-detail-page`
|
||||
**Branch**: `004-property-detail-page`
|
||||
**Input**: `spec.md`, `plan.md`, `data-model.md`, `contracts/rest.md`, `DESIGN.md`
|
||||
**Generated**: 2026-04-13
|
||||
**Status**: Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
- [ ] T[NNN] [P?] [USN?] Description — path/to/file.ext
|
||||
```
|
||||
|
||||
- **[P]** — Paralelizável (arquivo diferente, sem dependência de tarefa incompleta)
|
||||
- **[USN]** — User Story associada (US1–US3)
|
||||
- IDs sequenciais na ordem de execução recomendada
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend — Modificações no Modelo Existente
|
||||
|
||||
**Objetivo**: Estender o modelo `Property` com as colunas `code` e `description` exigidas pelo contrato da spec, e criar o schema `PropertyDetailOut`. Estas tarefas bloqueiam as rotas novas.
|
||||
|
||||
**⚠️ CRÍTICO**: T005 (GET /slug) depende de T001 e T002 estarem completas.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T001 | S | — | data-model.md §Property, plan.md §backend |
|
||||
| T002 | S | T001 | data-model.md §Schemas, spec.md §API Contract |
|
||||
|
||||
- [ ] T001 Adicionar colunas `code` (VARCHAR 30, UNIQUE, nullable) e `description` (TEXT, nullable) ao modelo `Property` — `backend/app/models/property.py`
|
||||
- **Done when**: `from app.models.property import Property` importa sem erro; `Property.code` e `Property.description` são atributos `db.Column` declarados exatamente como em `data-model.md §Property`; `code` tem `unique=True, nullable=True`; `description` tem `nullable=True, type_=db.Text`.
|
||||
|
||||
- [ ] T002 Adicionar `PropertyDetailOut(PropertyOut)` ao schema de propriedades com campos `address: str | None`, `code: str | None`, `description: str | None` — `backend/app/schemas/property.py`
|
||||
- **Done when**: `from app.schemas.property import PropertyDetailOut` importa sem erro; `PropertyDetailOut.model_validate(property_instance)` serializa corretamente incluindo `address`, `code` e `description`; `model_config = ConfigDict(from_attributes=True)` herdado de `PropertyOut`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Backend — ContactLead (Novo Modelo, Schemas e Rotas)
|
||||
|
||||
**Objetivo**: Criar a tabela `contact_leads`, os schemas Pydantic de validação/resposta e os dois novos endpoints. Depende da Phase 1 estar concluída.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T003 | S | — | data-model.md §ContactLead, spec.md §Modelos |
|
||||
| T004 | S | — | data-model.md §Schemas, spec.md §POST /contact |
|
||||
| T005 | M | T001, T002 | spec.md §GET /slug, FR-B01, FR-B02 |
|
||||
| T006 | M | T003, T004 | spec.md §POST /contact, FR-B03, FR-B04 |
|
||||
| T007 | S | T003 | plan.md §backend, data-model.md §ContactLead |
|
||||
| T008 | M | T001, T003, T007 | spec.md §FR-B06, data-model.md §Índices |
|
||||
|
||||
- [ ] T003 Criar modelo `ContactLead` com campos `id` (SERIAL PK), `property_id` (UUID FK → properties ON DELETE SET NULL, indexed), `name` (VARCHAR 150, NOT NULL), `email` (VARCHAR 254, NOT NULL), `phone` (VARCHAR 20, nullable), `message` (TEXT, NOT NULL), `created_at` (TIMESTAMP WITH TIMEZONE, server_default=NOW()); criar índice `ix_contact_leads_created_at` — `backend/app/models/lead.py`
|
||||
- **Done when**: `from app.models.lead import ContactLead` importa sem erro; `ContactLead.__tablename__ == "contact_leads"`; `property_id` FK tem `ondelete="SET NULL"` e `nullable=True`; índice `ix_contact_leads_property_id` declarado via `index=True` na coluna.
|
||||
|
||||
- [ ] T004 [P] Criar schemas Pydantic `ContactLeadIn` (name: str min=2/max=150, email: EmailStr, phone: str|None max=20, message: str min=10/max=2000) e `ContactLeadCreatedOut` (id: int, message: str) — `backend/app/schemas/lead.py`
|
||||
- **Done when**: `ContactLeadIn(name="A", email="invalido", phone=None, message="ok")` levanta `ValidationError`; `ContactLeadIn(name="João", email="j@j.com", phone=None, message="Tenho interesse")` passa; `from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut` importa sem erro.
|
||||
|
||||
- [ ] T005 Adicionar rota `GET /api/v1/properties/<slug>` ao blueprint `properties_bp`: busca `Property` com `slug=slug` e `is_active=True`; retorna `PropertyDetailOut.model_validate(p).model_dump(mode="json")` com status 200, ou `{"error": "Imóvel não encontrado"}` com status 404 — `backend/app/routes/properties.py`
|
||||
- **Done when**: `curl http://localhost:5000/api/v1/properties/slug-existente` retorna 200 com JSON contendo `photos`, `amenities`, `code`, `description`; `curl http://localhost:5000/api/v1/properties/slug-inexistente` retorna 404; imóvel com `is_active=False` retorna 404 (não 403).
|
||||
|
||||
- [ ] T006 Adicionar rota `POST /api/v1/properties/<slug>/contact` ao blueprint `properties_bp`: valida payload com `ContactLeadIn` (retorna 422 com `{"error": "Dados inválidos", "details": {...}}` se inválido); busca `Property` por `slug` + `is_active=True` (retorna 404 se não encontrado); cria e persiste `ContactLead` com `property_id` resolvido internamente; retorna `ContactLeadCreatedOut` com status 201 — `backend/app/routes/properties.py`
|
||||
- **Done when**: `POST /api/v1/properties/<slug>/contact` com payload válido retorna 201 `{"id": N, "message": "Mensagem enviada com sucesso!"}`; payload sem `email` retorna 422; slug inativo retorna 404; `property_id` do lead criado no banco corresponde ao imóvel (nunca aceito diretamente do cliente).
|
||||
|
||||
- [ ] T007 Importar `ContactLead` em `backend/app/models/__init__.py` para que Flask-Migrate detecte o modelo na geração de migration — `backend/app/models/__init__.py`
|
||||
- **Done when**: `from app.models import ContactLead` importa sem erro; Flask-Migrate detecta a tabela `contact_leads` ao gerar nova migration.
|
||||
|
||||
- [ ] T008 Gerar e aplicar migration Alembic cobrindo: (a) colunas `code` e `description` em `properties`; (b) tabela `contact_leads` com FK, índices e coluna TIMESTAMP WITH TIMEZONE — `backend/migrations/versions/<hash>_add_contact_leads_and_property_detail_fields.py`
|
||||
- **Done when**: `uv run flask --app app db migrate -m "add contact_leads and property detail fields"` cria arquivo de migration; revisão manual confirma presença de `op.add_column("properties", ...)` para `code` e `description` **e** `op.create_table("contact_leads", ...)`; `flask db upgrade` executa sem erro; `flask db downgrade -1` reverte sem erro; `flask db upgrade` re-aplica sem erro.
|
||||
|
||||
**Checkpoint Phase 2**: `curl http://localhost:5000/api/v1/properties/<slug>` retorna 200; `POST /api/v1/properties/<slug>/contact` com payload válido retorna 201 e grava no banco.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Frontend — Types & Services
|
||||
|
||||
**Objetivo**: Estender os tipos TypeScript e o serviço de propriedades para suportar detalhe de imóvel e envio de contato.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T009 | S | — | data-model.md §Types TypeScript |
|
||||
| T010 | S | T009 | spec.md §FR-F01, plan.md §frontend |
|
||||
| T011 | S | T009 | spec.md §US2, FR-F08 |
|
||||
|
||||
- [ ] T009 [P] Adicionar interface `PropertyDetail extends Property` (campos `address`, `code`, `description` todos `string | null`) e interface `ContactFormData` (name, email, phone, message: todos `string`) ao arquivo de tipos — `frontend/src/types/property.ts`
|
||||
- **Done when**: `import { PropertyDetail, ContactFormData } from '@/types/property'` compila sem erro TypeScript; `PropertyDetail` inclui todos os campos de `Property` (base) mais `address`, `code` e `description`; `ContactFormData` tem exatamente os 4 campos do contrato da spec.
|
||||
|
||||
- [ ] T010 [P] Adicionar função `getProperty(slug: string): Promise<PropertyDetail>` ao serviço de propriedades, chamando `GET /api/v1/properties/${slug}` via Axios; lança erro com `status: 404` repassado para o caller — `frontend/src/services/properties.ts`
|
||||
- **Done when**: Chamada `getProperty("slug-existente")` retorna `PropertyDetail` tipada; chamada com slug inexistente propaga o erro 404 (não silencia); sem nenhuma hardcoded URL (usa instância `api` do `src/services/api.ts`).
|
||||
|
||||
- [ ] T011 Adicionar função `submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number; message: string }>` ao serviço de propriedades, chamando `POST /api/v1/properties/${slug}/contact` via Axios — `frontend/src/services/properties.ts`
|
||||
- **Done when**: Função compila sem erro TypeScript; envia `data` como JSON body; propaga erros 4xx/5xx para o caller sem swallow silencioso.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Frontend — Componentes de Detalhe (US1, US2, US3)
|
||||
|
||||
**Objetivo**: Criar os componentes isolados de detalhe do imóvel. Todos os componentes seguem o design system definido em `DESIGN.md` (canvas `#08090a`, panel `#0f1011`, accent `#7170ff`, tipografia Inter Variable).
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T012 | M | T009 | spec.md §US1 cenários 2–4, FR-F02, FR-F03, FR-F04 |
|
||||
| T013 | S | T009 | spec.md §US1 cenário 1, FR-F02 |
|
||||
| T014 | S | T009 | spec.md §US3 cenários 1–2, FR-F07 |
|
||||
| T015 | S | T009 | spec.md §US1 cenários 5–7, FR-F05 |
|
||||
| T016 | M | T009, T011 | spec.md §US2 todos os cenários, FR-F08, FR-F09, FR-F10 |
|
||||
| T017 | S | T009 | spec.md §US1 cenário 8, FR-F11 |
|
||||
|
||||
- [ ] T012 [P] [US1] Criar componente `PhotoCarousel` recebendo `photos: PropertyPhotoOut[]` como prop; exibe foto ativa em tamanho grande + strip de miniaturas; miniatura ativa recebe destaque visual; suporta navegação por teclado (`←`/`→` via `keydown` listener) quando o elemento está em foco; suporta swipe touchscreen via `onTouchStart`/`onTouchEnd` calculando delta >= 50px; se `photos` for array vazio exibe placeholder visual (div cinza com ícone ou texto "Sem fotos"); se `photos.length === 1` oculta strip e botões de navegação — `frontend/src/components/PropertyDetail/PhotoCarousel.tsx`
|
||||
- **Done when**: Componente aceita `photos: PropertyPhotoOut[]`; clicar na miniatura da 3ª foto altera a foto principal; pressionar `←` recua e `→` avança; swipe horizontal muda a foto na direção do gesto; array vazio exibe placeholder sem erros de runtime; array com 1 elemento oculta strip e botões.
|
||||
|
||||
- [ ] T013 [P] [US1] Criar componente `StatsStrip` recebendo `bedrooms`, `bathrooms`, `parking_spots`, `area_m2` como props numéricas; exibe 4 cartões horizontais com ícone + valor + label ("Quartos", "Banheiros", "Vagas", "Área (m²)") usando tokens do design system — `frontend/src/components/PropertyDetail/StatsStrip.tsx`
|
||||
- **Done when**: Componente renderiza os 4 blocos de estatística; cada `parking_spots = 0` ainda exibe o bloco (não ocultar com zero); usa classes Tailwind com tokens existentes no `tailwind.config.ts`.
|
||||
|
||||
- [ ] T014 [P] [US3] Criar componente `AmenitiesSection` recebendo `amenities: AmenityOut[]` como prop; agrupa amenidades pelas chaves `"caracteristica"`, `"lazer"`, `"condominio"`, `"seguranca"` com títulos "Características", "Lazer", "Condomínio", "Segurança"; renderiza cada grupo como seção com checklist; grupos sem amenidade **não são renderizados**; se `amenities` for array vazio o componente não renderiza nada (retorna `null`) — `frontend/src/components/PropertyDetail/AmenitiesSection.tsx`
|
||||
- **Done when**: Array com amenidades nos grupos "caracteristica" e "lazer" renderiza exatamente 2 seções; grupo "seguranca" ausente não gera seção vazia; array vazio retorna `null` (verificar com React DevTools ou teste visual).
|
||||
|
||||
- [ ] T015 [P] [US1] Criar componente `PriceBox` recebendo `price: string`, `condo_fee: string | null`, `listing_type: "venda" | "aluguel"` como props; exibe label "Venda" ou "Aluguel" conforme `listing_type`; exibe `price` formatado em BRL; exibe linha de condomínio apenas se `condo_fee` não for `null`; em desktop (lg:) aplica `sticky top-6` para o container — `frontend/src/components/PropertyDetail/PriceBox.tsx`
|
||||
- **Done when**: `listing_type="aluguel"` com `condo_fee="650.00"` exibe linha de condomínio; `listing_type="venda"` com `condo_fee=null` não exibe linha de condomínio; preço é formatado (ex: "R$ 850.000,00"); container tem classe `lg:sticky lg:top-6`.
|
||||
|
||||
- [ ] T016 [P] [US2] Criar componente `ContactSection` recebendo `slug: string` e `propertyTitle: string` como props; exibe botão de WhatsApp que abre `https://wa.me/${VITE_WHATSAPP_NUMBER}?text=<texto_codificado>` em nova aba (texto menciona `code` e `title`); exibe formulário com campos `name` (obrigatório), `email` (obrigatório, validação de formato), `phone` (opcional), `message` (obrigatório); botão de envio fica desabilitado + spinner durante `submitting`; ao sucesso exibe "Mensagem enviada com sucesso!" e limpa o formulário; ao erro 5xx exibe "Erro ao enviar. Tente novamente mais tarde." preservando os dados digitados; `VITE_WHATSAPP_NUMBER` lido de `import.meta.env.VITE_WHATSAPP_NUMBER` (nunca hardcoded) — `frontend/src/components/PropertyDetail/ContactSection.tsx`
|
||||
- **Done when**: Formsubmit com campos em branco exibe erros nos campos obrigatórios sem fazer requisição; e-mail inválido exibe "E-mail inválido"; envio válido chama `submitContactForm` e exibe confirmação; botão fica desabilitado durante `submitting`; link WhatsApp abre `wa.me` com `target="_blank" rel="noopener noreferrer"`; número não está hardcoded no bundle.
|
||||
|
||||
- [ ] T017 [P] [US1] Criar componente `PropertyDetailSkeleton` com placeholders animados (`animate-pulse`) para: bloco de carrossel (height ~400px), strip de estatísticas (4 blocos), caixa de preço e área de descrição — `frontend/src/components/PropertyDetail/PropertyDetailSkeleton.tsx`
|
||||
- **Done when**: Componente não recebe props; exibe placeholders com `animate-pulse` e `bg-panel-dark` (ou `bg-surface-elevated`) correspondendo ao layout geral da página; nenhum layout shift perceptível ao substituir pelo conteúdo real.
|
||||
|
||||
**Checkpoint Phase 4**: Todos os componentes renderizam isoladamente sem erros de TypeScript (`npm run build` passa).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Montagem da Página e Roteamento
|
||||
|
||||
**Objetivo**: Montar a `PropertyDetailPage` integrando todos os componentes, registrar a rota `/imoveis/:slug` no roteador e tornar o `PropertyCard` clicável.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T018 | M | T010, T012–T017 | spec.md §US1–US3, FR-F01, FR-F11, FR-F12, FR-F13 |
|
||||
| T019 | S | T018 | spec.md §FR-F01, plan.md §App.tsx |
|
||||
| T020 | S | T019 | spec.md §FR-B07 |
|
||||
|
||||
- [ ] T018 [US1] Criar `PropertyDetailPage` com: estado `property: PropertyDetail | null`, `notFound: boolean`, `loading: boolean`; chama `getProperty(slug)` via `useEffect` ao montar (usando `slug` de `useParams()`); enquanto `loading=true` renderiza `<PropertyDetailSkeleton />`; se `notFound=true` renderiza estado "Imóvel não encontrado" com CTA `<Link to="/imoveis">Ver todos os imóveis</Link>`; quando `property` disponível renderiza: breadcrumb ("Imóveis > [Cidade] > [Bairro] > Título") + `<PhotoCarousel photos={property.photos} />` + `<StatsStrip ... />` + bloco de descrição + `<AmenitiesSection amenities={property.amenities} />` + layout de 2 colunas (descrição + `<PriceBox ... />` sticky) + `<ContactSection slug={slug} propertyTitle={property.title} />`; todos os links respeitam `FR-F06` — `frontend/src/pages/PropertyDetailPage.tsx`
|
||||
- **Done when**: Acessar `/imoveis/<slug-ativo>` renderiza todos os blocos; `loading` exibe skeleton sem layout shift; slug com 404 exibe estado de não encontrado com link; breadcrumb exibe cidade e bairro quando disponíveis; `npm run build` passa sem erros TypeScript.
|
||||
|
||||
- [ ] T019 Adicionar `<Route path="/imoveis/:slug" element={<PropertyDetailPage />} />` ao roteador em `App.tsx`; importar `PropertyDetailPage` — `frontend/src/App.tsx`
|
||||
- **Done when**: Navegar para `/imoveis/qualquer-slug` não lança erro 404 de rota no frontend; `npm run build` compila sem erros.
|
||||
|
||||
- [ ] T020 Envolver o elemento raiz retornado por `PropertyCard` com `<Link to={`/imoveis/${property.slug}`}>...</Link>` usando `react-router-dom`; garantir que o cursor mude para pointer e que não haja `<a>` aninhado — `frontend/src/components/PropertyCard.tsx`
|
||||
- **Done when**: Clicar em qualquer `PropertyCard` na listagem navega para `/imoveis/<slug>` sem reload de página; nenhum `<a>` aninhado dentro de outro `<a>` (inspecionar DOM); `npm run build` passa.
|
||||
|
||||
**Checkpoint Final**: Fluxo completo funcional — listagem `/imoveis` → clicar no card → `/imoveis/<slug>` com todos os blocos renderizados; formulário de contato grava lead no banco; botão WhatsApp abre link correto; 404 exibe estado amigável.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
T001 ──┐
|
||||
├── T005 (GET /slug) ──┐
|
||||
T002 ──┘ │
|
||||
├── T008 (migration) ── T018
|
||||
T003 ──── T007 ───────────────┤
|
||||
T004 ──── T006 (POST /contact)┘
|
||||
|
||||
T009 ──── T010 ──┐
|
||||
── T011 ──┼── T016
|
||||
│
|
||||
T012 ──┐ │
|
||||
T013 ──┤ │
|
||||
T014 ──┼─────────┴── T018 ── T019 ── T020
|
||||
T015 ──┤
|
||||
T017 ──┘
|
||||
```
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Backend (pode ser feito em paralelo com Frontend)
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Backend Phase 1+2
|
||||
# T001 → T002 → T003/T004 (paralelo) → T005 → T006 → T007 → T008
|
||||
|
||||
# Terminal 2 — Frontend Phase 3
|
||||
# T009 (types) → T010/T011 (services, mesmo arquivo: sequencial)
|
||||
```
|
||||
|
||||
### Frontend Components (todos paralelos entre si após T009)
|
||||
|
||||
```bash
|
||||
# T012, T013, T014, T015, T016, T017 podem ser implementados em paralelo
|
||||
# pois estão em arquivos distintos e dependem apenas de T009 (types)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy (MVP Scope)
|
||||
|
||||
| Prioridade | User Stories | Tarefas |
|
||||
|---|---|---|
|
||||
| **MVP Mínimo** | US1 (visualização) | T001–T008 (backend) + T009–T011 (services) + T013, T015, T017 (stats, price, skeleton) + T018–T020 (page + routing) |
|
||||
| **Adição rápida** | US2 (contato) | T016 (ContactSection) já no backend via T006 |
|
||||
| **Complemento** | US3 (amenidades) | T014 (AmenitiesSection) + T012 (PhotoCarousel com swipe) |
|
||||
|
||||
> **Sugestão MVP**: Implementar T001–T020 na ordem recomendada. O carrossel completo (swipe + teclado) e a seção de amenidades podem ser entregues numa iteração posterior sem quebrar a page.
|
||||
|
||||
---
|
||||
|
||||
## Verificações de Segurança
|
||||
|
||||
| Risco | Mitigação | Tarefa |
|
||||
|---|---|---|
|
||||
| `property_id` aceito do cliente | Backend resolve `property_id` via `slug` (nunca lê do body) | T006 |
|
||||
| `VITE_WHATSAPP_NUMBER` hardcoded | Lido de `import.meta.env.VITE_WHATSAPP_NUMBER` | T016 |
|
||||
| SQL Injection via slug | ORM SQLAlchemy com parâmetros vinculados (sem string concatenation) | T005, T006 |
|
||||
| XSS via conteúdo do imóvel | React escapa por padrão; sem `dangerouslySetInnerHTML` | T018 |
|
||||
| Open Redirect via breadcrumb | Links para `/imoveis?city=...` internos apenas (react-router `Link`) | T018 |
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Specification Quality Checklist: Sistema de Autenticação de Clientes
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Todas as decisões técnicas (JWT, bcrypt, React context, localStorage) foram fornecidas pelo autor como decisões já tomadas — o spec as descreve em linguagem de negócio/comportamento, não expondo a tecnologia.
|
||||
- Verificação de e-mail, recuperação de senha e rate limiting foram explicitamente excluídos do escopo e registrados em Assumptions.
|
||||
- API Contract incluída na seção de Requisitos como contrato comportamental (endpoints e formatos), sem mencionar implementação.
|
||||
- Spec aprovada para prosseguir para `/speckit.plan`.
|
||||
175
.specify/features/005-authentication/contracts/auth-api.md
Normal file
175
.specify/features/005-authentication/contracts/auth-api.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# API Contract: Auth Endpoints
|
||||
|
||||
**Prefixo**: `/api/v1/auth`
|
||||
**Blueprint**: `auth_bp` em `backend/app/routes/auth.py`
|
||||
**Content-Type**: `application/json`
|
||||
**Autenticação**: Bearer token via header `Authorization` (onde indicado)
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/auth/register
|
||||
|
||||
Cria uma nova conta de cliente. Token de acesso emitido imediatamente na resposta.
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "João Silva",
|
||||
"email": "joao@exemplo.com",
|
||||
"password": "minhasenha123"
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório | Regras |
|
||||
|-------|------|-------------|--------|
|
||||
| `name` | string | ✅ | 1–150 caracteres |
|
||||
| `email` | string (RFC 5321) | ✅ | Email válido; normalizado para lowercase antes de persistir |
|
||||
| `password` | string | ✅ | Mínimo 8 caracteres |
|
||||
|
||||
### Respostas
|
||||
|
||||
**201 Created**
|
||||
```json
|
||||
{
|
||||
"access_token": "<jwt_string>",
|
||||
"user": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "João Silva",
|
||||
"email": "joao@exemplo.com",
|
||||
"role": "client",
|
||||
"created_at": "2026-04-13T15:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**409 Conflict** — e-mail já cadastrado
|
||||
```json
|
||||
{ "error": "E-mail já cadastrado." }
|
||||
```
|
||||
|
||||
**422 Unprocessable Entity** — falha de validação Pydantic
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos.",
|
||||
"details": [
|
||||
{ "loc": ["body", "password"], "msg": "String should have at least 8 characters", "type": "string_too_short" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/auth/login
|
||||
|
||||
Autentica um cliente existente e emite token de acesso.
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "joao@exemplo.com",
|
||||
"password": "minhasenha123"
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório |
|
||||
|-------|------|-------------|
|
||||
| `email` | string (email válido) | ✅ |
|
||||
| `password` | string | ✅ |
|
||||
|
||||
### Respostas
|
||||
|
||||
**200 OK**
|
||||
```json
|
||||
{
|
||||
"access_token": "<jwt_string>",
|
||||
"user": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "João Silva",
|
||||
"email": "joao@exemplo.com",
|
||||
"role": "client",
|
||||
"created_at": "2026-04-13T15:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**401 Unauthorized** — e-mail não encontrado **ou** senha incorreta (mesma resposta para não revelar qual campo falhou — FR-007, SC-006)
|
||||
```json
|
||||
{ "error": "Credenciais inválidas." }
|
||||
```
|
||||
|
||||
**422 Unprocessable Entity** — falha de validação Pydantic
|
||||
```json
|
||||
{ "error": "Dados inválidos.", "details": [...] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/auth/me
|
||||
|
||||
Retorna dados do usuário autenticado. Requer `Authorization: Bearer <token>`.
|
||||
|
||||
### Headers
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt_string>
|
||||
```
|
||||
|
||||
### Respostas
|
||||
|
||||
**200 OK**
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "João Silva",
|
||||
"email": "joao@exemplo.com",
|
||||
"role": "client",
|
||||
"created_at": "2026-04-13T15:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**401 Unauthorized** — sem token, token inválido ou token expirado
|
||||
```json
|
||||
{ "error": "Token de acesso inválido ou expirado." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "<uuid-string do ClientUser>",
|
||||
"exp": <unix timestamp — now() + 7 dias>
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| Algorithm | HS256 |
|
||||
| Secret | `JWT_SECRET_KEY` (variável de ambiente) |
|
||||
| TTL | 7 dias (604800 s) |
|
||||
| Claim `sub` | UUID do `ClientUser` como string |
|
||||
|
||||
---
|
||||
|
||||
## Envelope de Erro Padrão
|
||||
|
||||
Todas as respostas de erro seguem o envelope:
|
||||
|
||||
```json
|
||||
{ "error": "<mensagem legível>" }
|
||||
```
|
||||
|
||||
Erros de validação 422 incluem `details` com a lista de erros Pydantic.
|
||||
Respostas de erro **nunca** incluem informações que permitam distinguir e-mail vs. senha incorretos (SC-006).
|
||||
|
||||
---
|
||||
|
||||
## Notas de Segurança
|
||||
|
||||
- Nenhuma resposta inclui `password_hash`
|
||||
- `401` em credenciais inválidas NÃO DEVE indicar qual campo está incorreto
|
||||
- `JWT_SECRET_KEY` NUNCA deve aparecer em logs ou respostas da API
|
||||
- CORS configurado explicitamente em `create_app()` (sem wildcard em produção)
|
||||
163
.specify/features/005-authentication/data-model.md
Normal file
163
.specify/features/005-authentication/data-model.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Data Model: 005-authentication
|
||||
|
||||
**Fase 1 — Modelo de Dados**
|
||||
**Data**: 2026-04-13
|
||||
|
||||
---
|
||||
|
||||
## Entidades
|
||||
|
||||
### ClientUser
|
||||
|
||||
**Tabela**: `client_users`
|
||||
**Módulo**: `backend/app/models/user.py`
|
||||
**Classe**: `ClientUser`
|
||||
|
||||
| Campo | Tipo SQLAlchemy | Tipo Python | Nullable | Constraints | Notas |
|
||||
|-------|-----------------|-------------|----------|-------------|-------|
|
||||
| `id` | `UUID(as_uuid=True)` | `uuid.UUID` | NOT NULL | PK, `default=uuid.uuid4` | Gerado no Python antes do flush |
|
||||
| `name` | `String(150)` | `str` | NOT NULL | — | Nome completo |
|
||||
| `email` | `String(254)` | `str` | NOT NULL | UNIQUE, INDEX | Normalizado para lowercase via schema Pydantic |
|
||||
| `password_hash` | `String(100)` | `str` | NOT NULL | — | Hash bcrypt (60 chars); String(100) com margem de segurança |
|
||||
| `role` | `String(20)` | `str` | NOT NULL | `default='client'` | Único papel ativo nesta versão |
|
||||
| `created_at` | `DateTime` | `datetime` | NOT NULL | `server_default=func.now()` | Timezone-naive UTC |
|
||||
|
||||
**Indexes**: `email` (único + índice explícito — frequente em queries de login)
|
||||
**Relacionamentos**: nenhum nesta versão
|
||||
|
||||
**DDL esperado** (gerenciado via Alembic, não escrever manualmente):
|
||||
```sql
|
||||
CREATE TABLE client_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(150) NOT NULL,
|
||||
email VARCHAR(254) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(100) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'client',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ix_client_users_email ON client_users (email);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schemas Pydantic
|
||||
|
||||
**Módulo**: `backend/app/schemas/auth.py`
|
||||
|
||||
### `RegisterIn`
|
||||
|
||||
Valida o corpo da requisição `POST /api/v1/auth/register`.
|
||||
|
||||
```python
|
||||
class RegisterIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=150)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def normalize_email(cls, v: str) -> str:
|
||||
return v.lower().strip()
|
||||
```
|
||||
|
||||
### `LoginIn`
|
||||
|
||||
Valida o corpo da requisição `POST /api/v1/auth/login`.
|
||||
|
||||
```python
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def normalize_email(cls, v: str) -> str:
|
||||
return v.lower().strip()
|
||||
```
|
||||
|
||||
### `UserOut`
|
||||
|
||||
Resposta segura com dados do usuário (sem `password_hash`).
|
||||
|
||||
```python
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### `AuthTokenOut`
|
||||
|
||||
Resposta de register e login bem-sucedidos.
|
||||
|
||||
```python
|
||||
class AuthTokenOut(BaseModel):
|
||||
access_token: str
|
||||
user: UserOut
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regras de Validação
|
||||
|
||||
| Regra | Campo | Resposta em falha |
|
||||
|-------|-------|------------------|
|
||||
| `min_length=8` | `password` em `RegisterIn` | HTTP 422 |
|
||||
| `min_length=1, max_length=150` | `name` em `RegisterIn` | HTTP 422 |
|
||||
| `EmailStr` | `email` em `RegisterIn` / `LoginIn` | HTTP 422 |
|
||||
| Email único | `email` em `ClientUser` | HTTP 409 (capturado na rota via `IntegrityError`) |
|
||||
| Normalize lowercase | `email` (ambos os schemas) | Aplicado silenciosamente via `field_validator` |
|
||||
|
||||
---
|
||||
|
||||
## Transições de Estado
|
||||
|
||||
O `ClientUser` não possui máquina de estados nesta versão:
|
||||
|
||||
- **Created**: via `POST /api/v1/auth/register`
|
||||
- **Read**: via `POST /api/v1/auth/login` + `GET /api/v1/auth/me`
|
||||
- **Update / Delete**: fora do escopo desta feature
|
||||
|
||||
---
|
||||
|
||||
## Tipos Frontend
|
||||
|
||||
**Módulo**: `frontend/src/types/auth.ts`
|
||||
|
||||
```typescript
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contexto de Autenticação Frontend
|
||||
|
||||
**Módulo**: `frontend/src/contexts/AuthContext.tsx`
|
||||
|
||||
```typescript
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
register: (name: string, email: string, password: string) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Inicialização**: ao montar `AuthProvider`, carregar `imob_token` do `localStorage` e chamar `GET /api/v1/auth/me` para hidratar `user`. Se o token estiver expirado/inválido, limpar o `localStorage` e definir estado unauthenticated.
|
||||
91
.specify/features/005-authentication/plan.md
Normal file
91
.specify/features/005-authentication/plan.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Implementation Plan: Sistema de Autenticação de Clientes
|
||||
|
||||
**Branch**: `005-authentication` | **Date**: 2026-04-13 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Sistema de autenticação para clientes: login com e-mail + senha (JWT, sem OAuth), auto-cadastro público.
|
||||
|
||||
## Summary
|
||||
|
||||
Adiciona autenticação JWT ao SaaS imobiliário. Backend: novo model `ClientUser` na tabela `client_users`, hashing de senha com bcrypt, emissão de token PyJWT com TTL de 7 dias, blueprint `/api/v1/auth` com endpoints register/login/me, e decorator `require_auth` reutilizável para proteger rotas. Frontend: `AuthContext` com persistência em `localStorage`, páginas Login e Cadastro no tema Linear escuro do projeto, interceptor Axios automático para injeção do header `Authorization`, e `ProtectedRoute` que guarda `/area-do-cliente/*`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
|
||||
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT>=2.9, bcrypt>=4.2, pydantic[email]; React 18, react-router-dom v6, Axios
|
||||
**Storage**: PostgreSQL 16 via Flask-SQLAlchemy — nova tabela `client_users` via migration Alembic
|
||||
**Testing**: pytest + pytest-flask (backend); nenhum teste automatizado de frontend nesta fase
|
||||
**Target Platform**: Linux/Docker (servidor) + navegadores modernos (SPA)
|
||||
**Project Type**: Web service REST + Single Page Application
|
||||
**Performance Goals**: <200ms p95 nos endpoints de autenticação
|
||||
**Constraints**: Stateless JWT, TTL de 7 dias, sem refresh token, sem verificação de e-mail, sem rate limiting (escopo MVP definido na spec)
|
||||
**Scale/Scope**: MVP, ~100 usuários iniciais, single-tenant
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
|
||||
| Princípio | Status | Justificativa |
|
||||
|-----------|--------|---------------|
|
||||
| **I. Design-First** | ✅ PASS | LoginPage e RegisterPage seguem DESIGN.md: fundo `#08090a`, panel `#0f1011`, tipografia Inter Variable, accent `#5e6ad2`/`#7170ff`, bordas `rgba(255,255,255,0.06)`. Mesmo estilo do PropertiesPage existente. |
|
||||
| **II. Separation of Concerns** | ✅ PASS | Flask retorna JSON puro; React é SPA. CORS configurado explicitamente em `create_app()`. Sem Jinja2 nas rotas da API. |
|
||||
| **III. Spec-Driven** | ✅ PASS | spec.md aprovado. Ciclo spec → plan → tasks → implement respeitado. |
|
||||
| **IV. Data Integrity** | ✅ PASS | Todos os inputs validados por Pydantic (`RegisterIn`/`LoginIn`). Migration Alembic. Tipos corretos (`String`, `UUID`, `DateTime`). `nullable=False` declarado explicitamente em todos os campos obrigatórios. |
|
||||
| **V. Security** | ✅ PASS | `JWT_SECRET_KEY` lido exclusivamente de variável de ambiente. bcrypt irreversível. 401 genérico em credenciais inválidas (SC-006). `password_hash` ausente em todas as respostas. |
|
||||
| **VI. Simplicity First** | ✅ PASS | 2 novos pacotes com razão clara (PyJWT para JWT, bcrypt para hashing). Sem refresh token, verificação de e-mail ou rate limiting (YAGNI — fora do escopo MVP conforme assumptions da spec). Sem flask-jwt-extended (overhead desnecessário). |
|
||||
|
||||
**Gate Result**: ✅ PASS — nenhuma violação. Prosseguir para Phase 0.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
.specify/features/005-authentication/
|
||||
├── plan.md # Este arquivo
|
||||
├── research.md # Fase 0 — resolução de unknowns (gerado)
|
||||
├── data-model.md # Fase 1 — modelo de dados (gerado)
|
||||
├── quickstart.md # Fase 1 — como rodar e testar (gerado)
|
||||
├── contracts/
|
||||
│ └── auth-api.md # Contratos dos endpoints /api/v1/auth (gerado)
|
||||
└── tasks.md # Fase 2 — gerado pelo /speckit.tasks
|
||||
```
|
||||
|
||||
### Source Code
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── models/
|
||||
│ │ └── user.py # ClientUser — NOVO
|
||||
│ ├── schemas/
|
||||
│ │ └── auth.py # RegisterIn, LoginIn, UserOut, AuthTokenOut — NOVO
|
||||
│ ├── routes/
|
||||
│ │ └── auth.py # auth_bp (/api/v1/auth) — NOVO
|
||||
│ ├── utils/
|
||||
│ │ └── auth.py # require_auth decorator — NOVO
|
||||
│ └── __init__.py # ATUALIZADO: user model import + auth blueprint + JWT_SECRET_KEY
|
||||
├── migrations/versions/
|
||||
│ └── <hash>_add_client_users.py # NOVO — gerado via flask db migrate
|
||||
└── pyproject.toml # ATUALIZADO: PyJWT>=2.9, bcrypt>=4.2, pydantic[email]
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── auth.ts # User, AuthState — NOVO
|
||||
├── contexts/
|
||||
│ └── AuthContext.tsx # AuthProvider, useAuth — NOVO
|
||||
├── services/
|
||||
│ ├── api.ts # ATUALIZADO: interceptor Authorization header
|
||||
│ └── auth.ts # registerUser, loginUser, getMe — NOVO
|
||||
├── pages/
|
||||
│ ├── LoginPage.tsx # rota /login — NOVO
|
||||
│ └── RegisterPage.tsx # rota /cadastro — NOVO
|
||||
├── components/
|
||||
│ ├── ProtectedRoute.tsx # redireciona se !isAuthenticated — NOVO
|
||||
│ └── Navbar.tsx # ATUALIZADO: Entrar / avatar + Sair
|
||||
└── App.tsx # ATUALIZADO: AuthProvider wrap + novas rotas
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*Sem violações de constituição — esta seção não se aplica.*
|
||||
137
.specify/features/005-authentication/quickstart.md
Normal file
137
.specify/features/005-authentication/quickstart.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Quickstart: 005-authentication
|
||||
|
||||
**Como configurar, migrar e testar o sistema de autenticação localmente.**
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Docker + Docker Compose rodando (`.\start.ps1` ou `docker-compose up`)
|
||||
- Backend acessível em `http://localhost:5000`
|
||||
- Frontend acessível em `http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
## 1. Variáveis de Ambiente
|
||||
|
||||
Adicione ao `backend/.env` (e ao `docker-compose.yml` se o container não herdar o `.env`):
|
||||
|
||||
```
|
||||
JWT_SECRET_KEY=sua-chave-secreta-longa-e-aleatoria-aqui
|
||||
```
|
||||
|
||||
> Gere uma chave segura no terminal:
|
||||
> ```powershell
|
||||
> python -c "import secrets; print(secrets.token_hex(32))"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 2. Instalar Novas Dependências (backend)
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv add "PyJWT>=2.9" "bcrypt>=4.2" "pydantic[email]"
|
||||
```
|
||||
|
||||
Verifique que `uv.lock` foi atualizado e commit junto com o `pyproject.toml` atualizado.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gerar e Aplicar a Migration
|
||||
|
||||
```powershell
|
||||
# Com DATABASE_URL definida ou container rodando:
|
||||
uv run flask db migrate -m "add client_users"
|
||||
uv run flask db upgrade
|
||||
```
|
||||
|
||||
Teste o ciclo completo antes de commitar:
|
||||
```powershell
|
||||
uv run flask db downgrade
|
||||
uv run flask db upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Testar os Endpoints Manualmente
|
||||
|
||||
### Cadastro
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "http://localhost:5000/api/v1/auth/register" `
|
||||
-Method POST `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"name":"Teste","email":"teste@exemplo.com","password":"senha1234"}'
|
||||
```
|
||||
|
||||
### Login
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "http://localhost:5000/api/v1/auth/login" `
|
||||
-Method POST `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"email":"teste@exemplo.com","password":"senha1234"}'
|
||||
```
|
||||
|
||||
### Perfil autenticado
|
||||
```powershell
|
||||
$TOKEN = "<access_token da resposta acima>"
|
||||
Invoke-WebRequest -Uri "http://localhost:5000/api/v1/auth/me" `
|
||||
-Headers @{ Authorization = "Bearer $TOKEN" }
|
||||
```
|
||||
|
||||
### Rota protegida sem token (deve retornar 401)
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "http://localhost:5000/api/v1/auth/me"
|
||||
# Espera: StatusCode 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Rodar os Testes (backend)
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend — Verificar os Fluxos
|
||||
|
||||
Com backend + frontend rodando:
|
||||
|
||||
| URL | Comportamento esperado |
|
||||
|-----|------------------------|
|
||||
| `http://localhost:5173/login` | Exibe formulário de login com estilo Linear dark |
|
||||
| `http://localhost:5173/cadastro` | Exibe formulário de cadastro |
|
||||
| `http://localhost:5173/area-do-cliente` | Redireciona para `/login` se não autenticado |
|
||||
| Após login bem-sucedido | Redireciona para `/area-do-cliente` |
|
||||
| Navbar sem autenticação | Botão "Entrar" visível |
|
||||
| Navbar autenticado | Inicial do usuário + botão "Sair" visível |
|
||||
|
||||
---
|
||||
|
||||
## 7. Verificar Token no localStorage
|
||||
|
||||
No console do navegador (após login):
|
||||
|
||||
```javascript
|
||||
localStorage.getItem('imob_token') // deve retornar o JWT string
|
||||
```
|
||||
|
||||
Após logout:
|
||||
|
||||
```javascript
|
||||
localStorage.getItem('imob_token') // deve retornar null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Checklist de Segurança Pós-implementação
|
||||
|
||||
- [ ] `JWT_SECRET_KEY` não aparece em nenhum arquivo versionado (`.env.example` pode ter placeholder)
|
||||
- [ ] `grep -r "JWT_SECRET" backend/app/` retorna apenas referências a `os.environ` ou `app.config`
|
||||
- [ ] Login com senha errada retorna 401 com mensagem genérica (não indica se e-mail ou senha está incorreto)
|
||||
- [ ] Cadastro com e-mail duplicado retorna 409 (não 500)
|
||||
- [ ] Acesso a `/api/v1/auth/me` sem token retorna 401 (não 500)
|
||||
- [ ] `password_hash` nunca aparece em nenhuma resposta JSON da API
|
||||
135
.specify/features/005-authentication/research.md
Normal file
135
.specify/features/005-authentication/research.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Research: 005-authentication
|
||||
|
||||
**Fase 0 — Resolução de Unknowns**
|
||||
**Data**: 2026-04-13
|
||||
|
||||
---
|
||||
|
||||
## Decision Log
|
||||
|
||||
### 1. Biblioteca de hashing de senha
|
||||
|
||||
**Decision**: `bcrypt>=4.2`
|
||||
**Rationale**: Bcrypt é o padrão da indústria para hashing de senhas — adaptativo, lento por design, resistente a ataques de força bruta com GPUs. Usar diretamente (sem passar por `werkzeug.security`) mantém a cadeia de dependências mínima (Princípio VI) e está alinhado com a instrução de design.
|
||||
**Alternatives considered**:
|
||||
- `werkzeug.security.generate_password_hash` — encapsula bcrypt mas adiciona dependência desnecessária
|
||||
- `argon2-cffi` — mais forte que bcrypt, porém não justificado para MVP com ~100 usuários
|
||||
- `passlib` — camada de abstração extra (YAGNI)
|
||||
|
||||
**Usage pattern**:
|
||||
```python
|
||||
import bcrypt
|
||||
|
||||
# Hash
|
||||
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||
password_hash = hashed.decode("utf-8") # armazenar como String(100)
|
||||
|
||||
# Verify
|
||||
bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Biblioteca JWT
|
||||
|
||||
**Decision**: `PyJWT>=2.9`
|
||||
**Rationale**: API limpa, amplamente adotada no ecossistema Flask, suporte nativo a expiração via claim `exp`. Leveza da dependência cumpre Princípio VI.
|
||||
**Alternatives considered**:
|
||||
- `python-jose` — orientado a JWE/JWKS, mais pesado (YAGNI)
|
||||
- `authlib` — voltado a OAuth2 / OpenID Connect (fora do escopo)
|
||||
- `flask-jwt-extended` — abstração extra útil para refresh tokens; sem refresh no escopo MVP, não se justifica
|
||||
|
||||
**Usage pattern**:
|
||||
```python
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Encode
|
||||
payload = {"sub": str(user.id), "exp": datetime.utcnow() + timedelta(days=7)}
|
||||
token = jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
# Decode (lança jwt.ExpiredSignatureError ou jwt.InvalidTokenError em falha)
|
||||
data = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. UUID como chave primária do ClientUser
|
||||
|
||||
**Decision**: `default=uuid.uuid4` (gerado no Python)
|
||||
**Rationale**: Consistência com o padrão já adotado no model `Property`. O ID gerado em Python fica disponível antes do flush, simplificando testes e a construção da resposta de registro.
|
||||
**Alternatives considered**:
|
||||
- `server_default=text("gen_random_uuid()")` — gerado no banco, menos consistente com padrão existente
|
||||
|
||||
---
|
||||
|
||||
### 4. Normalização de e-mail
|
||||
|
||||
**Decision**: `email.lower().strip()` como `@field_validator` nos schemas Pydantic (`RegisterIn` e `LoginIn`)
|
||||
**Rationale**: FR-005 exige normalização para minúsculas. Fazê-la na camada de schema garante que o banco nunca armazene variantes de case, e que a comparação no login seja sempre consistente — sem precisar de `LOWER()` em queries SQL.
|
||||
|
||||
---
|
||||
|
||||
### 5. Armazenamento do token no frontend
|
||||
|
||||
**Decision**: `localStorage` com chave `imob_token`
|
||||
**Rationale**: Escolha MVP pragmática. HttpOnly cookies seriam mais seguros contra XSS, mas exigiriam configuração adicional de CORS e CSRF. O projeto usa Axios com requisições XHR; consistência com o padrão SPA do projeto.
|
||||
**Alternatives considered**:
|
||||
- `sessionStorage` — perde estado ao fechar aba; viola SC-005
|
||||
- HttpOnly cookie — mais seguro para produção, mas exigiria mudança na política CORS/CSRF (fora do escopo MVP)
|
||||
|
||||
**Risk noted**: Token em `localStorage` é vulnerável a XSS. Mitigação via CSP headers fica na responsabilidade de infraestrutura (fora do escopo desta feature). Documentado como débito de segurança para versão futura.
|
||||
|
||||
---
|
||||
|
||||
### 6. Axios interceptor vs. wrapper manual
|
||||
|
||||
**Decision**: `axios.interceptors.request.use` na instância existente em `services/api.ts`
|
||||
**Rationale**: Centraliza a injeção do header `Authorization` em um único ponto. FR-015 é satisfeito sem nenhuma alteração nas chamadas existentes. Adicionar ao objeto Axios existente evita fragmentação de instâncias (Princípio VI).
|
||||
**Alternatives considered**:
|
||||
- Wrapper manual por serviço — duplicação de código
|
||||
- Novo arquivo `axiosInstance.ts` — fragmenta a instância sem ganho
|
||||
|
||||
---
|
||||
|
||||
### 7. Estrutura do `require_auth` decorator
|
||||
|
||||
**Decision**: Decorator simples em `backend/app/utils/auth.py` usando `flask.g`
|
||||
**Rationale**: Abordagem mínima e idiomática no Flask. `g` é o mecanismo nativo de context-local para guardar estado por requisição. Sem nova dependência.
|
||||
**Pattern**:
|
||||
```python
|
||||
from functools import wraps
|
||||
from flask import g, request, jsonify
|
||||
import jwt
|
||||
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = _extract_token(request)
|
||||
if not token:
|
||||
return jsonify({"error": "Token de acesso inválido ou expirado."}), 401
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
g.current_user = ClientUser.query.get(payload["sub"])
|
||||
if g.current_user is None:
|
||||
return jsonify({"error": "Token de acesso inválido ou expirado."}), 401
|
||||
except jwt.PyJWTError:
|
||||
return jsonify({"error": "Token de acesso inválido ou expirado."}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. `UserOut` sem `password_hash`
|
||||
|
||||
**Decision**: `UserOut` exclui `password_hash`
|
||||
**Rationale**: Nunca expor hash de senha em resposta da API. Princípio de menor privilégio (Constituição V). `UserOut` = {id, name, email, role, created_at}.
|
||||
|
||||
---
|
||||
|
||||
### 9. `pydantic[email]` para `EmailStr`
|
||||
|
||||
**Decision**: Adicionar `pydantic[email]` nas dependências (ou verificar se `email-validator` já está presente)
|
||||
**Rationale**: `pydantic.EmailStr` requer o pacote `email-validator`. Verificar `pyproject.toml` antes da tarefa de implementação — se ausente, adicionar junto com PyJWT e bcrypt.
|
||||
**Action**: A tarefa de instalação de dependências deve incluir `"pydantic[email]"` ou checar se `email-validator>=2.0` já está listado.
|
||||
140
.specify/features/005-authentication/spec.md
Normal file
140
.specify/features/005-authentication/spec.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Feature Specification: Sistema de Autenticação de Clientes
|
||||
|
||||
**Feature Branch**: `005-authentication`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Sistema de autenticação para clientes: login com e-mail + senha (JWT, sem OAuth), auto-cadastro público."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Cadastro Público de Novo Cliente (Priority: P1)
|
||||
|
||||
Qualquer pessoa pode criar uma conta no sistema fornecendo nome, e-mail e senha. Após o cadastro, o usuário recebe imediatamente um token de acesso e pode utilizar o sistema sem etapas adicionais de verificação.
|
||||
|
||||
**Why this priority**: É o ponto de entrada do sistema. Sem cadastro funcional, nenhuma outra funcionalidade de autenticação é utilizável. Entregar apenas P1 já permite que um novo usuário se registre e acesse a plataforma.
|
||||
|
||||
**Independent Test**: Pode ser testado isoladamente realizando um cadastro com dados válidos e verificando que o sistema retorna token de acesso e dados do usuário criado; e tentando cadastrar com e-mail duplicado ou senha fraca para verificar as rejeições.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um visitante não autenticado, **When** ele submete nome, e-mail válido e senha com pelo menos 8 caracteres, **Then** o sistema cria a conta, retorna status 201 e fornece um token de acesso junto com os dados básicos do usuário (id, nome, e-mail).
|
||||
2. **Given** um visitante tenta se cadastrar com um e-mail já existente no sistema, **When** ele submete o formulário, **Then** o sistema retorna status 409 indicando conflito, sem revelar informações sobre a conta existente.
|
||||
3. **Given** um visitante tenta se cadastrar com senha contendo menos de 8 caracteres, **When** ele submete o formulário, **Then** o sistema retorna status 422 indicando que a senha não atende aos requisitos mínimos.
|
||||
4. **Given** o formulário de cadastro com campo "confirmar senha" preenchido de forma diferente da senha, **When** o usuário tenta submeter, **Then** o sistema exibe mensagem de validação no formulário sem enviar a requisição.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Login de Cliente Existente (Priority: P2)
|
||||
|
||||
Um cliente já cadastrado pode acessar o sistema fornecendo seu e-mail e senha. Após autenticação bem-sucedida, recebe um token de acesso e é redirecionado para sua área pessoal.
|
||||
|
||||
**Why this priority**: É o fluxo principal de acesso recorrente ao sistema. Depende de P1 (a conta precisa existir), mas pode ser desenvolvido e testado de forma independente com dados pré-cadastrados.
|
||||
|
||||
**Independent Test**: Pode ser testado isoladamente realizando login com credenciais válidas (espera token + dados do usuário) e com credenciais inválidas (espera 401 com mensagem genérica). Erro de rede deve exibir mensagem amigável.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um cliente com conta cadastrada, **When** ele fornece e-mail e senha corretos, **Then** o sistema retorna status 200 com token de acesso e informações básicas do usuário (id, nome, e-mail).
|
||||
2. **Given** um visitante, **When** ele fornece e-mail não cadastrado ou senha incorreta, **Then** o sistema retorna status 401 com mensagem genérica — sem indicar qual campo está incorreto.
|
||||
3. **Given** um usuário no formulário de login, **When** ocorre erro de rede na requisição, **Then** o formulário exibe mensagem de erro amigável sem expor detalhes técnicos e permite nova tentativa.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Acesso a Rotas Protegidas com Token (Priority: P3)
|
||||
|
||||
Rotas marcadas como protegidas só podem ser acessadas por clientes autenticados. Requisições sem token ou com token inválido/expirado são rejeitadas automaticamente. A interface redireciona usuários não autenticados para o login.
|
||||
|
||||
**Why this priority**: Garante a integridade do sistema. Sem esse mecanismo, dados privados estariam acessíveis publicamente. Depende de P1 e P2 para que haja tokens válidos no sistema.
|
||||
|
||||
**Independent Test**: Pode ser testado tentando acessar uma rota protegida sem token (espera 401 e redirecionamento para /login), com token válido (espera dados normais), e com token expirado ou adulterado (espera 401).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um cliente autenticado com token válido, **When** ele acessa uma rota protegida enviando o token no cabeçalho de autorização, **Then** o sistema processa a requisição normalmente e retorna os dados solicitados.
|
||||
2. **Given** uma requisição sem token de autorização, **When** tenta acessar uma rota protegida, **Then** o sistema retorna status 401.
|
||||
3. **Given** uma requisição com token expirado ou adulterado, **When** tenta acessar uma rota protegida, **Then** o sistema retorna status 401.
|
||||
4. **Given** um usuário não autenticado navegando na interface, **When** tenta acessar uma página protegida, **Then** a interface redireciona automaticamente para a tela de login.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Visualização do Perfil do Cliente Autenticado (Priority: P4)
|
||||
|
||||
Um cliente autenticado pode consultar seus próprios dados de perfil: nome, e-mail, papel e data de criação da conta, sem precisar informar o próprio identificador na URL.
|
||||
|
||||
**Why this priority**: Funcionalidade de suporte à identidade do usuário logado. Depende dos fluxos P1–P3 para ter sentido prático.
|
||||
|
||||
**Independent Test**: Pode ser testado acessando o endpoint de perfil com token válido (espera dados completos do usuário autenticado) e sem token (espera 401).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um cliente autenticado, **When** ele consulta o endpoint de perfil próprio, **Then** o sistema retorna id, nome, e-mail, papel (role) e data de criação da conta.
|
||||
2. **Given** uma requisição sem token de autorização, **When** tenta consultar o perfil, **Then** o sistema retorna status 401.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **E-mail duplicado no cadastro**: Sistema retorna 409 Conflict sem revelar dados da conta existente.
|
||||
- **Senha abaixo do mínimo**: Sistema retorna 422 Unprocessable Entity com descrição do critério não atendido.
|
||||
- **Token JWT adulterado ou expirado**: Sistema retorna 401 Unauthorized em qualquer rota protegida.
|
||||
- **Credenciais inválidas no login**: Sistema retorna 401 com mensagem genérica — não revela se o e-mail ou a senha está errado.
|
||||
- **Erro de rede nos formulários**: Interface exibe mensagem de erro amigável e permite nova tentativa, sem expor erros técnicos.
|
||||
- **Usuário não autenticado em rota protegida**: Interface redireciona para /login preservando a intenção de navegação.
|
||||
- **Senha de confirmação não coincide**: Validação no formulário impede envio e exibe mensagem explicativa.
|
||||
- **Token armazenado localmente e sessão do servidor**: Como o sistema é stateless, não há sessão a invalidar; o logout apenas remove o token do armazenamento do navegador.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE permitir que qualquer visitante crie uma conta fornecendo nome, e-mail e senha, sem necessidade de aprovação prévia.
|
||||
- **FR-002**: O sistema DEVE validar que senhas tenham no mínimo 8 caracteres no momento do cadastro e retornar erro de validação (422) quando o critério não for atendido.
|
||||
- **FR-003**: O sistema DEVE rejeitar cadastros com e-mail já existente retornando resposta 409, sem revelar informações sobre a conta existente.
|
||||
- **FR-004**: O sistema DEVE armazenar senhas de forma irreversível — nunca em texto puro.
|
||||
- **FR-005**: O sistema DEVE normalizar e-mails para letras minúsculas tanto no armazenamento quanto na comparação durante o login.
|
||||
- **FR-006**: O sistema DEVE emitir um token de acesso com validade de 7 dias após cadastro ou login bem-sucedido.
|
||||
- **FR-007**: O sistema DEVE retornar resposta genérica (401) em caso de credenciais inválidas no login, sem indicar qual campo (e-mail ou senha) está incorreto.
|
||||
- **FR-008**: O sistema DEVE proteger as rotas designadas como privadas, rejeitando com 401 qualquer requisição que não apresente token válido no cabeçalho de autorização.
|
||||
- **FR-009**: O sistema DEVE disponibilizar endpoint para o cliente autenticado consultar seus próprios dados de perfil (id, nome, e-mail, papel, data de criação).
|
||||
- **FR-010**: O sistema DEVE carregar o segredo de assinatura do token a partir de variável de ambiente — nunca embutido no código-fonte.
|
||||
- **FR-011**: A interface DEVE manter o estado de autenticação do usuário entre navegações na aplicação durante a vigência do token.
|
||||
- **FR-012**: A interface DEVE redirecionar automaticamente usuários não autenticados para a tela de login ao tentar acessar áreas protegidas.
|
||||
- **FR-013**: A interface DEVE redirecionar usuários para a área do cliente após login ou cadastro bem-sucedido.
|
||||
- **FR-014**: A interface DEVE exibir opção de "Entrar" na barra de navegação para visitantes não autenticados, e o nome do usuário com opção de "Sair" quando autenticado.
|
||||
- **FR-015**: A interface DEVE adicionar o token de autenticação automaticamente em todas as requisições para rotas protegidas, sem necessidade de configuração manual por página.
|
||||
- **FR-016**: A interface DEVE exibir mensagem de erro amigável quando ocorrer falha de rede nos formulários de login ou cadastro.
|
||||
|
||||
### API Contract
|
||||
|
||||
| Endpoint | Método | Corpo da Requisição | Resposta de Sucesso | Respostas de Erro |
|
||||
|----------|--------|---------------------|---------------------|-------------------|
|
||||
| `/api/v1/auth/register` | POST | `{name, email, password}` | 201 `{access_token, user: {id, name, email}}` | 409 (e-mail duplicado), 422 (validação) |
|
||||
| `/api/v1/auth/login` | POST | `{email, password}` | 200 `{access_token, user: {id, name, email}}` | 401 (credenciais inválidas) |
|
||||
| `/api/v1/auth/me` | GET | — (Bearer token no header) | 200 `{id, name, email, role, created_at}` | 401 (sem token ou token inválido) |
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **ClientUser**: Representa um cliente cadastrado no sistema. Atributos: identificador único (UUID), nome completo (até 150 caracteres), e-mail (único no sistema, até 254 caracteres), senha protegida (hash irreversível), papel no sistema (padrão: 'client'), data e hora de criação da conta.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Um novo visitante consegue criar uma conta e receber token de acesso em menos de 2 minutos ao utilizar o formulário de cadastro.
|
||||
- **SC-002**: Um cliente cadastrado consegue realizar login e ser redirecionado para sua área em menos de 30 segundos.
|
||||
- **SC-003**: 100% das tentativas de acesso a rotas protegidas sem token válido resultam em rejeição (401) — nenhuma rota protegida é acessível sem autenticação.
|
||||
- **SC-004**: 100% das senhas armazenadas no sistema são protegidas — nenhuma é recuperável em texto puro a partir do banco de dados.
|
||||
- **SC-005**: O estado de autenticação do usuário persiste entre recarregamentos de página durante o período de validade do token.
|
||||
- **SC-006**: Nenhuma resposta do sistema revela se a falha de login se deve ao e-mail ou à senha incorretos — todas as falhas de credencial retornam a mesma mensagem genérica.
|
||||
- **SC-007**: O segredo de assinatura de token nunca aparece no código-fonte versionado — auditoria do repositório não encontra nenhuma ocorrência da chave em texto puro.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Sem verificação de e-mail**: A confirmação de conta via link de e-mail está fora do escopo desta versão (MVP). Toda conta criada é imediatamente ativa.
|
||||
- **Sem recuperação de senha**: O fluxo "esqueci minha senha" está fora do escopo desta versão.
|
||||
- **Apenas papel 'client'**: O papel 'admin' é reservado para uso futuro e não possui proteção ou fluxo de acesso implementados nesta versão.
|
||||
- **Sem refresh token**: A renovação automática do token de acesso está fora do escopo desta versão. O usuário precisará fazer login novamente após a expiração (7 dias).
|
||||
- **Sem rate limiting de login**: Limitação de tentativas de acesso é responsabilidade de infraestrutura e está fora do escopo desta feature.
|
||||
- **HTTPS em produção**: A segurança do token em trânsito é garantida pela camada de transporte. O sistema pressupõe ambiente HTTPS em produção.
|
||||
- **Token no armazenamento do navegador**: O token é mantido no armazenamento local do navegador; o logout é realizado removendo-o localmente (o servidor não invalida tokens emitidos, pois o sistema é stateless).
|
||||
- **Design conforme sistema vigente**: Telas de login e cadastro seguem o design system definido em `DESIGN.md` — tema Linear escuro com paleta de cores e tipografia já estabelecidas no projeto.
|
||||
251
.specify/features/005-authentication/tasks.md
Normal file
251
.specify/features/005-authentication/tasks.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Tasks: Sistema de Autenticação de Clientes
|
||||
|
||||
**Feature**: `005-authentication`
|
||||
**Branch**: `005-authentication`
|
||||
**Input**: `spec.md`, `plan.md`, `data-model.md`, `contracts/auth-api.md`, `quickstart.md`
|
||||
**Generated**: 2026-04-13
|
||||
**Status**: Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
- [ ] T[NNN] [P?] [USN?] Description — path/to/file.ext
|
||||
```
|
||||
|
||||
- **[P]** — Paralelizável (arquivo diferente, sem dependência de tarefa incompleta)
|
||||
- **[USN]** — User Story associada (US1–US4)
|
||||
- IDs sequenciais na ordem de execução recomendada
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup — Infraestrutura e Dependências
|
||||
|
||||
**Objetivo**: Instalar as bibliotecas necessárias e configurar a variável de ambiente do segredo JWT. Sem estas tarefas nenhum código de autenticação pode ser executado.
|
||||
|
||||
**⚠️ CRÍTICO**: Todas as fases seguintes dependem de T001 e T002 estarem concluídas.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T001 | S | — | plan.md §Primary Dependencies, quickstart.md §2 |
|
||||
| T002 | S | — | plan.md §Constraints, spec.md §FR-010, SC-007 |
|
||||
|
||||
- [X] T001 Adicionar `"PyJWT>=2.9"`, `"bcrypt>=4.2"` e `"pydantic[email]"` às dependências em `[project].dependencies` e executar `uv add "PyJWT>=2.9" "bcrypt>=4.2" "pydantic[email]"` para atualizar `uv.lock` — `backend/pyproject.toml`
|
||||
- **Done when**: `import jwt`, `import bcrypt` e `from pydantic import EmailStr` executam sem erro dentro do container; `uv.lock` reflete as três novas dependências; `uv run python -c "import jwt, bcrypt; from pydantic import EmailStr; print('ok')"` imprime `ok`.
|
||||
|
||||
- [X] T002 Adicionar variável `JWT_SECRET_KEY=<valor-gerado>` ao arquivo `backend/.env` (para desenvolvimento local) e como variável de ambiente no serviço `backend` do `docker-compose.yml`; o valor NUNCA deve ser string vazia ou placeholder óbvio como `"secret"` — `backend/.env` e `docker-compose.yml`
|
||||
- **Done when**: `docker-compose config` mostra `JWT_SECRET_KEY` definida para o serviço `backend`; `grep -r "JWT_SECRET_KEY" backend/app/` não retorna nenhuma ocorrência com valor embutido (apenas leituras via `os.environ` ou `current_app.config`); `backend/.env` contém chave com pelo menos 32 caracteres hexadecimais.
|
||||
|
||||
**Checkpoint Phase 1**: `uv run python -c "import jwt, bcrypt"` executa sem erro no container; `JWT_SECRET_KEY` disponível como variável de ambiente.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational — Modelo, Migration e Schemas Base
|
||||
|
||||
**Objetivo**: Criar a tabela `client_users` no banco de dados e os schemas Pydantic compartilhados que todas as user stories utilizam. Estas tarefas bloqueiam a implementação de qualquer endpoint.
|
||||
|
||||
**⚠️ CRÍTICO**: T005 (rotas de auth) e T009 (frontend types) não podem avançar sem T003 e T004 concluídos.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T003 | S | T001 | data-model.md §ClientUser, spec.md §Key Entities |
|
||||
| T004 | M | T003 | data-model.md §DDL, quickstart.md §3 |
|
||||
| T005 | S | T001 | data-model.md §Schemas Pydantic |
|
||||
|
||||
- [X] T003 Criar modelo SQLAlchemy `ClientUser` com colunas `id` (UUID PK, `default=uuid.uuid4`), `name` (String 150, NOT NULL), `email` (String 254, NOT NULL, unique=True, index=True), `password_hash` (String 100, NOT NULL), `role` (String 20, NOT NULL, `default='client'`), `created_at` (DateTime, NOT NULL, `server_default=func.now()`); importar o modelo em `backend/app/models/__init__.py` — `backend/app/models/user.py` e `backend/app/models/__init__.py`
|
||||
- **Done when**: `from app.models.user import ClientUser` importa sem erro; `ClientUser.__tablename__ == "client_users"`; `ClientUser.email` tem `unique=True` e `index=True`; `ClientUser.role` tem `default='client'`; `from app.models import ClientUser` importa sem erro (via `__init__.py`); Flask-Migrate detecta a tabela ao rodar `flask db migrate`.
|
||||
|
||||
- [X] T004 Gerar e aplicar migration Alembic criando a tabela `client_users` com todas as colunas e índice `ix_client_users_email` — `backend/migrations/versions/<hash>_add_client_users.py`
|
||||
- **Done when**: `uv run flask db migrate -m "add client_users"` cria arquivo de migration; revisão manual confirma `op.create_table("client_users", ...)` com colunas `id`, `name`, `email`, `password_hash`, `role`, `created_at` e `op.create_index("ix_client_users_email", "client_users", ["email"], unique=True)`; `flask db upgrade` executa sem erro; `flask db downgrade -1` reverte sem erro; `flask db upgrade` re-aplica sem erro.
|
||||
|
||||
- [X] T005 [P] Criar schemas Pydantic `RegisterIn` (name: str min=1/max=150, email: EmailStr normalizado lowercase, password: str min=8), `LoginIn` (email: EmailStr normalizado lowercase, password: str), `UserOut` (id: UUID, name: str, email: str, role: str, created_at: datetime; `model_config = ConfigDict(from_attributes=True)`), `AuthTokenOut` (access_token: str, user: UserOut) — `backend/app/schemas/auth.py`
|
||||
- **Done when**: `from app.schemas.auth import RegisterIn, LoginIn, UserOut, AuthTokenOut` importa sem erro; `RegisterIn(name="A", email="TESTE@EX.COM", password="12345678").email == "teste@ex.com"` (normalização lowercase); `RegisterIn(name="A", email="ok@ok.com", password="1234567")` levanta `ValidationError` (senha < 8 chars); `UserOut.model_validate(client_user_instance)` serializa sem `password_hash`; `AuthTokenOut(access_token="tok", user=user_out_instance)` serializa corretamente.
|
||||
|
||||
**Checkpoint Phase 2**: Tabela `client_users` existe no banco; `from app.schemas.auth import RegisterIn` funciona; `flask db upgrade` passa sem erro.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: US1 — Cadastro Público de Novo Cliente (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Qualquer visitante pode criar conta (POST /register); o sistema valida os dados, armazena senha com bcrypt e retorna token JWT + dados do usuário. O formulário de cadastro no frontend permite o fluxo completo.
|
||||
|
||||
**Independent Test**: `POST /api/v1/auth/register` com dados válidos retorna 201 + token; e-mail duplicado retorna 409; senha < 8 chars retorna 422; formulário no frontend completa o cadastro e redireciona para `/area-do-cliente`.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T006 | S | T005 | plan.md §require_auth decorator, spec.md §FR-008, FR-010 |
|
||||
| T007 | M | T003, T005, T006 | spec.md §API Contract, contracts/auth-api.md §register |
|
||||
| T008 | S | T007 | plan.md §backend/__init__.py |
|
||||
| T009 | S | — | plan.md §frontend types, spec.md §FR-011 |
|
||||
| T010 | S | T009 | plan.md §api.ts interceptor, spec.md §FR-015 |
|
||||
| T011 | S | T009 | plan.md §auth service, contracts/auth-api.md |
|
||||
| T012 | M | T009, T011 | plan.md §AuthContext, spec.md §FR-011, FR-013 |
|
||||
| T015 | M | T009, T011, T012 | spec.md §US1, FR-013, SC-001 |
|
||||
|
||||
- [X] T006 Criar decorator `require_auth` em `backend/app/utils/auth.py`: extrai Bearer token do header `Authorization`; decodifica via `jwt.decode(token, current_app.config["JWT_SECRET_KEY"], algorithms=["HS256"])`; em caso de token ausente, expirado ou inválido retorna `jsonify({"error": "Não autorizado."})` com status 401; em caso de sucesso, injeta `g.current_user_id = payload["sub"]` e chama o endpoint original — `backend/app/utils/auth.py`
|
||||
- **Done when**: `from app.utils.auth import require_auth` importa sem erro; rota decorada com `@require_auth` retorna 401 quando `Authorization` está ausente; retorna 401 com token manipulado; retorna 401 com token expirado (TTL forçado para 0s em teste); `g.current_user_id` contém o UUID como string após autenticação bem-sucedida.
|
||||
|
||||
- [X] T007 Criar blueprint `auth_bp` com prefixo `/api/v1/auth` contendo três endpoints: (1) `POST /register` — valida `RegisterIn`, verifica e-mail duplicado (retorna 409 se existir), faz hash da senha com `bcrypt.hashpw`, persiste `ClientUser`, emite JWT com `sub=str(user.id)` e `exp=utcnow+7dias`, retorna `AuthTokenOut` com status 201; (2) `POST /login` — valida `LoginIn`, busca usuário por email, verifica senha com `bcrypt.checkpw` (retorna 401 genérico se inválido), emite JWT, retorna `AuthTokenOut` com status 200; (3) `GET /me` protegida por `@require_auth` — busca usuário por `g.current_user_id`, retorna `UserOut` com status 200 (retorna 401 se usuário não encontrado) — `backend/app/routes/auth.py`
|
||||
- **Done when**: `POST /api/v1/auth/register` com dados válidos retorna 201 com `access_token` e `user` (sem `password_hash`); segundo POST com mesmo e-mail retorna 409; POST com senha de 5 chars retorna 422; `POST /api/v1/auth/login` com credenciais corretas retorna 200 + token; login com e-mail errado retorna 401; login com senha errada retorna 401 (mesma mensagem genérica); `GET /api/v1/auth/me` com token válido retorna 200 com `id`, `name`, `email`, `role`, `created_at`; `GET /api/v1/auth/me` sem token retorna 401.
|
||||
|
||||
- [X] T008 Registrar `auth_bp` na factory `create_app()` em `backend/app/__init__.py`: adicionar `from app.routes.auth import auth_bp` e `app.register_blueprint(auth_bp)`; garantir import de `ClientUser` antes do `db.create_all()` ou Alembic para que o modelo seja detectado; adicionar `app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]` (raise `KeyError` se ausente) — `backend/app/__init__.py`
|
||||
- **Done when**: Servidor Flask inicia sem erros após a alteração; `GET /api/v1/auth/register` retorna 405 (rota existe, método não permitido) — confirmando registro do blueprint; `app.config["JWT_SECRET_KEY"]` está definido em runtime; ausência da variável de ambiente no startup causa `KeyError` explícito (não silencioso).
|
||||
|
||||
- [X] T009 [P] Criar interfaces TypeScript `User` (id: string, name: string, email: string, role: string, created_at: string), `AuthTokenResponse` (access_token: string, user: User), `LoginCredentials` (email: string, password: string), `RegisterCredentials` (name: string, email: string, password: string, confirmPassword: string) e `AuthState` (user: User | null, isAuthenticated: boolean, isLoading: boolean) — `frontend/src/types/auth.ts`
|
||||
- **Done when**: `import { User, AuthState, AuthTokenResponse, LoginCredentials, RegisterCredentials } from '@/types/auth'` compila sem erro TypeScript; `RegisterCredentials` inclui `confirmPassword` (validação apenas no frontend); `AuthState.user` é `null` quando não autenticado.
|
||||
|
||||
- [X] T010 [P] Atualizar o arquivo de serviços para exportar instância Axios com `baseURL` do backend; adicionar interceptor de request que injeta `Authorization: Bearer <token>` quando `localStorage.getItem("auth_token")` não for null; adicionar interceptor de response que limpa `localStorage` e redireciona para `/login` em resposta 401 — `frontend/src/services/api.ts`
|
||||
- **Done when**: `import api from '@/services/api'` compila sem erro; requisição com token no `localStorage` inclui header `Authorization: Bearer <token>` (verificável no DevTools Network); requisição sem token não inclui o header; resposta 401 aciona limpeza do `localStorage` e navegação para `/login` sem loop (verificar que `/login` em si não dispara o interceptor em loop).
|
||||
|
||||
- [X] T011 [P] Criar serviço de autenticação com funções `registerUser(data: RegisterCredentials): Promise<AuthTokenResponse>`, `loginUser(data: LoginCredentials): Promise<AuthTokenResponse>` e `getMe(): Promise<User>`, todas chamando os endpoints do blueprint `auth_bp` via instância `api`; nenhuma URL hardcoded — `frontend/src/services/auth.ts`
|
||||
- **Done when**: `import { registerUser, loginUser, getMe } from '@/services/auth'` compila sem erro TypeScript; `registerUser` chama `POST /api/v1/auth/register`; `loginUser` chama `POST /api/v1/auth/login`; `getMe` chama `GET /api/v1/auth/me`; erros 4xx/5xx são propagados para o caller sem silenciamento; nenhuma URL hardcoded (usa instância `api` de `api.ts`).
|
||||
|
||||
- [X] T012 Criar `AuthContext` com `AuthProvider` exportado que: inicializa com `isLoading: true`; em `useEffect` inicial tenta `getMe()` (se `localStorage` tem `auth_token`), define `user` e `isAuthenticated: true` em sucesso, limpa token e define `isAuthenticated: false` em falha; expõe funções `login(credentials): Promise<void>` (chama `loginUser`, salva token, define user) e `register(credentials): Promise<void>` (chama `registerUser`, salva token, define user) e `logout(): void` (remove token, limpa user, navega para `/login`); exportar hook `useAuth()` que consome o contexto — `frontend/src/contexts/AuthContext.tsx`
|
||||
- **Done when**: `import { AuthProvider, useAuth } from '@/contexts/AuthContext'` compila sem erro; `useAuth()` fora do `AuthProvider` lança erro descritivo; após `login()` bem-sucedido `isAuthenticated === true` e `user` contém dados do servidor; `logout()` limpa estado e navega para `/login`; recarregar página com token válido no `localStorage` restaura sessão (`isAuthenticated: true`); recarregar com token expirado resulta em `isAuthenticated: false`.
|
||||
|
||||
- [X] T015 [US1] Criar `RegisterPage` com formulário contendo campos `name` (obrigatório), `email` (obrigatório, formato), `password` (obrigatório, mín. 8 chars), `confirmPassword` (obrigatório, deve igualar `password`); validação `confirmPassword !== password` impede envio e exibe mensagem no campo; ao submeter chama `register()` do `useAuth()`; em sucesso redireciona para `/area-do-cliente`; em erro de rede exibe mensagem amigável; exibe spinner no botão durante requisição; link para `/login` para usuários já cadastrados; segue design system DESIGN.md (fundo `#08090a`, painel `#0f1011`, accent `#5e6ad2`/`#7170ff`, tipografia Inter Variable) — `frontend/src/pages/RegisterPage.tsx`
|
||||
- **Done when**: Senha < 8 chars exibe erro de validação sem enviar requisição; `confirmPassword` diferente exibe "As senhas não coincidem" sem enviar; POST inválido (e-mail duplicado) exibe mensagem de erro amigável; POST válido redireciona para `/area-do-cliente`; botão desabilitado durante `isSubmitting`; `npm run build` não apresenta erros TypeScript; estilos correspondem ao design system vigente.
|
||||
|
||||
**Checkpoint Phase 3**: `POST /api/v1/auth/register` funciona end-to-end; formulário de cadastro no frontend completa o fluxo; usuário pode ver `/area-do-cliente` após cadastro.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: US2 — Login de Cliente Existente (Priority: P2)
|
||||
|
||||
**Goal**: Um cliente cadastrado pode autenticar-se com e-mail e senha, receber token JWT e ser redirecionado para `/area-do-cliente`. Erros de credencial retornam mensagem genérica.
|
||||
|
||||
**Independent Test**: Login com credenciais corretas redireciona para `/area-do-cliente`; e-mail errado retorna 401 com mensagem genérica; senha errada retorna 401 com mesma mensagem genérica; erro de rede exibe mensagem amigável.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T014 | M | T009, T011, T012 | spec.md §US2, FR-013, SC-002 |
|
||||
|
||||
> **Nota**: O endpoint `POST /api/v1/auth/login` foi implementado em T007 (Phase 3). Esta fase foca na interface do cliente.
|
||||
|
||||
- [X] T014 [US2] Criar `LoginPage` com formulário contendo campos `email` (obrigatório, formato) e `password` (obrigatório); ao submeter chama `login()` do `useAuth()`; em sucesso redireciona para `/area-do-cliente`; em erro 401 exibe "E-mail ou senha incorretos." sem indicar qual campo falhou; em erro de rede exibe "Erro de conexão. Tente novamente."; exibe spinner no botão durante requisição; link para `/cadastro` para novos usuários; segue design system DESIGN.md — `frontend/src/pages/LoginPage.tsx`
|
||||
- **Done when**: E-mail inválido (formato) exibe erro de validação sem enviar requisição; credenciais erradas exibem "E-mail ou senha incorretos." (mesmo texto para e-mail ou senha incorretos); credenciais corretas redirecionam para `/area-do-cliente`; botão desabilitado durante `isSubmitting`; `npm run build` não apresenta erros TypeScript; estilos correspondem ao design system vigente.
|
||||
|
||||
**Checkpoint Phase 4**: Login end-to-end funcional — cliente existente autentica-se e é redirecionado; 401 exibe mensagem genérica; erro de rede tratado.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: US3 — Acesso a Rotas Protegidas com Token (Priority: P3)
|
||||
|
||||
**Goal**: Rotas protegidas no frontend redirecionam para `/login` usuários não autenticados. Rotas protegidas no backend rejeitam requisições sem token válido com 401.
|
||||
|
||||
**Independent Test**: Acessar `/area-do-cliente` sem token redireciona para `/login`; com token válido renderiza conteúdo; token adulterado/expirado retorna 401 do backend e redireciona para `/login` no frontend.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T013 | S | T009, T012 | spec.md §US3, FR-012 |
|
||||
| T016 | M | T012, T013, T014, T015 | spec.md §FR-011, FR-012, FR-013, FR-015 |
|
||||
|
||||
> **Nota**: O decorator `@require_auth` foi implementado em T006 (Phase 3). Esta fase foca no roteamento protegido do frontend e na integração final do App.tsx.
|
||||
|
||||
- [X] T013 [P] [US3] Criar componente `ProtectedRoute` que consome `useAuth()`; enquanto `isLoading === true` renderiza spinner ou `null` (evita redirect prematuro); se `!isAuthenticated` retorna `<Navigate to="/login" replace />`; caso contrário renderiza `<Outlet />` — `frontend/src/components/ProtectedRoute.tsx`
|
||||
- **Done when**: Sem token no `localStorage`, acessar rota protegida redireciona para `/login`; com token válido, a rota protegida renderiza normalmente; durante `isLoading` (verificação inicial) não há redirect prematuro; `npm run build` não apresenta erros TypeScript.
|
||||
|
||||
- [X] T016 [US3] Atualizar `App.tsx` para: envolver toda a árvore de rotas com `<AuthProvider>`; adicionar rota `<Route path="/login" element={<LoginPage />} />`; adicionar rota `<Route path="/cadastro" element={<RegisterPage />} />`; criar grupo de rotas protegidas com `<Route element={<ProtectedRoute />}>` contendo `<Route path="/area-do-cliente" element={<ClientAreaPage />} />` (pode ser página placeholder); importar todos os novos componentes e páginas — `frontend/src/App.tsx`
|
||||
- **Done when**: `/login` renderiza `LoginPage`; `/cadastro` renderiza `RegisterPage`; `/area-do-cliente` sem autenticação redireciona para `/login`; `/area-do-cliente` com autenticação renderiza conteúdo (pode ser placeholder); `npm run build` sem erros TypeScript; `<AuthProvider>` envolve todas as rotas.
|
||||
|
||||
**Checkpoint Phase 5**: Fluxo completo de proteção funcional — usuário não autenticado é redirecionado; token inválido é tratado; `ProtectedRoute` garante isolamento.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: US4 — Visualização do Perfil do Cliente Autenticado (Priority: P4)
|
||||
|
||||
**Goal**: Cliente autenticado pode consultar seus dados de perfil via `GET /api/v1/auth/me`. A Navbar exibe nome do usuário + opção de logout quando autenticado.
|
||||
|
||||
**Independent Test**: `GET /api/v1/auth/me` com token válido retorna `id, name, email, role, created_at`; sem token retorna 401; Navbar exibe "Entrar" para visitantes e nome + "Sair" para autenticados.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T017 | S | T012 | spec.md §US4, FR-014, SC-005 |
|
||||
|
||||
> **Nota**: O endpoint `GET /api/v1/auth/me` foi implementado em T007 (Phase 3). Esta fase foca na Navbar.
|
||||
|
||||
- [X] T017 [US4] Atualizar `Navbar` para consumir `useAuth()`; quando `!isAuthenticated` exibe botão/link "Entrar" apontando para `/login`; quando `isAuthenticated` exibe nome do usuário (`user.name`) truncado se necessário + botão "Sair" que chama `logout()`; durante `isLoading` exibe placeholder neutro (evita flash de estado incorreto); garantir que o estado mude reativamente sem reload — `frontend/src/components/Navbar.tsx`
|
||||
- **Done when**: Navbar exibe "Entrar" para visitante; após login exibe nome do usuário e "Sair"; clicar em "Sair" chama `logout()`, limpa sessão e exibe "Entrar" novamente; durante `isLoading` não há flash de "Entrar"/"Sair"; `npm run build` não apresenta erros TypeScript.
|
||||
|
||||
**Checkpoint Phase 6**: Perfil consultável via API; Navbar reflete estado de autenticação reativamente.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Objetivo**: Validações finais de segurança, conformidade com requisitos não-funcionais e verificação do quickstart.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T018 | S | T007 | spec.md §SC-006, SC-007, FR-007 |
|
||||
| T019 | S | T001–T017 | quickstart.md §4, spec.md §SC-001–SC-003 |
|
||||
|
||||
- [X] T018 [P] Auditar `backend/app/routes/auth.py` e `backend/app/utils/auth.py` para garantir: (a) nenhuma resposta contém `password_hash`; (b) mensagens de erro de login são idênticas para e-mail inexistente e senha incorreta (SC-006); (c) `JWT_SECRET_KEY` nunca aparece em log ou resposta; (d) `bcrypt.checkpw` é chamado mesmo quando o usuário não existe (para evitar timing attack de enumeração de e-mail) — `backend/app/routes/auth.py` e `backend/app/utils/auth.py`
|
||||
- **Done when**: `grep -r "password_hash" backend/app/routes/ backend/app/schemas/` retorna apenas declarações de model, nunca em serialização de resposta; curl de login com e-mail inexistente e com senha errada retornam exatamente a mesma resposta JSON; `grep -r "JWT_SECRET_KEY" backend/app/` mostra apenas leituras via `current_app.config`, nunca o valor em si; revisão manual confirma `bcrypt.checkpw` executado mesmo para usuário não encontrado (dummy hash).
|
||||
|
||||
- [X] T019 Executar o roteiro completo do `quickstart.md`: instalar dependências, gerar/aplicar migration, testar os três endpoints via `Invoke-WebRequest` (register, login, me), verificar que o frontend completa o fluxo cadastro → área do cliente e login → área do cliente — `quickstart.md` (verificação, sem edição de código)
|
||||
- **Done when**: Todos os comandos do quickstart executam sem erro; `POST /register` retorna 201; `POST /login` retorna 200; `GET /me` com token retorna 200; frontend em `http://localhost:5173/cadastro` completa cadastro e redireciona; `http://localhost:5173/login` completa login e redireciona; `http://localhost:5173/area-do-cliente` sem sessão redireciona para `/login`.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
T001 ──┬── T003 ──── T004
|
||||
│ └── T007 ──────┐
|
||||
└── T005 ──── T007 │
|
||||
│
|
||||
T002 ──────────── T008 ◄────────┘
|
||||
|
||||
T006 ──────────── T007
|
||||
|
||||
T009 ──┬── T010
|
||||
├── T011 ──┬── T012 ──┬── T013 ──┐
|
||||
│ └── T015 ├── T014 │
|
||||
│ └── T015 │
|
||||
└── (via T012) │
|
||||
│
|
||||
T013 ──────────── T016 ◄────────────────┘
|
||||
T014 ──────────── T016
|
||||
T015 ──────────── T016
|
||||
|
||||
T012 ──────────── T017
|
||||
|
||||
T007, T016, T017 ── T018, T019
|
||||
```
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Backend e Frontend em paralelo
|
||||
|
||||
```
|
||||
# Terminal 1 — Backend
|
||||
T001 → T002 → T003 → T004 → T005 (paralelo com T003) →
|
||||
T006 → T007 → T008 → auditar T018
|
||||
|
||||
# Terminal 2 — Frontend (pode iniciar após T001)
|
||||
T009 → T010 (paralelo), T011 (paralelo) →
|
||||
T012 → T013 (paralelo), T014 (paralelo), T015 (paralelo) →
|
||||
T016 → T017
|
||||
```
|
||||
|
||||
### Componentes frontend em paralelo (após T012)
|
||||
|
||||
```
|
||||
# T013, T014, T015 podem ser implementados em paralelo
|
||||
# pois estão em arquivos distintos e dependem apenas de T012
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy (MVP Scope)
|
||||
|
||||
| Prioridade | Fase | User Story | Tarefas | Entregável |
|
||||
|---|---|---|---|---|
|
||||
| **MVP** | Setup + Foundational + Phase 3 | US1 (Cadastro) | T001–T008, T009–T012, T015 | Usuário pode se cadastrar e acessar área do cliente |
|
||||
| **Incremental** | Phase 4 | US2 (Login) | T014 | Usuário existente pode fazer login |
|
||||
| **Incremental** | Phase 5 | US3 (Proteção) | T013, T016 | Rotas protegidas e redirecionamento |
|
||||
| **Completude** | Phase 6 | US4 (Perfil) | T017 | Navbar com estado de autenticação |
|
||||
|
||||
**MVP mínimo**: T001 → T002 → T003 → T004 → T005 → T006 → T007 → T008 → T009 → T010 → T011 → T012 → T015 → T016 (básico)
|
||||
36
.specify/features/006-client-area/checklists/requirements.md
Normal file
36
.specify/features/006-client-area/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Área do Cliente
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items passed on first validation pass.
|
||||
- API Contract section included as supplementary reference (not part of spec template); aligns with Constitution Principle II (separation of concerns, API contract documented before implementation).
|
||||
- Spec is ready for `/speckit.plan`.
|
||||
143
.specify/features/006-client-area/contracts/admin.md
Normal file
143
.specify/features/006-client-area/contracts/admin.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Contract: Admin Endpoints
|
||||
|
||||
**Blueprint**: `admin_bp` — prefix `/api/v1/admin`
|
||||
**Auth**: JWT Bearer — `require_auth` — MVP: qualquer ClientUser autenticado
|
||||
**Dívida Técnica**: Verificação de role admin adiada para feature pós-MVP (ver Constitution Check no plan.md)
|
||||
|
||||
---
|
||||
|
||||
# POST /api/v1/admin/boletos
|
||||
|
||||
Cria um boleto para um cliente, opcionalmente vinculado a um imóvel.
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
POST /api/v1/admin/boletos
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"property_id": "4gb96g75-6828-5673-c4gd-3d074g77bg33",
|
||||
"description": "Aluguel referente a Maio/2026",
|
||||
"amount": 3500.00,
|
||||
"due_date": "2026-05-10",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Campos obrigatórios: `user_id`, `description`, `amount`, `due_date`.
|
||||
Campos opcionais: `property_id`, `url`.
|
||||
|
||||
---
|
||||
|
||||
## Response 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "d5be07h8-9012-6784-d5he-4e185h88ch33",
|
||||
"description": "Aluguel referente a Maio/2026",
|
||||
"amount": "3500.00",
|
||||
"due_date": "2026-05-10",
|
||||
"status": "pending",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 404 Not Found
|
||||
|
||||
```json
|
||||
{ "error": "Cliente não encontrado" }
|
||||
```
|
||||
|
||||
Quando `user_id` não corresponde a nenhum `ClientUser` existente.
|
||||
|
||||
---
|
||||
|
||||
## Response 422 Unprocessable Entity
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos",
|
||||
"details": ["user_id: field required", "amount: field required"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 401 Unauthorized
|
||||
|
||||
```json
|
||||
{ "error": "Token inválido ou ausente" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# PUT /api/v1/admin/visits/<id>/status
|
||||
|
||||
Atualiza o status de uma VisitRequest e opcionalmente define a data/hora agendada.
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
PUT /api/v1/admin/visits/b3fc85f6-1234-4562-b3fc-2c963f66af11/status
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "confirmed",
|
||||
"scheduled_at": "2026-05-01T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
`status` obrigatório: `"pending"` | `"confirmed"` | `"cancelled"` | `"completed"`
|
||||
`scheduled_at` opcional: ISO 8601 datetime string.
|
||||
|
||||
---
|
||||
|
||||
## Response 200 OK
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "b3fc85f6-1234-4562-b3fc-2c963f66af11",
|
||||
"property": {
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"title": "Apartamento 3 quartos Jardins",
|
||||
"slug": "apartamento-3-quartos-jardins"
|
||||
},
|
||||
"message": "Gostaria de visitar no final de semana.",
|
||||
"status": "confirmed",
|
||||
"scheduled_at": "2026-05-01T10:00:00Z",
|
||||
"created_at": "2026-04-13T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 404 Not Found
|
||||
|
||||
```json
|
||||
{ "error": "Visita não encontrada" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 422 Unprocessable Entity
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos",
|
||||
"details": ["status: value is not a valid enumeration member"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 401 Unauthorized
|
||||
|
||||
```json
|
||||
{ "error": "Token inválido ou ausente" }
|
||||
```
|
||||
52
.specify/features/006-client-area/contracts/me-boletos.md
Normal file
52
.specify/features/006-client-area/contracts/me-boletos.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Contract: GET /api/v1/me/boletos
|
||||
|
||||
**Blueprint**: `client_bp`
|
||||
**Auth**: JWT Bearer — `require_auth` — ClientUser only
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
GET /api/v1/me/boletos
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
Sem parâmetros de query ou corpo.
|
||||
|
||||
---
|
||||
|
||||
## Response 200 OK
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "d5be07h8-9012-6784-d5he-4e185h88ch33",
|
||||
"description": "Aluguel referente a Maio/2026",
|
||||
"amount": "3500.00",
|
||||
"due_date": "2026-05-10",
|
||||
"status": "pending",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
},
|
||||
{
|
||||
"id": "e6cf18i9-0123-7895-e6if-5f296i99di44",
|
||||
"description": "Taxa de condomínio Abril/2026",
|
||||
"amount": "450.00",
|
||||
"due_date": "2026-04-30",
|
||||
"status": "paid",
|
||||
"url": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Ordenado por `due_date ASC` (vencimentos próximos primeiro). Lista vazia `[]` quando sem boletos.
|
||||
|
||||
`url` é `null` quando o link ainda não foi preenchido pelo admin. O botão de acesso deve ser desabilitado neste caso.
|
||||
|
||||
---
|
||||
|
||||
## Response 401 Unauthorized
|
||||
|
||||
```json
|
||||
{ "error": "Token inválido ou ausente" }
|
||||
```
|
||||
134
.specify/features/006-client-area/contracts/me-favorites.md
Normal file
134
.specify/features/006-client-area/contracts/me-favorites.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Contract: GET /api/v1/me/favorites
|
||||
|
||||
**Blueprint**: `client_bp`
|
||||
**Auth**: JWT Bearer — `require_auth` — ClientUser only
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
GET /api/v1/me/favorites
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
Sem parâmetros de query ou corpo.
|
||||
|
||||
---
|
||||
|
||||
## Response 200 OK
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"property": {
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"title": "Apartamento 3 quartos Jardins",
|
||||
"slug": "apartamento-3-quartos-jardins",
|
||||
"price": "850000.00",
|
||||
"condo_fee": "1200.00",
|
||||
"type": "venda",
|
||||
"subtype": { "id": 1, "name": "Apartamento" },
|
||||
"bedrooms": 3,
|
||||
"bathrooms": 2,
|
||||
"parking_spots": 2,
|
||||
"area_m2": 120,
|
||||
"city": { "id": 1, "name": "São Paulo" },
|
||||
"neighborhood": { "id": 5, "name": "Jardins" },
|
||||
"is_featured": true,
|
||||
"photos": [
|
||||
{ "url": "https://...", "alt_text": "", "display_order": 0 }
|
||||
],
|
||||
"amenities": [{ "id": 1, "name": "Piscina" }],
|
||||
"address": "Rua das Flores, 100",
|
||||
"code": "AP001",
|
||||
"description": "Apartamento espaçoso com varanda gourmet."
|
||||
},
|
||||
"created_at": "2026-04-13T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Lista vazia `[]` quando o cliente não possui favoritos.
|
||||
|
||||
---
|
||||
|
||||
## Response 401 Unauthorized
|
||||
|
||||
```json
|
||||
{ "error": "Token inválido ou ausente" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Contract: POST /api/v1/me/favorites
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
POST /api/v1/me/favorites
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "property_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"property": { /* PropertyDetailOut completo */ },
|
||||
"created_at": "2026-04-13T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 409 Conflict
|
||||
|
||||
```json
|
||||
{ "error": "Imóvel já está nos favoritos" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 404 Not Found
|
||||
|
||||
```json
|
||||
{ "error": "Imóvel não encontrado" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 422 Unprocessable Entity
|
||||
|
||||
```json
|
||||
{ "error": "Dados inválidos", "details": ["property_id: field required"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Contract: DELETE /api/v1/me/favorites/<property_id>
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
DELETE /api/v1/me/favorites/3fa85f64-5717-4562-b3fc-2c963f66afa6
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response 204 No Content
|
||||
|
||||
Sem corpo.
|
||||
|
||||
---
|
||||
|
||||
## Response 404 Not Found
|
||||
|
||||
```json
|
||||
{ "error": "Favorito não encontrado" }
|
||||
```
|
||||
61
.specify/features/006-client-area/contracts/me-visits.md
Normal file
61
.specify/features/006-client-area/contracts/me-visits.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Contract: GET /api/v1/me/visits
|
||||
|
||||
**Blueprint**: `client_bp`
|
||||
**Auth**: JWT Bearer — `require_auth` — ClientUser only
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
```
|
||||
GET /api/v1/me/visits
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
Sem parâmetros de query ou corpo.
|
||||
|
||||
---
|
||||
|
||||
## Response 200 OK
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "b3fc85f6-1234-4562-b3fc-2c963f66af11",
|
||||
"property": {
|
||||
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"title": "Apartamento 3 quartos Jardins",
|
||||
"slug": "apartamento-3-quartos-jardins"
|
||||
},
|
||||
"message": "Gostaria de visitar no final de semana.",
|
||||
"status": "pending",
|
||||
"scheduled_at": null,
|
||||
"created_at": "2026-04-13T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "c4ad96g7-5678-5673-c4gd-3d074g77bg22",
|
||||
"property": {
|
||||
"id": "4gb96g75-6828-5673-c4gd-3d074g77bg33",
|
||||
"title": "Casa 4 quartos Alphaville",
|
||||
"slug": "casa-4-quartos-alphaville"
|
||||
},
|
||||
"message": "Tenho interesse em visitar esta semana.",
|
||||
"status": "confirmed",
|
||||
"scheduled_at": "2026-05-01T10:00:00Z",
|
||||
"created_at": "2026-04-10T08:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Ordenado por `created_at DESC`. Lista vazia `[]` quando sem visitas.
|
||||
|
||||
`property` pode ser `null` se o imóvel foi removido do banco.
|
||||
`scheduled_at` é `null` enquanto status for `pending`.
|
||||
|
||||
---
|
||||
|
||||
## Response 401 Unauthorized
|
||||
|
||||
```json
|
||||
{ "error": "Token inválido ou ausente" }
|
||||
```
|
||||
217
.specify/features/006-client-area/data-model.md
Normal file
217
.specify/features/006-client-area/data-model.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Data Model: Área do Cliente (Feature 006)
|
||||
|
||||
**Date**: 2026-04-13
|
||||
**Depends On**: Feature 005 — `client_users` table (ClientUser model)
|
||||
|
||||
---
|
||||
|
||||
## Entidades Novas
|
||||
|
||||
### SavedProperty
|
||||
|
||||
| Coluna | Tipo SQLAlchemy | Nullable | Restrições |
|
||||
|--------|----------------|----------|------------|
|
||||
| `id` | `UUID(as_uuid=True)` | NOT NULL | PK, default=uuid4 |
|
||||
| `user_id` | `UUID(as_uuid=True)` | NOT NULL | FK → `client_users.id` ON DELETE CASCADE |
|
||||
| `property_id` | `UUID(as_uuid=True)` | NULL | FK → `properties.id` ON DELETE SET NULL |
|
||||
| `created_at` | `DateTime` | NOT NULL | server_default=now() |
|
||||
|
||||
**Unique constraint**: `(user_id, property_id)` — `uq_saved_property_user_property`
|
||||
|
||||
**Relacionamentos**:
|
||||
- `user` → ClientUser (lazy="joined")
|
||||
- `property` → Property (lazy="joined") — usado para retornar detalhes do imóvel na rota favorites
|
||||
|
||||
**Lógica de remoção**: se o imóvel for deletado (`ON DELETE SET NULL`), `property_id` vira NULL; o registro SavedProperty é mantido para não perder histórico. A rota GET /me/favorites filtra registros onde `property.is_active = True` ou exibe badge "Imóvel indisponível".
|
||||
|
||||
---
|
||||
|
||||
### VisitRequest
|
||||
|
||||
| Coluna | Tipo SQLAlchemy | Nullable | Restrições |
|
||||
|--------|----------------|----------|------------|
|
||||
| `id` | `UUID(as_uuid=True)` | NOT NULL | PK, default=uuid4 |
|
||||
| `user_id` | `UUID(as_uuid=True)` | NULL | FK → `client_users.id` ON DELETE SET NULL |
|
||||
| `property_id` | `UUID(as_uuid=True)` | NULL | FK → `properties.id` ON DELETE SET NULL |
|
||||
| `message` | `Text` | NOT NULL | — |
|
||||
| `status` | `VARCHAR(20)` | NOT NULL | default=`'pending'`; valores: `pending`, `confirmed`, `cancelled`, `completed` |
|
||||
| `scheduled_at` | `DateTime` | NULL | Preenchido pelo admin ao confirmar |
|
||||
| `created_at` | `DateTime` | NOT NULL | server_default=now() |
|
||||
|
||||
**Relacionamentos**:
|
||||
- `user` → ClientUser (lazy="select")
|
||||
- `property` → Property (lazy="joined") — para retornar PropertyBrief embutido
|
||||
|
||||
**Transições de status**:
|
||||
```
|
||||
pending → confirmed (admin, com scheduled_at)
|
||||
pending → cancelled (admin)
|
||||
confirmed → completed (admin)
|
||||
confirmed → cancelled (admin)
|
||||
```
|
||||
*(Transições não são validadas em código no MVP — qualquer valor do enum é aceito via API admin)*
|
||||
|
||||
---
|
||||
|
||||
### Boleto
|
||||
|
||||
| Coluna | Tipo SQLAlchemy | Nullable | Restrições |
|
||||
|--------|----------------|----------|------------|
|
||||
| `id` | `UUID(as_uuid=True)` | NOT NULL | PK, default=uuid4 |
|
||||
| `user_id` | `UUID(as_uuid=True)` | NULL | FK → `client_users.id` ON DELETE SET NULL |
|
||||
| `property_id` | `UUID(as_uuid=True)` | NULL | FK → `properties.id` ON DELETE SET NULL |
|
||||
| `description` | `String(200)` | NOT NULL | — |
|
||||
| `amount` | `Numeric(12, 2)` | NOT NULL | — |
|
||||
| `due_date` | `Date` | NOT NULL | — |
|
||||
| `status` | `VARCHAR(20)` | NOT NULL | default=`'pending'`; valores: `pending`, `paid`, `overdue` |
|
||||
| `url` | `String(500)` | NULL | Link externo do boleto/PDF |
|
||||
| `created_at` | `DateTime` | NOT NULL | server_default=now() |
|
||||
|
||||
**Relacionamentos**:
|
||||
- `user` → ClientUser (lazy="select")
|
||||
- `property` → Property (lazy="joined") — para exibir imóvel vinculado na listagem
|
||||
|
||||
---
|
||||
|
||||
## Entidades Existentes com Impacto
|
||||
|
||||
### ClientUser (Feature 005 — pré-requisito)
|
||||
|
||||
Nenhuma alteração de schema. Relacionamentos inversos adicionados opcionalmente:
|
||||
```python
|
||||
saved_properties = db.relationship("SavedProperty", backref="user", ...) # opcional
|
||||
visit_requests = db.relationship("VisitRequest", ...) # opcional
|
||||
boletos = db.relationship("Boleto", ...) # opcional
|
||||
```
|
||||
*(Relacionamentos inversos são adicionados nos novos modelos via `backref`, não em ClientUser diretamente)*
|
||||
|
||||
### Property (existente)
|
||||
|
||||
Nenhuma alteração de schema. Relacionamentos inversos são implícitos via `backref` nos novos modelos.
|
||||
|
||||
---
|
||||
|
||||
## Migração Alembic
|
||||
|
||||
Nome do arquivo: `xxxx_add_saved_properties_visit_requests_boletos.py`
|
||||
|
||||
```sql
|
||||
-- saved_properties
|
||||
CREATE TABLE saved_properties (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES client_users(id) ON DELETE CASCADE,
|
||||
property_id UUID REFERENCES properties(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_saved_property_user_property UNIQUE (user_id, property_id)
|
||||
);
|
||||
|
||||
-- visit_requests
|
||||
CREATE TABLE visit_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES client_users(id) ON DELETE SET NULL,
|
||||
property_id UUID REFERENCES properties(id) ON DELETE SET NULL,
|
||||
message TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
scheduled_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- boletos
|
||||
CREATE TABLE boletos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES client_users(id) ON DELETE SET NULL,
|
||||
property_id UUID REFERENCES properties(id) ON DELETE SET NULL,
|
||||
description VARCHAR(200) NOT NULL,
|
||||
amount NUMERIC(12, 2) NOT NULL,
|
||||
due_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
url VARCHAR(500),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Downgrade**: `DROP TABLE boletos; DROP TABLE visit_requests; DROP TABLE saved_properties;`
|
||||
|
||||
---
|
||||
|
||||
## Schemas Pydantic (backend/app/schemas/client_area.py)
|
||||
|
||||
### Saída
|
||||
|
||||
```python
|
||||
class PropertyBrief(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
slug: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class SavedPropertyOut(BaseModel):
|
||||
property: PropertyDetailOut # reutiliza schema existente de property.py
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class VisitRequestOut(BaseModel):
|
||||
id: UUID
|
||||
property: PropertyBrief | None
|
||||
message: str
|
||||
status: str
|
||||
scheduled_at: datetime | None
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class BoletoOut(BaseModel):
|
||||
id: UUID
|
||||
description: str
|
||||
amount: Decimal
|
||||
due_date: date
|
||||
status: str
|
||||
url: str | None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
```
|
||||
|
||||
### Entrada
|
||||
|
||||
```python
|
||||
class FavoriteIn(BaseModel):
|
||||
property_id: UUID
|
||||
|
||||
class VisitStatusIn(BaseModel):
|
||||
status: Literal["pending", "confirmed", "cancelled", "completed"]
|
||||
scheduled_at: datetime | None = None
|
||||
|
||||
class BoletoCreateIn(BaseModel):
|
||||
user_id: UUID
|
||||
property_id: UUID | None = None
|
||||
description: str
|
||||
amount: Decimal
|
||||
due_date: date
|
||||
url: str | None = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipos TypeScript (frontend/src/types/clientArea.ts)
|
||||
|
||||
```typescript
|
||||
export interface VisitRequest {
|
||||
id: string;
|
||||
property: { id: string; title: string; slug: string } | null;
|
||||
message: string;
|
||||
status: "pending" | "confirmed" | "cancelled" | "completed";
|
||||
scheduled_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Boleto {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
due_date: string;
|
||||
status: "pending" | "paid" | "overdue";
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ComparisonState {
|
||||
properties: Property[]; // max 3 — Property importado de types/property.ts
|
||||
}
|
||||
```
|
||||
113
.specify/features/006-client-area/plan.md
Normal file
113
.specify/features/006-client-area/plan.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Implementation Plan: Área do Cliente
|
||||
|
||||
**Branch**: `master` | **Date**: 2026-04-13 | **Spec**: [spec.md](.specify/features/006-client-area/spec.md)
|
||||
**Input**: Feature specification from `.specify/features/006-client-area/spec.md`
|
||||
**Depends On**: Feature 005 — `ClientUser` model, `require_auth` decorator, JWT middleware
|
||||
|
||||
## Summary
|
||||
|
||||
Implementação da Área do Cliente: favoritos (persistidos no backend), comparação de imóveis (localStorage), histórico de visitas e boletos. O backend expõe dois blueprints novos (`/api/v1/me` e `/api/v1/admin`) com autenticação JWT. O frontend adiciona rotas protegidas sob `/area-do-cliente`, contextos React para favoritos e comparação, e componentes de interação (HeartButton, ComparisonBar).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
|
||||
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT (backend) · React 18, react-router-dom v6, Axios, Tailwind CSS 3.4 (frontend)
|
||||
**Storage**: PostgreSQL 16 — 3 novas tabelas: `saved_properties`, `visit_requests`, `boletos`
|
||||
**Testing**: pytest (backend) — testes de rotas com client fixture; Vite build check (frontend)
|
||||
**Target Platform**: Servidor Linux (Docker) + SPA na mesma origem via proxy Vite
|
||||
**Project Type**: Web service (Flask REST API) + SPA (React)
|
||||
**Performance Goals**: Favoritar/desfavoritar < 2 s; tabela de comparação renderizada < 1 s para 3 imóveis
|
||||
**Constraints**: Comparação não persiste no backend; admin sem UI no MVP; sem integração com gateway de pagamento
|
||||
**Scale/Scope**: MVP — funcionalidades essenciais da área logada; admin opera via API direta
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| I. Design-First | ✅ PASS | Todos os novos componentes React usam tokens do DESIGN.md: fundo `#08090a`, painéis `#0f1011`, acento `#5e6ad2`/`#7170ff`. Nenhum estilo inline fora do sistema. |
|
||||
| II. Separation of Concerns | ✅ PASS | Flask retorna JSON puro; React é SPA. Contextos (`FavoritesContext`, `ComparisonContext`) são camada de estado frontend apenas. |
|
||||
| III. Spec-Driven | ✅ PASS | spec.md aprovado → plan.md (este arquivo) → tasks.md → implementação. |
|
||||
| IV. Data Integrity | ✅ PASS | Todas as entradas via Pydantic schemas. `amount` usa `Numeric(12, 2)`. Novas tabelas via migração Alembic. Foreign keys explicitamente anuláveis ou não-anuláveis conforme modelo. |
|
||||
| V. Security | ⚠️ PARTIAL | `/api/v1/me/*` protegido por `require_auth`. `/api/v1/admin/*` protegido por `require_auth` mas **sem verificação de role no MVP** — qualquer ClientUser autenticado pode acessar rotas admin. FR-018 exige 403 para token de ClientUser; esta verificação é **adiada** e documentada como dívida técnica (ver Complexity Tracking). |
|
||||
| VI. Simplicity First | ✅ PASS | Comparação em localStorage (sem backend). Sem gateway de pagamento. Admin sem UI. Nenhuma abstração nova sem 3+ usos concretos. |
|
||||
|
||||
**POST-DESIGN RE-CHECK**: ✅ Após Phase 1, o modelo de dados não introduz complexidade adicional. Violação de Princípio V documentada como dívida técnica deliberada para MVP.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
.specify/features/006-client-area/
|
||||
├── plan.md # Este arquivo
|
||||
├── research.md # Fase 0 — decisões e alternativas
|
||||
├── data-model.md # Fase 1 — entidades e relacionamentos
|
||||
├── quickstart.md # Fase 1 — como rodar e testar localmente
|
||||
├── contracts/
|
||||
│ ├── me-favorites.md
|
||||
│ ├── me-visits.md
|
||||
│ ├── me-boletos.md
|
||||
│ └── admin.md
|
||||
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── models/
|
||||
│ │ ├── saved_property.py # NOVO — SavedProperty
|
||||
│ │ ├── visit_request.py # NOVO — VisitRequest
|
||||
│ │ ├── boleto.py # NOVO — Boleto
|
||||
│ │ └── client_user.py # PRÉ-REQUISITO (Feature 005)
|
||||
│ ├── schemas/
|
||||
│ │ └── client_area.py # NOVO — todos os schemas de entrada/saída
|
||||
│ ├── routes/
|
||||
│ │ ├── client_area.py # NOVO — blueprint client_bp (/api/v1/me)
|
||||
│ │ └── admin.py # NOVO — blueprint admin_bp (/api/v1/admin)
|
||||
│ └── __init__.py # ATUALIZAR — registrar modelos e blueprints
|
||||
└── migrations/
|
||||
└── versions/
|
||||
└── xxxx_add_saved_properties_visit_requests_boletos.py # NOVO
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── clientArea.ts # NOVO — VisitRequest, Boleto, ComparisonState
|
||||
├── contexts/
|
||||
│ ├── ComparisonContext.tsx # NOVO — estado de comparação (localStorage)
|
||||
│ └── FavoritesContext.tsx # NOVO — favoritos (backend, apenas logado)
|
||||
├── components/
|
||||
│ ├── ComparisonBar.tsx # NOVO — barra flutuante rodapé
|
||||
│ ├── HeartButton.tsx # NOVO — toggle favorito
|
||||
│ ├── PropertyCard.tsx # ATUALIZAR — HeartButton + botão Comparar
|
||||
│ └── PropertyDetail/
|
||||
│ └── ContactSection.tsx # ATUALIZAR — criar VisitRequest se logado
|
||||
├── layouts/
|
||||
│ └── ClientLayout.tsx # NOVO — sidebar da área do cliente
|
||||
├── pages/
|
||||
│ └── client/
|
||||
│ ├── ClientDashboardPage.tsx # NOVO
|
||||
│ ├── FavoritesPage.tsx # NOVO
|
||||
│ ├── ComparisonPage.tsx # NOVO
|
||||
│ ├── VisitsPage.tsx # NOVO
|
||||
│ └── BoletosPage.tsx # NOVO
|
||||
├── services/
|
||||
│ └── clientArea.ts # NOVO — getFavorites, addFavorite, removeFavorite, getVisits, getBoletos
|
||||
└── App.tsx # ATUALIZAR — rotas protegidas + providers de contexto
|
||||
|
||||
backend/tests/
|
||||
├── test_client_area.py # NOVO — testes de rotas /me e /admin
|
||||
└── conftest.py # ATUALIZAR — fixture client_user + auth token
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Option 2). Backend Flask REST API + frontend React SPA. Estrutura de arquivos segue o padrão já estabelecido no projeto (um arquivo por model, um arquivo por blueprint, schemas agrupados por domínio).
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violação | Por que necessária | Alternativa mais simples rejeitada porque |
|
||||
|----------|-------------------|-------------------------------------------|
|
||||
| Princípio V: rotas `/api/v1/admin/*` sem verificação de role no MVP | Admin opera via API direta; implementar RBAC completo exige tabela de roles, seed e testes adicionais fora do escopo desta feature | Adicionar `require_admin_role` desde já acoplaria esta feature a Feature 005 e bloquearia o MVP sem benefício real — único usuário da API admin é o próprio dono da aplicação |
|
||||
172
.specify/features/006-client-area/quickstart.md
Normal file
172
.specify/features/006-client-area/quickstart.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Quickstart: Área do Cliente (Feature 006)
|
||||
|
||||
**Pré-requisito**: Feature 005 (autenticação) implementada e funcionando.
|
||||
|
||||
---
|
||||
|
||||
## 1. Rodar o ambiente de desenvolvimento
|
||||
|
||||
```powershell
|
||||
# Na raiz do projeto
|
||||
.\start.ps1
|
||||
```
|
||||
|
||||
Isso inicia backend (Flask) na porta 5000 e frontend (Vite) na porta 5173 via Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## 2. Aplicar a migration
|
||||
|
||||
```bash
|
||||
# Dentro do container backend ou com uv no host
|
||||
docker compose exec backend flask db upgrade
|
||||
```
|
||||
|
||||
Verifica que as tabelas `saved_properties`, `visit_requests` e `boletos` foram criadas:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U postgres -d imobiliaria -c "\dt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Obter um token de ClientUser
|
||||
|
||||
```bash
|
||||
# Registrar um cliente (se Feature 005 estiver implementada)
|
||||
curl -X POST http://localhost:5000/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "João Silva", "email": "joao@test.com", "password": "Senha123!"}'
|
||||
|
||||
# Login para obter token
|
||||
curl -X POST http://localhost:5000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "joao@test.com", "password": "Senha123!"}'
|
||||
# → {"access_token": "eyJ..."}
|
||||
export TOKEN="eyJ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Testar rotas de favoritos
|
||||
|
||||
```bash
|
||||
# Listar favoritos (deve retornar [])
|
||||
curl http://localhost:5000/api/v1/me/favorites \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Adicionar imóvel aos favoritos (usar UUID de um imóvel existente)
|
||||
curl -X POST http://localhost:5000/api/v1/me/favorites \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"property_id": "<uuid-do-imovel>"}'
|
||||
# → 201
|
||||
|
||||
# Tentar adicionar novamente (deve retornar 409)
|
||||
curl -X POST http://localhost:5000/api/v1/me/favorites \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"property_id": "<uuid-do-imovel>"}'
|
||||
# → 409
|
||||
|
||||
# Remover favorito
|
||||
curl -X DELETE http://localhost:5000/api/v1/me/favorites/<uuid-do-imovel> \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# → 204
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Testar criação de boleto (admin)
|
||||
|
||||
```bash
|
||||
# Primeiro, obter o user_id do cliente criado
|
||||
USER_ID="<uuid-do-client-user>"
|
||||
|
||||
curl -X POST http://localhost:5000/api/v1/admin/boletos \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"user_id\": \"$USER_ID\",
|
||||
\"description\": \"Aluguel Maio/2026\",
|
||||
\"amount\": 3500.00,
|
||||
\"due_date\": \"2026-05-10\"
|
||||
}"
|
||||
# → 201
|
||||
|
||||
# Listar boletos do cliente
|
||||
curl http://localhost:5000/api/v1/me/boletos \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testar atualização de status de visita (admin)
|
||||
|
||||
```bash
|
||||
# Listar visitas (deve retornar [] inicialmente)
|
||||
curl http://localhost:5000/api/v1/me/visits \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Criar visita via formulário de contato (com token JWT no header)
|
||||
curl -X POST http://localhost:5000/api/v1/properties/<slug>/contact \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "João Silva", "email": "joao@test.com", "message": "Quero visitar."}'
|
||||
|
||||
# Obter ID da visita criada e atualizar status
|
||||
VISIT_ID="<uuid-da-visita>"
|
||||
curl -X PUT http://localhost:5000/api/v1/admin/visits/$VISIT_ID/status \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "confirmed", "scheduled_at": "2026-05-01T10:00:00"}'
|
||||
# → 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Rodar testes de backend
|
||||
|
||||
```bash
|
||||
docker compose exec backend uv run pytest tests/test_client_area.py -v
|
||||
```
|
||||
|
||||
Ou localmente (se Python 3.12 instalado):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest tests/test_client_area.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Acessar a área do cliente no navegador
|
||||
|
||||
1. Abrir http://localhost:5173
|
||||
2. Fazer login com as credenciais criadas no passo 3
|
||||
3. Navegar para http://localhost:5173/area-do-cliente
|
||||
4. Testar favoritar um imóvel do catálogo (coração no card)
|
||||
5. Acessar http://localhost:5173/area-do-cliente/favoritos e verificar o imóvel favoritado
|
||||
6. Adicionar imóveis à comparação (botão "Comparar" nos cards), acessar http://localhost:5173/area-do-cliente/comparar
|
||||
|
||||
---
|
||||
|
||||
## 9. UUID de imóveis existentes
|
||||
|
||||
Para obter UUIDs de imóveis para testar:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/api/v1/properties?per_page=3 | python -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solução de Problemas
|
||||
|
||||
| Problema | Causa Provável | Solução |
|
||||
|----------|----------------|---------|
|
||||
| 401 em todas as rotas `/me` | Feature 005 não implementada ou `require_auth` não disponível | Verificar se `ClientUser` model e `require_auth` decorator existem |
|
||||
| `relation "client_users" does not exist` | Migration da Feature 005 não aplicada | `flask db upgrade` para aplicar todas as migrations pendentes |
|
||||
| `relation "saved_properties" does not exist` | Migration desta feature não aplicada | `flask db upgrade` |
|
||||
| HeartButton não aparece nos cards | `FavoritesContext` não injetado no `App.tsx` | Verificar providers em `App.tsx` |
|
||||
| Rotas `/area-do-cliente/*` não redirecionam para login | `ProtectedRoute` não configurado | Verificar `App.tsx` e componente `ProtectedRoute` de Feature 005 |
|
||||
87
.specify/features/006-client-area/research.md
Normal file
87
.specify/features/006-client-area/research.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Research: Área do Cliente (Feature 006)
|
||||
|
||||
**Date**: 2026-04-13
|
||||
**Status**: Complete — todos os NEEDS CLARIFICATION resolvidos
|
||||
|
||||
---
|
||||
|
||||
## 1. Padrão de favoritos: backend vs. localStorage
|
||||
|
||||
**Decisão**: Favoritos persistidos no **backend** (tabela `saved_properties`).
|
||||
**Rationale**: Requisito explícito no spec (FR-003, SC-001): estado mantido entre sessões/dispositivos. localStorage não atende porque o usuário espera ver seus favoritos ao fazer login em outro dispositivo.
|
||||
**Alternativas consideradas**: localStorage com sync eventual — rejeitado; adiciona complexidade de sincronização sem benefício para o MVP.
|
||||
|
||||
---
|
||||
|
||||
## 2. Comparação: backend vs. localStorage
|
||||
|
||||
**Decisão**: Comparação persistida apenas em **localStorage** (chave `imob_comparison`).
|
||||
**Rationale**: Spec é explícito (FR-006, Assumptions): "usar armazenamento local do navegador é suficiente para os requisitos do MVP". Comparação é sessão temporária de decisão de compra, não dado de longo prazo.
|
||||
**Alternativas consideradas**: Persistir no backend com `comparison_lists` — rejeitado (YAGNI: nenhum requisito exige cross-device para comparação).
|
||||
|
||||
---
|
||||
|
||||
## 3. VisitRequest: criação automática via formulário de contato
|
||||
|
||||
**Decisão**: Integração **opcional no MVP** — apenas se o JWT válido estiver presente no header da requisição de contato. O endpoint `POST /api/v1/properties/<slug>/contact` cria VisitRequest quando autenticado; caso contrário, cria apenas ContactLead.
|
||||
**Rationale**: FR-012 e User Story 5 exigem a integração, mas é marcada como "opcional para MVP" no design. Evitar bloqueio da feature se Feature 005 não estiver completamente integrada. Abordagem: verificar `Authorization` header sem obrigar — se ausente ou inválido, seguir fluxo normal de ContactLead.
|
||||
**Alternativas consideradas**: Novo endpoint separado para criação de VisitRequest — rejeitado; duplica a UX de submissão do formulário de contato.
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin: verificação de role vs. require_auth apenas
|
||||
|
||||
**Decisão**: MVP usa apenas **`require_auth`** nas rotas admin, sem verificação de role.
|
||||
**Rationale**: Único usuário que acessa a API admin no MVP é o proprietário da aplicação. Implementar RBAC completo sem tabela de roles seria over-engineering. Dívida técnica documentada no Constitution Check.
|
||||
**Alternativas consideradas**: `require_admin_role` via campo `is_admin` no ClientUser — possível futuro; não implementar agora (YAGNI).
|
||||
|
||||
---
|
||||
|
||||
## 5. Carregamento do FavoritesContext
|
||||
|
||||
**Decisão**: `FavoritesContext` carrega a lista de favoritos via `GET /api/v1/me/favorites` na inicialização do `AuthContext` (quando `user !== null`). Armazena apenas os `UUIDs` dos imóveis favoritados em um `Set<string>`, não os objetos completos de Property.
|
||||
**Rationale**: O contexto precisa responder rapidamente ao estado "favoritado ou não" para cada card. Armazenar apenas IDs é O(1) para lookup. Os dados completos são carregados pela `FavoritesPage` sob demanda. Evita duplicar grande payload de imóveis no contexto global.
|
||||
**Alternativas consideradas**: Armazenar objetos Property completos no contexto — rejeitado; memory footprint desnecessário para o caso de uso principal (mostrar coração preenchido/vazio).
|
||||
|
||||
---
|
||||
|
||||
## 6. Estrutura do ClientLayout
|
||||
|
||||
**Decisão**: `ClientLayout.tsx` com sidebar lateral fixa em desktop, colapsável em mobile.
|
||||
**Rationale**: Padrão de dashboard consistente com DESIGN.md (fundo `#08090a`, painéis `#0f1011`). Sidebar com links: Dashboard, Favoritos, Comparar, Visitas, Boletos. User info no topo da sidebar.
|
||||
**Alternativas consideradas**: Tabs horizontais no topo — rejeitado; não escala bem com 5+ seções e viola o padrão de dashboard visual estabelecido.
|
||||
|
||||
---
|
||||
|
||||
## 7. Unicidade de SavedProperty
|
||||
|
||||
**Decisão**: Unique constraint na combinação `(user_id, property_id)` no banco de dados (além da validação no código).
|
||||
**Rationale**: FR-001 exige 409 para duplicata. A constraint no banco garante integridade mesmo em race conditions. O código verifica antes de inserir e retorna 409 em `IntegrityError` do SQLAlchemy.
|
||||
**Alternativas consideradas**: Verificar apenas em código — rejeitado; sujeito a race condition em inserts paralelos.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tipo monetário para `Boleto.amount`
|
||||
|
||||
**Decisão**: `Numeric(12, 2)` — mesmo padrão de `Property.price`.
|
||||
**Rationale**: Princípio IV da constituição: "Sensitive fields (e.g., prices, area measurements) MUST use appropriate numeric types — no float for money".
|
||||
**Alternativas consideradas**: `Float` — rejeitado explicitamente pela constituição.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ordenação das listas
|
||||
|
||||
**Decisão**:
|
||||
- `GET /api/v1/me/visits` → ordenado por `created_at DESC`
|
||||
- `GET /api/v1/me/boletos` → ordenado por `due_date ASC` (vencimentos próximos primeiro)
|
||||
- `GET /api/v1/me/favorites` → ordenado por `created_at DESC` (mais recentes primeiro)
|
||||
|
||||
**Rationale**: Visitas: mais recentes primeiro é padrão de log/histórico. Boletos: vencimentos próximos primeiro é mais útil financeiramente. Favoritos: mais recentes primeiro é o comportamento esperado numa lista de "wishlist".
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependência de Feature 005
|
||||
|
||||
**Decisão**: Assumir que `ClientUser`, `require_auth` e JWT middleware estão disponíveis.
|
||||
**Rationale**: Declarado explicitamente como pré-requisito no spec e nas instruções. Se Feature 005 ainda não estiver implementada, as rotas retornarão 500 até que o middleware seja injetado — identificável rapidamente em teste.
|
||||
**Alternativas consideradas**: Mock de `require_auth` para desenvolvimento paralelo — possível, mas não necessário; o plano assume sequência correta de implementação.
|
||||
247
.specify/features/006-client-area/spec.md
Normal file
247
.specify/features/006-client-area/spec.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Feature Specification: Área do Cliente
|
||||
|
||||
**Feature Branch**: `006-client-area`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
**Depends On**: Feature 005 (Autenticação de Clientes — ClientUser model e JWT middleware)
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Favoritar e Desfavoritar Imóvel (Priority: P1)
|
||||
|
||||
Um cliente autenticado pode favoritar um imóvel a partir do card ou da página de detalhes. O estado do botão de coração é persistido no backend e permanece entre sessões.
|
||||
|
||||
**Why this priority**: Favoritos é o recurso mais imediato de retenção do usuário na plataforma. Incentiva o retorno e aumenta o tempo de sessão.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma isolada ao verificar que um cliente autenticado consegue adicionar e remover um imóvel de favoritos, e que ao recarregar a página o estado é mantido.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o cliente está autenticado e navega pelo catálogo, **When** clica no botão de coração em um PropertyCard, **Then** o imóvel é adicionado aos favoritos, o coração fica preenchido e a ação é persistida na API.
|
||||
2. **Given** o imóvel já está favoritado, **When** o cliente clica no coração novamente, **Then** o imóvel é removido dos favoritos, o coração fica vazio e o backend reflete a remoção.
|
||||
3. **Given** o cliente não está autenticado, **When** clica no coração, **Then** é redirecionado para a página de login e, após autenticar, retorna ao imóvel.
|
||||
4. **Given** o cliente já favoritou o mesmo imóvel, **When** uma segunda requisição de adição é enviada, **Then** o sistema retorna 409 sem duplicar o registro.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Página de Favoritos (Priority: P2)
|
||||
|
||||
Um cliente autenticado acessa `/area-do-cliente/favoritos` e visualiza todos os imóveis que marcou como favoritos, podendo desfavoritar diretamente da lista.
|
||||
|
||||
**Why this priority**: Sem a página de favoritos o botão de coração não tem destino, tornando o recurso incompleto do ponto de vista do usuário.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando que a página exibe corretamente todos os imóveis favoritados e permite removê-los da lista.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o cliente possui imóveis favoritados, **When** acessa `/area-do-cliente/favoritos`, **Then** vê uma grade de PropertyCards com botão de desfavoritar em cada um.
|
||||
2. **Given** o cliente não possui nenhum favorito, **When** acessa a página, **Then** vê o estado vazio: "Nenhum favorito ainda".
|
||||
3. **Given** o cliente clica para desfavoritar na página de favoritos, **When** a remoção é confirmada, **Then** o card desaparece da lista sem recarregar a página inteira.
|
||||
4. **Given** o cliente não está autenticado, **When** acessa a rota diretamente, **Then** é redirecionado para a página de login.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Painel Principal (Dashboard) (Priority: P3)
|
||||
|
||||
Um cliente autenticado acessa `/area-do-cliente` e vê um painel de resumo com contadores e atalhos para as seções da área do cliente.
|
||||
|
||||
**Why this priority**: É o ponto de entrada da área do cliente; sem ele o usuário não tem orientação após o login.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando que os contadores exibidos refletem dados reais do cliente (favoritos, visitas pendentes, boletos ativos).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o cliente está autenticado, **When** acessa `/area-do-cliente`, **Then** vê cards de resumo mostrando: total de favoritos, visitas pendentes e boletos ativos.
|
||||
2. **Given** o cliente clica em um card de resumo (ex.: "Favoritos"), **Then** é navegado para a subseção correspondente.
|
||||
3. **Given** todos os contadores estão zerados, **When** o cliente acessa o painel, **Then** os cards exibem "0" sem mensagens de erro.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Comparar Imóveis (Priority: P4)
|
||||
|
||||
Um cliente pode adicionar até 3 imóveis a uma lista de comparação e visualizar uma tabela lado a lado em `/area-do-cliente/comparar`. A seleção é mantida localmente durante a sessão.
|
||||
|
||||
**Why this priority**: Recurso diferencial que ajuda na decisão de compra; não requer autenticação de dados no backend, sendo implementável de forma autônoma.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando a barra flutuante de comparação, adição/remoção de imóveis e a renderização correta da tabela comparativa.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o cliente visualiza um imóvel no catálogo, **When** clica em "Comparar", **Then** o imóvel é adicionado à barra flutuante de comparação no rodapé da tela.
|
||||
2. **Given** o cliente já tem 3 imóveis na comparação, **When** tenta adicionar um quarto, **Then** recebe uma mensagem informando que o limite de 3 imóveis foi atingido e não ocorre adição.
|
||||
3. **Given** o cliente tem ao menos 1 imóvel na barra, **When** clica em "Ver Comparação" ou acessa `/area-do-cliente/comparar`, **Then** vê uma tabela com colunas por imóvel e linhas para: preço, área, quartos, banheiros, vagas, condomínio, tipo, bairro e comodidades.
|
||||
4. **Given** o cliente clica em "Remover" em uma coluna da tabela, **Then** o imóvel é removido e a tabela é atualizada.
|
||||
5. **Given** o cliente recarrega a página, **Then** os imóveis selecionados para comparação são restaurados do armazenamento local.
|
||||
6. **Given** a lista de comparação está vazia e o cliente acessa `/area-do-cliente/comparar`, **Then** vê estado vazio com sugestão de selecionar imóveis do catálogo.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Histórico de Visitas (Priority: P5)
|
||||
|
||||
Um cliente autenticado acessa `/area-do-cliente/visitas` e visualiza o histórico de solicitações de visita com status atual.
|
||||
|
||||
**Why this priority**: Permite ao cliente acompanhar suas solicitações, reduzindo contato direto e dúvidas recorrentes para a equipe de vendas.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando que as visitas criadas ao submeter o formulário de contato (como usuário logado) aparecem listadas com os status corretos.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o cliente possui visitas cadastradas, **When** acessa `/area-do-cliente/visitas`, **Then** vê uma lista cronológica com: imóvel vinculado, mensagem enviada, status atual (badge colorido) e data agendada (quando confirmada).
|
||||
2. **Given** o status de uma visita é alterado pelo admin, **When** o cliente recarrega a página, **Then** o novo status é refletido.
|
||||
3. **Given** o cliente não tem nenhuma visita, **When** acessa a página, **Then** vê "Nenhuma visita agendada".
|
||||
4. **Given** o cliente está autenticado e submete o formulário de contato na página de um imóvel, **When** a solicitação é enviada, **Then** uma VisitRequest é criada com status "pending" e aparece no histórico.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 — Boletos (Priority: P6)
|
||||
|
||||
Um cliente autenticado acessa `/area-do-cliente/boletos` e visualiza os boletos criados pelo admin, podendo acessar o link de pagamento ou baixar o PDF.
|
||||
|
||||
**Why this priority**: Acesso a boletos é funcionalidade financeira crítica para clientes em processo de locação ou compra.
|
||||
|
||||
**Independent Test**: Pode ser testado com boletos criados diretamente via API de admin, verificando listagem e acesso ao link.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o cliente possui boletos vinculados, **When** acessa `/area-do-cliente/boletos`, **Then** vê uma tabela com: imóvel (quando vinculado), descrição, valor, vencimento, badge de status e botão para acessar o boleto.
|
||||
2. **Given** o cliente clica no botão de acesso ao boleto, **Then** é aberto o link/URL do boleto em nova aba.
|
||||
3. **Given** o boleto está com status "paid", **Then** o badge exibe "Pago" em cor distinta dos demais status.
|
||||
4. **Given** o cliente não possui boletos, **When** acessa a página, **Then** vê "Nenhum boleto disponível".
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 — Admin cria Boleto via API (Priority: P7)
|
||||
|
||||
Um administrador autentica na API e cria um boleto para um cliente, opcionalmente vinculado a um imóvel.
|
||||
|
||||
**Why this priority**: Backend necessário para suportar a P6; não há UI de admin no MVP.
|
||||
|
||||
**Independent Test**: Pode ser testado diretamente via chamada à API POST /api/v1/admin/boletos com token de admin.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin envia POST `/api/v1/admin/boletos` com os campos obrigatórios, **Then** o boleto é criado com status "pending" e retorna 201 com os dados do boleto.
|
||||
2. **Given** o campo `user_id` não corresponde a um ClientUser existente, **Then** a API retorna 404.
|
||||
3. **Given** campos obrigatórios estão ausentes (user_id, description, amount, due_date), **Then** a API retorna 422.
|
||||
|
||||
---
|
||||
|
||||
### User Story 8 — Admin atualiza status de Visita via API (Priority: P8)
|
||||
|
||||
Um administrador atualiza o status de uma solicitação de visita e opcionalmente define a data/hora agendada.
|
||||
|
||||
**Why this priority**: Necessário para o ciclo completo de visitas; sem esta operação o cliente nunca vê status diferente de "pending".
|
||||
|
||||
**Independent Test**: Pode ser testado via PUT `/api/v1/admin/visits/<id>/status` e verificando a mudança no retorno de GET /api/v1/me/visits.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o admin envia PUT `/api/v1/admin/visits/<id>/status` com `{"status": "confirmed", "scheduled_at": "2026-05-01T10:00:00"}`, **Then** a VisitRequest é atualizada e retorna 200.
|
||||
2. **Given** o id não existe, **Then** retorna 404.
|
||||
3. **Given** o valor de `status` não é um dos permitidos (pending/confirmed/cancelled/completed), **Then** retorna 422.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece quando o cliente tenta favoritar um imóvel que foi removido/desativado do catálogo? O registro SavedProperty permanece; o imóvel é exibido com indicação "Imóvel indisponível" ou omitido da listagem de favoritos.
|
||||
- O que acontece com a comparação se um dos imóveis armazenados no localStorage deixar de existir? A aplicação ignora silenciosamente o id inválido ao carregar e exibe apenas os imóveis válidos.
|
||||
- O que acontece quando a VisitRequest é criada e o cliente posteriormente envia o contato como anônimo (para o mesmo imóvel)? O ContactLead é criado normalmente (usuário anônimo) sem afetar a VisitRequest existente.
|
||||
- O que acontece com boletos cujo `url` é nulo? O botão de acesso é desabilitado ou ocultado; o boleto ainda aparece na listagem.
|
||||
- O que acontece se o token JWT expirar durante a navegação na área do cliente? O Axios interceptor (feature 005) redireciona para o login; a rota protegida bloqueia o acesso.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
**Favoritos**
|
||||
|
||||
- **FR-001**: O sistema DEVE permitir que clientes autenticados adicionem imóveis à lista de favoritos; tentativas de adicionar um imóvel já favoritado DEVEM retornar 409.
|
||||
- **FR-002**: O sistema DEVE permitir que clientes autenticados removam imóveis da lista de favoritos; tentativas de remover um imóvel não favoritado DEVEM retornar 404.
|
||||
- **FR-003**: A lista de favoritos de um cliente DEVE ser persistida no backend e recuperada entre sessões.
|
||||
- **FR-004**: O botão de coração DEVE refletir o estado de favorito do imóvel para o cliente autenticado corrente.
|
||||
|
||||
**Comparação**
|
||||
|
||||
- **FR-005**: A aplicação DEVE permitir que o usuário adicione até 3 imóveis à lista de comparação; a adição de um quarto imóvel DEVE ser bloqueada com mensagem de feedback.
|
||||
- **FR-006**: A lista de comparação DEVE ser persistida no armazenamento local do navegador e restaurada ao recarregar a página.
|
||||
- **FR-007**: A página de comparação DEVE exibir uma tabela lado a lado com as seguintes características: preço, área, quartos, banheiros, vagas, condomínio, tipo, bairro e comodidades.
|
||||
- **FR-008**: Uma barra flutuante de comparação DEVE ser exibida no rodapé sempre que houver ao menos 1 imóvel na lista.
|
||||
|
||||
**Painel do Cliente**
|
||||
|
||||
- **FR-009**: A rota `/area-do-cliente` DEVE exibir cards de resumo com o total de favoritos, o número de visitas com status "pending" e o número de boletos com status "pending".
|
||||
- **FR-010**: Todas as rotas sob `/area-do-cliente` DEVEM ser protegidas; clientes não autenticados DEVEM ser redirecionados para o login.
|
||||
|
||||
**Visitas**
|
||||
|
||||
- **FR-011**: A rota `/area-do-cliente/visitas` DEVE exibir todas as VisitRequests do cliente autenticado, ordenadas por data de criação decrescente.
|
||||
- **FR-012**: Ao submeter o formulário de contato na página de detalhes de um imóvel, se o usuário estiver autenticado, o sistema DEVE criar uma VisitRequest vinculada ao cliente além do ContactLead.
|
||||
- **FR-013**: O admin DEVE poder atualizar o status de uma VisitRequest via API, incluindo opcionalmente a data/hora agendada.
|
||||
|
||||
**Boletos**
|
||||
|
||||
- **FR-014**: A rota `/area-do-cliente/boletos` DEVE exibir todos os boletos do cliente autenticado, ordenados por data de vencimento decrescente.
|
||||
- **FR-015**: O admin DEVE poder criar boletos para qualquer cliente via API, com ou sem vínculo a um imóvel.
|
||||
- **FR-016**: Boletos com `url` preenchida DEVEM exibir um botão de acesso; boletos sem `url` DEVEM exibir o botão desabilitado.
|
||||
|
||||
**API**
|
||||
|
||||
- **FR-017**: Todas as rotas sob `/api/v1/me/` DEVEM exigir token JWT válido de ClientUser; ausência ou token inválido DEVE retornar 401.
|
||||
- **FR-018**: Todas as rotas sob `/api/v1/admin/` DEVEM exigir autenticação de admin; acesso com token de ClientUser DEVE retornar 403.
|
||||
|
||||
---
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **SavedProperty**: Associação entre um cliente e um imóvel favoritado. Atributos: identificador único, referência ao cliente, referência ao imóvel, data de criação. Unicidade garantida por par (cliente, imóvel).
|
||||
- **VisitRequest**: Solicitação de visita feita por um cliente para um imóvel. Atributos: identificador único, referência ao cliente, referência ao imóvel, mensagem livre, status do fluxo (pendente/confirmado/cancelado/concluído), data/hora agendada (opcional), data de criação.
|
||||
- **Boleto**: Documento de cobrança criado pelo admin para um cliente, opcionalmente vinculado a um imóvel. Atributos: identificador único, referência ao cliente, referência ao imóvel (opcional), descrição, valor monetário, data de vencimento, status (pendente/pago/vencido), URL de acesso (opcional), data de criação.
|
||||
- **ComparisonList** (frontend): Lista temporária de até 3 imóveis selecionados para comparação. Armazenada localmente no navegador; não persiste no backend.
|
||||
|
||||
---
|
||||
|
||||
## API Contract
|
||||
|
||||
> Todas as rotas requerem `Authorization: Bearer <token>` (client JWT, exceto rotas `/admin/` que requerem token de admin).
|
||||
|
||||
| Método | Rota | Corpo / Parâmetros | Respostas |
|
||||
|--------|------|--------------------|-----------|
|
||||
| GET | `/api/v1/me/favorites` | — | 200: `[{property completo}]` · 401 |
|
||||
| POST | `/api/v1/me/favorites` | `{property_id}` | 201 · 409 se já favoritado · 401 · 404 se imóvel não existe |
|
||||
| DELETE | `/api/v1/me/favorites/<property_id>` | — | 204 · 404 · 401 |
|
||||
| GET | `/api/v1/me/visits` | — | 200: `[{id, property:{id,title,slug}, message, status, scheduled_at, created_at}]` · 401 |
|
||||
| GET | `/api/v1/me/boletos` | — | 200: `[{id, description, amount, due_date, status, url}]` · 401 |
|
||||
| POST | `/api/v1/admin/boletos` | `{user_id, property_id?, description, amount, due_date, url?}` | 201: boleto criado · 404 cliente não existe · 422 campos inválidos · 401/403 |
|
||||
| PUT | `/api/v1/admin/visits/<id>/status` | `{status, scheduled_at?}` | 200: visita atualizada · 404 · 422 status inválido · 401/403 |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Clientes autenticados conseguem favoritar e desfavoritar imóveis em menos de 2 segundos por ação, com estado persistido entre sessões.
|
||||
- **SC-002**: A tabela de comparação é renderizada em menos de 1 segundo para até 3 imóveis; a seleção é restaurada corretamente ao recarregar a página.
|
||||
- **SC-003**: 100% das rotas da área do cliente bloqueiam acesso não autenticado, redirecionando para o login sem expor dados.
|
||||
- **SC-004**: O painel exibe contadores precisos — favoritos, visitas pendentes e boletos ativos — sem discrepâncias em relação ao banco de dados.
|
||||
- **SC-005**: Clientes conseguem localizar e acessar um boleto em até 3 cliques a partir do painel principal.
|
||||
- **SC-006**: Todas as mudanças de status de visita feitas pelo admin refletem no painel do cliente na próxima atualização de página.
|
||||
- **SC-007**: O formulário de contato na página de detalhes, quando submetido por cliente autenticado, cria a VisitRequest e ela aparece imediatamente no histórico de visitas.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O modelo `ClientUser` e o middleware de autenticação JWT (Feature 005) são pré-requisitos e estarão disponíveis antes da implementação desta feature.
|
||||
- O admin não terá uma interface gráfica no MVP; operações de admin (criação de boletos e atualização de status de visita) são executadas diretamente via API.
|
||||
- Boletos são gerados externamente; o sistema apenas armazena e exibe o link/URL de acesso. Nenhuma integração com gateway de pagamento é necessária no MVP.
|
||||
- A comparação de imóveis não será persistida no backend; usar armazenamento local do navegador é suficiente para os requisitos do MVP.
|
||||
- Uma VisitRequest é criada somente quando o cliente está autenticado. Usuários anônimos continuam gerando ContactLeads sem criar VisitRequests.
|
||||
- O status de boletos pode ser atualizado manualmente pelo admin via chamada à API (implícito no PUT /api/v1/admin/boletos/<id>) o que fica como extensão futura; no MVP o status só é definido na criação.
|
||||
- O design de todos os componentes segue o tema Linear dark definido em `DESIGN.md` (fundo `#08090a`, acento `#5e6ad2`/`#7170ff`).
|
||||
- A lista de favoritos retornada pela API inclui todos os detalhes do imóvel necessários para renderização do PropertyCard, sem chamadas adicionais.
|
||||
294
.specify/features/006-client-area/tasks.md
Normal file
294
.specify/features/006-client-area/tasks.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
# Tasks: Área do Cliente
|
||||
|
||||
**Feature**: `006-client-area`
|
||||
**Branch**: `master`
|
||||
**Input**: `spec.md`, `plan.md`, `data-model.md`, `contracts/me-favorites.md`, `contracts/me-visits.md`, `contracts/me-boletos.md`, `contracts/admin.md`, `quickstart.md`
|
||||
**Generated**: 2026-04-13
|
||||
**Depends On**: Feature 005 — `client_users` table, `require_auth` decorator, `ClientUser` model, `AuthProvider`, `AuthContext`
|
||||
**Status**: Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
- [ ] T[NNN] [P?] [USN?] Description — path/to/file.ext
|
||||
```
|
||||
|
||||
- **[P]** — Paralelizável (arquivo diferente, sem dependência de tarefa incompleta)
|
||||
- **[USN]** — User Story associada (US1–US8)
|
||||
- IDs sequenciais na ordem de execução recomendada
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational — Modelos SQLAlchemy e Migration
|
||||
|
||||
**Objetivo**: Criar os três modelos de dados novos (`SavedProperty`, `VisitRequest`, `Boleto`) e gerar a migration Alembic correspondente. Todas as rotas de backend dependem destas tarefas estarem concluídas.
|
||||
|
||||
**⚠️ CRÍTICO**: Nenhum endpoint de `/api/v1/me/*` ou `/api/v1/admin/*` pode ser implementado antes de T001–T005 estarem concluídos.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T001 | S | Feature 005 | data-model.md §SavedProperty |
|
||||
| T002 | S | Feature 005 | data-model.md §VisitRequest |
|
||||
| T003 | S | Feature 005 | data-model.md §Boleto |
|
||||
| T004 | S | T001, T002, T003 | plan.md §backend/app/models/__init__.py |
|
||||
| T005 | M | T004 | data-model.md §Migração Alembic |
|
||||
|
||||
- [ ] T001 [P] Criar modelo SQLAlchemy `SavedProperty` com colunas: `id` (UUID PK, `default=uuid4`), `user_id` (UUID NOT NULL, FK → `client_users.id` `ondelete="CASCADE"`), `property_id` (UUID NULL, FK → `properties.id` `ondelete="SET NULL"`), `created_at` (DateTime NOT NULL, `server_default=func.now()`); constraint única `uq_saved_property_user_property (user_id, property_id)`; relacionamentos `user` → `ClientUser` (`lazy="joined"`) e `property` → `Property` (`lazy="joined"`) — `backend/app/models/saved_property.py`
|
||||
- **Done when**: `from app.models.saved_property import SavedProperty` importa sem erro; `SavedProperty.__tablename__ == "saved_properties"`; `SavedProperty.user_id.property.foreign_keys` aponta para `client_users.id`; `SavedProperty.property_id` tem `nullable=True` e FK para `properties.id`; `flask db migrate` detecta o novo modelo.
|
||||
|
||||
- [ ] T002 [P] Criar modelo SQLAlchemy `VisitRequest` com colunas: `id` (UUID PK, `default=uuid4`), `user_id` (UUID NULL, FK → `client_users.id` `ondelete="SET NULL"`), `property_id` (UUID NULL, FK → `properties.id` `ondelete="SET NULL"`), `message` (Text NOT NULL), `status` (VARCHAR(20) NOT NULL, `default='pending'`), `scheduled_at` (DateTime NULL), `created_at` (DateTime NOT NULL, `server_default=func.now()`); relacionamentos `user` → `ClientUser` (`lazy="select"`) e `property` → `Property` (`lazy="joined"`) — `backend/app/models/visit_request.py`
|
||||
- **Done when**: `from app.models.visit_request import VisitRequest` importa sem erro; `VisitRequest.__tablename__ == "visit_requests"`; `VisitRequest.status.default.arg == "pending"`; `VisitRequest.scheduled_at` tem `nullable=True`; `VisitRequest.message` tem `nullable=False`.
|
||||
|
||||
- [ ] T003 [P] Criar modelo SQLAlchemy `Boleto` com colunas: `id` (UUID PK, `default=uuid4`), `user_id` (UUID NULL, FK → `client_users.id` `ondelete="SET NULL"`), `property_id` (UUID NULL, FK → `properties.id` `ondelete="SET NULL"`), `description` (String(200) NOT NULL), `amount` (Numeric(12, 2) NOT NULL), `due_date` (Date NOT NULL), `status` (VARCHAR(20) NOT NULL, `default='pending'`), `url` (String(500) NULL), `created_at` (DateTime NOT NULL, `server_default=func.now()`); relacionamentos `user` → `ClientUser` (`lazy="select"`) e `property` → `Property` (`lazy="joined"`) — `backend/app/models/boleto.py`
|
||||
- **Done when**: `from app.models.boleto import Boleto` importa sem erro; `Boleto.__tablename__ == "boletos"`; `Boleto.amount` é instância de `db.Numeric(12, 2)`; `Boleto.url` tem `nullable=True`; `Boleto.due_date` usa `db.Date`.
|
||||
|
||||
- [ ] T004 Importar `SavedProperty`, `VisitRequest` e `Boleto` em `backend/app/models/__init__.py` para que os modelos sejam detectados pelo Flask-SQLAlchemy e pelo Alembic na geração de migrations — `backend/app/models/__init__.py`
|
||||
- **Done when**: `from app.models import SavedProperty, VisitRequest, Boleto` importa sem erro; `flask db migrate` executado em branco não reporta novas tabelas (confirmando que os modelos já estão registrados no metadata do SQLAlchemy).
|
||||
|
||||
- [ ] T005 Gerar e revisar migration Alembic que cria as tabelas `saved_properties`, `visit_requests` e `boletos` com todas as colunas, foreign keys ON DELETE e constraint única conforme `data-model.md §Migração Alembic` — `backend/migrations/versions/<hash>_add_saved_properties_visit_requests_boletos.py`
|
||||
- **Done when**: `flask db migrate -m "add saved_properties visit_requests boletos"` cria o arquivo de migration; revisão manual confirma `op.create_table("saved_properties", ...)`, `op.create_table("visit_requests", ...)` e `op.create_table("boletos", ...)` com todas as colunas listadas em `data-model.md`; `op.create_unique_constraint("uq_saved_property_user_property", "saved_properties", ["user_id", "property_id"])` está presente; `flask db upgrade` executa sem erro; `flask db downgrade -1` reverte sem erro; `flask db upgrade` re-aplica sem erro.
|
||||
|
||||
**Checkpoint Phase 1**: `flask db upgrade` cria as três tabelas no banco; `from app.models import SavedProperty, VisitRequest, Boleto` importa sem erro em contexto de aplicativo Flask.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational — Schemas Pydantic e Blueprints
|
||||
|
||||
**Objetivo**: Criar os schemas Pydantic de entrada/saída e os dois blueprints Flask (`client_bp` e `admin_bp`), registrando-os na factory. Estas tarefas são pré-requisito para qualquer teste de endpoint.
|
||||
|
||||
**⚠️ CRÍTICO**: T007 e T008 (rotas) dependem de T006 (schemas). T009 finaliza o registro na aplicação.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T006 | M | T001, T002, T003 | contracts/ (todos), data-model.md |
|
||||
| T007 | M | T005, T006, Feature 005 | contracts/me-favorites.md, contracts/me-visits.md, contracts/me-boletos.md |
|
||||
| T008 | M | T005, T006, Feature 005 | contracts/admin.md, spec.md §US7, §US8 |
|
||||
| T009 | S | T007, T008 | plan.md §backend/app/__init__.py |
|
||||
|
||||
- [ ] T006 Criar schemas Pydantic em `backend/app/schemas/client_area.py`: `PropertyBrief` (id: UUID, title: str, slug: str), `FavoriteOut` (property: PropertyBrief | None, created_at: datetime), `VisitRequestOut` (id: UUID, property: PropertyBrief | None, message: str, status: str, scheduled_at: datetime | None, created_at: datetime), `BoletoOut` (id: UUID, description: str, amount: Decimal, due_date: date, status: str, url: str | None), `CreateBoletoIn` (user_id: UUID, description: str max_length=200, amount: Decimal ge=Decimal("0.01"), due_date: date, property_id: UUID | None = None, url: str | None = None, max_length=500), `UpdateVisitStatusIn` (status: Literal["pending", "confirmed", "cancelled", "completed"], scheduled_at: datetime | None = None); todos com `model_config = ConfigDict(from_attributes=True)` — `backend/app/schemas/client_area.py`
|
||||
- **Done when**: `from app.schemas.client_area import FavoriteOut, VisitRequestOut, BoletoOut, CreateBoletoIn, UpdateVisitStatusIn` importa sem erro; `CreateBoletoIn(user_id=uuid4(), description="X", amount=Decimal("100"), due_date=date.today())` valida sem erro; `CreateBoletoIn(..., amount=Decimal("-1"), ...)` levanta `ValidationError`; `UpdateVisitStatusIn(status="invalid")` levanta `ValidationError`; `UpdateVisitStatusIn(status="confirmed")` valida sem erro.
|
||||
|
||||
- [ ] T007 Criar blueprint `client_bp` com prefixo `/api/v1/me`; todos os endpoints decorados com `@require_auth` (Feature 005): `GET /favorites` → filtra `SavedProperty` por `user_id = g.current_user_id`, retorna lista de `FavoriteOut` (200), inclui `property=None` para imóveis deletados; `POST /favorites` → aceita `{"property_id": "<uuid>"}`, cria `SavedProperty`, retorna 201 — ou 409 `{"error": "Já adicionado aos favoritos"}` se registro duplicado; `DELETE /favorites/<property_id>` → remove `SavedProperty` do usuário, retorna 204 — ou 404 `{"error": "Favorito não encontrado"}` se não existir; `GET /visits` → retorna lista de `VisitRequestOut` do usuário ordenada por `created_at DESC` (200); `GET /boletos` → retorna lista de `BoletoOut` do usuário ordenada por `due_date ASC` (200) — `backend/app/routes/client_area.py`
|
||||
- **Done when**: `from app.routes.client_area import client_bp` importa sem erro; `GET /api/v1/me/favorites` sem token retorna 401; com token válido retorna 200 + lista JSON; `POST /api/v1/me/favorites` com `property_id` válido retorna 201; segunda POST com mesmo `property_id` retorna 409; `DELETE /api/v1/me/favorites/<id>` com id não favoritado retorna 404; com id favoritado retorna 204; `GET /api/v1/me/visits` retorna 200 + lista ordenada por `created_at DESC`; `GET /api/v1/me/boletos` retorna 200 + lista ordenada por `due_date ASC`.
|
||||
|
||||
- [ ] T008 Criar blueprint `admin_bp` com prefixo `/api/v1/admin`; endpoints protegidos por `@require_auth` (MVP sem verificação de role — comentário `# TODO: verificar role admin — dívida técnica MVP`): `POST /boletos` → valida `CreateBoletoIn`, busca `ClientUser` por `user_id` (retorna 404 `{"error": "Cliente não encontrado"}` se inexistente), persiste `Boleto`, retorna 201 com `BoletoOut`; `PUT /visits/<id>/status` → valida `UpdateVisitStatusIn`, busca `VisitRequest` por `id` (retorna 404 `{"error": "Visita não encontrada"}` se inexistente), atualiza `status` e `scheduled_at`, retorna 200 com `VisitRequestOut` — `backend/app/routes/admin.py`
|
||||
- **Done when**: `from app.routes.admin import admin_bp` importa sem erro; `POST /api/v1/admin/boletos` com campos obrigatórios retorna 201 com `id`, `description`, `amount`, `status="pending"`; `user_id` inexistente retorna 404 `{"error": "Cliente não encontrado"}`; campos obrigatórios ausentes retornam 422; `PUT /api/v1/admin/visits/<uuid>/status` com `{"status": "confirmed", "scheduled_at": "2026-05-01T10:00:00"}` retorna 200 com `id` e `status="confirmed"`; id inexistente retorna 404; `status="invalido"` retorna 422.
|
||||
|
||||
- [ ] T009 Registrar `client_bp` e `admin_bp` na factory `create_app()` com `app.register_blueprint(client_bp)` e `app.register_blueprint(admin_bp)` — `backend/app/__init__.py`
|
||||
- **Done when**: Flask inicia sem erros após a alteração; `GET /api/v1/me/favorites` sem token retorna 401 (rota existe); `POST /api/v1/admin/boletos` sem token retorna 401 (rota existe); `flask routes` lista as rotas `client_bp.*` e `admin_bp.*`.
|
||||
|
||||
**Checkpoint Phase 2**: `GET /api/v1/me/favorites` com token válido retorna `[]` (sem favoritos); `POST /api/v1/admin/boletos` com dados válidos retorna 201 com corpo JSON.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: US1 — Favoritar e Desfavoritar Imóvel (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Cliente autenticado consegue favoritar/desfavoritar imóvel pelo botão de coração no card ou na página de detalhe. Estado é persistido no backend e recuperado entre sessões.
|
||||
|
||||
**Independent Test**: Cliente autenticado adiciona favorito → recarrega página → coração permanece preenchido. Clica novamente → removido. Cliente não autenticado clica no coração → redirecionado para `/login`.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T010 | S | — | plan.md §frontend/types, spec.md §US1 |
|
||||
| T011 | S | T010 | contracts/me-favorites.md, plan.md §services/clientArea.ts |
|
||||
| T013 | M | T010, T011, Feature 005 (AuthContext) | plan.md §FavoritesContext, spec.md §FR-003, FR-004 |
|
||||
| T014 | S | T013 | plan.md §HeartButton, spec.md §US1 SC-001 |
|
||||
| T023 | M | T014, T012 | plan.md §PropertyCard.tsx, spec.md §US1 SC-001, §US4 SC-001 |
|
||||
|
||||
- [ ] T010 [P] [US1] Criar interfaces TypeScript: `PropertyBrief` (id: string, title: string, slug: string), `SavedFavorite` (property: PropertyBrief | null, created_at: string), `VisitRequest` (id: string, property: PropertyBrief | null, message: string, status: "pending" | "confirmed" | "cancelled" | "completed", scheduled_at: string | null, created_at: string), `Boleto` (id: string, description: string, amount: string, due_date: string, status: "pending" | "paid" | "overdue", url: string | null), `ComparisonState` (ids: string[], properties: Property[]) — `frontend/src/types/clientArea.ts`
|
||||
- **Done when**: `import { SavedFavorite, VisitRequest, Boleto, ComparisonState } from '@/types/clientArea'` compila sem erro TypeScript; `VisitRequest.status` aceita apenas os 4 literais; `Boleto.status` aceita apenas "pending" | "paid" | "overdue"; `Boleto.url` é `string | null`; `ComparisonState.properties` usa o tipo `Property` de `@/types/property`.
|
||||
|
||||
- [ ] T011 [P] [US1] Criar `frontend/src/services/clientArea.ts` exportando: `getFavorites(): Promise<SavedFavorite[]>` → `GET /api/v1/me/favorites`; `addFavorite(propertyId: string): Promise<void>` → `POST /api/v1/me/favorites`; `removeFavorite(propertyId: string): Promise<void>` → `DELETE /api/v1/me/favorites/<propertyId>`; `getVisits(): Promise<VisitRequest[]>` → `GET /api/v1/me/visits`; `getBoletos(): Promise<Boleto[]>` → `GET /api/v1/me/boletos`; todas usando a instância axios de `@/services/api` — `frontend/src/services/clientArea.ts`
|
||||
- **Done when**: `import { getFavorites, addFavorite, removeFavorite, getVisits, getBoletos } from '@/services/clientArea'` compila sem erro TypeScript; `addFavorite` envia `POST /api/v1/me/favorites` com `{ property_id: id }` no body; `removeFavorite` envia `DELETE /api/v1/me/favorites/<id>`; build TypeScript sem erros.
|
||||
|
||||
- [ ] T013 [US1] Criar `FavoritesContext` com `FavoritesProvider` que: ao montar, verifica autenticação via `AuthContext` e, se autenticado, carrega favoritos via `getFavorites()` armazenando `favoriteIds: string[]`; expõe `isFavorite(id: string): boolean` e `toggleFavorite(id: string): Promise<void>` — quando não autenticado `toggleFavorite` redireciona para `/login` via `useNavigate` sem chamar a API; quando autenticado, chama `addFavorite` ou `removeFavorite` conforme estado atual e atualiza `favoriteIds` localmente — `frontend/src/contexts/FavoritesContext.tsx`
|
||||
- **Done when**: `import { useFavorites, FavoritesProvider } from '@/contexts/FavoritesContext'` compila sem erro TypeScript; `isFavorite(id)` retorna `true` para id presente em `favoriteIds`; `toggleFavorite(id)` dispara `removeFavorite` quando já favoritado e `addFavorite` quando não favoritado; usuário não autenticado ao chamar `toggleFavorite` é navegado para `/login` sem chamada à API; build TypeScript sem erros.
|
||||
|
||||
- [ ] T014 [US1] Criar componente funcional `HeartButton` recebendo `propertyId: string`; usa `useFavorites()` para obter `isFavorite(propertyId)` e `toggleFavorite`; exibe SVG de coração preenchido (favoritado, cor `#7170ff`) ou vazio (não favoritado, cor `text-gray-400`) com Tailwind; exibe spinner ou opacidade reduzida durante loading da operação; tem `aria-label` dinâmico ("Adicionar aos favoritos" / "Remover dos favoritos"); `onClick` chama `toggleFavorite(propertyId)` e previne propagação do evento — `frontend/src/components/HeartButton.tsx`
|
||||
- **Done when**: `import HeartButton from '@/components/HeartButton'` compila sem erro TypeScript; `<HeartButton propertyId="test-id" />` renderiza sem erro; coração altera visual após `toggleFavorite`; `aria-label` reflete o estado; build TypeScript sem erros.
|
||||
|
||||
- [ ] T023 [US1] Adicionar `HeartButton` ao canto superior direito da imagem do `PropertyCard.tsx` (sobreposição com `absolute top-2 right-2`); adicionar botão "Comparar" / "Remover da comparação" ao lado do `HeartButton` usando `useComparison()` para chamar `toggleComparison(property)` — botão desabilitado com tooltip quando limite de 3 atingido e imóvel não está na lista; adicionar os mesmos dois botões na área de ações do cabeçalho de `PropertyDetailPage.tsx` — `frontend/src/components/PropertyCard.tsx` e `frontend/src/pages/PropertyDetailPage.tsx`
|
||||
- **Done when**: `PropertyCard` exibe `HeartButton` e botão Comparar sobrepostos na imagem sem quebrar layout; clicar no coração persiste favorito via `FavoritesContext`; clicar em "Comparar" adiciona ao `ComparisonContext`; `PropertyDetailPage` exibe ambos os botões; botão Comparar exibe "Remover da comparação" quando imóvel já está na lista; botão Comparar com `disabled` e tooltip explicativo ao tentar adicionar 4º item; build TypeScript sem erros.
|
||||
|
||||
**Checkpoint Phase 3 (US1)**: Click no coração do `PropertyCard` → favoritar → recarregar página → coração preenchido. Usuário não autenticado → redireciona para login.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: US2 — Página de Favoritos (Priority: P2)
|
||||
|
||||
**Goal**: Cliente acessa `/area-do-cliente/favoritos` e vê grade de imóveis favoritados. Pode desfavoritar diretamente da lista sem recarregar a página inteira.
|
||||
|
||||
**Independent Test**: Página exibe grade de `PropertyCard`; desfavoritar remove o card imediatamente; estado vazio "Nenhum favorito ainda" com link para o catálogo.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T016 | M | Feature 005 (AuthContext) | plan.md §ClientLayout, spec.md §US2–US6 |
|
||||
| T018 | S | T013, T016 | spec.md §US2 |
|
||||
|
||||
- [ ] T016 [US2] Criar `ClientLayout` com sidebar de navegação lateral contendo cinco links: Dashboard (`/area-do-cliente`), Favoritos (`/area-do-cliente/favoritos`), Comparar (`/area-do-cliente/comparar`), Visitas (`/area-do-cliente/visitas`), Boletos (`/area-do-cliente/boletos`); paleta DESIGN.md: fundo sidebar `bg-[#0f1011]`, texto `text-[#e2e2e2]`, item ativo com borda/fundo `#5e6ad2`; renderiza `<Outlet />` para a página filha; rota protegida — se `!isAuthenticated` redireciona para `/login` via `<Navigate replace />` — `frontend/src/layouts/ClientLayout.tsx`
|
||||
- **Done when**: `import ClientLayout from '@/layouts/ClientLayout'` compila sem erro TypeScript; sidebar renderiza os 5 links de navegação; link da rota ativa exibe estilo destacado; `<Outlet />` renderiza a página filha; usuário não autenticado acessando qualquer subrota de `/area-do-cliente` é redirecionado para `/login`; build TypeScript sem erros.
|
||||
|
||||
- [ ] T018 [US2] Criar `FavoritesPage` que fetcha `getFavorites()` no mount; exibe grade de `PropertyCard` com `HeartButton` para cada favorito; ao desfavoritar um item via `toggleFavorite`, remove o card da lista localmente (re-fetch ou filtro por `favoriteIds`); estado vazio exibe "Nenhum favorito ainda" com botão/link "Explorar imóveis" apontando para `/imoveis`; exibe skeleton durante loading — `frontend/src/pages/client/FavoritesPage.tsx`
|
||||
- **Done when**: `import FavoritesPage from '@/pages/client/FavoritesPage'` compila sem erro TypeScript; página exibe grade de cards quando há favoritos; desfavoritar um card o remove sem reload completo; estado vazio exibe "Nenhum favorito ainda" com link para catálogo; build TypeScript sem erros.
|
||||
|
||||
**Checkpoint Phase 4 (US2)**: `/area-do-cliente/favoritos` renderiza grade de favoritos; desfavoritar remove o card imediatamente do DOM.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: US3 — Painel Principal / Dashboard (Priority: P3)
|
||||
|
||||
**Goal**: Cliente acessa `/area-do-cliente` e vê painel com contadores de favoritos, visitas pendentes e boletos ativos, com links diretos para cada seção.
|
||||
|
||||
**Independent Test**: 3 cards de resumo com contadores reais; "0" exibido sem erros quando todos zerados; clicar em card navega para a seção correta.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T017 | S | T016, T011 | spec.md §US3 |
|
||||
|
||||
- [ ] T017 [US3] Criar `ClientDashboardPage` que ao montar fetcha em paralelo `getFavorites()`, `getVisits()` e `getBoletos()`; calcula: `total de favoritos`, `visitas com status="pending"`, `boletos com status="pending" ou "overdue"`; exibe 3 cards clicáveis com ícone, label e contador; cada card navega via `Link` para a subseção correspondente; exibe skeleton durante loading inicial — `frontend/src/pages/client/ClientDashboardPage.tsx`
|
||||
- **Done when**: `import ClientDashboardPage from '@/pages/client/ClientDashboardPage'` compila sem erro TypeScript; página exibe 3 cards de resumo com contadores; contadores exibem "0" sem erro quando dados vazios; card "Favoritos" navega para `/area-do-cliente/favoritos`; card "Visitas" navega para `/area-do-cliente/visitas`; card "Boletos" navega para `/area-do-cliente/boletos`; build TypeScript sem erros.
|
||||
|
||||
**Checkpoint Phase 5 (US3)**: `/area-do-cliente` renderiza painel com 3 cards; clicar em "Favoritos" navega para `/area-do-cliente/favoritos`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: US4 — Comparar Imóveis (Priority: P4)
|
||||
|
||||
**Goal**: Usuário adiciona até 3 imóveis à comparação (sem backend, apenas localStorage), vê barra flutuante no rodapé e acessa tabela comparativa lado a lado em `/area-do-cliente/comparar`.
|
||||
|
||||
**Independent Test**: Barra flutuante aparece ao adicionar imóvel; limite de 3 bloqueado com feedback; tabela comparativa exibe 9 linhas de atributos; localStorage persiste entre reloads.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T012 | M | T010 | plan.md §ComparisonContext, spec.md §US4, §FR-005–FR-009 |
|
||||
| T015 | S | T012 | plan.md §ComparisonBar, spec.md §US4 SC-001 |
|
||||
| T019 | M | T012 | spec.md §US4 |
|
||||
| T024 | S | T015, T022 | plan.md §App.tsx, spec.md §US4 SC-001 |
|
||||
|
||||
- [ ] T012 [P] [US4] Criar `ComparisonContext` com `ComparisonProvider` que: persiste `ids: string[]` em `localStorage` sob chave `comparison_ids` e `properties: Property[]` sob `comparison_properties`; restaura estado do localStorage na inicialização (ignora silenciosamente ids cujos dados não estejam disponíveis); expõe `comparisonItems: Property[]`, `isInComparison(id: string): boolean`, `toggleComparison(property: Property): void` — quando lista tem 3 itens e o imóvel não está nela, exibe `alert` ou `toast` "Limite de 3 imóveis para comparação atingido" e retorna sem adicionar; `clearComparison(): void` — limpa lista e localStorage — `frontend/src/contexts/ComparisonContext.tsx`
|
||||
- **Done when**: `import { useComparison, ComparisonProvider } from '@/contexts/ComparisonContext'` compila sem erro TypeScript; `toggleComparison(p1)` adiciona ao array; `isInComparison(p1.id)` retorna `true`; segundo `toggleComparison(p1)` remove; ao ter 3 items, `toggleComparison(p4)` não modifica o array e exibe feedback; `localStorage` atualizado após cada operação; reload da página restaura os items; `clearComparison()` limpa array e localStorage; build TypeScript sem erros.
|
||||
|
||||
- [ ] T015 [US4] Criar `ComparisonBar` barra flutuante renderizada no rodapé quando `comparisonItems.length > 0`: posição `fixed bottom-0 left-0 right-0 z-50`; fundo `bg-[#0f1011]` com borda superior `border-t border-[#5e6ad2]`; exibe thumbnails (foto + título truncado) dos imóveis selecionados; botão "×" por thumbnail chama `toggleComparison(item)` para remover; contador "N imóvel(is)"; botão "Ver Comparação" navega para `/area-do-cliente/comparar`; botão "Limpar" chama `clearComparison()`; barra ausente do DOM quando lista vazia — `frontend/src/components/ComparisonBar.tsx`
|
||||
- **Done when**: `import ComparisonBar from '@/components/ComparisonBar'` compila sem erro TypeScript; barra aparece com 1+ items de comparação; botão "×" remove o item; "Ver Comparação" navega para `/area-do-cliente/comparar`; "Limpar" esvazia a lista; barra não renderiza quando lista vazia; build TypeScript sem erros.
|
||||
|
||||
- [ ] T019 [US4] Criar `ComparisonPage` em `/area-do-cliente/comparar`: quando `comparisonItems.length > 0` exibe tabela HTML com cabeçalho (foto + título + botão "Remover" por coluna) e linhas para: Preço, Área (m²), Quartos, Banheiros, Vagas, Condomínio, Tipo, Bairro e Comodidades; quando lista vazia exibe estado vazio "Selecione imóveis no catálogo para comparar" com link `<Link to="/imoveis">` — `frontend/src/pages/client/ComparisonPage.tsx`
|
||||
- **Done when**: `import ComparisonPage from '@/pages/client/ComparisonPage'` compila sem erro TypeScript; tabela exibe colunas para cada imóvel na comparação; linha "Quartos" exibe valor correto para cada imóvel; botão "Remover" na coluna chama `toggleComparison` e remove a coluna; estado vazio exibe mensagem com link para `/imoveis`; build TypeScript sem erros.
|
||||
|
||||
- [ ] T024 [US4] Renderizar `<ComparisonBar />` no `App.tsx` fora do bloco `<Routes>` (após `</Routes>`), dentro dos providers `ComparisonProvider`, para que seja visível em todas as páginas — `frontend/src/App.tsx`
|
||||
- **Done when**: `ComparisonBar` é visível no catálogo de imóveis ao adicionar um imóvel; barra persiste ao navegar entre páginas; `ComparisonBar` não aparece quando lista de comparação está vazia; build TypeScript sem erros (esta task é executada junto com T022).
|
||||
|
||||
**Checkpoint Phase 6 (US4)**: Adicionar imóvel no catálogo → barra flutuante aparece no rodapé; recarregar página → imóveis restaurados do localStorage; `/area-do-cliente/comparar` exibe tabela comparativa.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: US5 — Histórico de Visitas (Priority: P5)
|
||||
|
||||
**Goal**: Cliente acessa `/area-do-cliente/visitas` e vê histórico de solicitações de visita com status atual, data agendada quando confirmada e imóvel vinculado.
|
||||
|
||||
**Independent Test**: Listagem exibe visitas com badge de status correto; `property=null` exibe "Imóvel removido"; estado vazio "Nenhuma visita agendada".
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T020 | S | T016, T011 | spec.md §US5, contracts/me-visits.md |
|
||||
|
||||
- [ ] T020 [US5] Criar `VisitsPage` que fetcha `getVisits()` e exibe lista cronológica (mais recente primeiro); cada item exibe: imóvel vinculado (link para `/imoveis/<slug>` com título, ou texto "Imóvel removido" quando `property` for null), mensagem enviada, badge de status colorido (`pending`=cinza/azul, `confirmed`=verde, `cancelled`=vermelho, `completed`=roxo) e data agendada formatada como `DD/MM/YYYY HH:mm` quando `scheduled_at` não for null; estado vazio "Nenhuma visita agendada"; skeleton durante loading — `frontend/src/pages/client/VisitsPage.tsx`
|
||||
- **Done when**: `import VisitsPage from '@/pages/client/VisitsPage'` compila sem erro TypeScript; página exibe lista de visitas com badge colorido por status; `property=null` exibe "Imóvel removido" sem erro; `scheduled_at` formatado quando presente; estado vazio exibe "Nenhuma visita agendada"; build TypeScript sem erros.
|
||||
|
||||
**Checkpoint Phase 7 (US5)**: `/area-do-cliente/visitas` renderiza histórico de visitas com badges de status corretos para cada item.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: US6 — Boletos (Priority: P6)
|
||||
|
||||
**Goal**: Cliente acessa `/area-do-cliente/boletos` e vê tabela de boletos com valor, vencimento, badge de status e botão de acesso ao link (desabilitado quando `url=null`).
|
||||
|
||||
**Independent Test**: Tabela com colunas corretas; badge "Pago" em verde; botão "Acessar Boleto" desabilitado quando `url=null`; estado vazio "Nenhum boleto disponível".
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T021 | S | T016, T011 | spec.md §US6, contracts/me-boletos.md |
|
||||
|
||||
- [ ] T021 [US6] Criar `BoletosPage` que fetcha `getBoletos()` e exibe tabela com colunas: Imóvel (título do `property` quando vinculado, ou "—"), Descrição, Valor (formatado como BRL, ex: `R$ 3.500,00`), Vencimento (formatado como `DD/MM/YYYY`), Status (badge: `pending`=amarelo, `paid`=verde, `overdue`=vermelho), Ação (botão "Acessar Boleto" com `target="_blank"` e `rel="noopener noreferrer"` — desabilitado/oculto quando `url` é `null`); estado vazio "Nenhum boleto disponível" — `frontend/src/pages/client/BoletosPage.tsx`
|
||||
- **Done when**: `import BoletosPage from '@/pages/client/BoletosPage'` compila sem erro TypeScript; tabela exibe todas as 6 colunas; botão "Acessar Boleto" tem `target="_blank"` e `rel="noopener noreferrer"`; botão desabilitado quando `url=null`; badge "Pago" exibe cor verde para `status="paid"`; estado vazio exibe "Nenhum boleto disponível"; build TypeScript sem erros.
|
||||
|
||||
**Checkpoint Phase 8 (US6)**: `/area-do-cliente/boletos` renderiza tabela de boletos; boleto com `url=null` desabilita botão de acesso.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: US7+US8 — Endpoints de Admin (Priority: P7/P8)
|
||||
|
||||
**Goal**: Admin cria boleto via `POST /api/v1/admin/boletos` (US7) e atualiza status de visita via `PUT /api/v1/admin/visits/<id>/status` (US8). Sem UI no MVP — operação exclusivamente via API.
|
||||
|
||||
*Ambos os endpoints foram implementados em T008 (Phase 2). Nenhuma task adicional nesta fase.*
|
||||
|
||||
**⚠️ Dívida Técnica MVP**: Verificação de role admin está ausente — qualquer `ClientUser` autenticado pode acessar estas rotas. Documentado em `plan.md §Constitution Check §V. Security` e marcado com comentário `# TODO` em `backend/app/routes/admin.py`.
|
||||
|
||||
**Checkpoint Phase 9 (US7+US8)**: Verificado no Checkpoint Phase 2. `POST /api/v1/admin/boletos` cria boleto; `PUT /api/v1/admin/visits/<id>/status` atualiza status.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Polish — Integração React (App.tsx + Providers)
|
||||
|
||||
**Goal**: Conectar todos os contextos e rotas protegidas da área do cliente no `App.tsx`; garantir que `AuthProvider`, `FavoritesProvider` e `ComparisonProvider` englobam toda a árvore de rotas; adicionar `ComparisonBar` globalmente.
|
||||
|
||||
| ID | Complexidade | Deps | spec_ref |
|
||||
|----|-------------|------|----------|
|
||||
| T022 | M | T012, T013, T016, T017, T018, T019, T020, T021, Feature 005 (AuthProvider) | plan.md §App.tsx, spec.md §FR-012 |
|
||||
|
||||
- [ ] T022 Atualizar `App.tsx` para envolver toda a árvore de rotas com `<AuthProvider>` (externo), `<FavoritesProvider>` e `<ComparisonProvider>` (nesta ordem de fora para dentro); adicionar bloco de rotas `<Route path="/area-do-cliente" element={<ClientLayout />}>` com rotas filhas: `index` → `<ClientDashboardPage />`, `favoritos` → `<FavoritesPage />`, `comparar` → `<ComparisonPage />`, `visitas` → `<VisitsPage />`, `boletos` → `<BoletosPage />`; renderizar `<ComparisonBar />` após `</Routes>` dentro dos providers (já cobre T024) — `frontend/src/App.tsx`
|
||||
- **Done when**: `vite build` completa sem erros TypeScript; `/area-do-cliente` renderiza `ClientDashboardPage`; `/area-do-cliente/favoritos` renderiza `FavoritesPage`; `/area-do-cliente/comparar` renderiza `ComparisonPage`; `/area-do-cliente/visitas` renderiza `VisitsPage`; `/area-do-cliente/boletos` renderiza `BoletosPage`; usuário não autenticado acessando `/area-do-cliente` é redirecionado para `/login`; `<ComparisonBar />` está visível em qualquer rota quando há itens de comparação; `useFavorites()` funciona em qualquer componente filho; `useComparison()` funciona em qualquer componente filho.
|
||||
|
||||
**Checkpoint Phase 10 (Polish)**: `vite build` passa sem erros; fluxo end-to-end: favoritar imóvel no catálogo → acessar `/area-do-cliente/favoritos` → ver imóvel na lista → desfavoritar → imóvel removido da lista.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Feature 005 (pré-requisito obrigatório)
|
||||
│
|
||||
├── T001 [P] ──┐
|
||||
├── T002 [P] ──┼── T004 ── T005 ── T006 ── T007 ──┐
|
||||
└── T003 [P] ──┘ T008 ──┘── T009
|
||||
│
|
||||
┌──────────────────────────────────────────┘
|
||||
│ (backend pronto — frontend independente começa em paralelo)
|
||||
│
|
||||
T010 [P] ─── T011 [P]
|
||||
│ │
|
||||
T013 ──── T014 T012 [P]
|
||||
│ │
|
||||
T023 ──────────────────┘
|
||||
│
|
||||
T016 (ClientLayout — base de todas as páginas)
|
||||
/ | | | \
|
||||
T017 T018 T019 T020 T021
|
||||
\
|
||||
T022 (App.tsx — conecta tudo + T024)
|
||||
```
|
||||
|
||||
## Estratégia de Implementação (MVP Incremental)
|
||||
|
||||
| Incremento | Tasks | Entregável verificável |
|
||||
|------------|-------|------------------------|
|
||||
| **MVP (US1)** | T001→T004→T005→T006→T007(favorites)→T009→T010→T011→T013→T014→T023→T022(parcial) | Favoritar/desfavoritar no catálogo com persistência |
|
||||
| **Incremento 1 (US2+US3)** | T016→T017→T018 | Área do cliente com dashboard e página de favoritos |
|
||||
| **Incremento 2 (US4)** | T012→T015→T019→T024→T022 | Comparação com barra flutuante e tabela |
|
||||
| **Incremento 3 (US5+US6)** | T020→T021 | Histórico de visitas e boletos |
|
||||
| **Incremento 4 (US7+US8)** | T008 (já feito) | Admin cria boletos e atualiza visitas via API |
|
||||
|
||||
---
|
||||
|
||||
## Sumário
|
||||
|
||||
| Métrica | Valor |
|
||||
|---------|-------|
|
||||
| Total de tasks | 24 |
|
||||
| Tasks backend | 9 (T001–T009) |
|
||||
| Tasks frontend | 15 (T010–T024) |
|
||||
| Tasks paralelizáveis [P] | 8 (T001, T002, T003, T010, T011, T012, T016's predecessor) |
|
||||
| User stories cobertas | 8 (US1–US8) |
|
||||
| Fases | 10 |
|
||||
| MVP mínimo (US1 only) | 12 tasks |
|
||||
94
.specify/features/007-admin-panel/contracts/boletos.md
Normal file
94
.specify/features/007-admin-panel/contracts/boletos.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Contract: Admin Boletos Endpoints
|
||||
|
||||
**Prefix:** `/api/v1/admin/boletos`
|
||||
**Auth:** JWT Bearer (admin)
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/boletos
|
||||
Lista todos os boletos.
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"description": "Aluguel Maio",
|
||||
"amount": 3500.00,
|
||||
"due_date": "2026-05-10",
|
||||
"status": "pending",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/admin/boletos
|
||||
Cria um novo boleto.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"description": "Aluguel Maio",
|
||||
"amount": 3500.00,
|
||||
"due_date": "2026-05-10",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 201
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"description": "Aluguel Maio",
|
||||
"amount": 3500.00,
|
||||
"due_date": "2026-05-10",
|
||||
"status": "pending",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/v1/admin/boletos/:id
|
||||
Atualiza um boleto existente.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"description": "Aluguel Maio",
|
||||
"amount": 3500.00,
|
||||
"due_date": "2026-05-10",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"description": "Aluguel Maio",
|
||||
"amount": 3500.00,
|
||||
"due_date": "2026-05-10",
|
||||
"status": "pending",
|
||||
"url": "https://boleto.banco.com.br/abc123"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/boletos/:id
|
||||
Remove um boleto.
|
||||
|
||||
### Response 204
|
||||
77
.specify/features/007-admin-panel/contracts/clientes.md
Normal file
77
.specify/features/007-admin-panel/contracts/clientes.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Contract: Admin Clientes Endpoints
|
||||
|
||||
**Prefix:** `/api/v1/admin/client-users`
|
||||
**Auth:** JWT Bearer (admin)
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/client-users
|
||||
Lista todos os clientes.
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "João",
|
||||
"email": "joao@email.com",
|
||||
"role": "client"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/admin/client-users
|
||||
Cria um novo cliente.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"name": "João",
|
||||
"email": "joao@email.com",
|
||||
"password": "senha123",
|
||||
"role": "client"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 201
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "João",
|
||||
"email": "joao@email.com",
|
||||
"role": "client"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/v1/admin/client-users/:id
|
||||
Atualiza um cliente existente.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"name": "João",
|
||||
"email": "joao@email.com",
|
||||
"role": "client"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "João",
|
||||
"email": "joao@email.com",
|
||||
"role": "client"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/client-users/:id
|
||||
Remove um cliente.
|
||||
|
||||
### Response 204
|
||||
28
.specify/features/007-admin-panel/contracts/favoritos.md
Normal file
28
.specify/features/007-admin-panel/contracts/favoritos.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Contract: Admin Favoritos Endpoints
|
||||
|
||||
**Prefix:** `/api/v1/admin/favoritos`
|
||||
**Auth:** JWT Bearer (admin)
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/favoritos
|
||||
Lista todos os favoritos de todos os clientes.
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"created_at": "2026-04-01T12:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/favoritos/:id
|
||||
Remove um favorito.
|
||||
|
||||
### Response 204
|
||||
81
.specify/features/007-admin-panel/contracts/properties.md
Normal file
81
.specify/features/007-admin-panel/contracts/properties.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Contract: Admin Properties Endpoints
|
||||
|
||||
**Prefix:** `/api/v1/admin/properties`
|
||||
**Auth:** JWT Bearer (admin)
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/properties
|
||||
Lista todos os imóveis cadastrados.
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Casa 1",
|
||||
"address": "Rua X, 123",
|
||||
"price": 500000.00,
|
||||
"status": "ativo"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/admin/properties
|
||||
Cria um novo imóvel.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"title": "Casa 1",
|
||||
"address": "Rua X, 123",
|
||||
"price": 500000.00,
|
||||
"status": "ativo"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 201
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Casa 1",
|
||||
"address": "Rua X, 123",
|
||||
"price": 500000.00,
|
||||
"status": "ativo"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/v1/admin/properties/:id
|
||||
Atualiza um imóvel existente.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"title": "Casa 1",
|
||||
"address": "Rua X, 123",
|
||||
"price": 500000.00,
|
||||
"status": "ativo"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Casa 1",
|
||||
"address": "Rua X, 123",
|
||||
"price": 500000.00,
|
||||
"status": "ativo"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/properties/:id
|
||||
Remove um imóvel.
|
||||
|
||||
### Response 204
|
||||
86
.specify/features/007-admin-panel/contracts/visitas.md
Normal file
86
.specify/features/007-admin-panel/contracts/visitas.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Contract: Admin Visitas Endpoints
|
||||
|
||||
**Prefix:** `/api/v1/admin/visitas`
|
||||
**Auth:** JWT Bearer (admin)
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/visitas
|
||||
Lista todos os pedidos de visita.
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"message": "Gostaria de agendar uma visita",
|
||||
"status": "pending",
|
||||
"scheduled_at": "2026-05-15T14:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/admin/visitas
|
||||
Cria um novo pedido de visita.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"message": "Gostaria de agendar uma visita",
|
||||
"status": "pending",
|
||||
"scheduled_at": "2026-05-15T14:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 201
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"message": "Gostaria de agendar uma visita",
|
||||
"status": "pending",
|
||||
"scheduled_at": "2026-05-15T14:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/v1/admin/visitas/:id
|
||||
Atualiza um pedido de visita.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"message": "Gostaria de agendar uma visita",
|
||||
"status": "confirmed",
|
||||
"scheduled_at": "2026-05-15T14:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Response 200
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"property_id": "uuid",
|
||||
"message": "Gostaria de agendar uma visita",
|
||||
"status": "confirmed",
|
||||
"scheduled_at": "2026-05-15T14:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/visitas/:id
|
||||
Remove um pedido de visita.
|
||||
|
||||
### Response 204
|
||||
28
.specify/features/007-admin-panel/plan.md
Normal file
28
.specify/features/007-admin-panel/plan.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Implementation Plan: Painel Administrativo
|
||||
|
||||
**Branch**: `007-admin-panel` | **Date**: 2026-04-13 | **Spec**: [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Implementar o painel admin com CRUD completo para imóveis, clientes, boletos, visitas e favoritos. Cada entidade terá tela dedicada, formulários de criação/edição, confirmação de remoção e integração com endpoints REST protegidos.
|
||||
|
||||
## Technical Context
|
||||
- React 18, TypeScript 5.5, Tailwind CSS 3.4
|
||||
- Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT
|
||||
- Endpoints REST sob `/api/v1/admin/*`
|
||||
|
||||
## Project Structure
|
||||
- `/admin/properties` — CRUD Imóveis
|
||||
- `/admin/clientes` — CRUD Clientes
|
||||
- `/admin/boletos` — CRUD Boletos
|
||||
- `/admin/visitas` — CRUD Visitas
|
||||
- `/admin/favoritos` — Visualização/remoção de favoritos
|
||||
|
||||
## UI/UX
|
||||
- Sidebar admin
|
||||
- Tabelas, formulários, modais
|
||||
- Mensagens de erro claras
|
||||
|
||||
## Security
|
||||
- Apenas admins autenticados acessam
|
||||
- JWT obrigatório
|
||||
32
.specify/features/007-admin-panel/spec.md
Normal file
32
.specify/features/007-admin-panel/spec.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Feature Specification: Painel Administrativo
|
||||
|
||||
**Feature Branch**: `007-admin-panel`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
|
||||
## Contexto
|
||||
|
||||
O painel administrativo permite que usuários com perfil admin gerenciem imóveis, clientes, boletos, visitas e favoritos. Todas as operações CRUD são protegidas por autenticação JWT e restrição de role.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Admin gerencia imóveis
|
||||
**Given** um admin autenticado, **When** acessa o painel, **Then** pode criar, editar, remover e listar imóveis.
|
||||
|
||||
### US2 — Admin gerencia clientes
|
||||
**Given** um admin autenticado, **When** acessa o painel, **Then** pode criar, editar, remover e listar clientes.
|
||||
|
||||
### US3 — Admin gerencia boletos
|
||||
**Given** um admin autenticado, **When** acessa o painel, **Then** pode criar, editar, remover e listar boletos.
|
||||
|
||||
### US4 — Admin gerencia visitas
|
||||
**Given** um admin autenticado, **When** acessa o painel, **Then** pode criar, editar, remover e listar visitas.
|
||||
|
||||
### US5 — Admin gerencia favoritos
|
||||
**Given** um admin autenticado, **When** acessa o painel, **Then** pode visualizar e remover favoritos de qualquer cliente.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Apenas admins autenticados acessam o painel
|
||||
- Todas as operações CRUD disponíveis para cada entidade
|
||||
- Validação e mensagens de erro amigáveis
|
||||
- UI consistente com o restante do sistema
|
||||
43
.specify/features/007-admin-panel/tasks.md
Normal file
43
.specify/features/007-admin-panel/tasks.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Tasks: Painel Administrativo
|
||||
|
||||
**Feature**: `007-admin-panel`
|
||||
**Branch**: `007-admin-panel`
|
||||
**Input**: `spec.md`, `plan.md`, `contracts/`
|
||||
**Generated**: 2026-04-13
|
||||
|
||||
---
|
||||
|
||||
|
||||
## CRUD Imóveis
|
||||
- [x] T001 Criar componente e rota de listagem de imóveis
|
||||
- [x] T002 Implementar formulário/modal de criação de imóvel
|
||||
- [x] T003 Implementar edição de imóvel
|
||||
- [x] T004 Implementar remoção de imóvel com confirmação
|
||||
|
||||
## CRUD Clientes
|
||||
- [x] T005 Criar componente e rota de listagem de clientes
|
||||
- [x] T006 Implementar formulário/modal de criação de cliente
|
||||
- [x] T007 Implementar edição de cliente
|
||||
- [x] T008 Implementar remoção de cliente com confirmação
|
||||
|
||||
## CRUD Boletos
|
||||
- [x] T009 Criar componente e rota de listagem de boletos
|
||||
- [x] T010 Implementar formulário/modal de criação de boleto
|
||||
- [x] T011 Implementar edição de boleto
|
||||
- [x] T012 Implementar remoção de boleto com confirmação
|
||||
|
||||
## CRUD Visitas
|
||||
- [x] T013 Criar componente e rota de listagem de visitas
|
||||
- [x] T014 Implementar formulário/modal de criação de visita
|
||||
- [x] T015 Implementar edição de visita
|
||||
- [x] T016 Implementar remoção de visita com confirmação
|
||||
|
||||
## Favoritos
|
||||
- [x] T017 Criar componente e rota de listagem de favoritos
|
||||
- [x] T018 Implementar remoção de favorito
|
||||
|
||||
## Segurança/Admin
|
||||
- [x] T019 Garantir proteção de rotas e autenticação admin
|
||||
|
||||
## Testes
|
||||
- [ ] T020 Testar todos os fluxos CRUD e mensagens de erro
|
||||
68
.specify/features/008-admin-properties-redesign/plan.md
Normal file
68
.specify/features/008-admin-properties-redesign/plan.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Plan: Redesign da Listagem de Imóveis (Admin)
|
||||
|
||||
**Feature**: `008-admin-properties-redesign`
|
||||
**Input**: `spec.md`, `DESIGN.md`
|
||||
|
||||
## Arquitetura
|
||||
|
||||
### Backend
|
||||
- `PropertyAdminOut` estendido com `photos: list[PhotoOut]`, `city_name`, `neighborhood_name`, `code`
|
||||
- `GET /api/v1/admin/properties` com query params: `?q`, `?city_id`, `?neighborhood_id`, `?page`, `?per_page`
|
||||
- `GET /api/v1/admin/cities` → lista todas as cidades
|
||||
- `GET /api/v1/admin/neighborhoods?city_id=` → bairros por cidade
|
||||
|
||||
### Frontend
|
||||
- `AdminPropertiesPage.tsx` reescrita com:
|
||||
- `FilterBar`: `<input>` busca + `<select>` cidade + `<select>` bairro (dependente)
|
||||
- `PropertyCard`: card com carrossel de imagens, badges, dados e ações
|
||||
- `Pagination`: controles prev/next com número de página
|
||||
- Debounce de 350ms no input de busca
|
||||
|
||||
## Design Tokens Utilizados
|
||||
| Elemento | Token Tailwind |
|
||||
|---|---|
|
||||
| Fundo da página | `bg-canvas` |
|
||||
| Cards | `bg-surface border border-borderPrimary` |
|
||||
| Texto principal | `text-textPrimary` |
|
||||
| Texto secundário | `text-textSecondary` |
|
||||
| Texto muted | `text-textTertiary` |
|
||||
| Botão primário | `bg-brand hover:bg-accentHover text-white` |
|
||||
| Input/Select | `bg-panel border border-borderPrimary text-textSecondary` |
|
||||
| Badge status ativo | `bg-statusEmerald/10 text-statusEmerald` |
|
||||
| Badge status inativo | `bg-red-500/10 text-red-400` |
|
||||
|
||||
## Estrutura de Dados
|
||||
|
||||
### `GET /admin/properties` response
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"total": 25,
|
||||
"page": 1,
|
||||
"per_page": 12,
|
||||
"pages": 3
|
||||
}
|
||||
```
|
||||
|
||||
### PropertyAdminOut (estendido)
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Apto 3 dorms",
|
||||
"address": "Rua X, 123",
|
||||
"price": 450000.00,
|
||||
"type": "venda",
|
||||
"bedrooms": 3,
|
||||
"bathrooms": 2,
|
||||
"parking_spots": 1,
|
||||
"area_m2": 85,
|
||||
"is_active": true,
|
||||
"is_featured": false,
|
||||
"code": "AP001",
|
||||
"city_name": "São Paulo",
|
||||
"neighborhood_name": "Vila Madalena",
|
||||
"photos": [
|
||||
{ "url": "https://...", "alt_text": "Sala", "display_order": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
44
.specify/features/008-admin-properties-redesign/spec.md
Normal file
44
.specify/features/008-admin-properties-redesign/spec.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Feature Specification: Redesign da Listagem de Imóveis (Admin)
|
||||
|
||||
**Feature Branch**: `008-admin-properties-redesign`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
A página de listagem de imóveis do admin está com design defasado (tabela simples, sem visual system). Esta feature moderniza a interface seguindo o Design System (DESIGN.md): cards com carrossel de imagens, barra de filtros numa linha (busca por nome, filtro de cidade, filtro de bairro) e paginação.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Admin filtra e busca imóveis (P1)
|
||||
**Given** admin na página `/admin/properties`, **When** digita no campo de busca ou seleciona cidade/bairro, **Then** a lista atualiza em tempo real (debounce) sem reload.
|
||||
|
||||
**Acceptance Scenarios:**
|
||||
1. **Given** lista com 30 imóveis, **When** digita "Apto", **Then** lista mostra apenas imóveis com "Apto" no título.
|
||||
2. **Given** filtro com cidade selecionada, **When** seleciona bairro do dropdown dependente, **Then** lista filtra por cidade+bairro.
|
||||
3. **Given** busca ativa, **When** limpa input, **Then** lista volta a mostrar todos.
|
||||
|
||||
### US2 — Admin navega com paginação (P1)
|
||||
**Given** admin com mais de 12 imóveis, **When** a página carrega, **Then** exibe no máximo 12 cards e controles de paginação.
|
||||
|
||||
**Acceptance Scenarios:**
|
||||
1. **Given** 25 imóveis, **When** página 1 carrega, **Then** exibe 12 cards e botão "Próxima".
|
||||
2. **Given** página 2 ativa, **When** clica "Anterior", **Then** volta para página 1.
|
||||
3. **Given** filtros ativos, **When** muda para próxima página, **Then** paginação respeita os filtros.
|
||||
|
||||
### US3 — Admin visualiza cards modernos com carrossel (P2)
|
||||
**Given** imóvel com múltiplas fotos, **When** o card é renderizado, **Then** exibe carrossel com navegação por setas e indicador de posição.
|
||||
|
||||
**Acceptance Scenarios:**
|
||||
1. **Given** imóvel com 3 fotos, **When** card renderiza, **Then** foto 1 é exibida com setas prev/next.
|
||||
2. **Given** seta "next" clicada, **When** última foto ativa, **Then** volta para a primeira (loop).
|
||||
3. **Given** imóvel sem fotos, **When** card renderiza, **Then** exibe placeholder com ícone de imagem.
|
||||
|
||||
## Acceptance Criteria Gerais
|
||||
- Design 100% fiel ao DESIGN.md (tokens de cor, tipografia, bordas)
|
||||
- Filtros na mesma linha horizontal (busca + cidade + bairro)
|
||||
- Cards em grid: 3 colunas (lg), 2 (md), 1 (sm)
|
||||
- Carrossel de imagens com loop, setas e contador "1/N"
|
||||
- Paginação com previous/next e indicador de página atual
|
||||
- Backend retorna fotos e metadados de cidade/bairro no payload
|
||||
- Sem regressões nas funcionalidades de criar/editar/remover
|
||||
22
.specify/features/008-admin-properties-redesign/tasks.md
Normal file
22
.specify/features/008-admin-properties-redesign/tasks.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Tasks: Redesign da Listagem de Imóveis (Admin)
|
||||
|
||||
**Feature**: `008-admin-properties-redesign`
|
||||
**Branch**: `008-admin-properties-redesign`
|
||||
**Input**: `spec.md`, `plan.md`
|
||||
**Generated**: 2026-04-14
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
- [x] T001 Estender `PropertyAdminOut` com photos, city_name, neighborhood_name, code
|
||||
- [x] T002 Adicionar paginação e filtros (q, city_id, neighborhood_id) no endpoint GET /admin/properties
|
||||
- [x] T003 Adicionar endpoint GET /admin/cities
|
||||
- [x] T004 Adicionar endpoint GET /admin/neighborhoods?city_id=
|
||||
|
||||
## Frontend
|
||||
|
||||
- [x] T005 Reescrever AdminPropertiesPage com FilterBar, cards grid e paginação
|
||||
- [x] T006 Implementar carrossel de imagens por card com setas e contador
|
||||
- [x] T007 Implementar dropdown de bairros dependente da cidade selecionada
|
||||
- [x] T008 Aplicar design tokens do DESIGN.md em toda a página
|
||||
34
.specify/features/009-property-form-completo/spec.md
Normal file
34
.specify/features/009-property-form-completo/spec.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Feature Specification: Formulário Completo de Imóvel (Admin)
|
||||
|
||||
**Feature Branch**: `009-property-form-completo`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
O formulário de criação/edição de imóveis no admin estava expondo apenas 3 campos (título, endereço, preço). Esta feature expande para o conjunto completo de dados de um imóvel, incluindo gestão de fotos por URL, separação de vagas cobertas/descobertas, e todos os campos de localização e características.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Admin cadastra imóvel completo (P1)
|
||||
**Given** admin clica em "+ Novo Imóvel", **When** preenche o formulário completo, **Then** o imóvel é criado com todos os dados e fotos.
|
||||
|
||||
**Acceptance Scenarios:**
|
||||
1. **Given** formulário aberto, **When** admin preenche nome e preço e envia, **Then** imóvel é criado (mínimo obrigatório).
|
||||
2. **Given** formulário com fotos adicionadas por URL, **When** salva, **Then** fotos aparecem no carrossel do card.
|
||||
3. **Given** formulário aberto para edição, **When** remove uma foto e salva, **Then** foto não aparece mais no card.
|
||||
|
||||
### US2 — Admin edita imóvel com todos os dados (P1)
|
||||
**Given** admin clica em "Editar" num card, **When** o painel lateral abre, **Then** todos os campos aparecem preenchidos com os dados atuais.
|
||||
|
||||
**Acceptance Scenarios:**
|
||||
1. **Given** imóvel com 3 fotos, **When** formulário de edição abre, **Then** 3 fotos aparecem na lista gerenciável.
|
||||
2. **Given** cidade selecionada no formulário, **When** admin abre dropdown de bairro, **Then** apenas bairros daquela cidade aparecem.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Campos obrigatórios: nome, preço, tipo (venda/aluguel)
|
||||
- Campos opcionais: código, endereço, cidade, bairro, quartos, banheiros, vagas cobertas, vagas descobertas, área, condomínio, descrição, fotos
|
||||
- Fotos gerenciadas por URL (add infinito, remoção individual)
|
||||
- Dropdowns de cidade/bairro dependentes
|
||||
- Design 100% fiel ao DESIGN.md
|
||||
- Formulário em painel lateral (slide-over full-height) para comportar todos os campos
|
||||
20
.specify/features/009-property-form-completo/tasks.md
Normal file
20
.specify/features/009-property-form-completo/tasks.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Tasks: Formulário Completo de Imóvel (Admin)
|
||||
|
||||
**Feature**: `009-property-form-completo`
|
||||
**Generated**: 2026-04-14
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
- [x] T001 Migration: adicionar coluna `parking_spots_covered` em `properties`
|
||||
|
||||
## Backend
|
||||
- [x] T002 Atualizar modelo Property com `parking_spots_covered`
|
||||
- [x] T003 Atualizar `PropertyCreateIn` com todos os campos + photos
|
||||
- [x] T004 Atualizar `PropertyAdminOut` com todos os campos
|
||||
- [x] T005 Atualizar endpoints create/update para processar fotos e todos os campos
|
||||
|
||||
## Frontend
|
||||
- [x] T006 Reescrever `PropertyForm.tsx` com todos os campos e seções
|
||||
- [x] T007 Gestão de fotos por URL (add infinito, remove individual)
|
||||
- [x] T008 Adaptar `AdminPropertiesPage` para painel lateral e callbacks completos
|
||||
42
.specify/features/010-property-form-v2/spec.md
Normal file
42
.specify/features/010-property-form-v2/spec.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Feature Specification: Cadastro Completo de Imóvel v2
|
||||
|
||||
**Feature Branch**: `010-property-form-v2`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
Expansão da feature 009 com: upload real de fotos, IPTU anual, validação completa, código auto-gerado, formulário full-screen, guard de saída não salva, e nova página de gestão de Cidades/Bairros/Amenidades.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Upload de fotos (P1)
|
||||
**Given** admin no formulário de imóvel, **When** arrasta ou seleciona fotos, **Then** o arquivo é enviado ao backend e a URL retornada é adicionada à lista.
|
||||
|
||||
### US2 — Código auto-gerado (P2)
|
||||
**Given** admin clica em novo imóvel, **When** formulário abre, **Then** campo código já vem preenchido com valor sequencial/único editável.
|
||||
|
||||
### US3 — Validação completa (P1)
|
||||
**Given** admin tenta salvar, **When** campo obrigatório vazio, **Then** campo fica destacado em vermelho com mensagem.
|
||||
|
||||
### US4 — Formulário full-screen (P2)
|
||||
**Given** admin clica em editar ou novo imóvel, **When** formulário abre, **Then** ocupa toda a área de conteúdo (respeitando sidebar + header).
|
||||
|
||||
### US5 — Guard de saída não salva (P2)
|
||||
**Given** admin editou campos mas não salvou, **When** tenta navegar ou fechar aba, **Then** alerta de confirmação aparece.
|
||||
|
||||
### US6 — Gestão de Cidades e Bairros (P1)
|
||||
**Given** admin acessa `/admin/cidades`, **When** página carrega, **Then** pode criar/editar/remover cidades e seus bairros.
|
||||
|
||||
### US7 — Gestão de Amenidades (P2)
|
||||
**Given** admin no formulário de imóvel, **When** seleciona amenidades, **Then** as selecionadas ficam marcadas; pode adicionar nova amenidade na hora.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Upload multipart de imagens (jpg/png/webp, máx 5 MB)
|
||||
- Código auto-gerado no padrão `IM-NNNN` baseado no último código existente
|
||||
- Campos obrigatórios com highlight visual e mensagem
|
||||
- IPTU anual (Decimal, opcional)
|
||||
- Formulário ocupa `calc(100vh - 56px)` com scroll interno por seção
|
||||
- `beforeunload` + `usePrompt` para guard de navegação
|
||||
- CRUD completo de cidades com aninhamento de bairros
|
||||
- Amenidades listadas por grupo com toggles, e botão para criar nova
|
||||
31
.specify/features/010-property-form-v2/tasks.md
Normal file
31
.specify/features/010-property-form-v2/tasks.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Tasks: Cadastro Completo de Imóvel v2
|
||||
|
||||
**Feature**: `010-property-form-v2`
|
||||
**Generated**: 2026-04-14
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
- [ ] T001 Migration: adicionar `iptu_anual` em `properties`
|
||||
|
||||
## Backend
|
||||
- [ ] T002 Endpoint `POST /admin/upload/photo` — multipart, retorna URL pública
|
||||
- [ ] T003 Servir arquivos de upload via Flask (static ou send_from_directory)
|
||||
- [ ] T004 Endpoint `GET /admin/next-property-code` — retorna próximo código sequencial
|
||||
- [ ] T005 Endpoints CRUD `/admin/cities` (POST, PUT, DELETE)
|
||||
- [ ] T006 Endpoints CRUD `/admin/neighborhoods` (POST, PUT, DELETE)
|
||||
- [ ] T007 Endpoints `GET /admin/amenities`, `POST`, `DELETE /:id`
|
||||
- [ ] T008 Endpoints `POST/DELETE /admin/properties/:id/amenities/:amenity_id`
|
||||
- [ ] T009 Incluir `iptu_anual` e `amenity_ids` nos schemas de property
|
||||
|
||||
## Frontend
|
||||
- [ ] T010 AdminCitiesPage — CRUD de cidades + bairros aninhados
|
||||
- [ ] T011 Adicionar link "Cidades" e "Amenidades" no AdminLayout
|
||||
- [ ] T012 AdminAmenitiesPage — CRUD de amenidades por grupo
|
||||
- [ ] T013 PropertyForm: layout full-screen (100vh - header - sidebar)
|
||||
- [ ] T014 PropertyForm: campo IPTU anual
|
||||
- [ ] T015 PropertyForm: upload de foto com preview + progress
|
||||
- [ ] T016 PropertyForm: campo código auto-preenchido na abertura
|
||||
- [ ] T017 PropertyForm: validação visual de campos obrigatórios
|
||||
- [ ] T018 PropertyForm: seção de amenidades multi-select por grupo
|
||||
- [ ] T019 PropertyForm: guard de saída não salva (beforeunload + prompt)
|
||||
321
.specify/features/011-enrich-client-profile/plan.md
Normal file
321
.specify/features/011-enrich-client-profile/plan.md
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
# Implementation Plan: Enriquecimento do Perfil de Cliente
|
||||
|
||||
**Branch**: `011-enrich-client-profile` | **Date**: 2026-04-14 | **Spec**: [spec.md](spec.md)
|
||||
**Depends On**: Feature 005 — `ClientUser` model base; Feature 007 — admin panel routes/pages
|
||||
|
||||
## Summary
|
||||
|
||||
Expansão do perfil de cliente com 12 novas colunas opcionais na tabela `client_users` (contato, dados pessoais, endereço, observações). Reescrita completa de `AdminClientesPage.tsx` (tabela rica com avatar, busca, links de contato) e `ClienteForm.tsx` (formulário full-screen com 5 seções e máscaras). Backend recebe migration Alembic, atualização do model SQLAlchemy e novos schemas Pydantic, além de atualização dos handlers de criação/edição.
|
||||
|
||||
## 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, react-router-dom v6, Axios (frontend)
|
||||
**Storage**: PostgreSQL 16 — 12 novas colunas nullable em `client_users`
|
||||
**Testing**: pytest (backend) — testes de rotas admin ; Vite build check (frontend)
|
||||
**Target Platform**: Servidor Linux (Docker) + SPA na mesma origem via proxy Vite
|
||||
**Project Type**: Web service (Flask REST API) + SPA (React)
|
||||
**Performance Goals**: Listagem de clientes renderizada < 1 s; busca local (sem debounce de rede) < 100 ms
|
||||
**Constraints**: Todos os 12 campos novos são opcionais (nullable no DB, Optional no schema); sem validação de CPF por dígito verificador no MVP; máscaras são apenas visuais (frontend-only)
|
||||
**Scale/Scope**: MVP — dados pessoais e endereço completos; sem integração com correios/CEP lookup
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| I. Design-First | ✅ PASS | Avatar com iniciais usa fundo `#5e6ad2`; tabela usa tokens de cor dark existentes; formulário segue padrão `PropertyForm`. |
|
||||
| II. Separation of Concerns | ✅ PASS | Backend retorna JSON puro. Máscaras são responsabilidade exclusiva do frontend. |
|
||||
| III. Spec-Driven | ✅ PASS | spec.md aprovado → plan.md (este) → tasks.md → implementação. |
|
||||
| IV. Data Integrity | ✅ PASS | 12 colunas nullable; entrada validada via Pydantic antes de persistir. `cpf` armazenado sem formatação (apenas dígitos) — máscara é aplicada só na UI. |
|
||||
| V. Security | ✅ PASS | Endpoints `/api/v1/admin/clientes/*` já protegidos por `require_auth` + verificação de role admin. Nenhuma rota nova pública. |
|
||||
| VI. Simplicity First | ✅ PASS | Busca filtrada no frontend (sem endpoint de search). Sem lookup de CEP externo. Sem validação de dígito verificador de CPF. |
|
||||
|
||||
**POST-DESIGN RE-CHECK**: ✅ A adição de 12 colunas nullable não introduz lógica de negócio nova. Reescrita de components é cirúrgica — apenas dois arquivos de página/formulário.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Migration Alembic
|
||||
└── adiciona 12 colunas nullable em client_users
|
||||
│
|
||||
▼
|
||||
SQLAlchemy Model (user.py) ◄── já contém as 12 colunas (model estava adiantado)
|
||||
│
|
||||
▼
|
||||
Pydantic Schemas (auth.py ou novo admin_clients.py)
|
||||
├── ClientUserOut — leitura (inclui todos os campos)
|
||||
├── ClientUserCreateIn — criação (campos obrigatórios + 12 opcionais)
|
||||
└── ClientUserUpdateIn — edição (todos Optional)
|
||||
│
|
||||
▼
|
||||
Route Handlers (admin.py)
|
||||
├── GET /api/v1/admin/clientes — lista com novos campos
|
||||
├── POST /api/v1/admin/clientes — cria com novos campos
|
||||
└── PUT /api/v1/admin/clientes/<id> — atualiza com novos campos
|
||||
│
|
||||
▼
|
||||
Frontend Services (adminService.ts ou clienteService.ts)
|
||||
│
|
||||
▼
|
||||
AdminClientesPage.tsx ◄── reescrita: tabela rica + busca + avatar
|
||||
ClienteForm.tsx ◄── reescrita: full-screen + 5 seções + máscaras
|
||||
```
|
||||
|
||||
## Database Changes
|
||||
|
||||
### Migration: `xxxx_enrich_client_users_profile.py`
|
||||
|
||||
- **Arquivo**: `backend/migrations/versions/xxxx_enrich_client_users_profile.py`
|
||||
- **`revision`**: gerado automaticamente pelo Alembic
|
||||
- **`down_revision`**: `f1a2b3c4d5e6` (último migration existente: add_iptu_anual)
|
||||
- **Estratégia**: `op.batch_alter_table` para compatibilidade SQLite em testes
|
||||
|
||||
**Colunas adicionadas** (todas `nullable=True`):
|
||||
|
||||
| Coluna | Tipo SQLAlchemy | Tamanho | Observação |
|
||||
|--------|----------------|---------|------------|
|
||||
| `phone` | `String` | 20 | Telefone fixo ou celular |
|
||||
| `whatsapp` | `String` | 20 | Número WhatsApp |
|
||||
| `cpf` | `String` | 14 | Armazenado sem máscara (11 dígitos) |
|
||||
| `birth_date` | `Date` | — | Data de nascimento |
|
||||
| `address_street` | `String` | 200 | Logradouro |
|
||||
| `address_number` | `String` | 20 | Número |
|
||||
| `address_complement` | `String` | 100 | Complemento |
|
||||
| `address_neighborhood` | `String` | 100 | Bairro |
|
||||
| `address_city` | `String` | 100 | Cidade |
|
||||
| `address_state` | `String` | 2 | UF (2 chars) |
|
||||
| `address_zip` | `String` | 9 | CEP com hífen (8 dígitos) |
|
||||
| `notes` | `Text` | — | Observações internas do admin |
|
||||
|
||||
**Upgrade**:
|
||||
```python
|
||||
def upgrade():
|
||||
with op.batch_alter_table('client_users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('phone', sa.String(20), nullable=True))
|
||||
batch_op.add_column(sa.Column('whatsapp', sa.String(20), nullable=True))
|
||||
batch_op.add_column(sa.Column('cpf', sa.String(14), nullable=True))
|
||||
batch_op.add_column(sa.Column('birth_date', sa.Date(), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_street', sa.String(200), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_number', sa.String(20), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_complement', sa.String(100), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_neighborhood', sa.String(100), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_city', sa.String(100), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_state', sa.String(2), nullable=True))
|
||||
batch_op.add_column(sa.Column('address_zip', sa.String(9), nullable=True))
|
||||
batch_op.add_column(sa.Column('notes', sa.Text(), nullable=True))
|
||||
```
|
||||
|
||||
**Downgrade**: `batch_op.drop_column` para cada coluna na ordem inversa.
|
||||
|
||||
> **Nota**: O model `ClientUser` em `backend/app/models/user.py` já possui as 12 colunas declaradas (adiantado manualmente). A migration é necessária para sincronizar o banco existente.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### 1. SQLAlchemy Model (`backend/app/models/user.py`)
|
||||
|
||||
**Status**: ✅ Já implementado — as 12 colunas estão declaradas no model atual. Nenhuma alteração necessária; apenas a migration precisa ser executada.
|
||||
|
||||
### 2. Pydantic Schemas
|
||||
|
||||
**Arquivo**: `backend/app/schemas/auth.py` (atualizar) ou extrair para `backend/app/schemas/admin_clients.py`
|
||||
|
||||
#### `ClientUserOut`
|
||||
```python
|
||||
class ClientUserOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
# Contato
|
||||
phone: Optional[str] = None
|
||||
whatsapp: Optional[str] = None
|
||||
# Dados pessoais
|
||||
cpf: Optional[str] = None
|
||||
birth_date: Optional[date] = None
|
||||
# Endereço
|
||||
address_street: Optional[str] = None
|
||||
address_number: Optional[str] = None
|
||||
address_complement: Optional[str] = None
|
||||
address_neighborhood: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_state: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
# Observações
|
||||
notes: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
```
|
||||
|
||||
#### `ClientUserCreateIn`
|
||||
```python
|
||||
class ClientUserCreateIn(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: str = "client"
|
||||
# 12 campos opcionais (mesmos tipos de ClientUserOut)
|
||||
phone: Optional[str] = None
|
||||
# ... demais 11 campos
|
||||
```
|
||||
|
||||
#### `ClientUserUpdateIn`
|
||||
```python
|
||||
class ClientUserUpdateIn(BaseModel):
|
||||
name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
# 12 campos opcionais
|
||||
phone: Optional[str] = None
|
||||
# ... demais 11 campos
|
||||
```
|
||||
|
||||
### 3. Route Handlers (`backend/app/routes/admin.py`)
|
||||
|
||||
Endpoints já existem. Alterações necessárias:
|
||||
|
||||
| Endpoint | Ação | Mudança |
|
||||
|----------|------|---------|
|
||||
| `GET /api/v1/admin/clientes` | Listar | Substituir `UserOut` → `ClientUserOut` na serialização |
|
||||
| `POST /api/v1/admin/clientes` | Criar | Validar com `ClientUserCreateIn`; persistir os 12 novos campos |
|
||||
| `PUT /api/v1/admin/clientes/<id>` | Editar | Validar com `ClientUserUpdateIn`; atualizar campos presentes (ignorar `None`) |
|
||||
|
||||
**Padrão de update** (apenas campos enviados):
|
||||
```python
|
||||
data = ClientUserUpdateIn(**request.get_json())
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
if field == "password":
|
||||
setattr(user, "password_hash", bcrypt.hashpw(...))
|
||||
else:
|
||||
setattr(user, field, value)
|
||||
```
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### 1. Types (`frontend/src/types/`)
|
||||
|
||||
**Arquivo**: `frontend/src/types/clientUser.ts` (novo) ou atualizar `frontend/src/types/index.ts`
|
||||
|
||||
```typescript
|
||||
export interface ClientUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
phone?: string;
|
||||
whatsapp?: string;
|
||||
cpf?: string;
|
||||
birth_date?: string;
|
||||
address_street?: string;
|
||||
address_number?: string;
|
||||
address_complement?: string;
|
||||
address_neighborhood?: string;
|
||||
address_city?: string;
|
||||
address_state?: string;
|
||||
address_zip?: string;
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `AdminClientesPage.tsx` — Reescrita completa
|
||||
|
||||
**Arquivo**: `frontend/src/pages/admin/AdminClientesPage.tsx`
|
||||
|
||||
**Funcionalidades**:
|
||||
- `useState` para lista de clientes, loading, erro, termo de busca, cliente selecionado, modo (`list` | `form`)
|
||||
- `useEffect` → `GET /api/v1/admin/clientes` ao montar
|
||||
- **Avatar com iniciais**: `div` circular com fundo `#5e6ad2`, iniciais em uppercase
|
||||
- **Barra de busca**: input controlado que filtra `filteredClientes` em memória por `name`, `email`, `phone`, `cpf`
|
||||
- **Tabela responsiva**:
|
||||
- Mobile: nome, avatar, e-mail, ações
|
||||
- Desktop: + telefone (`tel:` link), WhatsApp (ícone abre `https://wa.me/55{numero}`), CPF, endereço resumido (`bairro, cidade/UF`), tipo, cadastro
|
||||
- **Ações por linha**: botão Editar → abre `ClienteForm`; botão Excluir → `DELETE` + confirm
|
||||
- **Botão "Novo Cliente"** abre `ClienteForm` sem `clienteId`
|
||||
|
||||
### 3. `ClienteForm.tsx` — Reescrita completa
|
||||
|
||||
**Arquivo**: `frontend/src/pages/admin/ClienteForm.tsx`
|
||||
|
||||
**Layout**: Full-screen (mesmo padrão de `PropertyForm.tsx`), com header fixo contendo título e botões Cancelar/Salvar.
|
||||
|
||||
**5 Seções**:
|
||||
|
||||
| Seção | Campos |
|
||||
|-------|--------|
|
||||
| **Dados Pessoais** | Nome*, CPF (máscara `000.000.000-00`), Data de Nascimento |
|
||||
| **Contato** | Telefone (máscara `(00) 00000-0000`), WhatsApp (máscara `(00) 00000-0000`) |
|
||||
| **Endereço** | CEP (máscara `00000-000`), Logradouro, Número, Complemento, Bairro, Cidade, Estado (select UF) |
|
||||
| **Acesso** | E-mail*, Senha (obrigatória no create, opcional no edit), Tipo (client / admin) |
|
||||
| **Observações** | `textarea` para notas internas |
|
||||
|
||||
**Máscaras** (implementação manual via `onChange`, sem lib externa):
|
||||
- CPF: `###.###.###-##`
|
||||
- Telefone/WhatsApp: `(##) #####-####`
|
||||
- CEP: `#####-###`
|
||||
|
||||
**Submit**:
|
||||
- Criação: `POST /api/v1/admin/clientes` com `ClientUserCreateIn`
|
||||
- Edição: `PUT /api/v1/admin/clientes/{id}` com `ClientUserUpdateIn` (apenas campos alterados)
|
||||
- Senha omitida no payload de edição se campo vazio
|
||||
|
||||
**Validação frontend** (mínima — backend é fonte da verdade):
|
||||
- Nome: não vazio
|
||||
- E-mail: formato básico
|
||||
- Senha: mínimo 8 chars se preenchida
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Gerar migration vazia: `flask db revision --autogenerate -m "enrich_client_users_profile"`
|
||||
2. Revisar script gerado — confirmar que `down_revision = 'f1a2b3c4d5e6'`
|
||||
3. Usar `batch_alter_table` para compatibilidade com SQLite (conftest de testes usa SQLite in-memory)
|
||||
4. Executar: `flask db upgrade head`
|
||||
5. Verificar colunas: `\d client_users` no psql
|
||||
|
||||
> **Atenção**: O model já está sincronizado. Se `autogenerate` detectar diferença entre o model e o banco, ele gerará as colunas automaticamente. Caso contrário, escrever o `upgrade()` manualmente conforme o script acima.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
.specify/features/011-enrich-client-profile/
|
||||
├── plan.md # Este arquivo
|
||||
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── models/
|
||||
│ │ └── user.py # SEM ALTERAÇÃO — 12 colunas já presentes
|
||||
│ ├── schemas/
|
||||
│ │ └── auth.py # ATUALIZAR — adicionar ClientUserOut, ClientUserCreateIn, ClientUserUpdateIn
|
||||
│ └── routes/
|
||||
│ └── admin.py # ATUALIZAR — usar novos schemas nos endpoints de clientes
|
||||
└── migrations/
|
||||
└── versions/
|
||||
└── xxxx_enrich_client_users_profile.py # NOVO — batch_alter_table 12 colunas
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── clientUser.ts # NOVO — interface ClientUser expandida
|
||||
└── pages/
|
||||
└── admin/
|
||||
├── AdminClientesPage.tsx # REESCRITA — tabela rica + busca + avatar
|
||||
└── ClienteForm.tsx # REESCRITA — 5 seções + máscaras
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Item | Decisão | Alternativas rejeitadas |
|
||||
|------|---------|------------------------|
|
||||
| Máscaras de campo | Implementação manual via `onChange` | `react-input-mask` ou `imask` — dependência extra para funcionalidade trivial |
|
||||
| Busca de clientes | Filtragem local no frontend | Endpoint `GET /clientes?q=...` — overkill para o volume esperado de clientes no MVP |
|
||||
| Armazenamento de CPF | Somente dígitos no banco, máscara apenas na UI | Armazenar formatado — dificulta queries futuras |
|
||||
| Validação de CPF | Apenas formato (tamanho) | Dígito verificador — custo/benefício baixo no MVP |
|
||||
| Formulário full-screen | Padrão de `PropertyForm` reutilizado | Modal/slide-over — inconsistente com o padrão do admin panel |
|
||||
34
.specify/features/011-enrich-client-profile/spec.md
Normal file
34
.specify/features/011-enrich-client-profile/spec.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Feature Specification: Enriquecimento do Perfil de Cliente
|
||||
|
||||
**Feature Branch**: `011-enrich-client-profile`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
O cadastro de clientes atualmente possui apenas: nome, email, senha e tipo (role). Esta feature expande o perfil do cliente adicionando: telefone, WhatsApp, CPF, data de nascimento, endereço completo (logradouro, número, complemento, bairro, cidade, estado, CEP) e observações internas do admin. A página de gerenciamento de clientes no admin também será redesenhada com busca, avatar com iniciais e exibição rica das novas informações.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Cadastro rico (P1)
|
||||
**Given** admin em novo cliente, **When** preenche o formulário, **Then** pode informar dados pessoais (CPF, nascimento), contato (tel, whatsapp), endereço completo e observações internas.
|
||||
|
||||
### US2 — Exibição rica no admin (P1)
|
||||
**Given** admin na listagem de clientes, **When** visualiza a tabela, **Then** vê avatar com iniciais, e-mail, telefone com link `tel://`, link WhatsApp, CPF, endereço resumido, tipo e data de cadastro.
|
||||
|
||||
### US3 — Busca de clientes (P2)
|
||||
**Given** admin na listagem, **When** digita na barra de busca, **Then** filtra por nome, e-mail, telefone ou CPF em tempo real.
|
||||
|
||||
### US4 — Máscaras de campo (P2)
|
||||
**Given** admin digitando no formulário, **When** preenche CPF/telefone/CEP, **Then** máscara é aplicada automaticamente.
|
||||
|
||||
### US5 — Formulário full-screen consistente (P2)
|
||||
**Given** admin clica em criar/editar cliente, **When** formulário abre, **Then** ocupa tela inteira com seções (Dados pessoais, Contato, Endereço, Acesso, Observações), consistente com PropertyForm.
|
||||
|
||||
## Acceptance Criteria
|
||||
- 12 novas colunas em `client_users`: `phone`, `whatsapp`, `cpf`, `birth_date`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_zip`, `notes`
|
||||
- Alembic migration com `down_revision` apontando para `f1a2b3c4d5e6`
|
||||
- Schemas Pydantic `ClientUserOut` / `ClientUserCreateIn` / `ClientUserUpdateIn` expandidos
|
||||
- Tabela de clientes responsiva com avatar, busca, WhatsApp link, colunas hidden em mobile
|
||||
- `ClienteForm` em tela cheia com 5 seções e máscaras de CPF/telefone/CEP
|
||||
- Campos novos são opcionais no backend
|
||||
24
.specify/features/011-enrich-client-profile/tasks.md
Normal file
24
.specify/features/011-enrich-client-profile/tasks.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Tasks: Enriquecimento do Perfil de Cliente
|
||||
|
||||
**Feature**: `011-enrich-client-profile`
|
||||
**Generated**: 2026-04-14
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
- [x] T001 Migration a2b3c4d5e6f7: adicionar 12 colunas a `client_users` (`phone`, `whatsapp`, `cpf`, `birth_date`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_zip`, `notes`) em `backend/migrations/versions/a2b3c4d5e6f7_enrich_client_users.py`
|
||||
|
||||
## Backend
|
||||
- [x] T002 Expandir modelo `ClientUser` com 12 novas colunas nullable em `backend/app/models/user.py`
|
||||
- [x] T003 Expandir schema `ClientUserOut` com todos os novos campos + `created_at` em `backend/app/schemas/auth.py`
|
||||
- [x] T004 Expandir schema `ClientUserCreateIn` com todos os novos campos opcionais em `backend/app/schemas/auth.py`
|
||||
- [x] T005 Expandir schema `ClientUserUpdateIn` com todos os novos campos opcionais em `backend/app/schemas/auth.py`
|
||||
- [x] T006 Atualizar handler `create_client_user` para persistir novos campos em `backend/app/routes/admin.py`
|
||||
- [x] T007 Atualizar handler `update_client_user` para atualizar novos campos em `backend/app/routes/admin.py`
|
||||
|
||||
## Frontend
|
||||
- [x] T008 Exportar interface `ClientUser` de `frontend/src/pages/admin/AdminClientesPage.tsx`
|
||||
- [x] T009 `AdminClientesPage`: avatar com iniciais, tabela responsiva, busca local, link WhatsApp em `frontend/src/pages/admin/AdminClientesPage.tsx`
|
||||
- [x] T010 `AdminClientesPage`: formulário em tela cheia ao invés de modal em `frontend/src/pages/admin/AdminClientesPage.tsx`
|
||||
- [x] T011 `ClienteForm`: reescrever com 5 seções (Dados pessoais, Contato, Endereço, Acesso, Observações) em `frontend/src/components/ClienteForm.tsx`
|
||||
- [x] T012 `ClienteForm`: máscaras para CPF, telefone/WhatsApp e CEP em `frontend/src/components/ClienteForm.tsx`
|
||||
424
.specify/features/012-register-rich-profile/plan.md
Normal file
424
.specify/features/012-register-rich-profile/plan.md
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
# Implementation Plan: Cadastro Rico de Cliente (Perfil Completo no Registro)
|
||||
|
||||
**Branch**: `master` | **Date**: 2026-04-14 | **Spec**: [spec.md](spec.md)
|
||||
**Depends On**: Feature 011 — migration `a2b3c4d5e6f7` que adicionou as colunas ricas a `client_users`
|
||||
|
||||
## Summary
|
||||
|
||||
Expansão do formulário público `/cadastro` para coletar dados opcionais de contato (telefone, WhatsApp), CPF, data de nascimento e endereço completo. Alterações em 4 arquivos: `RegisterIn` schema (backend), `register()` handler (backend), `RegisterCredentials` type (frontend) e `RegisterPage.tsx` (frontend). Sem migration — todas as colunas já existem na tabela `client_users` desde a feature 011.
|
||||
|
||||
## 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, react-router-dom v6, Axios (frontend)
|
||||
**Storage**: PostgreSQL 16 — tabela `client_users` já possui as colunas adicionadas por `a2b3c4d5e6f7`
|
||||
**Testing**: pytest (backend) · Vite build check (frontend)
|
||||
**Target Platform**: Servidor Linux (Docker) + SPA na mesma origem via proxy Vite
|
||||
**Project Type**: Web service (Flask REST API) + SPA (React)
|
||||
**Performance Goals**: Formulário renderizado sem re-renders desnecessários; submit < 500 ms
|
||||
**Constraints**: Todos os novos campos são opcionais; máscaras apenas visuais (frontend-only); sem validação de dígito verificador de CPF no MVP; sem lookup de CEP externo
|
||||
**Scale/Scope**: MVP — formulário de auto-registro público expandido; nenhuma nova rota criada
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| I. Design-First | ✅ PASS | Seções usam `border-white/[0.06]`, `bg-[#0f1011]`, `#5e6ad2` — tokens idênticos ao `RegisterPage` atual e ao `DESIGN.md`. |
|
||||
| II. Separation of Concerns | ✅ PASS | Backend retorna JSON puro. Máscaras são responsabilidade exclusiva do frontend. Nenhuma lógica de negócio no frontend. |
|
||||
| III. Spec-Driven | ✅ PASS | spec.md aprovado → plan.md (este) → tasks.md → implementação. |
|
||||
| IV. Data Integrity | ✅ PASS | Campos opcionais validados via Pydantic antes de persistir. `cpf` armazenado sem máscara (apenas dígitos). `birth_date` validado como `date` pelo Pydantic. |
|
||||
| V. Security | ✅ PASS | Endpoint `POST /auth/register` já público por design. Nenhum campo novo é sensível além do que já era. Sem exposição de dados internos (campos de admin como `notes` não entram no `RegisterIn`). |
|
||||
| VI. Simplicity First | ✅ PASS | Sem migração nova, sem nova rota, sem nova lib de máscara. Alterações cirúrgicas em 4 arquivos existentes. |
|
||||
|
||||
**POST-DESIGN RE-CHECK**: ✅ O design adiciona estado local ao `RegisterPage` e expande um schema Pydantic — sem introduzir infraestrutura nova.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
RegisterIn (schemas/auth.py)
|
||||
└── novos campos Optional adicionados
|
||||
│
|
||||
▼
|
||||
register() handler (routes/auth.py)
|
||||
└── ClientUser(**data) recebe os novos campos
|
||||
│
|
||||
▼
|
||||
RegisterCredentials (types/auth.ts)
|
||||
└── interface expandida com campos opcionais
|
||||
│
|
||||
▼
|
||||
registerUser() (services/auth.ts)
|
||||
└── SEM ALTERAÇÃO — desestrutura apenas confirmPassword,
|
||||
todos os outros campos são passados automaticamente no payload
|
||||
│
|
||||
▼
|
||||
RegisterPage.tsx
|
||||
└── formulário expandido com 3 seções visuais:
|
||||
(1) Acesso — campos obrigatórios (nome, e-mail, senha, confirmar senha)
|
||||
(2) Contato — campos opcionais (telefone, WhatsApp, CPF, data de nascimento)
|
||||
(3) Endereço — campos opcionais (logradouro, número, complemento, bairro, cidade, estado, CEP)
|
||||
```
|
||||
|
||||
## Database Changes
|
||||
|
||||
**Nenhuma migration necessária.** Todas as colunas utilizadas já foram adicionadas pela feature 011 via migration `a2b3c4d5e6f7_enrich_client_users.py`:
|
||||
|
||||
| Coluna | Tipo | Status |
|
||||
|--------|------|--------|
|
||||
| `phone` | `String(20)` | ✅ Já existe |
|
||||
| `whatsapp` | `String(20)` | ✅ Já existe |
|
||||
| `cpf` | `String(14)` | ✅ Já existe |
|
||||
| `birth_date` | `Date` | ✅ Já existe |
|
||||
| `address_street` | `String(200)` | ✅ Já existe |
|
||||
| `address_number` | `String(20)` | ✅ Já existe |
|
||||
| `address_complement` | `String(100)` | ✅ Já existe |
|
||||
| `address_neighborhood` | `String(100)` | ✅ Já existe |
|
||||
| `address_city` | `String(100)` | ✅ Já existe |
|
||||
| `address_state` | `String(2)` | ✅ Já existe |
|
||||
| `address_zip` | `String(9)` | ✅ Já existe |
|
||||
|
||||
O model `ClientUser` em `backend/app/models/user.py` já mapeia todas essas colunas.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### 1. Pydantic Schema — `backend/app/schemas/auth.py`
|
||||
|
||||
**Classe**: `RegisterIn`
|
||||
|
||||
Adicionar import de `Optional` e `date` do módulo `datetime`, depois expandir os campos:
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
# Campos existentes (sem alteração)
|
||||
name: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
# Novos campos opcionais — contato
|
||||
phone: Optional[str] = None
|
||||
whatsapp: Optional[str] = None
|
||||
|
||||
# Novos campos opcionais — dados pessoais
|
||||
cpf: Optional[str] = None
|
||||
birth_date: Optional[date] = None
|
||||
|
||||
# Novos campos opcionais — endereço
|
||||
address_street: Optional[str] = None
|
||||
address_number: Optional[str] = None
|
||||
address_complement: Optional[str] = None
|
||||
address_neighborhood: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_state: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
|
||||
# Validators existentes permanecem inalterados
|
||||
```
|
||||
|
||||
> **Nota sobre `cpf`**: o frontend envia o valor sem máscara (apenas dígitos, ex.: `"12345678901"`). A máscara é removida no `onChange` antes do submit.
|
||||
|
||||
### 2. Route Handler — `backend/app/routes/auth.py`
|
||||
|
||||
**Função**: `register()`
|
||||
|
||||
Substituir a criação de `ClientUser` para incluir os novos campos opcionais via `model_dump`:
|
||||
|
||||
```python
|
||||
@auth_bp.post("/register")
|
||||
def register():
|
||||
try:
|
||||
data = RegisterIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
existing = ClientUser.query.filter_by(email=data.email).first()
|
||||
if existing:
|
||||
return jsonify({"error": "E-mail já cadastrado"}), 409
|
||||
|
||||
pwd_hash = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
# Extrair campos opcionais (excluir 'password' que é tratado separadamente)
|
||||
optional_fields = data.model_dump(
|
||||
exclude={"name", "email", "password"},
|
||||
exclude_none=True,
|
||||
)
|
||||
|
||||
user = ClientUser(
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
password_hash=pwd_hash,
|
||||
**optional_fields,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
token = _generate_token(user.id, current_app.config["JWT_SECRET_KEY"])
|
||||
user_out = UserOut.model_validate(user)
|
||||
return (
|
||||
jsonify(
|
||||
AuthTokenOut(access_token=token, user=user_out).model_dump(mode="json")
|
||||
),
|
||||
201,
|
||||
)
|
||||
```
|
||||
|
||||
> **Alternativa mais simples** (também válida): passar cada campo explicitamente no construtor do `ClientUser`, evitando o `**optional_fields`. Escolher conforme preferência de legibilidade.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### 1. Types — `frontend/src/types/auth.ts`
|
||||
|
||||
**Interface**: `RegisterCredentials`
|
||||
|
||||
Adicionar os campos opcionais:
|
||||
|
||||
```typescript
|
||||
export interface RegisterCredentials {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
// Contato
|
||||
phone?: string;
|
||||
whatsapp?: string;
|
||||
// Dados pessoais
|
||||
cpf?: string;
|
||||
birthDate?: string; // enviado como "YYYY-MM-DD" para o backend (campo birth_date)
|
||||
// Endereço
|
||||
addressStreet?: string;
|
||||
addressNumber?: string;
|
||||
addressComplement?: string;
|
||||
addressNeighborhood?: string;
|
||||
addressCity?: string;
|
||||
addressState?: string;
|
||||
addressZip?: string;
|
||||
}
|
||||
```
|
||||
|
||||
> **Nota sobre nomes de campo**: o serviço `registerUser` faz `const { confirmPassword: _c, ...payload } = data` e envia `payload` diretamente. O backend espera `snake_case`. Portanto, os campos camelCase do frontend devem ser mapeados para snake_case no payload. Dois enfoques possíveis:
|
||||
> - **Opção A (preferida)**: manter os nomes camelCase na interface e fazer o mapeamento explícito em `registerUser` — mais explícito e seguro.
|
||||
> - **Opção B**: usar snake_case na interface desde o início (alinhado com o contrato do backend) — menos ergonômico em React.
|
||||
>
|
||||
> O plano adota a **Opção A** — mapeamento explícito em `registerUser`.
|
||||
|
||||
### 2. Service — `frontend/src/services/auth.ts`
|
||||
|
||||
**Função**: `registerUser`
|
||||
|
||||
Adicionar mapeamento de camelCase → snake_case para os novos campos:
|
||||
|
||||
```typescript
|
||||
export async function registerUser(data: RegisterCredentials): Promise<AuthTokenResponse> {
|
||||
const {
|
||||
confirmPassword: _confirmPassword,
|
||||
birthDate,
|
||||
addressStreet,
|
||||
addressNumber,
|
||||
addressComplement,
|
||||
addressNeighborhood,
|
||||
addressCity,
|
||||
addressState,
|
||||
addressZip,
|
||||
...rest
|
||||
} = data
|
||||
|
||||
const payload = {
|
||||
...rest,
|
||||
...(birthDate && { birth_date: birthDate }),
|
||||
...(addressStreet && { address_street: addressStreet }),
|
||||
...(addressNumber && { address_number: addressNumber }),
|
||||
...(addressComplement && { address_complement: addressComplement }),
|
||||
...(addressNeighborhood && { address_neighborhood: addressNeighborhood }),
|
||||
...(addressCity && { address_city: addressCity }),
|
||||
...(addressState && { address_state: addressState }),
|
||||
...(addressZip && { address_zip: addressZip }),
|
||||
}
|
||||
|
||||
const response = await api.post<AuthTokenResponse>('/auth/register', payload)
|
||||
return response.data
|
||||
}
|
||||
```
|
||||
|
||||
> Campos de telefone (`phone`) e whatsapp permanecem com o mesmo nome em camelCase e snake_case, então são passados via `...rest` sem necessidade de remapeamento. CPF também (`cpf`).
|
||||
|
||||
### 3. Page — `frontend/src/pages/RegisterPage.tsx`
|
||||
|
||||
**Estratégia**: Expandir o componente existente mantendo a estrutura atual para os campos obrigatórios (Seção 1 — Acesso) e adicionar duas seções visuais abaixo, separadas por divisórias e labels de seção.
|
||||
|
||||
**Layout das 3 seções**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Criar conta │
|
||||
│ Acesse a área do cliente │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [erro global, se houver] │
|
||||
│ │
|
||||
│ ── ACESSO ──────────────────────── │
|
||||
│ Nome * │
|
||||
│ E-mail * │
|
||||
│ Senha * │
|
||||
│ Confirmar Senha * │
|
||||
│ │
|
||||
│ ── CONTATO (opcional) ──────────── │
|
||||
│ Telefone WhatsApp │
|
||||
│ CPF Data de Nascimento │
|
||||
│ │
|
||||
│ ── ENDEREÇO (opcional) ─────────── │
|
||||
│ CEP Estado │
|
||||
│ Logradouro │
|
||||
│ Número Complemento │
|
||||
│ Bairro Cidade │
|
||||
│ │
|
||||
│ [Criar conta] │
|
||||
│ Já tem conta? Entrar │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Novo estado** (`useState`) a adicionar ao componente:
|
||||
|
||||
| State var | Tipo | Initial |
|
||||
|-----------|------|---------|
|
||||
| `phone` | `string` | `''` |
|
||||
| `whatsapp` | `string` | `''` |
|
||||
| `cpf` | `string` | `''` |
|
||||
| `birthDate` | `string` | `''` |
|
||||
| `addressStreet` | `string` | `''` |
|
||||
| `addressNumber` | `string` | `''` |
|
||||
| `addressComplement` | `string` | `''` |
|
||||
| `addressNeighborhood` | `string` | `''` |
|
||||
| `addressCity` | `string` | `''` |
|
||||
| `addressState` | `string` | `''` |
|
||||
| `addressZip` | `string` | `''` |
|
||||
|
||||
**Atualização do `handleSubmit`**:
|
||||
|
||||
```typescript
|
||||
await register({
|
||||
name, email, password, confirmPassword,
|
||||
phone: phone || undefined,
|
||||
whatsapp: whatsapp || undefined,
|
||||
cpf: cpf.replace(/\D/g, '') || undefined, // armazena apenas dígitos
|
||||
birthDate: birthDate || undefined,
|
||||
addressStreet: addressStreet || undefined,
|
||||
addressNumber: addressNumber || undefined,
|
||||
addressComplement: addressComplement || undefined,
|
||||
addressNeighborhood: addressNeighborhood || undefined,
|
||||
addressCity: addressCity || undefined,
|
||||
addressState: addressState || undefined,
|
||||
addressZip: addressZip || undefined,
|
||||
})
|
||||
```
|
||||
|
||||
**Máscaras inline** (mesmas implementações de `ClienteForm.tsx`, copiadas no topo do arquivo antes do componente):
|
||||
|
||||
```typescript
|
||||
function maskCpf(v: string) {
|
||||
return v.replace(/\D/g, '').slice(0, 11)
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
|
||||
}
|
||||
|
||||
function maskPhone(v: string) {
|
||||
const d = v.replace(/\D/g, '').slice(0, 11)
|
||||
if (d.length <= 10)
|
||||
return d.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3').trim().replace(/-$/, '')
|
||||
return d.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3').trim().replace(/-$/, '')
|
||||
}
|
||||
|
||||
function maskZip(v: string) {
|
||||
return v.replace(/\D/g, '').slice(0, 8)
|
||||
.replace(/(\d{5})(\d)/, '$1-$2')
|
||||
}
|
||||
```
|
||||
|
||||
**CSS / Classes Tailwind usadas nas seções novas** (idênticas ao formulário existente):
|
||||
|
||||
```
|
||||
// Divisória de seção
|
||||
<div className="border-t border-white/[0.06] pt-4">
|
||||
<p className="text-xs font-medium text-white/40 uppercase tracking-wider mb-3">Contato</p>
|
||||
...
|
||||
</div>
|
||||
|
||||
// Grid 2 colunas para campos curtos
|
||||
<div className="grid grid-cols-2 gap-3">...</div>
|
||||
|
||||
// Input (mesma classe atual)
|
||||
"w-full rounded-lg border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-sm text-white
|
||||
placeholder-white/30 focus:border-[#5e6ad2]/60 focus:outline-none focus:ring-1 focus:ring-[#5e6ad2]/30"
|
||||
```
|
||||
|
||||
**Tamanho máximo do card**: o `max-w-sm` atual pode ser expandido para `max-w-md` ou `max-w-lg` para acomodar o grid de 2 colunas na seção de endereço sem quebrar o layout.
|
||||
|
||||
## Context — `AuthContext.tsx`
|
||||
|
||||
A função `register` em `AuthContext.tsx` recebe `RegisterCredentials` e a repassa para `registerUser`. Verificar se a assinatura já aceita o tipo expandido — se `RegisterCredentials` for tipado corretamente, nenhuma alteração é necessária no contexto.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
.specify/features/012-register-rich-profile/
|
||||
├── plan.md # Este arquivo
|
||||
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
└── app/
|
||||
├── schemas/
|
||||
│ └── auth.py # ATUALIZAR — RegisterIn: 11 novos campos Optional
|
||||
└── routes/
|
||||
└── auth.py # ATUALIZAR — register(): ClientUser recebe novos campos
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── types/
|
||||
│ └── auth.ts # ATUALIZAR — RegisterCredentials: 11 novos campos opcionais
|
||||
├── services/
|
||||
│ └── auth.ts # ATUALIZAR — registerUser(): mapeamento camelCase → snake_case
|
||||
└── pages/
|
||||
└── RegisterPage.tsx # ATUALIZAR — 3 seções + máscaras inline
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Item | Decisão | Alternativas rejeitadas |
|
||||
|------|---------|------------------------|
|
||||
| Nomes de campo frontend→backend | Mapeamento explícito em `registerUser` (camelCase → snake_case) | Snake_case na interface React — menos ergonômico |
|
||||
| CPF no payload | Enviar apenas dígitos (`cpf.replace(/\D/g, '')`) | Enviar formatado — inconsistente com `ClienteForm.tsx` que armazena sem máscara |
|
||||
| Layout do formulário | Seções inline separadas por `border-t` — sem accordion/collapse | Accordion collapsível — mais complexo, benefício marginal para um formulário pequeno |
|
||||
| Largura do card | `max-w-md` para acomodar grid 2 cols | Manter `max-w-sm` com campos full-width — mais estreito, menos legível |
|
||||
| Máscaras | Inline no arquivo (`maskCpf`, `maskPhone`, `maskZip`) copiadas de `ClienteForm.tsx` | Extrair para `utils/masks.ts` — generalização prematura para MVP |
|
||||
| Validação de campos opcionais | Nenhuma validação frontend — backend é fonte da verdade | Validação de comprimento de CPF — custo/benefício baixo no MVP |
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: [Document the selected structure and reference the real
|
||||
directories captured above]
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
36
.specify/features/012-register-rich-profile/spec.md
Normal file
36
.specify/features/012-register-rich-profile/spec.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Feature Specification: Cadastro Rico de Cliente (Perfil Completo no Registro)
|
||||
|
||||
**Feature Branch**: `012-register-rich-profile`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
A página `/cadastro` atualmente coleta apenas nome, email, senha e confirmação de senha. A feature 011 adicionou campos ricos ao model `ClientUser` (phone, whatsapp, cpf, birth_date, address_*) e ao painel admin, mas o fluxo de auto-registro do cliente (`/cadastro`) não foi atualizado. Esta feature expande o formulário público de cadastro para também permitir que o próprio cliente informe seus dados de contato (telefone, WhatsApp) e documento (CPF) já no ato do registro. Os campos de endereço e data de nascimento serão opcionais e disponíveis. O formulário mantém etapas lógicas para não sobrecarregar o usuário.
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Dados de contato no cadastro (P1)
|
||||
**Given** usuário na página `/cadastro`, **When** preenche o formulário, **Then** pode opcionalmente informar telefone e WhatsApp além de nome/email/senha.
|
||||
|
||||
### US2 — CPF no cadastro (P1)
|
||||
**Given** usuário na página `/cadastro`, **When** preenche o formulário, **Then** pode opcionalmente informar CPF com máscara automática.
|
||||
|
||||
### US3 — Dados de endereço no cadastro (P2)
|
||||
**Given** usuário na página `/cadastro`, **When** expande seção de endereço (ou preenche naturalmente), **Then** pode informar logradouro, número, complemento, bairro, cidade, estado e CEP.
|
||||
|
||||
### US4 — Data de nascimento no cadastro (P2)
|
||||
**Given** usuário na página `/cadastro`, **When** preenche seus dados pessoais, **Then** pode informar data de nascimento.
|
||||
|
||||
### US5 — Campos opcionais sem bloqueio (P1)
|
||||
**Given** usuário preenchendo o cadastro, **When** não preenche campos opcionais, **Then** o cadastro é realizado normalmente sem erro.
|
||||
|
||||
## Acceptance Criteria
|
||||
- `RegisterIn` schema em `auth.py` aceita os novos campos opcionais: `phone`, `whatsapp`, `cpf`, `birth_date`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_zip`
|
||||
- Endpoint `POST /auth/register` passa novos campos ao `ClientUser` ao criar
|
||||
- `RegisterCredentials` em `types/auth.ts` expandido com campos opcionais
|
||||
- `registerUser` em `services/auth.ts` passa os novos campos (exceto `confirmPassword`)
|
||||
- `RegisterPage.tsx` expandida com seções: **Acesso** (nome, email, senha, confirmar senha), **Contato** (telefone, WhatsApp, CPF, data de nascimento), **Endereço** (logradouro, número, complemento, bairro, cidade, estado, CEP)
|
||||
- Máscaras automáticas para CPF (`000.000.000-00`), telefone (`(00) 00000-0000`) e CEP (`00000-000`)
|
||||
- Visual consistente com o design atual da `RegisterPage` (dark, `border-white/6`, `bg-[#0f1011]`)
|
||||
- Seções de Contato e Endereço separadas visualmente mas acessíveis sem scroll excessivo
|
||||
18
.specify/features/012-register-rich-profile/tasks.md
Normal file
18
.specify/features/012-register-rich-profile/tasks.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Tasks: Cadastro Rico de Cliente (Perfil Completo no Registro)
|
||||
|
||||
**Feature**: `012-register-rich-profile`
|
||||
**Generated**: 2026-04-14
|
||||
**Depends On**: Feature 011 — migration `a2b3c4d5e6f7_enrich_client_users.py` (colunas já existem em `client_users`)
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
- [x] T001 `RegisterIn` schema: adicionar 11 campos opcionais (`phone`, `whatsapp`, `cpf`, `birth_date`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_zip`) em `backend/app/schemas/auth.py`
|
||||
- [x] T002 `auth.py` `register()`: passar novos campos ao construtor de `ClientUser` em `backend/app/routes/auth.py`
|
||||
|
||||
## Frontend
|
||||
- [x] T003 [P] `types/auth.ts`: expandir `RegisterCredentials` com 11 campos opcionais em `frontend/src/types/auth.ts`
|
||||
- [x] T004 `RegisterPage.tsx`: adicionar estados para novos campos + funções de máscara (`maskCpf`, `maskPhone`, `maskZip`) em `frontend/src/pages/RegisterPage.tsx`
|
||||
- [x] T005 `RegisterPage.tsx`: seção "Contato" com campos `phone`, `whatsapp`, `cpf`, `birth_date` em `frontend/src/pages/RegisterPage.tsx`
|
||||
- [x] T006 `RegisterPage.tsx`: seção "Endereço" com campos `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_zip` em `frontend/src/pages/RegisterPage.tsx`
|
||||
- [x] T007 `RegisterPage.tsx`: incluir novos campos no objeto passado para `register()` em `frontend/src/pages/RegisterPage.tsx`
|
||||
433
.specify/features/013-header-only-nav/plan.md
Normal file
433
.specify/features/013-header-only-nav/plan.md
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
# Implementation Plan: Navegação Unificada no Header (Remoção do Sidebar)
|
||||
|
||||
**Branch**: `013-header-only-nav` | **Date**: 2026-04-14 | **Spec**: [spec.md](spec.md)
|
||||
**Depends On**: Nenhuma dependência de feature anterior — alterações puramente de layout/componente frontend.
|
||||
|
||||
## Summary
|
||||
|
||||
Remoção dos sidebars de `AdminLayout` e `ClientLayout`, consolidando todos os itens de navegação no `Navbar` (header fixo) via dropdowns contextuais. O dropdown "Admin ▾" aparece apenas para `isAdmin`; o dropdown "Minha Conta ▾" aparece apenas para usuários autenticados não-admin. Nenhuma rota nova, nenhuma alteração de backend. Alterações em 3 arquivos frontend: `Navbar.tsx`, `AdminLayout.tsx` e `ClientLayout.tsx`.
|
||||
|
||||
**Observação sobre ClientLayout + Navbar**: `ClientLayout.tsx` atualmente **não** importa nem renderiza `<Navbar />` (ao contrário de `AdminLayout`). O `App.tsx` também não possui um `<Navbar />` global. Para que o dropdown "Minha Conta ▾" seja exibido nas rotas `/area-do-cliente/*`, **`ClientLayout` deve passar a renderizar `<Navbar />`** como parte desta feature.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.5 (frontend)
|
||||
**Primary Dependencies**: React 18, react-router-dom v6, Tailwind CSS 3.4 (já utilizados — sem novas dependências)
|
||||
**Storage**: Nenhuma alteração de banco de dados
|
||||
**Testing**: Vite build check (frontend) — sem testes unitários para componentes de layout no projeto atual
|
||||
**Target Platform**: SPA (React) servida via Vite proxy
|
||||
**Project Type**: Web SPA (React)
|
||||
**Performance Goals**: Sem re-renders desnecessários no toggle de dropdown; fechamento por clique externo via `useRef + useEffect`
|
||||
**Constraints**: Sem bibliotecas externas novas; dropdowns implementados com React + Tailwind puro; mobile: itens expandidos inline no menu hamburger (sem toggle aninhado)
|
||||
**Scale/Scope**: Refactor de layout puro — 3 arquivos frontend, sem impacto em rotas ou dados
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| I. Design-First | ✅ PASS | Tokens `bg-[#0f1011]`, `border-white/[0.06]`, `bg-white/[0.06]`, `shadow-xl`, `rounded-xl` são idênticos aos já utilizados no projeto conforme `DESIGN.md`. |
|
||||
| II. Separation of Concerns | ✅ PASS | Navbar contém lógica de navegação; layouts são thin wrappers sem lógica própria. |
|
||||
| III. Spec-Driven | ✅ PASS | spec.md aprovado → plan.md (este) → implementação. |
|
||||
| IV. Data Integrity | ✅ PASS | Nenhuma alteração de dados — feature puramente de UI. |
|
||||
| V. Security | ✅ PASS | Visibilidade dos dropdowns controlada pelos mesmos guards já existentes (`isAdmin`, `isAuthenticated`). Nenhuma rota protegida exposta. |
|
||||
| VI. Simplicity First | ✅ PASS | Remoção de código (sidebars) > adição. Sem abstração nova, sem nova lib. Alterações cirúrgicas em 3 arquivos. |
|
||||
|
||||
**POST-DESIGN RE-CHECK**: ✅ Adicionar `useRef + useEffect` ao Navbar e `<Navbar />` ao ClientLayout são as únicas adições de infraestrutura — totalmente justificadas pelo requisito de UX.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
App.tsx (inalterado)
|
||||
├── /area-do-cliente → ProtectedRoute → ClientLayout → <Outlet />
|
||||
│ ClientLayout: <> <Navbar /> <main pt-14><Outlet /></main> </>
|
||||
│ ↑ adicionado nesta feature
|
||||
│
|
||||
└── /admin → AdminRoute → AdminLayout → <Outlet />
|
||||
AdminLayout: <> <Navbar /> <main pt-14><Outlet /></main> </>
|
||||
(sidebar removido; flex wrapper removido)
|
||||
|
||||
Navbar.tsx (ampliado)
|
||||
├── adminNavItems[] — 7 itens /admin/*
|
||||
├── clientNavItems[] — 5 itens /area-do-cliente/*
|
||||
├── adminDropdownOpen (useState)
|
||||
├── clientDropdownOpen (useState)
|
||||
├── adminDropdownRef (useRef) + useEffect para fechar no outside click
|
||||
├── clientDropdownRef (useRef) + useEffect para fechar no outside click
|
||||
├── Desktop: dropdown "Admin ▾" (isAdmin) / "Minha Conta ▾" (isAuthenticated não-admin)
|
||||
└── Mobile: itens admin ou cliente expandidos inline no menu hamburger
|
||||
```
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### 1. `Navbar.tsx` — `frontend/src/components/Navbar.tsx`
|
||||
|
||||
#### 1.1 Imports adicionais
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
```
|
||||
|
||||
> `NavLink` substitui `Link` nos itens de dropdown para suporte a `isActive`.
|
||||
|
||||
#### 1.2 Arrays de navegação
|
||||
|
||||
```tsx
|
||||
const adminNavItems = [
|
||||
{ to: '/admin/properties', label: 'Imóveis' },
|
||||
{ to: '/admin/clientes', label: 'Clientes' },
|
||||
{ to: '/admin/boletos', label: 'Boletos' },
|
||||
{ to: '/admin/visitas', label: 'Visitas' },
|
||||
{ to: '/admin/favoritos', label: 'Favoritos' },
|
||||
{ to: '/admin/cidades', label: 'Cidades' },
|
||||
{ to: '/admin/amenidades', label: 'Amenidades' },
|
||||
]
|
||||
|
||||
const clientNavItems = [
|
||||
{ to: '/area-do-cliente', label: 'Painel', end: true },
|
||||
{ to: '/area-do-cliente/favoritos',label: 'Favoritos',end: false },
|
||||
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false },
|
||||
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false },
|
||||
{ to: '/area-do-cliente/boletos', label: 'Boletos', end: false },
|
||||
]
|
||||
```
|
||||
|
||||
#### 1.3 Estado e refs no componente
|
||||
|
||||
```tsx
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false)
|
||||
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
|
||||
|
||||
const adminDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Fechar dropdown ao clicar fora
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (adminDropdownRef.current && !adminDropdownRef.current.contains(e.target as Node)) {
|
||||
setAdminDropdownOpen(false)
|
||||
}
|
||||
if (clientDropdownRef.current && !clientDropdownRef.current.contains(e.target as Node)) {
|
||||
setClientDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
```
|
||||
|
||||
#### 1.4 Desktop — Dropdown "Admin ▾"
|
||||
|
||||
Substituir o `<li>` do link "Painel Admin" no `navLinks` map por:
|
||||
|
||||
```tsx
|
||||
{isAdmin && (
|
||||
<li className="relative" ref={adminDropdownRef}>
|
||||
<button
|
||||
onClick={() => setAdminDropdownOpen(prev => !prev)}
|
||||
className="flex items-center gap-1 text-sm text-[#5e6ad2] hover:text-[#7170ff] font-semibold transition-colors"
|
||||
>
|
||||
Admin
|
||||
<span className="text-[10px] opacity-70">▾</span>
|
||||
</button>
|
||||
{adminDropdownOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 w-44 bg-[#111] rounded-xl border border-white/[0.06] shadow-xl py-2 z-50">
|
||||
{adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setAdminDropdownOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block px-4 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/[0.06] text-white font-medium'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/[0.04]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 1.5 Desktop — Auth section refatorada
|
||||
|
||||
Substituir o bloco `isAuthenticated && user` na auth section (atualmente com "Admin" button + avatar link + "Sair"):
|
||||
|
||||
```tsx
|
||||
{isAuthenticated && user ? (
|
||||
<>
|
||||
{isAdmin ? (
|
||||
/* Dropdown Admin já está no navLinks — apenas avatar + sair aqui */
|
||||
<>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-[#5e6ad2] text-xs font-semibold text-white flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150 font-medium"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
/* Dropdown "Minha Conta ▾" para cliente */
|
||||
<div className="relative" ref={clientDropdownRef}>
|
||||
<button
|
||||
onClick={() => setClientDropdownOpen(prev => !prev)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-[#5e6ad2] text-xs font-semibold text-white flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="max-w-[100px] truncate font-medium">{user.name}</span>
|
||||
<span className="text-[10px] opacity-70">▾</span>
|
||||
</button>
|
||||
{clientDropdownOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 w-48 bg-[#111] rounded-xl border border-white/[0.06] shadow-xl py-2 z-50">
|
||||
{clientNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setClientDropdownOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block px-4 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/[0.06] text-white font-medium'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/[0.04]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="my-1 border-t border-white/[0.06]" />
|
||||
<button
|
||||
onClick={() => { setClientDropdownOpen(false); logout() }}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-white/50 hover:text-white hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-lg bg-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-white transition hover:bg-[#6872d8]"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
)}
|
||||
```
|
||||
|
||||
> **Nota**: O link "Admin" (botão amarelo) e o link "Painel Admin" em `navLinks` são removidos. O dropdown no botão admin já contém todos os itens.
|
||||
|
||||
#### 1.6 Mobile — Itens admin/cliente inline
|
||||
|
||||
No mobile menu, substituir o bloco auth existente por:
|
||||
|
||||
```tsx
|
||||
{!isLoading && (
|
||||
isAuthenticated && user ? (
|
||||
<>
|
||||
{isAdmin
|
||||
? adminNavItems.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block py-2.5 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-[#5e6ad2]'
|
||||
: 'text-[#5e6ad2]/70 hover:text-[#5e6ad2]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))
|
||||
: clientNavItems.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'text-white font-medium'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); logout() }}
|
||||
className="block py-2.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150 font-medium w-full text-left"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<li>
|
||||
<Link
|
||||
to="/login"
|
||||
className="block py-2.5 text-sm font-medium text-[#5e6ad2] hover:text-[#7170ff] transition-colors duration-150"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `AdminLayout.tsx` — `frontend/src/layouts/AdminLayout.tsx`
|
||||
|
||||
Remover `<aside>` e wrapper `<div className="flex ...">`. O layout resultante é um thin wrapper:
|
||||
|
||||
**Antes:**
|
||||
```tsx
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex min-h-screen pt-14 bg-[#08090a]">
|
||||
<aside className="hidden lg:flex w-56 flex-col border-r border-white/[0.06] bg-panel px-3 py-6">
|
||||
<nav className="flex flex-1 flex-col gap-0.5">
|
||||
{adminNav.map(item => (
|
||||
<NavLink key={item.to} to={item.to} className={...}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Depois:**
|
||||
```tsx
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="pt-14 min-h-screen bg-[#08090a]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Imports removidos**: `NavLink` (não utilizado no layout após simplificação). Manter `Outlet`, `Navbar`.
|
||||
|
||||
> A constante `adminNav` no topo do arquivo é removida pois os itens migram para `Navbar.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `ClientLayout.tsx` — `frontend/src/layouts/ClientLayout.tsx`
|
||||
|
||||
Remover `<aside>`, o mobile nav bar e toda a lógica de navegação local. Adicionar `<Navbar />` (atualmente ausente no ClientLayout).
|
||||
|
||||
**Antes:**
|
||||
```tsx
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// ... navItems, adminNavItems arrays ...
|
||||
|
||||
export default function ClientLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
// ...
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#08090a] pt-14">
|
||||
<aside ...> {/* sidebar desktop */} </aside>
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
<div className="lg:hidden ..."> {/* mobile nav bar */} </div>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Depois:**
|
||||
```tsx
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from '../components/Navbar';
|
||||
|
||||
export default function ClientLayout() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="pt-14 min-h-screen bg-[#08090a]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Imports removidos**: `NavLink`, `useNavigate`, `ThemeToggle`, `useAuth` (toda a lógica de user/logout migra para Navbar).
|
||||
**Imports adicionados**: `Navbar`.
|
||||
|
||||
> As constantes `navItems` e `adminNavItems` são removidas — os itens migram para `Navbar.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes
|
||||
|
||||
**Nenhuma alteração necessária.** Feature puramente frontend.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Arquivo | Tipo | Operação |
|
||||
|---------|------|----------|
|
||||
| `frontend/src/components/Navbar.tsx` | Frontend | Modificar — adicionar arrays, estados, refs, dropdowns |
|
||||
| `frontend/src/layouts/AdminLayout.tsx` | Frontend | Simplificar — remover aside + flex wrapper |
|
||||
| `frontend/src/layouts/ClientLayout.tsx` | Frontend | Simplificar — remover aside + mobile nav, adicionar Navbar |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Checklist
|
||||
|
||||
- [ ] `AdminLayout.tsx`: sem `<aside>`, sem `div.flex`, main ocupa 100% da largura
|
||||
- [ ] `ClientLayout.tsx`: sem `<aside>`, sem mobile nav bar, `<Navbar />` presente
|
||||
- [ ] `Navbar.tsx`: dropdown "Admin ▾" visível apenas para `isAdmin`
|
||||
- [ ] `Navbar.tsx`: dropdown "Minha Conta ▾" visível apenas para `isAuthenticated` não-admin
|
||||
- [ ] Dropdown fecha ao clicar fora (`useRef + useEffect`)
|
||||
- [ ] Item ativo destacado no dropdown via `NavLink isActive`
|
||||
- [ ] Mobile: itens admin ou cliente expandidos inline, sem toggle aninhado
|
||||
- [ ] Sem dependências externas novas
|
||||
- [ ] `vite build` sem erros de TypeScript
|
||||
45
.specify/features/013-header-only-nav/spec.md
Normal file
45
.specify/features/013-header-only-nav/spec.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Feature Specification: Navegação Unificada no Header (remoção do Sidebar)
|
||||
|
||||
**Feature Branch**: `013-header-only-nav`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
Atualmente o painel admin (`AdminLayout`) e a área do cliente (`ClientLayout`) possuem sidebars verticais com os itens de navegação. O layout com sidebar ocupa espaço horizontal desnecessário e fragmenta a navegação entre header e sidebar. Esta feature remove os sidebars de ambos os layouts e move todos os itens de navegação para o Navbar (header fixo) através de dropdowns, mantendo a experiência consistente e o layout de tela cheia para o conteúdo principal.
|
||||
|
||||
### Estado atual
|
||||
|
||||
- `AdminLayout.tsx`: sidebar com 7 itens (Imóveis, Clientes, Boletos, Visitas, Favoritos, Cidades, Amenidades) + Navbar
|
||||
- `ClientLayout.tsx`: sidebar com 5 itens (Painel, Favoritos, Comparar, Visitas, Boletos) + link admin + Navbar
|
||||
- `Navbar.tsx`: links públicos + botão "Painel Admin" único sem dropdown
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — Admin sem sidebar (P1)
|
||||
**Given** admin autenticado acessando qualquer rota `/admin/*`, **When** visualiza o header, **Then** vê um dropdown "Admin ▾" com todos os 7 itens de navegação admin, sem sidebar lateral.
|
||||
|
||||
### US2 — Área do cliente sem sidebar (P1)
|
||||
**Given** cliente autenticado acessando qualquer rota `/area-do-cliente/*`, **When** visualiza o header, **Then** vê um dropdown "Minha Conta ▾" com todos os 5 itens de navegação da área do cliente, sem sidebar lateral.
|
||||
|
||||
### US3 — Conteúdo usa 100% da largura (P1)
|
||||
**Given** qualquer rota admin ou cliente, **When** sidebar é removido, **Then** o main content ocupa 100% da largura disponível (sem `w-56` reservado para sidebar).
|
||||
|
||||
### US4 — Dropdown fecha ao clicar fora (P2)
|
||||
**Given** dropdown aberto no header, **When** usuário clica fora do dropdown, **Then** dropdown fecha automaticamente.
|
||||
|
||||
### US5 — Navegação mobile (P1)
|
||||
**Given** usuário em dispositivo mobile, **When** abre o menu hamburger, **Then** vê todos os itens admin ou cliente no menu mobile expandido.
|
||||
|
||||
### US6 — Item ativo destacado no dropdown (P2)
|
||||
**Given** usuário em rota ativa (ex: `/admin/clientes`), **When** abre o dropdown admin, **Then** o item correspondente aparece destacado visualmente.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `AdminLayout.tsx`: remover `<aside>` sidebar, remover estrutura `pt-14/flex` redundante, main ocupa 100% da largura
|
||||
- `ClientLayout.tsx`: remover `<aside>` sidebar, remover mobile nav bar extra, main ocupa 100% da largura
|
||||
- `Navbar.tsx`: adicionar dropdown "Admin ▾" visível apenas para `isAdmin`, com os 7 itens do admin
|
||||
- `Navbar.tsx`: adicionar dropdown "Minha Conta ▾" visível apenas para `isAuthenticated` e não-admin, com os 5 itens do cliente
|
||||
- Dropdown fecha ao clicar fora (`useEffect` + `ref` ou `onBlur`)
|
||||
- Sem dependências externas novas (apenas React + react-router-dom + Tailwind já utilizados)
|
||||
- Mobile: itens de dropdown aparecem expandidos no menu hamburger
|
||||
23
.specify/features/013-header-only-nav/tasks.md
Normal file
23
.specify/features/013-header-only-nav/tasks.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Tasks: Navegação Unificada no Header (Remoção do Sidebar)
|
||||
|
||||
**Feature**: `013-header-only-nav`
|
||||
**Generated**: 2026-04-14
|
||||
**Depends On**: Nenhuma — alterações puramente de layout/componente frontend
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Navbar.tsx
|
||||
- [x] T001 Adicionar arrays `adminNavItems` e `clientNavItems` no Navbar em `frontend/src/components/Navbar.tsx`
|
||||
- [x] T002 Adicionar estados `adminDropdownOpen` / `clientDropdownOpen` + refs para fechar ao clicar fora em `frontend/src/components/Navbar.tsx`
|
||||
- [x] T003 Substituir botão "Painel Admin" por dropdown "Admin ▾" com 7 itens (apenas `isAdmin`) em `frontend/src/components/Navbar.tsx`
|
||||
- [x] T004 Adicionar dropdown "Minha Conta ▾" com 5 itens do cliente + botão Sair (apenas `isAuthenticated` não-admin) em `frontend/src/components/Navbar.tsx`
|
||||
- [x] T005 Expandir mobile menu para incluir itens admin/cliente inline em `frontend/src/components/Navbar.tsx`
|
||||
|
||||
## Frontend — AdminLayout.tsx
|
||||
- [x] T006 Remover `<aside>` sidebar do AdminLayout em `frontend/src/layouts/AdminLayout.tsx`
|
||||
- [x] T007 Simplificar estrutura: Navbar + main full-width com `pt-14` em `frontend/src/layouts/AdminLayout.tsx`
|
||||
|
||||
## Frontend — ClientLayout.tsx
|
||||
- [x] T008 Remover `<aside>` sidebar e mobile nav bar do ClientLayout em `frontend/src/layouts/ClientLayout.tsx`
|
||||
- [x] T009 Adicionar `<Navbar />` ao ClientLayout (atualmente ausente) em `frontend/src/layouts/ClientLayout.tsx`
|
||||
- [x] T010 Simplificar estrutura: main full-width com `pt-14` em `frontend/src/layouts/ClientLayout.tsx`
|
||||
49
.specify/features/014-properties-grid-ux/spec.md
Normal file
49
.specify/features/014-properties-grid-ux/spec.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Feature Specification: Grid de Imóveis — 4 Colunas, Altura Uniforme e UX/UI
|
||||
|
||||
**Feature Branch**: `014-properties-grid-ux`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: In Progress
|
||||
|
||||
## Contexto
|
||||
|
||||
A página `/imoveis` atualmente exibe um grid de 3 colunas (`xl:grid-cols-3`) com cards de altura variável conforme o comprimento do título. Os separadores visuais entre cards ficam descalinhados, a paginação usa `per_page=12` (múltiplo de 3) e o `max-w` do layout é 1200px. Esta feature reestrutura o grid para 4 colunas, garante altura uniforme nos cards alinhando o rodapé (stats e botão comparar) ao fundo, aumenta o `max-w` para acomodar 4 colunas ao lado do filtro, e aplica melhorias de UX/UI: localização no card (cidade/bairro), ícone de estacionamento, skeleton atualizado, e ajuste de `per_page` para 16.
|
||||
|
||||
### Estado atual
|
||||
|
||||
- `CatalogPage` (ou equivalente): `xl:grid-cols-3`, `max-w-[1200px]`, `per_page=12`
|
||||
- `PropertyCard.tsx`: altura variável conforme título; sem exibição de cidade/bairro; sem ícone de vagas
|
||||
- `PropertyCardSkeleton.tsx`: reflete estrutura de 3 colunas com 12 itens no loading
|
||||
- `FilterSidebar.tsx` desktop: `w-64`
|
||||
|
||||
## User Stories
|
||||
|
||||
### US1 — 4 colunas no grid (P1)
|
||||
**Given** usuário na página `/imoveis` em tela >= 1280px, **When** grid de imóveis é renderizado, **Then** exibe 4 cards por linha. Em telas menores: 1 coluna (< 640px), 2 colunas (640–1023px), 3 colunas (1024–1279px).
|
||||
|
||||
### US2 — Altura uniforme nos cards (P1)
|
||||
**Given** grid com cards de títulos longos e curtos na mesma linha, **When** renderizado, **Then** todos os cards de uma mesma linha têm exatamente a mesma altura, com o rodapé (stats + botão comparar) sempre alinhado na parte inferior.
|
||||
|
||||
### US3 — Localização no card (P2)
|
||||
**Given** card de um imóvel com cidade e/ou bairro informados, **When** exibido no grid, **Then** mostra cidade e bairro abaixo do título em texto menor e discreto.
|
||||
|
||||
### US4 — Ícone de vagas no card (P2)
|
||||
**Given** imóvel com `parking_spots > 0`, **When** exibido no grid, **Then** exibe ícone de carro + número de vagas na seção de stats.
|
||||
|
||||
### US5 — Paginação compatível (P1)
|
||||
**Given** grid de 4 colunas, **When** nova página carrega, **Then** `per_page=16` (múltiplo de 4) e skeleton também exibe 16 itens.
|
||||
|
||||
### US6 — Responsividade revisada (P1)
|
||||
**Given** usuário em qualquer dispositivo, **When** página carrega, **Then** layout adapta corretamente: filtro como overlay no mobile, grid responsivo sem overflow horizontal.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Grid alterado para `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4`
|
||||
- `max-w` do layout aumentado para `max-w-[1400px]`
|
||||
- `FilterSidebar` desktop reduzida de `w-64` para `w-56`
|
||||
- `per_page` default alterado de `12` para `16`
|
||||
- `PropertyCard.tsx`: elemento raiz do link usa `flex flex-col h-full`; rodapé (stats + botão comparar) usa `mt-auto`
|
||||
- `PropertyCard.tsx`: exibe cidade e bairro abaixo do título quando disponíveis
|
||||
- `PropertyCard.tsx`: exibe ícone de carro + `parking_spots` quando `parking_spots > 0`
|
||||
- `PropertyCardSkeleton.tsx`: atualizado para refletir nova estrutura (placeholder de localização + parking)
|
||||
- Skeleton renderiza 16 itens durante o estado de loading
|
||||
- Nenhuma dependência externa nova
|
||||
24
.specify/features/014-properties-grid-ux/tasks.md
Normal file
24
.specify/features/014-properties-grid-ux/tasks.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Tasks: Grid de Imóveis — 4 Colunas, Altura Uniforme e UX/UI
|
||||
|
||||
**Feature**: `014-properties-grid-ux`
|
||||
**Generated**: 2026-04-14
|
||||
**Depends On**: Nenhuma — alterações puramente de layout/componente frontend
|
||||
|
||||
---
|
||||
|
||||
## Frontend — PropertiesPage.tsx
|
||||
- [x] T001 Alterar grid de xl:grid-cols-3 para xl:grid-cols-4 (manter sm:grid-cols-2, lg:grid-cols-3) em `frontend/src/pages/PropertiesPage.tsx`
|
||||
- [x] T002 Alterar max-w do layout de 1200px para 1400px (todas as ocorrências na página) em `frontend/src/pages/PropertiesPage.tsx`
|
||||
- [x] T003 Alterar FilterSidebar desktop de w-64 para w-56 em `frontend/src/components/FilterSidebar.tsx`
|
||||
- [x] T004 Alterar per_page default de 12 para 16 (filtersFromParams + handleFiltersChange) em `frontend/src/pages/PropertiesPage.tsx`
|
||||
- [x] T005 Alterar skeleton de 9 para 16 itens no loading em `frontend/src/pages/PropertiesPage.tsx`
|
||||
|
||||
## Frontend — PropertyCard.tsx
|
||||
- [x] T006 Adicionar h-full e flex flex-col no Link container para altura uniforme em `frontend/src/components/PropertyCard.tsx`
|
||||
- [x] T007 Adicionar mt-auto no rodapé de stats + botão comparar em `frontend/src/components/PropertyCard.tsx`
|
||||
- [x] T008 Exibir localização (cidade e/ou bairro) abaixo do título em `frontend/src/components/PropertyCard.tsx`
|
||||
- [x] T009 Adicionar ícone de carro + parking_spots nos stats quando > 0 em `frontend/src/components/PropertyCard.tsx`
|
||||
|
||||
## Frontend — PropertyCardSkeleton.tsx
|
||||
- [x] T010 Adicionar skeleton de linha de localização após título em `frontend/src/components/PropertyCardSkeleton.tsx`
|
||||
- [x] T011 Adicionar skeleton de parking nos stats em `frontend/src/components/PropertyCardSkeleton.tsx`
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Specification Quality Checklist: Analytics Dashboard (Admin)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-14
|
||||
**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 gerado com base em descrição detalhada fornecida pelo usuário
|
||||
- Todos os itens passaram na validação inicial — feature pronta para `/speckit.plan`
|
||||
341
.specify/features/016-analytics-dashboard/plan.md
Normal file
341
.specify/features/016-analytics-dashboard/plan.md
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
# Implementation Plan: Analytics Dashboard (Admin)
|
||||
|
||||
**Branch**: `016-analytics-dashboard` | **Date**: 2026-04-14 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `.specify/features/016-analytics-dashboard/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Adicionar um dashboard de analytics ao painel admin que exibe métricas de acesso ao site (total por dia/semana/mês, gráfico de linha dos últimos N dias, top 10 páginas e top 10 imóveis mais visitados). O rastreamento é feito via `before_request` hook no Flask — sem bibliotecas de charting externas no frontend (SVG/CSS com Tailwind tokens existentes).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
|
||||
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
|
||||
**Storage**: PostgreSQL 16 — nova tabela `page_views`
|
||||
**Testing**: pytest (backend) · Vite build check (frontend)
|
||||
**Target Platform**: Web service (REST API) + SPA
|
||||
**Project Type**: Web application (backend + frontend)
|
||||
**Performance Goals**: Resposta do dashboard < 3 s (SC-001); overhead do hook < 50 ms por requisição (SC-002)
|
||||
**Constraints**: IP jamais persistido em claro — somente SHA-256 hash (FR-010, SC-005); rastreamento falha silenciosamente sem bloquear visitante (FR-009); somente admins autenticados acessam endpoints (FR-011)
|
||||
**Scale/Scope**: Volume proporcional ao tráfego; top-10 fixo; sem paginação em v1; sem polling automático em v1
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Princípio | Status | Observação |
|
||||
|-----------|--------|------------|
|
||||
| I. Design-First | PASS | Frontend usa tokens Tailwind do sistema (bg-canvas, bg-panel, bg-surface, text-textPrimary, border-borderPrimary, bg-brand etc.); gráfico de linha via SVG inline — sem libs externas |
|
||||
| II. Separação de Responsabilidades | PASS | Flask expõe 3 endpoints REST JSON puros; React consome via Axios; sem SSR |
|
||||
| III. Spec-Driven | PASS | spec.md com user stories e acceptance scenarios aprovado; spec → plan → tasks → implement |
|
||||
| IV. Integridade de Dados | PASS | Migração Alembic para page_views; SQLAlchemy ORM para queries; Pydantic v2 nos schemas; property_id nullable sem FK (preserva histórico se imóvel deletado) |
|
||||
| V. Segurança | PASS | Endpoints /api/v1/admin/analytics/* protegidos por require_admin; IP armazenado como SHA-256 hash irreversível com salt; rotas admin e auth excluídas do rastreamento (FR-003) |
|
||||
| VI. Simplicidade | PASS | before_request no app factory sem camada extra; SVG puro sem Recharts/Chart.js; 3 endpoints de agregação SQL simples |
|
||||
|
||||
**Constitution Check Post-Design**: PASS — nenhuma violação identificada após Phase 1.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```
|
||||
.specify/features/016-analytics-dashboard/
|
||||
├── plan.md # Este arquivo
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── contracts/
|
||||
│ └── analytics-api.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks — NÃO criado por /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── models/
|
||||
│ │ └── page_view.py # NOVO — modelo PageView (tabela page_views)
|
||||
│ ├── routes/
|
||||
│ │ └── analytics.py # NOVO — Blueprint analytics_bp, 3 endpoints GET
|
||||
│ ├── schemas/
|
||||
│ │ └── analytics.py # NOVO — Pydantic schemas de resposta
|
||||
│ └── __init__.py # MODIFICADO — importa model, registra blueprint, adds before_request hook
|
||||
├── migrations/versions/
|
||||
│ └── xxxx_add_page_views.py # NOVO — Alembic migration up/down
|
||||
└── tests/
|
||||
└── test_analytics.py # NOVO — 9 cenários de teste
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
├── pages/admin/
|
||||
│ └── AdminAnalyticsPage.tsx # NOVO — cards, gráfico SVG, tabelas
|
||||
├── services/
|
||||
│ └── analytics.ts # NOVO — 3 funções Axios tipadas
|
||||
└── components/
|
||||
└── Navbar.tsx # MODIFICADO — link "Analytics" na nav admin
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> Nenhuma violação da Constituição identificada.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Research
|
||||
|
||||
*Saída em: [research.md](research.md)*
|
||||
|
||||
### Unknowns Resolvidos
|
||||
|
||||
#### 1. Estratégia de rastreamento: before_request vs. after_request
|
||||
|
||||
- **Decisão**: `before_request` com commit dentro de `try/except` que engole todas as exceções.
|
||||
- **Racional**: Não bloqueia a resposta ao visitante (FR-009). `before_request` é mais simples — rota e método já estão disponíveis sem inspecionar o response object.
|
||||
- **Alternativas descartadas**: Celery/queue assíncrona — complexidade desnecessária para o volume esperado (YAGNI, Princípio VI).
|
||||
|
||||
#### 2. Anonimização do IP
|
||||
|
||||
- **Decisão**: `hashlib.sha256((ip + salt).encode()).hexdigest()` onde salt = `app.config["IP_SALT"]` (variável de ambiente `IP_SALT`, obrigatória, sem padrão).
|
||||
- **Racional**: Hash irreversível + salt impede rainbow-table; atende SC-005 e FR-010.
|
||||
- **Alternativas descartadas**: Truncar IP (reversível), MD5 (colisões conhecidas), bcrypt (lento demais para before_request).
|
||||
|
||||
#### 3. Extração do property_id do caminho
|
||||
|
||||
- **Decisão**: Regex `r"^/api/v1/properties/([0-9a-f-]{36})$"` aplicado sobre `request.path` dentro do hook.
|
||||
- **Racional**: A rota de detalhe (`/api/v1/properties/<uuid>`) é o único endpoint a associar `property_id`. Regex simples sem importar o mapa interno de rotas do Flask.
|
||||
- **Alternativas descartadas**: `request.view_args` (nem sempre disponível em before_request cross-blueprint).
|
||||
|
||||
#### 4. Gráfico de linha sem biblioteca externa
|
||||
|
||||
- **Decisão**: SVG inline gerado no componente React com `<polyline>` calculado a partir de `daily_series`. Eixo X = dias, eixo Y normalizado pela altura do viewBox.
|
||||
- **Racional**: Sem nova dependência npm (Princípio VI). Recharts/Chart.js adicionariam >200 KB ao bundle sem justificativa para o escopo da feature.
|
||||
- **Alternativas descartadas**: Recharts (dependência nova injustificada), bar chart CSS-only (menos informativo para série temporal contínua).
|
||||
|
||||
#### 5. Filtro de período no frontend
|
||||
|
||||
- **Decisão**: Estado local `days: 7 | 30 | 90` na página; passado como query param `?days=N` para os 3 endpoints.
|
||||
- **Racional**: Spec FR-008 define exatamente 3 períodos fixos; query param simples é suficiente.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Design & Contracts
|
||||
|
||||
*Saídas em: [data-model.md](data-model.md) · [contracts/analytics-api.md](contracts/analytics-api.md) · [quickstart.md](quickstart.md)*
|
||||
|
||||
### Data Model — tabela `page_views`
|
||||
|
||||
| Coluna | Tipo | Nullable | Notas |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | UUID (PK, default gen_random_uuid()) | NO | |
|
||||
| `path` | VARCHAR(500) | NO | Caminho da URL, ex: `/imoveis/slug` |
|
||||
| `property_id` | UUID (sem FK constraint) | YES | Preenchido apenas para rotas de detalhe de imóvel |
|
||||
| `accessed_at` | TIMESTAMP (server default now()) | NO | **Indexado** — base de todas as queries por período |
|
||||
| `ip_hash` | VARCHAR(64) | NO | SHA-256 hex do (IP + salt) |
|
||||
| `user_agent` | VARCHAR(500) | YES | Truncado em 500 chars |
|
||||
|
||||
**Índices**: `ix_page_views_accessed_at`, `ix_page_views_property_id`.
|
||||
|
||||
**Sem FK em property_id**: preserva registros históricos mesmo após remoção de imóveis.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**Blueprint**: `analytics_bp`, montado em `/api/v1/admin` → prefixo efetivo `/api/v1/admin/analytics`
|
||||
|
||||
Todos os endpoints exigem `Authorization: Bearer <token>` de administrador (`require_admin`).
|
||||
|
||||
#### GET /api/v1/admin/analytics/summary?days=30
|
||||
|
||||
Cards de métricas + série temporal diária.
|
||||
|
||||
```json
|
||||
{
|
||||
"today": 42,
|
||||
"this_week": 310,
|
||||
"this_month": 1204,
|
||||
"period_total": 1204,
|
||||
"daily_series": [
|
||||
{ "date": "2026-03-16", "count": 38 },
|
||||
{ "date": "2026-03-17", "count": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `today`, `this_week`, `this_month`: calculados a partir da data corrente, independente de `days`.
|
||||
- `daily_series`: exatamente `days` entradas; dias sem dados têm `count: 0`.
|
||||
|
||||
#### GET /api/v1/admin/analytics/top-pages?days=30
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "path": "/imoveis/apartamento-centro", "count": 87 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Top 10 caminhos por contagem de acessos no período.
|
||||
|
||||
#### GET /api/v1/admin/analytics/top-properties?days=30
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"property_id": "uuid",
|
||||
"title": "Apartamento Centro",
|
||||
"cover_photo": "/uploads/foto.jpg",
|
||||
"count": 87
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- JOIN com `properties` para obter `title` e `cover_photo` (primeira foto da relação `photos`).
|
||||
- LEFT JOIN — se imóvel não existir mais, o item é omitido do resultado.
|
||||
|
||||
### Before-Request Hook (rastreamento)
|
||||
|
||||
Inserido em `create_app()` após o registro do `analytics_bp`:
|
||||
|
||||
```python
|
||||
import hashlib, re
|
||||
from app.models.page_view import PageView
|
||||
|
||||
@app.before_request
|
||||
def _track_page_view():
|
||||
try:
|
||||
if request.method != "GET":
|
||||
return
|
||||
path = request.path
|
||||
excluded = ("/api/v1/admin", "/api/v1/auth", "/static")
|
||||
if any(path.startswith(p) for p in excluded):
|
||||
return
|
||||
ip_raw = request.remote_addr or ""
|
||||
salt = current_app.config.get("IP_SALT", "")
|
||||
ip_hash = hashlib.sha256((ip_raw + salt).encode()).hexdigest()
|
||||
property_id = None
|
||||
m = re.match(r"^/api/v1/properties/([0-9a-f-]{36})$", path)
|
||||
if m:
|
||||
property_id = m.group(1)
|
||||
pv = PageView(
|
||||
path=path[:500],
|
||||
ip_hash=ip_hash,
|
||||
user_agent=(request.user_agent.string or "")[:500],
|
||||
property_id=property_id,
|
||||
)
|
||||
db.session.add(pv)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
```
|
||||
|
||||
### Pydantic Schemas (`backend/app/schemas/analytics.py`)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class DailyPoint(BaseModel):
|
||||
date: str # "YYYY-MM-DD"
|
||||
count: int
|
||||
|
||||
class AnalyticsSummary(BaseModel):
|
||||
today: int
|
||||
this_week: int
|
||||
this_month: int
|
||||
period_total: int
|
||||
daily_series: list[DailyPoint]
|
||||
|
||||
class TopPageItem(BaseModel):
|
||||
path: str
|
||||
count: int
|
||||
|
||||
class TopPagesResponse(BaseModel):
|
||||
items: list[TopPageItem]
|
||||
|
||||
class TopPropertyItem(BaseModel):
|
||||
property_id: str
|
||||
title: str
|
||||
cover_photo: str | None
|
||||
count: int
|
||||
|
||||
class TopPropertiesResponse(BaseModel):
|
||||
items: list[TopPropertyItem]
|
||||
```
|
||||
|
||||
### Alembic Migration (esqueleto)
|
||||
|
||||
```python
|
||||
# upgrade
|
||||
op.create_table(
|
||||
"page_views",
|
||||
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("path", sa.String(500), nullable=False),
|
||||
sa.Column("property_id", sa.UUID(), nullable=True),
|
||||
sa.Column("accessed_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("ip_hash", sa.String(64), nullable=False),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_page_views_accessed_at", "page_views", ["accessed_at"])
|
||||
op.create_index("ix_page_views_property_id", "page_views", ["property_id"])
|
||||
|
||||
# downgrade
|
||||
op.drop_index("ix_page_views_property_id", table_name="page_views")
|
||||
op.drop_index("ix_page_views_accessed_at", table_name="page_views")
|
||||
op.drop_table("page_views")
|
||||
```
|
||||
|
||||
### Frontend — AdminAnalyticsPage.tsx
|
||||
|
||||
**Rota**: `/admin/analytics` (protegida por `AdminRoute`, adicionada no router em `App.tsx`)
|
||||
|
||||
**Componentes internos** (inline na página — sem arquivos separados por simplicidade v1):
|
||||
- `PeriodFilter`: botões "7d / 30d / 90d", ativo com `bg-brand text-white`; inativo com `bg-surface text-textSecondary`
|
||||
- `MetricCard`: `bg-panel border border-borderSubtle shadow-card rounded-lg`; valor em `text-textPrimary text-2xl font-semibold`; label em `text-textSecondary text-sm`
|
||||
- `LineChart`: SVG `viewBox="0 0 600 120"`, `<polyline>` calculado; linha em `stroke="#7170ff"`; área de fundo com `<polygon>` opacity 0.1 `fill="#5e6ad2"`; eixo X com labels de data
|
||||
- `TopPagesTable` e `TopPropertiesTable`: `bg-panel`, linhas alternadas com `bg-surface`, `text-textPrimary` e `text-textSecondary`
|
||||
|
||||
**Serviço (`frontend/src/services/analytics.ts`)**:
|
||||
```typescript
|
||||
import { api } from './api'
|
||||
|
||||
export const getAnalyticsSummary = (days: number) =>
|
||||
api.get<AnalyticsSummary>(`/admin/analytics/summary?days=${days}`)
|
||||
|
||||
export const getTopPages = (days: number) =>
|
||||
api.get<TopPagesResponse>(`/admin/analytics/top-pages?days=${days}`)
|
||||
|
||||
export const getTopProperties = (days: number) =>
|
||||
api.get<TopPropertiesResponse>(`/admin/analytics/top-properties?days=${days}`)
|
||||
```
|
||||
|
||||
### Testes pytest — 9 cenários
|
||||
|
||||
| # | Cenário |
|
||||
|---|---------|
|
||||
| 1 | `GET /summary` sem token → 401 |
|
||||
| 2 | `GET /summary` com token admin → 200, shape correto |
|
||||
| 3 | `GET /summary` com 0 registros → campos zerados, `daily_series` com `count: 0` para cada dia |
|
||||
| 4 | `GET /top-pages` retorna apenas acessos dentro do período (`days`) |
|
||||
| 5 | `GET /top-properties` LEFT JOIN correto — imóvel inexistente omitido |
|
||||
| 6 | Hook NOT registra acesso a `/api/v1/admin/*` |
|
||||
| 7 | Hook NOT registra acesso a `/api/v1/auth/*` |
|
||||
| 8 | Hook registra acesso a rota pública e cria `PageView` no banco |
|
||||
| 9 | Falha de banco no hook não propaga exceção para o cliente |
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Entrega
|
||||
|
||||
- [ ] `backend/app/models/page_view.py` criado e importado em `__init__.py`
|
||||
- [ ] Hook `before_request` em `create_app()`, excluindo `/api/v1/admin/*`, `/api/v1/auth/*`, `/static`
|
||||
- [ ] Migração Alembic criada e testada (upgrade + downgrade)
|
||||
- [ ] 3 endpoints GET em `backend/app/routes/analytics.py`, protegidos por `require_admin`
|
||||
- [ ] Pydantic schemas em `backend/app/schemas/analytics.py`
|
||||
- [ ] Testes em `backend/tests/test_analytics.py` (9 cenários)
|
||||
- [ ] `frontend/src/services/analytics.ts` com 3 funções tipadas
|
||||
- [ ] `AdminAnalyticsPage.tsx` com cards, gráfico SVG e tabelas usando tokens do design system
|
||||
- [ ] Link "Analytics" adicionado no `Navbar.tsx` (área admin)
|
||||
- [ ] Rota `/admin/analytics` registrada no router em `App.tsx`
|
||||
- [ ] Variável de ambiente `IP_SALT` documentada como obrigatória
|
||||
118
.specify/features/016-analytics-dashboard/spec.md
Normal file
118
.specify/features/016-analytics-dashboard/spec.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Feature Specification: Analytics Dashboard (Admin)
|
||||
|
||||
**Feature Branch**: `016-analytics-dashboard`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Dashboard de Analytics no painel admin para acompanhar acessos ao site."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Visualizar Métricas de Acesso (Priority: P1)
|
||||
|
||||
O administrador do sistema acessa a página de analytics no painel admin e visualiza um resumo dos acessos ao site: total de visitas hoje, nesta semana e neste mês, além de um gráfico mostrando a evolução de acessos nos últimos 30 dias.
|
||||
|
||||
**Why this priority**: É o coração do dashboard — sem essa visão consolidada, o administrador não consegue avaliar o desempenho do site. Entrega valor imediato ao permitir compreender tendências de tráfego.
|
||||
|
||||
**Independent Test**: Pode ser testado de forma completa acessando `/admin/analytics` após algumas visitas registradas. O administrador deve ver os cards de métricas e o gráfico com dados reais.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o administrador está autenticado e existem registros de acesso, **When** ele acessa `/admin/analytics`, **Then** vê cards com total de acessos de hoje, da semana atual e do mês atual.
|
||||
2. **Given** o administrador está na página de analytics, **When** a página carrega, **Then** um gráfico de linha exibe os acessos diários dos últimos 30 dias.
|
||||
3. **Given** o administrador está na página de analytics, **When** ele altera o filtro de período para "7 dias", "30 dias" ou "90 dias", **Then** todas as métricas e gráficos atualizam para refletir o período selecionado.
|
||||
4. **Given** não existe nenhum acesso registrado, **When** o administrador acessa o dashboard, **Then** vê métricas zeradas e mensagem indicando ausência de dados.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Consultar Páginas e Imóveis Mais Acessados (Priority: P2)
|
||||
|
||||
O administrador consulta as tabelas que listam as 10 páginas e os 10 imóveis mais visitados do site, para entender quais conteúdos são mais relevantes para os visitantes.
|
||||
|
||||
**Why this priority**: Permite decisões de negócio — quais imóveis destacar, quais páginas otimizar. Depende dos dados de rastreamento já coletados (P1).
|
||||
|
||||
**Independent Test**: Testável de forma independente realizando múltiplas visitas a páginas e imóveis distintos e verificando o ranking exibido no dashboard.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** existem registros de acesso a múltiplas páginas, **When** o administrador visualiza o dashboard, **Then** uma tabela exibe as 10 páginas mais acessadas com o caminho e o total de visitas, ordenadas do maior para o menor.
|
||||
2. **Given** existem registros de visualização de imóveis, **When** o administrador visualiza o dashboard, **Then** uma tabela exibe os 10 imóveis mais vistos com título, foto de capa e total de visualizações.
|
||||
3. **Given** há menos de 10 páginas/imóveis distintos com registros, **When** o administrador visualiza as tabelas, **Then** a tabela exibe apenas os itens disponíveis (sem erros ou linhas vazias).
|
||||
4. **Given** o administrador aplica um filtro de período, **When** filtra por "7 dias", **Then** as tabelas de top páginas e top imóveis refletem somente os acessos dentro do período.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Rastreamento Automático de Visitas Públicas (Priority: P3)
|
||||
|
||||
Cada vez que um visitante acessa uma página pública do site, o sistema registra automaticamente essa visita sem exigir nenhuma ação do usuário, preservando a privacidade ao armazenar um hash do IP em vez do IP completo.
|
||||
|
||||
**Why this priority**: É o mecanismo que alimenta todas as métricas do dashboard. Sem rastreamento, não há dados a exibir. Classificado como P3 pois pode ser implementado em paralelo com as histórias de UI, e os dados iniciais podem ser simulados nos testes.
|
||||
|
||||
**Independent Test**: Testável acessando páginas públicas e verificando no banco de dados que os registros foram criados com os campos corretos (path, timestamp, hash de IP, user agent).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um visitante acessa qualquer página pública (home, listagem, detalhe, sobre, contato), **When** a página é carregada, **Then** um registro de acesso é criado com caminho, horário, hash do IP e user agent.
|
||||
2. **Given** um visitante acessa a página de detalhe de um imóvel, **When** a página carrega, **Then** o registro de acesso é associado ao ID do imóvel correspondente, incrementando seu contador de visualizações.
|
||||
3. **Given** uma requisição é feita para rotas administrativas (`/admin/*`) ou de autenticação (`/api/auth/*`), **When** qualquer usuário acessa essas rotas, **Then** nenhum registro de acesso é criado (rastreamento excluído).
|
||||
4. **Given** o mesmo visitante acessa múltiplas páginas em sequência, **When** os acessos são registrados, **Then** cada acesso gera um registro separado com seu próprio timestamp.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece se o banco de dados estiver indisponível no momento do registro de uma visita? O acesso do visitante não deve ser bloqueado — o rastreamento deve falhar silenciosamente.
|
||||
- Como o sistema lida com bots/crawlers? User agent é armazenado mas não há filtro automático de bots na v1 (pode ser adicionado posteriormente).
|
||||
- O que acontece se um imóvel referenciado em um acesso for deletado? O registro de acesso deve ser mantido, com `property_id` referenciando um imóvel inexistente (integridade referencial relaxada ou via soft delete).
|
||||
- Como se comporta o gráfico se houver dias sem nenhum acesso no período selecionado? O gráfico deve exibir esses dias com valor zero, sem lacunas na linha do tempo.
|
||||
- O dashboard exibe dados em tempo real ou com delay? Os dados refletem os acessos até o momento da consulta (sem polling automático na v1).
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE registrar automaticamente cada acesso a páginas públicas com: caminho da URL, data/hora do acesso, user agent e hash anonimizado do IP.
|
||||
- **FR-002**: O sistema DEVE associar o registro de acesso ao ID do imóvel quando a página visitada for a de detalhe de um imóvel.
|
||||
- **FR-003**: O sistema NÃO DEVE registrar acessos a rotas administrativas (`/admin/*`) nem a rotas de autenticação (`/api/auth/*`).
|
||||
- **FR-004**: O sistema DEVE disponibilizar ao administrador cards de métricas com total de acessos do dia atual, da semana atual e do mês atual.
|
||||
- **FR-005**: O sistema DEVE disponibilizar ao administrador um gráfico de linha com o total de acessos por dia para o período selecionado.
|
||||
- **FR-006**: O sistema DEVE disponibilizar ao administrador uma tabela com as 10 páginas mais acessadas (caminho + total de acessos) para o período selecionado.
|
||||
- **FR-007**: O sistema DEVE disponibilizar ao administrador uma tabela com os 10 imóveis mais visualizados (título + foto de capa + total de visualizações) para o período selecionado.
|
||||
- **FR-008**: O administrador DEVE conseguir filtrar todas as métricas e rankings por período: últimos 7 dias, últimos 30 dias ou últimos 90 dias.
|
||||
- **FR-009**: O rastreamento de acessos NÃO DEVE impactar a disponibilidade das páginas públicas — falhas no registro devem ser silenciosas para o visitante.
|
||||
- **FR-010**: O IP do visitante DEVE ser armazenado exclusivamente como hash irreversível, nunca em texto claro.
|
||||
- **FR-011**: Somente administradores autenticados DEVEM ter acesso aos dados de analytics.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Registro de Acesso (PageView)**: Representa uma visita a uma página pública. Atributos: identificador único, caminho da URL visitada, referência opcional ao imóvel visualizado, data/hora do acesso, hash do IP do visitante, user agent do navegador.
|
||||
- **Resumo de Analytics (Summary)**: Agregação calculada a partir dos registros de acesso, contendo totais por período (dia/semana/mês) e séries temporais diárias.
|
||||
- **Ranking de Páginas**: Lista ordenada das páginas com maior número de acessos em um período, com caminho e contagem.
|
||||
- **Ranking de Imóveis**: Lista ordenada dos imóveis com maior número de visualizações em um período, com título, foto e contagem.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: O administrador consegue visualizar as métricas de acesso em menos de 3 segundos após acessar a página de analytics.
|
||||
- **SC-002**: 100% dos acessos a páginas públicas são registrados sem impactar o tempo de resposta percebido pelo visitante (registro assíncrono ou com overhead inferior a 50ms por requisição).
|
||||
- **SC-003**: O dashboard exibe corretamente métricas zeradas quando não há dados para o período selecionado, sem erros ou telas em branco.
|
||||
- **SC-004**: A troca de filtro de período atualiza todas as métricas, gráfico e tabelas em menos de 2 segundos.
|
||||
- **SC-005**: Nenhum dado pessoal identificável (IP completo) é armazenado — auditoria deve confirmar que apenas hashes são persistidos.
|
||||
- **SC-006**: Acessos a rotas de administração (`/admin/*`) não aparecem nos registros de analytics após 1 semana de uso em produção.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O sistema de autenticação de administradores já está implementado e funcional (feature 005/007).
|
||||
- A tabela de imóveis (`properties`) já existe com os campos de título e foto de capa.
|
||||
- O painel admin já possui layout e navegação estabelecidos — a página de analytics será adicionada como nova rota dentro da estrutura existente.
|
||||
- A privacidade de IPs via hash é suficiente para conformidade com boas práticas; conformidade formal com LGPD/GDPR fica fora do escopo desta feature.
|
||||
- Não há requisito de exportação de dados (CSV/PDF) nesta versão.
|
||||
- Filtros de período são globais — não há filtro por tipo de dispositivo, origem de tráfego ou localização geográfica nesta versão (v1).
|
||||
- Os dados de analytics não precisam de paginação na v1 — top 10 é o limite exibido nas tabelas.
|
||||
- O rastreamento é implementado no lado do servidor (middleware backend), não no frontend, para garantir consistência mesmo em casos de bloqueadores de JavaScript.
|
||||
199
.specify/features/016-analytics-dashboard/tasks.md
Normal file
199
.specify/features/016-analytics-dashboard/tasks.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Tasks: Analytics Dashboard (Admin)
|
||||
|
||||
**Input**: Design documents from `.specify/features/016-analytics-dashboard/`
|
||||
**Branch**: `016-analytics-dashboard`
|
||||
**Prerequisites**: plan.md ✓, spec.md ✓
|
||||
|
||||
**Organization**: Tasks organizadas por user story para permitir implementação e teste independentes de cada história.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Pode rodar em paralelo (arquivos diferentes, sem dependências incompletas)
|
||||
- **[Story]**: User story correspondente ([US1], [US2], [US3])
|
||||
- Todos os caminhos são relativos à raiz do repositório
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Configuração obrigatória que deve existir antes de qualquer implementação.
|
||||
|
||||
- [ ] T001 Add `IP_SALT` env variable to `backend/app/config.py` — em `BaseConfig` adicionar `IP_SALT = os.environ.get("IP_SALT", "")` e em `TestingConfig` adicionar `IP_SALT = "test-salt-analytics-016"` para evitar falha silenciosa durante testes
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Infraestrutura compartilhada por todas as user stories — modelo, migração, schemas e blueprint.
|
||||
|
||||
**⚠️ CRITICAL**: Nenhuma user story pode ser implementada até esta fase estar completa.
|
||||
|
||||
- [ ] T002 Create `backend/app/models/page_view.py` — modelo SQLAlchemy `PageView` com `__tablename__ = "page_views"` e colunas: `id` (`db.UUID(as_uuid=True)`, PK, `default=uuid.uuid4`), `path` (`db.String(500)`, not null), `property_id` (`db.UUID(as_uuid=True)`, nullable, **sem ForeignKey**), `accessed_at` (`db.DateTime`, not null, `default=datetime.utcnow`), `ip_hash` (`db.String(64)`, not null), `user_agent` (`db.String(500)`, nullable); declarar índices via `__table_args__` com `db.Index("ix_page_views_accessed_at", "accessed_at")` e `db.Index("ix_page_views_property_id", "property_id")`
|
||||
|
||||
- [ ] T003 [P] Create `backend/app/schemas/analytics.py` — schemas Pydantic v2 (`from pydantic import BaseModel`): `DailyPoint(date: str, count: int)`, `AnalyticsSummary(today: int, this_week: int, this_month: int, period_total: int, daily_series: list[DailyPoint])`, `TopPageItem(path: str, count: int)`, `TopPagesResponse(items: list[TopPageItem])`, `TopPropertyItem(property_id: str, title: str, cover_photo: str | None, count: int)`, `TopPropertiesResponse(items: list[TopPropertyItem])`
|
||||
|
||||
- [ ] T004 [P] Create `backend/app/routes/analytics.py` — scaffold do blueprint: `analytics_bp = Blueprint("analytics", __name__)`; importar `require_admin` de `app.utils.auth`, `db` de `app.extensions`, `PageView` de `app.models.page_view`, todos os schemas de `app.schemas.analytics`; criar as 3 funções de endpoint decoradas com `@analytics_bp.get(...)` e `@require_admin` ainda retornando `({}, 200)` como placeholder (corpo implementado em T007, T012, T013)
|
||||
|
||||
- [ ] T005 Update `backend/app/__init__.py` — na seção de import de models adicionar `from app.models import page_view as _page_view_models # noqa: F401`; na seção de registro de blueprints adicionar `from app.routes.analytics import analytics_bp` e `app.register_blueprint(analytics_bp, url_prefix="/api/v1/admin")`
|
||||
|
||||
- [ ] T006 Generate and apply Alembic migration — dentro de `backend/` executar `alembic revision --autogenerate -m "add_page_views"`; abrir o arquivo gerado em `backend/migrations/versions/` e verificar/corrigir para que contenha: `op.create_table("page_views", sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), sa.Column("path", sa.String(500), nullable=False), sa.Column("property_id", sa.UUID(), nullable=True), sa.Column("accessed_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), sa.Column("ip_hash", sa.String(64), nullable=False), sa.Column("user_agent", sa.String(500), nullable=True), sa.PrimaryKeyConstraint("id"))` e os dois `op.create_index`; no `downgrade` adicionar `op.drop_index` para ambos os índices antes de `op.drop_table`; executar `alembic upgrade head`
|
||||
|
||||
**Checkpoint**: Modelo registrado no ORM, tabela criada no banco, blueprint montado em `/api/v1/admin`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Visualizar Métricas de Acesso (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Administrador acessa `/admin/analytics`, vê cards com totais de hoje/semana/mês, seleciona período (7d/30d/90d) e visualiza gráfico de linha SVG com a série diária do período.
|
||||
|
||||
**Independent Test**: Inserir `PageView`s diretamente via fixture, chamar `GET /api/v1/admin/analytics/summary?days=30` com token admin e verificar resposta com shape correto (`today`, `this_week`, `this_month`, `period_total`, `daily_series` com exatamente 30 entradas); na UI acessar `/admin/analytics` e confirmar que cards e gráfico renderizam (com zeros se DB vazio).
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T007 [US1] Implement `GET /summary` in `backend/app/routes/analytics.py` — `@analytics_bp.get("/analytics/summary")` com `@require_admin`; ler query param `days = int(request.args.get("days", 30))`; calcular os 4 contadores via SQLAlchemy: `today` = `db.session.query(func.count(PageView.id)).filter(func.date(PageView.accessed_at) == date.today()).scalar()`, `this_week` = count com `PageView.accessed_at >= datetime.utcnow().replace(hour=0,minute=0,second=0) - timedelta(days=datetime.utcnow().weekday())`, `this_month` = count com dia 1 do mês corrente, `period_total` = count com `PageView.accessed_at >= datetime.utcnow() - timedelta(days=days)`; para `daily_series`: query `GROUP BY func.date(PageView.accessed_at)` within the period → produzir dict `{date_str: count}` → iterar `range(days-1, -1, -1)` construindo lista de `DailyPoint` (count=0 se data ausente); retornar `jsonify(AnalyticsSummary(...).model_dump())`
|
||||
|
||||
- [ ] T008 [P] [US1] Create `frontend/src/services/analytics.ts` — exportar interfaces TypeScript: `DailyPoint`, `AnalyticsSummary`, `TopPageItem`, `TopPagesResponse`, `TopPropertyItem`, `TopPropertiesResponse`; exportar função `getAnalyticsSummary(days: number): Promise<AnalyticsSummary>` implementada como `api.get<AnalyticsSummary>(\`/admin/analytics/summary?days=\${days}\`).then(r => r.data)` usando a instância `api` importada de `./api`
|
||||
|
||||
- [ ] T009 [P] [US1] Create `frontend/src/pages/admin/AdminAnalyticsPage.tsx` — estado `const [days, setDays] = useState<7|30|90>(30)` e `const [summary, setSummary] = useState<AnalyticsSummary | null>(null)` e `const [isLoading, setIsLoading] = useState(true)`; `useEffect` que chama `getAnalyticsSummary(days)` ao montar e ao trocar `days`; componente inline `PeriodFilter`: 3 botões `[7, 30, 90]` com classes Tailwind ativo=`bg-brand text-white rounded-md px-3 py-1 text-sm font-medium`, inativo=`bg-surface text-textSecondary rounded-md px-3 py-1 text-sm`; componente inline `MetricCard({ label, value })`: `div` com `bg-panel border border-borderSubtle shadow-card rounded-lg p-4`, `<p className="text-textSecondary text-sm mb-1">{label}</p>`, `<p className="text-textPrimary text-2xl font-semibold">{value}</p>`; renderizar 3 cards (Hoje, Esta Semana, Este Mês) e um card total do período; componente inline `LineChart({ series }: { series: DailyPoint[] })`: SVG `viewBox="0 0 600 120"` com `<polyline>` cujos pontos são calculados como `x = (i / (series.length-1)) * 580 + 10`, `y = 110 - (count / maxCount) * 100` (tratando `maxCount=0` como 1 para evitar NaN), `stroke="#7170ff" strokeWidth="2" fill="none"`; `<polygon>` de área preenchendo até y=110 com `fill="#5e6ad2" fillOpacity="0.1"`; labels do eixo X: renderizar data a cada 7ª posição em `<text>` com `fontSize="9" fill="#9ca3af"`; quando `maxCount === 0` exibir `<text x="300" y="65" textAnchor="middle" fill="#9ca3af" fontSize="12">Sem dados</text>` no centro; skeleton com `animate-pulse div` do Tailwind enquanto `isLoading === true`
|
||||
|
||||
- [ ] T010 [US1] Add `{ to: '/admin/analytics', label: 'Analytics' }` to `adminNavItems` array in `frontend/src/components/Navbar.tsx` — inserir como último item do array (após `{ to: '/admin/amenidades', label: 'Amenidades' }`)
|
||||
|
||||
- [ ] T011 [US1] Register route in `frontend/src/App.tsx` — adicionar `import AdminAnalyticsPage from './pages/admin/AdminAnalyticsPage'` no bloco de imports de páginas admin; adicionar `<Route path="analytics" element={<AdminAnalyticsPage />} />` dentro do bloco `<Route path="/admin" element={<AdminRoute>...}>` junto com as outras rotas admin existentes
|
||||
|
||||
**Checkpoint**: US1 totalmente funcional. Admin acessa `/admin/analytics`, vê cards e gráfico (zeros se nenhum PageView no banco). Link "Analytics" aparece na navbar do painel admin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Consultar Páginas e Imóveis Mais Acessados (Priority: P2)
|
||||
|
||||
**Goal**: Administrador vê tabelas com top-10 páginas mais acessadas e top-10 imóveis mais visualizados para o período selecionado.
|
||||
|
||||
**Independent Test**: Inserir `PageView`s com paths e `property_id`s distintos; chamar `GET /api/v1/admin/analytics/top-pages?days=30` e `GET /api/v1/admin/analytics/top-properties?days=30` e verificar listas com `path+count` e `property_id+title+cover_photo+count` ordenadas DESC; confirmar que itens com `property_id` inexistente são omitidos de `/top-properties`.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T012 [US2] Implement `GET /top-pages` in `backend/app/routes/analytics.py` — `@analytics_bp.get("/analytics/top-pages")` com `@require_admin`; ler `days`; query SQLAlchemy: `db.session.query(PageView.path, func.count(PageView.id).label("count")).filter(PageView.accessed_at >= datetime.utcnow() - timedelta(days=days)).group_by(PageView.path).order_by(func.count(PageView.id).desc()).limit(10).all()`; mapear para `list[TopPageItem]`; retornar `jsonify(TopPagesResponse(items=...).model_dump())`
|
||||
|
||||
- [ ] T013 [US2] Implement `GET /top-properties` in `backend/app/routes/analytics.py` — `@analytics_bp.get("/analytics/top-properties")` com `@require_admin`; ler `days`; query SQLAlchemy: selecionar `PageView.property_id`, `func.count(PageView.id).label("count")` WHERE `PageView.property_id != None` AND no período, `GROUP BY PageView.property_id`, `ORDER BY count DESC`, `LIMIT 10`; para cada resultado fazer `Property.query.get(row.property_id)` (pular se None — preserva comportamento de imóvel deletado); obter `cover_photo` via `PropertyPhoto.query.filter_by(property_id=prop.id).order_by(PropertyPhoto.display_order).first()`; montar `TopPropertyItem` e retornar `jsonify(TopPropertiesResponse(items=...).model_dump())`; importar `Property` de `app.models.property` e `PropertyPhoto` de `app.models.property`
|
||||
|
||||
- [ ] T014 [P] [US2] Add typed functions to `frontend/src/services/analytics.ts` — adicionar `getTopPages(days: number): Promise<TopPagesResponse>` e `getTopProperties(days: number): Promise<TopPropertiesResponse>` seguindo o mesmo padrão de `getAnalyticsSummary`
|
||||
|
||||
- [ ] T015 [US2] Add ranking tables to `frontend/src/pages/admin/AdminAnalyticsPage.tsx` — adicionar estado `const [topPages, setTopPages] = useState<TopPagesResponse | null>(null)` e `const [topProperties, setTopProperties] = useState<TopPropertiesResponse | null>(null)`; atualizar o `useEffect` para disparar os 3 fetches em paralelo via `Promise.all([getAnalyticsSummary(days), getTopPages(days), getTopProperties(days)])`; componente inline `TopPagesTable({ items })`: `table` com `bg-panel rounded-lg overflow-hidden w-full`, cabeçalho `text-textSecondary text-xs uppercase`, linhas com `text-textPrimary text-sm even:bg-surface`, colunas "Página" + "Acessos"; componente inline `TopPropertiesTable({ items })`: mesma estrutura, coluna com imagem `<img src={item.cover_photo || '/placeholder.jpg'} className="w-10 h-10 object-cover rounded" />` + título + visualizações; exibir mensagem "Sem dados para o período" quando listas estiverem vazias; renderizar as duas tabelas na página abaixo do gráfico em layout grid `grid-cols-1 lg:grid-cols-2 gap-6`
|
||||
|
||||
**Checkpoint**: US1 e US2 funcionais. Admin vê cards, gráfico e tabelas de ranking em uma única página com filtro de período unificado.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Rastreamento Automático de Visitas Públicas (Priority: P3)
|
||||
|
||||
**Goal**: Cada GET a uma rota pública cria um `PageView` com IP anonimizado e `property_id` quando aplicável, sem bloquear a resposta ao visitante.
|
||||
|
||||
**Independent Test**: No ambiente de teste, fazer `client.get("/api/v1/properties")` e verificar `PageView.query.count() == 1` com `path == "/api/v1/properties"` e `ip_hash` de 64 chars; fazer `client.get("/api/v1/admin/analytics/summary", headers=...)` e confirmar que o count NÃO aumenta.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T016 [US3] Add `_track_page_view` before_request hook in `backend/app/__init__.py` — adicionar `import hashlib` e `import re` no topo do arquivo (junto aos outros imports de stdlib); dentro de `create_app()`, APÓS o registro de todos os blueprints, adicionar:
|
||||
```python
|
||||
@app.before_request
|
||||
def _track_page_view():
|
||||
try:
|
||||
if request.method != "GET":
|
||||
return
|
||||
path = request.path
|
||||
excluded = ("/api/v1/admin", "/api/v1/auth", "/static")
|
||||
if any(path.startswith(p) for p in excluded):
|
||||
return
|
||||
ip_raw = request.remote_addr or ""
|
||||
salt = current_app.config.get("IP_SALT", "")
|
||||
ip_hash = hashlib.sha256((ip_raw + salt).encode()).hexdigest()
|
||||
property_id = None
|
||||
m = re.match(r"^/api/v1/properties/([0-9a-f-]{36})$", path)
|
||||
if m:
|
||||
property_id = m.group(1)
|
||||
pv = PageView(
|
||||
path=path[:500],
|
||||
ip_hash=ip_hash,
|
||||
user_agent=(request.user_agent.string or "")[:500],
|
||||
property_id=property_id,
|
||||
)
|
||||
db.session.add(pv)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
```
|
||||
Importar `PageView` de `app.models.page_view` no topo da função `create_app` (após os outros imports de modelo já existentes); importar `request` e `current_app` de `flask` caso não estejam já importados
|
||||
|
||||
**Checkpoint**: Rastreamento ativo. Acessar qualquer rota pública GET cria PageView no banco; `/api/v1/admin/*` e `/api/v1/auth/*` não são registrados; falha no commit não propaga exception ao visitante.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Tests & Polish
|
||||
|
||||
**Purpose**: Cobrir os 9 cenários de teste especificados no plan.md.
|
||||
|
||||
- [ ] T017 Create `backend/tests/test_analytics.py` — helper `_make_admin_token(app)` que cria um `ClientUser(role="admin")` no banco e assina um JWT com `app.config["JWT_SECRET_KEY"]`; helper `_insert_page_view(db, path, accessed_at=None, property_id=None)` para inserção direta; implementar os 9 testes:
|
||||
|
||||
**Cenário 1** — `test_summary_no_token_returns_401`: `GET /api/v1/admin/analytics/summary` sem header `Authorization` → `assert resp.status_code == 401`
|
||||
|
||||
**Cenário 2** — `test_summary_with_admin_token_returns_200_and_correct_shape`: criar admin, obter token, `GET /summary?days=7` → 200; verificar `data.keys()` contém `today, this_week, this_month, period_total, daily_series`; `len(data["daily_series"]) == 7`; todos os items de `daily_series` têm chaves `date` e `count`
|
||||
|
||||
**Cenário 3** — `test_summary_without_data_returns_all_zeros`: sem PageViews, `GET /summary?days=7` → `today == 0`, `this_week == 0`, `this_month == 0`, `period_total == 0`, todos os `count` em `daily_series` são 0
|
||||
|
||||
**Cenário 4** — `test_top_pages_respects_days_filter`: inserir 2 PageViews com `accessed_at = datetime.utcnow() - timedelta(days=60)` e 1 recente; `GET /top-pages?days=7` → somente o recente aparece no resultado
|
||||
|
||||
**Cenário 5** — `test_top_properties_omits_missing_property`: inserir PageView com `property_id = uuid4()` (imóvel inexistente); `GET /top-properties?days=30` → `data["items"] == []`
|
||||
|
||||
**Cenário 6** — `test_hook_does_not_track_admin_routes`: `GET /api/v1/admin/analytics/summary` com token admin → `PageView.query.count() == 0`
|
||||
|
||||
**Cenário 7** — `test_hook_does_not_track_auth_routes`: `POST /api/v1/auth/login` com body JSON → `PageView.query.count() == 0`
|
||||
|
||||
**Cenário 8** — `test_hook_tracks_public_get_route`: `client.get("/api/v1/properties")` → `PageView.query.count() == 1`; verificar `pv.path == "/api/v1/properties"` e `len(pv.ip_hash) == 64`
|
||||
|
||||
**Cenário 9** — `test_hook_swallows_db_exception_without_blocking_request`: usar `unittest.mock.patch("app.extensions.db.session.commit", side_effect=Exception("DB down"))`; `client.get("/api/v1/properties")` → `assert resp.status_code == 200` (a requisição não é bloqueada)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
T001 → T002,T003,T004 (paralelo) → T005 → T006
|
||||
→ T007 [US1 backend]
|
||||
→ T008,T009 [US1 frontend, paralelo]
|
||||
T007 → T012,T013 [US2 backend]
|
||||
T008 → T014 [US2 serviço]
|
||||
T009 → T015 [US2 UI]
|
||||
T005 → T016 [US3 hook]
|
||||
T006,T007,T012,T013,T016 → T017 [Tests]
|
||||
T009 → T010,T011 [Nav + Router]
|
||||
```
|
||||
|
||||
## Parallel Execution per Phase
|
||||
|
||||
| Fase | Tarefas Paralelas |
|
||||
|------|------------------|
|
||||
| Phase 2 | T003 ∥ T004 (schemas e blueprint são arquivos distintos independentes) |
|
||||
| Phase 3 | T008 ∥ T009 (serviço frontend e página são independentes do backend e entre si) |
|
||||
| Phase 4 | T012 ∥ T013 (dois endpoints distintos no mesmo arquivo, mas sem dependência entre si); T014 ∥ T012,T013 |
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
**MVP (somente US1)** — ordem mínima para entregar o dashboard funcional com zeros:
|
||||
> T001 → T002 → T003,T004 → T005 → T006 → T007 → T008,T009 → T010 → T011
|
||||
|
||||
Com US1 completo o admin já tem uma página funcional; os dados serão zero até US3 ser implementado.
|
||||
|
||||
**Entrega incremental**:
|
||||
1. **MVP** (T001–T011): Dashboard com cards e gráfico — vazio mas sem erros
|
||||
2. **+US2** (T012–T015): Tabelas de top páginas e imóveis aparecem na mesma página
|
||||
3. **+US3** (T016): Rastreamento ativo — dados reais começam a popular o dashboard
|
||||
4. **+Tests** (T017): Cobertura dos 9 cenários de aceitação
|
||||
|
||||
**Total de tarefas**: 17
|
||||
| Fase | Tarefas |
|
||||
|------|---------|
|
||||
| Setup | 1 (T001) |
|
||||
| Foundational | 5 (T002–T006) |
|
||||
| US1 (P1) | 5 (T007–T011) |
|
||||
| US2 (P2) | 4 (T012–T015) |
|
||||
| US3 (P3) | 1 (T016) |
|
||||
| Tests & Polish | 1 (T017) |
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Redesign da Lista de Imóveis com Carrossel 3D
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-16
|
||||
**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 passou por validação sem necessidade de clarificações — a descrição original era suficientemente detalhada.
|
||||
- Assumption sobre CSS puro para efeito 3D documentada (sem libs externas).
|
||||
- Edge cases cobrem 0, 1, 2 e 3+ fotos por imóvel.
|
||||
132
.specify/features/017-properties-list-3d-carousel/spec.md
Normal file
132
.specify/features/017-properties-list-3d-carousel/spec.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Feature Specification: Redesign da Lista de Imóveis com Carrossel 3D
|
||||
|
||||
**Feature Branch**: `017-properties-list-3d-carousel`
|
||||
**Created**: 2026-04-16
|
||||
**Status**: Draft
|
||||
**Input**: Redesenhar a página /imoveis para exibir imóveis em linhas com carrossel 3D de fotos empilhadas em efeito fan/stack
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Visualizar Lista de Imóveis com Cards em Linha (Priority: P1)
|
||||
|
||||
Um visitante acessa a página `/imoveis` e vê os imóveis dispostos em uma lista vertical de cards horizontais. Cada card ocupa a largura total disponível e exibe as informações principais ao lado de um carrossel 3D de fotos.
|
||||
|
||||
**Why this priority**: É a mudança central da feature — sem o novo layout de lista, todo o restante não existe. Entrega valor imediato ao usuário que busca escanear imóveis com mais informação visível por item.
|
||||
|
||||
**Independent Test**: Pode ser testado acessando `/imoveis` e verificando que os imóveis aparecem em linhas horizontais (não em grid), cada uma com altura ~260px no desktop, com foto à esquerda e informações à direita.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o usuário acessa `/imoveis`, **When** a página carrega, **Then** os imóveis são apresentados em uma lista vertical com cards horizontais de altura fixa ~260px no desktop.
|
||||
2. **Given** o card está visível, **When** o usuário o visualiza, **Then** vê à esquerda um bloco de fotos (~320px de largura) e à direita as informações do imóvel ocupando o espaço restante.
|
||||
3. **Given** o usuário está em dispositivo móvel, **When** visualiza um card, **Then** o carrossel de fotos aparece acima e as informações aparecem abaixo (layout empilhado vertical).
|
||||
4. **Given** os imóveis estão carregando, **When** a requisição ainda não completou, **Then** um skeleton no formato de linha é exibido no lugar dos cards.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Interagir com o Carrossel 3D de Fotos (Priority: P2)
|
||||
|
||||
Um visitante deseja ver diferentes fotos de um imóvel na listagem sem precisar entrar na página de detalhe. Ele clica nas fotos empilhadas atrás da principal para trazê-las à frente.
|
||||
|
||||
**Why this priority**: O carrossel 3D é o diferencial visual desta feature. Sem ele, os cards de linha seriam comuns; com ele, o usuário pode pré-visualizar fotos diretamente na listagem, reduzindo a necessidade de navegar para a página de detalhe.
|
||||
|
||||
**Independent Test**: Pode ser testado clicando em qualquer uma das fotos de fundo no carrossel de um card e verificando que a foto clicada passa para a posição frontal com animação suave.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um card de imóvel está visível, **When** o usuário o carrega, **Then** exibe 3 fotos empilhadas em perspectiva 3D: a primeira na frente (posição destaque) e as demais levemente rotacionadas e deslocadas atrás.
|
||||
2. **Given** o carrossel exibe 3 fotos em stack, **When** o usuário clica em uma foto de fundo, **Then** ela é trazida para a posição frontal com animação de 300ms e as demais redistribuem-se para as posições de fundo.
|
||||
3. **Given** o imóvel possui menos de 3 fotos, **When** o carrossel é renderizado, **Then** as posições restantes são preenchidas com a última foto disponível ou com um placeholder visual.
|
||||
4. **Given** o imóvel possui 1 ou mais fotos, **When** o carrossel é renderizado, **Then** a animação de rotação ocorre apenas quando há mais de uma foto disponível.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Visualizar Informações e Navegar para Detalhe do Imóvel (Priority: P2)
|
||||
|
||||
Um visitante vê as informações essenciais do imóvel diretamente no card da listagem e pode, a partir dele, navegar para a página de detalhe ou acionar a comparação.
|
||||
|
||||
**Why this priority**: O card precisa transmitir informação suficiente para o usuário decidir se vale a pena ver o detalhe completo, sem sobrecarregar visualmente o layout de lista.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando que cada card exibe título, localização, badge de tipo, preço, stats e contém links/botões funcionais para detalhe e comparação.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um card de imóvel está visível, **When** o usuário o vê, **Then** encontra: título do imóvel (até 2 linhas), bairro e cidade, badge Venda ou Aluguel, preço em destaque, e stats de quartos, banheiros, área m² e vagas.
|
||||
2. **Given** o usuário vê o card, **When** clica no título ou no link de detalhe, **Then** é redirecionado para a página de detalhe do imóvel.
|
||||
3. **Given** o usuário vê o card, **When** clica no botão "Comparar", **Then** o imóvel é adicionado à lista de comparação (comportamento existente mantido).
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Filtrar Imóveis com Sidebar Preservada (Priority: P1)
|
||||
|
||||
Um visitante usa os filtros da sidebar para refinar os resultados. O novo layout de lista deve preservar a sidebar e atualizar os cards de forma idêntica ao comportamento atual.
|
||||
|
||||
**Why this priority**: A sidebar de filtros é funcionalidade central da página `/imoveis`. Qualquer redesign que quebre os filtros compromete o fluxo de busca do usuário.
|
||||
|
||||
**Independent Test**: Pode ser testado aplicando filtros na sidebar e verificando que os cards em linha refletem corretamente os resultados filtrados.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o usuário está na página `/imoveis`, **When** visualiza o layout, **Then** a FilterSidebar permanece à esquerda com largura fixa (w-56) e os cards de linha ocupam o espaço restante à direita.
|
||||
2. **Given** o usuário aplica um filtro na sidebar, **When** os resultados atualizam, **Then** os cards em linha exibem apenas os imóveis que correspondem aos filtros selecionados.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece quando um imóvel não possui nenhuma foto? → O carrossel exibe 3 placeholders visuais e a interação de clique fica desabilitada.
|
||||
- O que acontece com a animação 3D em browsers sem suporte a `perspective` CSS? → O carrossel exibe as fotos em layout flat (sem 3D) como fallback gracioso.
|
||||
- O que acontece quando a lista de imóveis retorna 0 resultados? → O estado vazio existente é exibido, adaptado para o novo layout de lista.
|
||||
- O que acontece quando o usuário clica rapidamente em múltiplas fotos? → A animação de 300ms é respeitada; cliques durante a transição são enfileirados ou ignorados para evitar estados visuais inconsistentes.
|
||||
- O que acontece com imóveis que possuem exatamente 1 foto? → A foto única ocupa a posição frontal; as 2 posições de fundo são preenchidas com a mesma foto ou placeholder.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: A página `/imoveis` DEVE exibir imóveis em layout de lista vertical (linha por linha) ao invés do grid atual.
|
||||
- **FR-002**: Cada card DEVE ter altura fixa de ~260px no desktop e layout responsivo empilhado (foto acima, info abaixo) no mobile.
|
||||
- **FR-003**: A seção de fotos de cada card DEVE ter largura fixa de ~320px no desktop e ocupar a largura total no mobile.
|
||||
- **FR-004**: O carrossel 3D DEVE sempre exibir 3 fotos em efeito fan/stack com perspectiva CSS: a primeira na frente (translateZ maior, sem rotação), e as demais atrás (rotateY + translateX progressivos).
|
||||
- **FR-005**: Clicar em uma foto de fundo DEVE trazê-la para a posição frontal com animação smooth de 300ms via CSS transition.
|
||||
- **FR-006**: Imóveis com menos de 3 fotos DEVEM preencher as posições faltantes repetindo a última foto disponível ou exibindo placeholder visual neutro.
|
||||
- **FR-007**: Cada card DEVE exibir: título do imóvel (máx. 2 linhas), bairro e cidade, badge de tipo (Venda/Aluguel), preço em destaque, número de quartos, banheiros, área m² e vagas de garagem.
|
||||
- **FR-008**: Cada card DEVE conter botão "Comparar" e link de navegação para a página de detalhe do imóvel.
|
||||
- **FR-009**: A FilterSidebar DEVE ser preservada com largura fixa à esquerda da lista de cards.
|
||||
- **FR-010**: O skeleton de carregamento DEVE ser atualizado para refletir o formato de linha (retângulo largo com seção de foto à esquerda e barras de texto à direita).
|
||||
- **FR-011**: A interação de clique no carrossel NÃO DEVE disparar navegação para a página de detalhe.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PropertyCard**: Representa um imóvel na listagem; contém dados de exibição (título, localização, preço, stats) e referência às fotos.
|
||||
- **PhotoCarousel3D**: Componente visual do carrossel; gerencia o estado de qual foto está na posição frontal e as posições de fundo das demais.
|
||||
- **CarouselPhoto**: Foto individual dentro do carrossel; possui posição (front/back-1/back-2) e índice de rotação 3D.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A página `/imoveis` exibe todos os imóveis no novo formato de lista sem perda de dados ou funcionalidade em relação ao layout anterior.
|
||||
- **SC-002**: A transição de fotos no carrossel é visualmente fluida e completa em até 300ms, sem saltos ou flickering.
|
||||
- **SC-003**: O layout é totalmente utilizável em dispositivos móveis: foto e informações acessíveis sem scroll horizontal.
|
||||
- **SC-004**: Os filtros da sidebar continuam funcionando corretamente — os resultados em lista refletem exatamente os critérios selecionados.
|
||||
- **SC-005**: Cards com imóveis de 0, 1, 2 ou 3+ fotos são renderizados sem erros visuais ou de console.
|
||||
- **SC-006**: O tempo de carregamento percebido pelo usuário não aumenta em relação ao layout de grid anterior (skeleton mantém a experiência progressiva).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O componente `FilterSidebar` existente não precisa de alterações — somente o layout à direita é redesenhado.
|
||||
- As fotos dos imóveis já estão acessíveis via as propriedades existentes do modelo `Property` (relação `photos`).
|
||||
- O efeito 3D é implementado exclusivamente com CSS (`perspective`, `rotateY`, `translateZ`, `transition`) sem bibliotecas externas de animação.
|
||||
- A funcionalidade de comparação (botão "Comparar") já existe e será reutilizada sem modificações de lógica.
|
||||
- O comportamento de paginação ou scroll infinito existente é mantido sem alterações.
|
||||
- O layout mobile usa breakpoint `md` do Tailwind CSS como ponto de corte entre desktop (horizontal) e mobile (empilhado).
|
||||
- Imóveis sem nenhuma foto recebem um placeholder visual genérico (ícone de câmera ou cor sólida) nas 3 posições do carrossel.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Homepage Imersiva com Scroll
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Todos os itens passaram na validação inicial. Sem necessidade de clarificações adicionais.
|
||||
- Escopo limita intencionalmente o upload de imagens (fora do escopo) — administrador usa URL.
|
||||
- `prefers-reduced-motion` incluído como FR-015 após identificação no edge case.
|
||||
393
.specify/features/018-homepage-scroll-hero/plan.md
Normal file
393
.specify/features/018-homepage-scroll-hero/plan.md
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
# Plano de Implementação — Feature 018: Homepage Imersiva com Scroll (Hero + Destaques)
|
||||
|
||||
## Objetivo
|
||||
|
||||
Transformar a homepage em uma experiência imersiva de scroll-sticky: o hero ocupa 100vh com imagem de fundo configurável, overlay escuro e texto centralizado; ao rolar, os cards de imóveis em destaque sobem sobre o hero com animação de entrada. O texto do hero (headline, subheadline, CTA) é movido para dentro do `HomeScrollScene`; a `HeroSection` separada é removida do fluxo principal.
|
||||
|
||||
## Escopo
|
||||
|
||||
- **Backend**: nova coluna `hero_image_url` na tabela `homepage_config`, migration Alembic, exposição no schema/rota
|
||||
- **Frontend — tipos**: adicionar `hero_image_url` a `HomepageConfig`
|
||||
- **Frontend — `HeroSection.tsx`**: adicionar prop `backgroundImage` (mantida para uso isolado/admin futuro)
|
||||
- **Frontend — `HomeScrollScene.tsx`**: aceitar todas as props do hero e renderizar headline/subheadline/CTA dentro do container sticky
|
||||
- **Frontend — `HomePage.tsx`**: remover `<HeroSection>` separado; passar todo o config para `HomeScrollScene`
|
||||
- Fallback de imagem padrão quando `hero_image_url` for nulo/vazio/inválido
|
||||
- Suporte a `prefers-reduced-motion`
|
||||
|
||||
---
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Backend — Migration Alembic
|
||||
|
||||
**Arquivo**: `backend/migrations/versions/<id>_add_hero_image_url_to_homepage_config.py`
|
||||
|
||||
- Criar migration manual seguindo o padrão existente (ex: `c8d9e0f1a2b3`):
|
||||
- `revision`: novo ID hexadecimal (ex: `d1e2f3a4b5c6`)
|
||||
- `down_revision`: `c8d9e0f1a2b3` (última migration conhecida)
|
||||
- `upgrade()`: `op.add_column("homepage_config", sa.Column("hero_image_url", sa.String(512), nullable=True))`
|
||||
- `downgrade()`: `op.drop_column("homepage_config", "hero_image_url")`
|
||||
|
||||
```python
|
||||
# Exemplo de estrutura mínima
|
||||
"""add hero_image_url to homepage_config
|
||||
|
||||
Revision ID: d1e2f3a4b5c6
|
||||
Revises: c8d9e0f1a2b3
|
||||
Create Date: 2026-04-17 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "d1e2f3a4b5c6"
|
||||
down_revision = "c8d9e0f1a2b3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"homepage_config",
|
||||
sa.Column("hero_image_url", sa.String(length=512), nullable=True),
|
||||
)
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("homepage_config", "hero_image_url")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend — Modelo `HomepageConfig`
|
||||
|
||||
**Arquivo**: `backend/app/models/homepage.py`
|
||||
|
||||
Adicionar campo após `featured_properties_limit`:
|
||||
|
||||
```python
|
||||
hero_image_url = db.Column(db.String(512), nullable=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Backend — Schemas Pydantic
|
||||
|
||||
**Arquivo**: `backend/app/schemas/homepage.py`
|
||||
|
||||
- Em `HomepageConfigOut`: adicionar `hero_image_url: str | None = None`
|
||||
- Em `HomepageConfigIn`: adicionar `hero_image_url: str | None = None`
|
||||
- Adicionar validator que trata string vazia como `None`:
|
||||
```python
|
||||
@field_validator("hero_image_url", mode="before")
|
||||
@classmethod
|
||||
def empty_str_to_none(cls, v: str | None) -> str | None:
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
```
|
||||
|
||||
> A rota `GET /api/v1/homepage-config` já serializa via `HomepageConfigOut.model_dump()` — nenhuma alteração necessária na rota.
|
||||
|
||||
---
|
||||
|
||||
### 4. Frontend — Tipo `HomepageConfig`
|
||||
|
||||
**Arquivo**: `frontend/src/types/homepage.ts`
|
||||
|
||||
Adicionar campo:
|
||||
|
||||
```typescript
|
||||
export interface HomepageConfig {
|
||||
hero_headline: string
|
||||
hero_subheadline: string | null
|
||||
hero_cta_label: string
|
||||
hero_cta_url: string
|
||||
featured_properties_limit: number
|
||||
hero_image_url?: string | null // ← novo
|
||||
}
|
||||
```
|
||||
|
||||
Atualizar `FALLBACK_CONFIG` em `HomePage.tsx`:
|
||||
|
||||
```typescript
|
||||
const FALLBACK_CONFIG: HomepageConfig = {
|
||||
hero_headline: 'Encontre o imóvel dos seus sonhos',
|
||||
hero_subheadline: 'Os melhores imóveis para comprar ou alugar na sua região',
|
||||
hero_cta_label: 'Ver Imóveis',
|
||||
hero_cta_url: '/imoveis',
|
||||
featured_properties_limit: 6,
|
||||
hero_image_url: null, // ← novo
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Frontend — `HeroSection.tsx` (atualização de interface)
|
||||
|
||||
**Arquivo**: `frontend/src/components/HeroSection.tsx`
|
||||
|
||||
Adicionar prop `backgroundImage?: string | null` à interface. No estado loaded, usar `backgroundImage` como `background-image` inline (quando presente) em vez do gradiente CSS, com overlay escuro por cima.
|
||||
|
||||
```typescript
|
||||
interface HeroSectionProps {
|
||||
headline: string
|
||||
subheadline: string | null
|
||||
ctaLabel: string
|
||||
ctaUrl: string
|
||||
isLoading?: boolean
|
||||
backgroundImage?: string | null // ← novo
|
||||
}
|
||||
```
|
||||
|
||||
No JSX do estado loaded, substituir o `style` do `<section>`:
|
||||
|
||||
```typescript
|
||||
// Se backgroundImage presente:
|
||||
style={backgroundImage
|
||||
? { backgroundImage: `url(${backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: { background: 'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(94,106,210,0.08) 0%, transparent 60%), #08090a' }
|
||||
}
|
||||
```
|
||||
|
||||
Adicionar overlay escuro semitransparente como div inside a section (antes do conteúdo), quando `backgroundImage` estiver presente:
|
||||
|
||||
```tsx
|
||||
{backgroundImage && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ background: 'rgba(0,0,0,0.52)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
Garantir que o conteúdo fique com `position: relative; z-index: 1` para ficar acima do overlay.
|
||||
|
||||
---
|
||||
|
||||
### 6. Frontend — `HomeScrollScene.tsx` (principal)
|
||||
|
||||
**Arquivo**: `frontend/src/components/HomeScrollScene.tsx`
|
||||
|
||||
#### 6a. Atualizar interface de props
|
||||
|
||||
```typescript
|
||||
interface HomeScrollSceneProps {
|
||||
headline: string
|
||||
subheadline?: string | null
|
||||
ctaLabel?: string
|
||||
ctaUrl?: string
|
||||
backgroundImage?: string | null
|
||||
isLoading?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
#### 6b. Renderizar hero text dentro do container sticky
|
||||
|
||||
O bloco `<div className="sticky top-0 h-screen ...">` já existe e contém a imagem/gradiente e `<ScrollHint>`. Adicionar dentro desse bloco, **após** a imagem e o overlay de gradiente e **antes** do `<ScrollHint>`, o conteúdo do hero:
|
||||
|
||||
```tsx
|
||||
{/* Hero text — centralizado sobre a imagem */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center px-6 text-center z-10 pt-14">
|
||||
{isLoading ? (
|
||||
<div className="w-full max-w-[800px] animate-pulse">
|
||||
<div className="h-16 md:h-20 lg:h-24 bg-white/[0.06] rounded-lg w-3/4 mx-auto mb-6" />
|
||||
<div className="h-6 bg-white/[0.06] rounded w-1/2 mx-auto mb-3" />
|
||||
<div className="h-6 bg-white/[0.06] rounded w-2/5 mx-auto mb-10" />
|
||||
<div className="h-11 bg-white/[0.06] rounded w-36 mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-[800px]">
|
||||
<h1
|
||||
className="text-[40px] md:text-[48px] lg:text-[72px] font-medium text-white leading-tight tracking-display-xl mb-6"
|
||||
style={{ fontFeatureSettings: '"cv01", "ss03"', textShadow: '0 2px 16px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
{headline}
|
||||
</h1>
|
||||
{subheadline && (
|
||||
<p className="text-lg md:text-xl text-white/70 font-light leading-relaxed mb-10 max-w-[560px] mx-auto"
|
||||
style={{ textShadow: '0 1px 8px rgba(0,0,0,0.4)' }}>
|
||||
{subheadline}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={ctaUrl ?? '/imoveis'}
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-brand hover:bg-accentHover text-white font-semibold text-sm rounded transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
|
||||
aria-label={ctaLabel}
|
||||
>
|
||||
{ctaLabel ?? 'Ver Imóveis'}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 6c. Overlay escuro sobre a imagem de fundo
|
||||
|
||||
Após o bloco `{imageUrl ? <img> : <div gradiente>}` e antes do overlay de gradiente existente (que suaviza edges), adicionar overlay semitransparente quando `backgroundImage` estiver presente:
|
||||
|
||||
```tsx
|
||||
{backgroundImage && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ background: 'rgba(0,0,0,0.50)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 6d. Remover prop `imageUrl` legada, renomear para `backgroundImage`
|
||||
|
||||
A prop atual é `imageUrl?: string`. Renomear internamente para `backgroundImage` para consistência com o resto do plano.
|
||||
|
||||
#### 6e. `prefers-reduced-motion`
|
||||
|
||||
No `RiseCard`, substituir as classes `transition-all duration-700` e `translate-y-12` com suporte a reduced-motion:
|
||||
|
||||
```tsx
|
||||
className={`
|
||||
transition-all duration-700 ease-out
|
||||
motion-reduce:transition-none motion-reduce:translate-y-0
|
||||
${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-12'}
|
||||
`}
|
||||
```
|
||||
|
||||
No `ScrollHint`, nos chevrons com `animation` inline, adicionar CSS `@media (prefers-reduced-motion: reduce)` desabilitando a animação:
|
||||
|
||||
```tsx
|
||||
style={{
|
||||
animation: `fadeDown 1.4s ease-in-out ${i * 0.2}s infinite`,
|
||||
}}
|
||||
```
|
||||
|
||||
Adicionar no bloco `<style>` existente:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scroll-chevron { animation: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
E adicionar `className="scroll-chevron"` nos chevrons do `ScrollHint`.
|
||||
|
||||
---
|
||||
|
||||
### 7. Frontend — `HomePage.tsx` (simplificação)
|
||||
|
||||
**Arquivo**: `frontend/src/pages/HomePage.tsx`
|
||||
|
||||
- Remover o import e o uso de `<HeroSection>` do retorno JSX
|
||||
- Passar todas as props do hero para `<HomeScrollScene>`:
|
||||
|
||||
```tsx
|
||||
// Antes:
|
||||
<HeroSection
|
||||
headline={config.hero_headline}
|
||||
subheadline={config.hero_subheadline}
|
||||
ctaLabel={config.hero_cta_label}
|
||||
ctaUrl={config.hero_cta_url}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<HomeScrollScene />
|
||||
|
||||
// Depois:
|
||||
<HomeScrollScene
|
||||
headline={config.hero_headline}
|
||||
subheadline={config.hero_subheadline}
|
||||
ctaLabel={config.hero_cta_label}
|
||||
ctaUrl={config.hero_cta_url}
|
||||
backgroundImage={config.hero_image_url ?? null}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
```
|
||||
|
||||
- Remover import de `HeroSection`
|
||||
- O `FALLBACK_CONFIG` já terá `hero_image_url: null` após a tarefa 4
|
||||
|
||||
---
|
||||
|
||||
### 8. Backend — Admin: exposição do campo `hero_image_url` no painel (opcional/P3)
|
||||
|
||||
> Fora do escopo crítico desta feature — a spec marca US-5 como P3. O painel admin não possui `AdminHomepagePage` ainda. O campo `hero_image_url` pode ser configurado diretamente via endpoint PATCH no banco até que a UI admin seja criada em feature futura.
|
||||
>
|
||||
> Se desejado nesta feature: adicionar endpoint `PATCH /api/v1/admin/homepage-config` em `backend/app/routes/admin.py` que aceita `HomepageConfigIn` e atualiza o registro único da tabela.
|
||||
|
||||
---
|
||||
|
||||
## Ordem de Execução Recomendada
|
||||
|
||||
```
|
||||
1. Migration Alembic → backend/migrations/versions/
|
||||
2. Modelo HomepageConfig → backend/app/models/homepage.py
|
||||
3. Schemas Pydantic → backend/app/schemas/homepage.py
|
||||
4. Tipo TS → frontend/src/types/homepage.ts
|
||||
5. HomeScrollScene → adicionar hero props e renderização
|
||||
6. HeroSection → adicionar backgroundImage prop
|
||||
7. HomePage → remover HeroSection, passar props
|
||||
8. (Opcional) Admin PATCH endpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contratos de Dados
|
||||
|
||||
### `GET /api/v1/homepage-config` — response (após migration)
|
||||
|
||||
```json
|
||||
{
|
||||
"hero_headline": "Encontre o imóvel dos seus sonhos",
|
||||
"hero_subheadline": "Os melhores imóveis para comprar ou alugar na sua região",
|
||||
"hero_cta_label": "Ver Imóveis",
|
||||
"hero_cta_url": "/imoveis",
|
||||
"featured_properties_limit": 6,
|
||||
"hero_image_url": "https://cdn.example.com/hero.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
### `HomeScrollScene` props
|
||||
|
||||
```typescript
|
||||
interface HomeScrollSceneProps {
|
||||
headline: string
|
||||
subheadline?: string | null
|
||||
ctaLabel?: string
|
||||
ctaUrl?: string
|
||||
backgroundImage?: string | null
|
||||
isLoading?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamento de Fallback
|
||||
|
||||
| Situação | Comportamento |
|
||||
|---|---|
|
||||
| `hero_image_url` é `null` ou não configurado | Gradiente CSS existente é exibido como fundo |
|
||||
| `hero_image_url` é string vazia `""` | Tratada como `null` pelo validator Pydantic |
|
||||
| Imagem de URL falha ao carregar (404/timeout) | Browser exibe fundo vazio; o gradiente CSS deve ser mantido como background do container (aplicar o gradiente como `background` do `<div>` wrapper, a `<img>` fica por cima com `object-fit: cover`) |
|
||||
| `prefers-reduced-motion` ativado | Animações `translateY` e chevron cascade são desabilitadas |
|
||||
|
||||
> **Implementação do fallback de imagem quebrada**: usar `onError` na `<img>` para esconder/remover a imagem, deixando o gradiente visível:
|
||||
> ```tsx
|
||||
> <img
|
||||
> src={backgroundImage}
|
||||
> onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||||
> ...
|
||||
> />
|
||||
> ```
|
||||
> O gradiente CSS deve estar sempre presente no background do container — a imagem fica posicionada `absolute inset-0` por cima.
|
||||
|
||||
---
|
||||
|
||||
## Critérios de Aceite (ligados às User Stories da spec)
|
||||
|
||||
- [ ] **US-1**: Hero ocupa 100vh, overlay escuro garante legibilidade do texto branco, imagem de fundo ou gradiente como fallback
|
||||
- [ ] **US-1**: Texto (headline, subheadline, CTA) centralizado sobre o fundo
|
||||
- [ ] **US-2**: Scroll sticky funciona — imagem permanece enquanto cards sobem
|
||||
- [ ] **US-2**: Cada card tem animação `opacity 0→1` + `translateY 48px→0` com stagger de 60ms (max 240ms)
|
||||
- [ ] **US-3**: `ScrollHint` visível sobre o hero com 3 chevrons em cascata
|
||||
- [ ] **US-4**: Redirecionamento para `/imoveis` após 800ms quando sentinel 100% visível
|
||||
- [ ] **US-4**: Overlay de transição (blur + spinner) antes de navegar
|
||||
- [ ] **US-5**: `hero_image_url` salvo no DB e retornado na API após migration
|
||||
- [ ] Edge: imagem com URL inválida → fallback para gradiente
|
||||
- [ ] Edge: `prefers-reduced-motion` → sem translateY nem chevron cascade
|
||||
- [ ] Edge: FALLBACK_CONFIG funciona sem `hero_image_url` configurado
|
||||
142
.specify/features/018-homepage-scroll-hero/spec.md
Normal file
142
.specify/features/018-homepage-scroll-hero/spec.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Feature Specification: Homepage Imersiva com Scroll — Hero + Destaque de Imóveis
|
||||
|
||||
**Feature Branch**: `018-homepage-scroll-hero`
|
||||
**Created**: 2026-04-17
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Homepage imersiva com scroll — hero + imagem de fundo + destaque de imóveis"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Visitante visualiza a hero fullscreen ao acessar a homepage (Priority: P1)
|
||||
|
||||
Ao entrar na homepage, o visitante vê uma imagem de fundo ocupando 100% da altura da tela com o texto principal (headline, subheadline e CTA) centralizado por cima. Um overlay semitransparente escuro garante que o texto branco seja legível independente da imagem usada.
|
||||
|
||||
**Why this priority**: É a primeira impressão do produto. Sem isso, toda a experiência de scroll não existe.
|
||||
|
||||
**Independent Test**: Pode ser testado acessando a homepage com e sem `hero_image_url` configurado no painel. Valor entregue: apresentação visual impactante imediata.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o administrador configurou `hero_image_url` no HomepageConfig, **When** um visitante acessa a homepage, **Then** a imagem aparece como fundo fullscreen (100vh), com overlay escuro semitransparente e o texto (headline, subheadline, CTA) centralizado sobre ela em cor branca.
|
||||
2. **Given** `hero_image_url` não está configurado, **When** um visitante acessa a homepage, **Then** uma imagem padrão do sistema é exibida como fundo, mantendo o mesmo layout e legibilidade.
|
||||
3. **Given** o visitante acessa a homepage em dispositivo móvel, **When** a página carrega, **Then** a imagem de fundo cobre 100% da altura da viewport sem distorção ou corte indesejado.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Visitante rola a tela e os cards de imóveis emergem sobre o hero (Priority: P1)
|
||||
|
||||
Ao começar a rolar a página para baixo, a imagem hero permanece "presa" (sticky) na tela enquanto os cards de imóveis em destaque sobem por cima dela, criando a sensação de que os imóveis emergem da cena. Cada card aparece com animação suave ao entrar no viewport.
|
||||
|
||||
**Why this priority**: É a interação central da feature — sem o scroll sticky + cards animados, a experiência imersiva não existe.
|
||||
|
||||
**Independent Test**: Pode ser testado rolando a página com imóveis em destaque cadastrados. Valor entregue: diferenciação visual clara da homepage.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está na homepage com imóveis em destaque cadastrados, **When** rola a tela para baixo, **Then** a imagem hero permanece visível e fixa enquanto a lista de cards sobe por cima dela.
|
||||
2. **Given** os cards estão fora do viewport inicial, **When** cada card entra na área visível da tela durante o scroll, **Then** ele aparece com animação de `opacity 0→1` combinada com `translateY(48px→0)`, com stagger de 60ms entre os cards.
|
||||
3. **Given** não há imóveis em destaque cadastrados, **When** o visitante acessa a homepage, **Then** a seção de cards não é exibida e a experiência de sticky hero ainda funciona normalmente.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Indicador visual guia o visitante a rolar a tela (Priority: P2)
|
||||
|
||||
Enquanto a imagem hero está visível na tela, um indicador de "Role para ver os destaques" aparece com três chevrons animados em cascata, incentivando o visitante a rolar.
|
||||
|
||||
**Why this priority**: Melhora a usabilidade e descoberta, mas a feature principal funciona sem ele.
|
||||
|
||||
**Independent Test**: Pode ser testado verificando a presença e visibilidade do indicador enquanto o hero está visível, e sua ausência após o scroll. Valor entregue: orientação visual ao usuário.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante está na homepage com a imagem hero visível, **When** a página carrega, **Then** aparece a mensagem "Role para ver os destaques" com 3 chevrons animados em cascata (animação encadeada, não simultânea).
|
||||
2. **Given** o indicador está visível, **When** o visitante rola até a imagem hero sair completamente do viewport, **Then** o indicador desaparece ou some com transição suave.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Visitante é redirecionado automaticamente para a listagem completa ao fim dos destaques (Priority: P2)
|
||||
|
||||
Ao chegar ao fim da lista de imóveis em destaque, o visitante é automaticamente levado para a página de listagem completa (`/imoveis`) após uma breve pausa, com um overlay de transição visual.
|
||||
|
||||
**Why this priority**: Cria um fluxo contínuo e natural entre a homepage e o catálogo, mas não bloqueia as funcionalidades principais.
|
||||
|
||||
**Independent Test**: Pode ser testado chegando ao fim dos cards e aguardando o redirecionamento. Valor entregue: redução de fricção no funil de aquisição.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o visitante rolou até o fim da lista de destaques, **When** o marcador invisível ao fim da lista fica 100% visível no viewport, **Then** após 800ms o visitante é redirecionado para `/imoveis` com overlay de transição (blur + spinner).
|
||||
2. **Given** o redirecionamento foi acionado, **When** o overlay de transição aparece, **Then** a tela exibe um efeito de blur sobre o conteúdo atual e um spinner indicando carregamento.
|
||||
3. **Given** o visitante não chegou ao fim da lista, **When** rola para cima novamente, **Then** nenhum redirecionamento é acionado.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Administrador configura a imagem de fundo do hero (Priority: P3)
|
||||
|
||||
O administrador pode definir a URL da imagem de fundo do hero através do painel de configuração da homepage, persistindo a informação no backend.
|
||||
|
||||
**Why this priority**: Permite customização, mas a homepage funciona com imagem padrão enquanto não configurada.
|
||||
|
||||
**Independent Test**: Pode ser testado salvando uma URL de imagem no painel e verificando que ela aparece na homepage. Valor entregue: controle editorial da vitrine principal.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** o administrador acessa o painel de configuração da homepage, **When** informa uma URL válida no campo de imagem do hero e salva, **Then** a homepage passa a exibir essa imagem como fundo.
|
||||
2. **Given** o administrador informa uma URL inválida ou vazia, **When** salva, **Then** o sistema usa a imagem padrão e não exibe erro ao visitante.
|
||||
3. **Given** a configuração foi salva, **When** a API de configuração da homepage é consultada, **Then** o campo `hero_image_url` aparece na resposta com o valor configurado.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece quando a URL da `hero_image_url` aponta para uma imagem que não carrega (404, timeout)? → A imagem padrão deve ser exibida como fallback.
|
||||
- O que acontece quando há apenas 1 imóvel em destaque? → A animação e o scroll redirect devem funcionar normalmente.
|
||||
- O que acontece quando o visitante tem preferência por `prefers-reduced-motion`? → As animações de entrada dos cards e dos chevrons devem ser suprimidas ou substituídas por transições simples de opacidade.
|
||||
- O que acontece quando `hero_image_url` é uma string vazia (`""`)?→ Tratada como `null`, usando a imagem padrão.
|
||||
- O que acontece quando o usuário rola rapidamente até o fim antes das animações terminarem? → Os cards já visíveis devem aparecer sem aguardar o stagger completo; o redirecionamento dispara normalmente.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE exibir a seção hero ocupando 100% da altura do viewport (100vh) ao carregar a homepage.
|
||||
- **FR-002**: O sistema DEVE exibir um overlay escuro semitransparente sobre a imagem do hero para garantir legibilidade do texto branco.
|
||||
- **FR-003**: O sistema DEVE exibir headline, subheadline e CTA configuráveis (via `HomepageConfig`) centralizados sobre o hero.
|
||||
- **FR-004**: O sistema DEVE usar `hero_image_url` da configuração como imagem de fundo do hero quando disponível, e uma imagem padrão quando não configurado ou inválido.
|
||||
- **FR-005**: O sistema DEVE manter a imagem hero "sticky" durante o scroll enquanto os cards de imóveis sobem por cima dela.
|
||||
- **FR-006**: O sistema DEVE exibir os imóveis em destaque como cards em layout de linha horizontal (`PropertyRowCard`).
|
||||
- **FR-007**: Cada card de imóvel em destaque DEVE ter animação de entrada (`opacity 0→1` + `translateY 48px→0`) ativada por `IntersectionObserver` com `threshold 0.05` quando o card entra no viewport.
|
||||
- **FR-008**: O stagger entre animações de cards consecutivos DEVE ser de 60ms.
|
||||
- **FR-009**: O sistema DEVE exibir um indicador "Role para ver os destaques" com 3 chevrons animados em cascata enquanto o hero estiver visível.
|
||||
- **FR-010**: O sistema DEVE ocultar o indicador de scroll quando a imagem hero sair do viewport.
|
||||
- **FR-011**: O sistema DEVE redirecionar automaticamente para `/imoveis` após 800ms quando um marcador invisível ao fim da lista de cards estiver 100% visível no viewport (`IntersectionObserver`, `threshold 1.0`).
|
||||
- **FR-012**: O redirecionamento DEVE ser acompanhado por um overlay de transição (blur + spinner) antes de navegar.
|
||||
- **FR-013**: A tabela `homepage_config` no banco de dados DEVE receber a nova coluna `hero_image_url` (texto de até 512 caracteres, opcional/nullable) via migration Alembic.
|
||||
- **FR-014**: A API `GET /homepage-config` DEVE incluir o campo `hero_image_url` na resposta.
|
||||
- **FR-015**: O sistema DEVE respeitar `prefers-reduced-motion` suprimindo ou simplificando animações de entrada para usuários que assim preferirem.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **HomepageConfig**: Registro único de configuração da homepage. Passa a incluir `hero_image_url` (URL da imagem de fundo do hero, opcional). Demais campos existentes (headline, subheadline, CTA) permanecem inalterados.
|
||||
- **FeaturedProperty** (imóvel em destaque): Imóvel marcado para aparecer na seção de destaques da homepage. Sem alteração de modelo; apenas o modo de exibição muda.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: O tempo de carregamento inicial da homepage (até a imagem hero ser visível) não ultrapassa 3 segundos em conexões padrão.
|
||||
- **SC-002**: 100% dos imóveis em destaque exibem animação de entrada ao entrar no viewport durante o scroll.
|
||||
- **SC-003**: O redirecionamento automático para `/imoveis` ocorre dentro de 800ms (±100ms) a partir do momento em que o marcador final é 100% visível.
|
||||
- **SC-004**: A imagem padrão é exibida corretamente em 100% dos acessos onde `hero_image_url` não está configurado ou não carrega.
|
||||
- **SC-005**: A experiência de scroll sticky funciona corretamente nos navegadores modernos (Chrome, Firefox, Safari, Edge — versões dos últimos 2 anos).
|
||||
- **SC-006**: Em dispositivos com `prefers-reduced-motion` ativado, nenhuma animação de movimento (translateY, chevron cascade) é executada.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O componente `HomeScrollScene.tsx` já existe no codebase mas ainda sem integração completa com o hero text — a spec assume que ele será refatorado para receber todo o conteúdo (hero + lista) como uma única unidade.
|
||||
- O `PropertyRowCard` já existe e está funcional; a spec não envolve alteração em seu layout interno.
|
||||
- A imagem padrão do sistema está disponível como asset estático no frontend.
|
||||
- O painel de administração já possui interface para editar configurações da `HomepageConfig`; a adição do campo `hero_image_url` segue o mesmo padrão já existente.
|
||||
- O endpoint `GET /homepage-config` já existe; a mudança é apenas aditiva (novo campo na resposta).
|
||||
- Os imóveis em destaque já são retornados por uma rota existente; não é necessário criar um novo endpoint.
|
||||
- A administração de `hero_image_url` é feita informando uma URL externa ou um path de asset já hospedado (upload de imagem está fora do escopo desta feature).
|
||||
- A migration Alembic será executada como parte do processo de deploy padrão do projeto.
|
||||
171
.specify/features/018-homepage-scroll-hero/tasks.md
Normal file
171
.specify/features/018-homepage-scroll-hero/tasks.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Tasks: Feature 018 — Homepage Imersiva com Scroll (Hero + Destaques)
|
||||
|
||||
**Feature Branch**: `018-homepage-scroll-hero`
|
||||
**Input**: `.specify/features/018-homepage-scroll-hero/plan.md`, `.specify/features/018-homepage-scroll-hero/spec.md`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description — caminho`
|
||||
|
||||
- **[P]**: Pode rodar em paralelo (arquivos diferentes, sem dependências de tarefas incompletas)
|
||||
- **[Story]**: User story correspondente (US1–US5, mapeado ao spec.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational — Backend + Frontend Types (Pré-requisitos bloqueantes)
|
||||
|
||||
**Purpose**: Expor `hero_image_url` no banco de dados, no backend e no frontend. Todas as fases de user story dependem desta fase.
|
||||
|
||||
**⚠️ CRÍTICO**: Nenhuma user story pode ser iniciada até esta fase estar completa.
|
||||
|
||||
- [ ] T001 Criar migration Alembic com `revision = "d1e2f3a4b5c6"`, `down_revision = "c8d9e0f1a2b3"`: `upgrade()` executa `op.add_column("homepage_config", sa.Column("hero_image_url", sa.String(512), nullable=True))`, `downgrade()` executa `op.drop_column` — `backend/migrations/versions/d1e2f3a4b5c6_add_hero_image_url_to_homepage_config.py`
|
||||
- [ ] T002 [P] Adicionar campo `hero_image_url = db.Column(db.String(512), nullable=True)` ao modelo `HomepageConfig` após `featured_properties_limit` — `backend/app/models/homepage.py`
|
||||
- [ ] T003 [P] Adicionar `hero_image_url: str | None = None` a `HomepageConfigOut` e `HomepageConfigIn`; em `HomepageConfigIn` incluir `@field_validator("hero_image_url", mode="before")` que converte string vazia ou de apenas espaços para `None` — `backend/app/schemas/homepage.py`
|
||||
- [ ] T004 [P] Adicionar campo `hero_image_url?: string | null` à interface `HomepageConfig`; adicionar `hero_image_url: null` ao objeto `FALLBACK_CONFIG` — `frontend/src/types/homepage.ts` e `frontend/src/pages/HomePage.tsx`
|
||||
|
||||
**Checkpoint**: `GET /api/v1/homepage-config` retorna `hero_image_url`; interface TypeScript corretamente tipada.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Visitante visualiza a hero fullscreen (P1) 🎯 MVP
|
||||
|
||||
**Goal**: Hero ocupa 100vh com imagem de fundo opcional, overlay escuro semitransparente e headline/subheadline/CTA centralizados; fallback para gradiente CSS quando sem imagem.
|
||||
|
||||
**Independent Test**: Acessar homepage com `hero_image_url` configurado e verificar imagem fullscreen com overlay escuro e texto branco legível. Acessar sem configuração e verificar exibição do gradiente padrão com texto igualmente legível.
|
||||
|
||||
- [ ] T005 [P] [US1] Adicionar prop `backgroundImage?: string | null` à interface `HeroSectionProps`; quando `backgroundImage` presente, aplicar `style={{ backgroundImage: \`url(\${backgroundImage})\`, backgroundSize: 'cover', backgroundPosition: 'center' }}` ao `<section>` (substituindo o gradiente); adicionar `<div className="absolute inset-0 pointer-events-none" style={{ background: 'rgba(0,0,0,0.52)' }} aria-hidden="true" />` dentro da section; garantir conteúdo com `position: relative; z-index: 1` — `frontend/src/components/HeroSection.tsx`
|
||||
- [ ] T006 [P] [US1] Atualizar `HomeScrollScene.tsx`: (a) expandir interface `HomeScrollSceneProps` para incluir `headline: string`, `subheadline?: string | null`, `ctaLabel?: string`, `ctaUrl?: string`, `backgroundImage?: string | null`, `isLoading?: boolean`; (b) renomear prop `imageUrl` → `backgroundImage` em toda a implementação; (c) adicionar `<div className="absolute inset-0 pointer-events-none" style={{ background: 'rgba(0,0,0,0.50)' }} aria-hidden="true" />` após o bloco `{backgroundImage ? <img> : <div gradiente>}` e antes do overlay de gradiente existente — apenas quando `backgroundImage` presente; (d) adicionar bloco de hero text com skeleton de loading e conteúdo real (h1, p, `<a>`) dentro do container sticky, posicionado com `className="absolute inset-0 flex flex-col items-center justify-center px-6 text-center z-10 pt-14"` — `frontend/src/components/HomeScrollScene.tsx`
|
||||
- [ ] T007 [US1] Remover import e uso de `<HeroSection>` do JSX de `HomePage`; passar ao `<HomeScrollScene>` as props: `headline={config.hero_headline}`, `subheadline={config.hero_subheadline}`, `ctaLabel={config.hero_cta_label}`, `ctaUrl={config.hero_cta_url}`, `backgroundImage={config.hero_image_url ?? null}`, `isLoading={isLoading}` — `frontend/src/pages/HomePage.tsx`
|
||||
|
||||
**Checkpoint**: Hero fullscreen com imagem de fundo (ou gradiente como fallback), overlay escuro e texto centralizado visível e legível.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Cards de imóveis emergem durante o scroll (P1)
|
||||
|
||||
**Goal**: Imagem hero permanece sticky enquanto cards sobem por cima com animação `opacity 0→1` + `translateY 48px→0`; stagger 60ms entre cards; usuários com `prefers-reduced-motion` não recebem animações de movimento.
|
||||
|
||||
**Independent Test**: Rolar a homepage com imóveis em destaque cadastrados; verificar sticky hero + animação de entrada por card com stagger. Ativar `prefers-reduced-motion: reduce` no DevTools e verificar ausência de `translateY`.
|
||||
|
||||
> **Nota**: Animação `RiseCard` (IntersectionObserver `threshold: 0.05`, stagger via `transitionDelay: ${index * 60}ms`, container sticky `h-screen z-0`) já está implementada. Esta fase adiciona apenas suporte a `prefers-reduced-motion`.
|
||||
|
||||
- [ ] T008 [US2] Adicionar classes `motion-reduce:transition-none motion-reduce:translate-y-0` ao `className` do `<div>` animado dentro de `RiseCard` para suprimir a transição CSS e o `translateY` quando `prefers-reduced-motion` estiver ativo — `frontend/src/components/HomeScrollScene.tsx`
|
||||
|
||||
**Checkpoint**: Cards animam com opacity + translateY normalmente; `prefers-reduced-motion` suprime o movimento mantendo a visibilidade.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Indicador visual guia o visitante a rolar (P2)
|
||||
|
||||
**Goal**: `ScrollHint` exibe "Role para ver os destaques" com 3 chevrons animados em cascata enquanto o hero é visível; animação suprimida em `prefers-reduced-motion`.
|
||||
|
||||
**Independent Test**: Verificar label "Role para ver os destaques" e 3 chevrons animados enquanto hero visível; ativar `prefers-reduced-motion: reduce` e verificar que chevrons param de animar.
|
||||
|
||||
> **Nota**: `ScrollHint` com 3 chevrons animados em cascata (keyframe `fadeDown`, `animation-delay: ${i * 0.2}s`) já existe. Esta fase atualiza o label e adiciona suporte a `prefers-reduced-motion`.
|
||||
|
||||
- [ ] T009 [US3] Em `HomeScrollScene.tsx`: alterar o label fixo passado ao `<ScrollHint>` de `"Imóveis em destaque"` para `"Role para ver os destaques"`; adicionar `className="scroll-chevron"` em cada `<svg>` dos chevrons dentro de `ScrollHint`; acrescentar ao bloco `<style>` inline a regra `@media (prefers-reduced-motion: reduce) { .scroll-chevron { animation: none !important; } }` — `frontend/src/components/HomeScrollScene.tsx`
|
||||
|
||||
**Checkpoint**: Indicador exibe texto correto; chevrons animam normalmente e têm animação suprimida em `prefers-reduced-motion`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 — Redirecionamento automático ao fim dos destaques (P2)
|
||||
|
||||
**Goal**: Após 800ms com marcador final 100% visível, redirecionar para `/imoveis` com overlay blur + spinner.
|
||||
|
||||
**Independent Test**: Rolar até o fim dos cards de destaque; aguardar 800ms; verificar overlay com blur e spinner e redirect para `/imoveis`.
|
||||
|
||||
> **Nota**: Implementação completa já existe em `HomeScrollScene.tsx`: `sentinelRef` + `IntersectionObserver threshold: 1.0` + `setTimeout 800ms` + estado `navigating` + overlay com `backdropFilter: blur(8px)` + spinner animado + `navigate('/imoveis')`. Esta fase apenas melhora a acessibilidade do overlay.
|
||||
|
||||
- [ ] T010 [US4] Adicionar `role="status"` e `aria-live="polite"` ao `<div>` do overlay de transição (bloco `{navigating && (...)}`), e `aria-label="Redirecionando para a listagem de imóveis"` ao spinner, para acessibilidade de screen readers — `frontend/src/components/HomeScrollScene.tsx`
|
||||
|
||||
**Checkpoint**: Redirect automático funcionando em 800ms com overlay blur+spinner e overlay acessível via `aria-live`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 5 — Administrador configura a imagem de fundo do hero (P3)
|
||||
|
||||
**Goal**: Admin persiste URL da imagem via painel; API retorna o valor; homepage exibe a imagem configurada.
|
||||
|
||||
**Independent Test**: Salvar URL válida via admin panel; consultar `GET /api/v1/homepage-config` e verificar `hero_image_url` na resposta; confirmar exibição na homepage. Salvar string vazia e verificar que API retorna `null` e fallback de gradiente é usado.
|
||||
|
||||
> **Nota**: A spec assume que o painel admin para `HomepageConfig` já existe, mas nenhum endpoint `PUT` nem página admin foram encontrados no codebase. Esta fase cria ambos. O schema `HomepageConfigIn` (atualizado em T003) já valida o campo.
|
||||
|
||||
- [ ] T011 [P] [US5] Adicionar endpoint `PUT /api/v1/admin/homepage-config` ao blueprint `homepage_bp`: requer JWT de admin (usar decorator de auth já existente em `backend/app/utils/auth.py`), valida payload com `HomepageConfigIn`, atualiza registro via SQLAlchemy (`.query.first()` + atribuição de atributos + `db.session.commit()`), retorna `HomepageConfigOut.model_validate(config).model_dump()` — `backend/app/routes/homepage.py`
|
||||
- [ ] T012 [US5] Criar `AdminHomepagePage.tsx` com formulário controlado (campos: `hero_headline`, `hero_subheadline`, `hero_cta_label`, `hero_cta_url`, `featured_properties_limit`, `hero_image_url`); buscar config atual com `GET /api/v1/homepage-config` ao montar; salvar com `PUT /api/v1/admin/homepage-config`; seguir padrão visual e de autenticação das demais páginas admin existentes — `frontend/src/pages/admin/AdminHomepagePage.tsx`
|
||||
|
||||
**Checkpoint**: Admin salva `hero_image_url` via painel; valor retornado pela API; homepage exibe imagem configurada.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Comportamentos de borda e qualidade transversal.
|
||||
|
||||
- [ ] T013 [P] Verificar fallback de imagem padrão: quando `backgroundImage` é `null`, `undefined` ou URL que resulta em erro (add `onError` handler no `<img>` de `HomeScrollScene.tsx` para setar `backgroundImage` state como `null` em caso de falha de carregamento) — `frontend/src/components/HomeScrollScene.tsx`
|
||||
- [ ] T014 [P] Verificar comportamento mobile: testar hero em viewports < 640px no Chrome DevTools garantindo `backgroundSize: cover` sem distorção; confirmar que hero text e CTA são legíveis em telas pequenas — `frontend/src/components/HomeScrollScene.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: Sem dependências — pode começar imediatamente
|
||||
- **User Stories (Phases 2–6)**: Todas dependem da conclusão da Phase 1
|
||||
- US1 (Phase 2) e US2 (Phase 3): US2 depende de T006 estar completo; demais são independentes entre si
|
||||
- US3 (Phase 4), US4 (Phase 5), US5 (Phase 6): podem rodar em paralelo entre si após Phase 1
|
||||
- **Polish (Phase 7)**: Após todas as user stories desejadas estarem completas
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (Phase 2, P1)**: Depende da Foundational (T001–T004)
|
||||
- **US2 (Phase 3, P1)**: Depende de T006 (props interface de HomeScrollScene já atualizada)
|
||||
- **US3 (Phase 4, P2)**: Depende apenas de Phase 1 — independente de US1/US2
|
||||
- **US4 (Phase 5, P2)**: Já implementado — apenas acessibilidade; depende de Phase 1
|
||||
- **US5 (Phase 6, P3)**: Depende de T002 e T003 (model e schema já prontos)
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
```
|
||||
Phase 1 (Foundational):
|
||||
T001 → [T002 ∥ T003 ∥ T004]
|
||||
|
||||
Phase 2 (US1):
|
||||
[T005 ∥ T006] → T007
|
||||
|
||||
Após Phase 2 (US2–US5 em paralelo):
|
||||
T008 (US2) ∥ T009 (US3) ∥ T010 (US4) ∥ [T011 → T012] (US5)
|
||||
|
||||
Phase 7 (Polish):
|
||||
T013 ∥ T014
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
**MVP (entrega mínima — 7 tarefas)**: Phases 1 + 2 (T001–T007)
|
||||
- Homepage já exibe hero imersivo fullscreen com imagem de fundo opcional, overlay escuro e hero text centralizado
|
||||
- Sticky scroll + cards animados continuam funcionando (já implementados)
|
||||
|
||||
**Incremental após MVP**:
|
||||
1. T008 (US2) — reduced-motion support para cards
|
||||
2. T009 (US3) — label correto + reduced-motion no ScrollHint
|
||||
3. T010 (US4) — acessibilidade no overlay de redirect (núcleo já funcional)
|
||||
4. T011–T012 (US5) — admin panel para configurar `hero_image_url`
|
||||
5. T013–T014 (Polish) — fallback de imagem + validação mobile
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Fase | User Story | Tarefas | Prioridade |
|
||||
|------|-----------|---------|-----------|
|
||||
| Foundational | — | T001–T004 | Bloqueante |
|
||||
| Phase 2 | US1 — Hero Fullscreen | T005–T007 | P1 (MVP) |
|
||||
| Phase 3 | US2 — Cards emergem | T008 | P1 |
|
||||
| Phase 4 | US3 — Indicador scroll | T009 | P2 |
|
||||
| Phase 5 | US4 — Auto-redirect | T010 | P2 |
|
||||
| Phase 6 | US5 — Admin config | T011–T012 | P3 |
|
||||
| Phase 7 | Polish | T013–T014 | — |
|
||||
| **Total** | | **14 tarefas** | |
|
||||
Loading…
Add table
Add a link
Reference in a new issue