feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -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).

View 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 | 2150 caracteres
`email` | string | sim | formato e-mail válido (RFC 5322)
`phone` | string | não | máximo 20 caracteres
`message` | string | sim | 102000 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

View 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, 2150 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, 102000 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).

View 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.050.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.

View 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`

View 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, 2150 chars
- `email`: obrigatório, EmailStr
- `phone`: opcional, max 20 chars
- `message`: obrigatório, 102000 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 |

View 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, 2150 caracteres
- `email`: obrigatório, formato de e-mail válido
- `phone`: opcional, string, máximo 20 caracteres
- `message`: obrigatório, string, 102000 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)

View 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 (US1US3)
- 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 24, 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 12, FR-F07 |
| T015 | S | T009 | spec.md §US1 cenários 57, 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, T012T017 | spec.md §US1US3, 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) | T001T008 (backend) + T009T011 (services) + T013, T015, T017 (stats, price, skeleton) + T018T020 (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 T001T020 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 |