sass-imobiliaria/.specify/features/004-property-detail-page/quickstart.md

370 lines
11 KiB
Markdown

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