# 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/") 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//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 { const response = await api.get(`/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 `
Sem fotos disponíveis
` - 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 → └── notFound → estado "Imóvel não encontrado" + Link para /imoveis └── loaded →

{property.title}

{property.description &&

{property.description}

}