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
|
|
@ -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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue