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

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 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:

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

ContactLead está em property.py — nenhuma linha nova necessária se já importa property.


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-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:

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

# 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: 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 groupObject.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:

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