370 lines
11 KiB
Markdown
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`
|