11 KiB
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 psmostra 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:
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:
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:
from app.models import property as _property_models # já existe — ContactLead está no mesmo arquivo
ContactLeadestá emproperty.py— nenhuma linha nova necessária se já importaproperty.
Passo 3 — Backend: gerar e aplicar migration
# 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:
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:
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-validatorestá empyproject.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:
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_bpjá existe e está registrado em__init__.py— nenhuma alteração no factory necessária.
Passo 6 — Backend: verificar rotas
# 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:
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:
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:
onKeyDownno container comtabIndex={0}; teclasArrowLeft/ArrowRight - Touch:
onTouchStartsalvatouchStartX;onTouchEnddetecta 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:
>emtext-text-quaternary/50
9d. PriceBox.tsx
- Props:
price: string, condo_fee: string | null, type: "venda" | "aluguel" - Sticky:
sticky top-24em desktop (lg:) - Badge tipo: fundo
#5e6ad2/20texto#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.groupByoureduce - Grid de checkmarks:
✓emtext-[#7170ff]+ nome emtext-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_NUMBERviaimport.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:
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}}:
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
# 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) comborder border-white/5 - Accent:
#7170ffpara 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-intercomtext-[510]para preço e títulos principais - Skeleton:
animate-pulse bg-white/5(mesma classe doPropertyCardSkeleton) - Breadcrumb:
text-text-quaternarycom último itemtext-text-primary