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

18 KiB
Raw Blame History

Tasks: Property Detail Page (Página de Detalhe do Imóvel)

Feature: 004-property-detail-page Branch: 004-property-detail-page Input: spec.md, plan.md, data-model.md, contracts/rest.md, DESIGN.md Generated: 2026-04-13 Status: Ready for implementation


Format

- [ ] T[NNN] [P?] [USN?] Description — path/to/file.ext
  • [P] — Paralelizável (arquivo diferente, sem dependência de tarefa incompleta)
  • [USN] — User Story associada (US1US3)
  • IDs sequenciais na ordem de execução recomendada

Phase 1: Backend — Modificações no Modelo Existente

Objetivo: Estender o modelo Property com as colunas code e description exigidas pelo contrato da spec, e criar o schema PropertyDetailOut. Estas tarefas bloqueiam as rotas novas.

⚠️ CRÍTICO: T005 (GET /slug) depende de T001 e T002 estarem completas.

ID Complexidade Deps spec_ref
T001 S data-model.md §Property, plan.md §backend
T002 S T001 data-model.md §Schemas, spec.md §API Contract
  • T001 Adicionar colunas code (VARCHAR 30, UNIQUE, nullable) e description (TEXT, nullable) ao modelo Propertybackend/app/models/property.py

    • Done when: from app.models.property import Property importa sem erro; Property.code e Property.description são atributos db.Column declarados exatamente como em data-model.md §Property; code tem unique=True, nullable=True; description tem nullable=True, type_=db.Text.
  • T002 Adicionar PropertyDetailOut(PropertyOut) ao schema de propriedades com campos address: str | None, code: str | None, description: str | Nonebackend/app/schemas/property.py

    • Done when: from app.schemas.property import PropertyDetailOut importa sem erro; PropertyDetailOut.model_validate(property_instance) serializa corretamente incluindo address, code e description; model_config = ConfigDict(from_attributes=True) herdado de PropertyOut.

Phase 2: Backend — ContactLead (Novo Modelo, Schemas e Rotas)

Objetivo: Criar a tabela contact_leads, os schemas Pydantic de validação/resposta e os dois novos endpoints. Depende da Phase 1 estar concluída.

ID Complexidade Deps spec_ref
T003 S data-model.md §ContactLead, spec.md §Modelos
T004 S data-model.md §Schemas, spec.md §POST /contact
T005 M T001, T002 spec.md §GET /slug, FR-B01, FR-B02
T006 M T003, T004 spec.md §POST /contact, FR-B03, FR-B04
T007 S T003 plan.md §backend, data-model.md §ContactLead
T008 M T001, T003, T007 spec.md §FR-B06, data-model.md §Índices
  • T003 Criar modelo ContactLead com campos id (SERIAL PK), property_id (UUID FK → properties ON DELETE SET NULL, indexed), name (VARCHAR 150, NOT NULL), email (VARCHAR 254, NOT NULL), phone (VARCHAR 20, nullable), message (TEXT, NOT NULL), created_at (TIMESTAMP WITH TIMEZONE, server_default=NOW()); criar índice ix_contact_leads_created_atbackend/app/models/lead.py

    • Done when: from app.models.lead import ContactLead importa sem erro; ContactLead.__tablename__ == "contact_leads"; property_id FK tem ondelete="SET NULL" e nullable=True; índice ix_contact_leads_property_id declarado via index=True na coluna.
  • T004 [P] Criar schemas Pydantic ContactLeadIn (name: str min=2/max=150, email: EmailStr, phone: str|None max=20, message: str min=10/max=2000) e ContactLeadCreatedOut (id: int, message: str) — backend/app/schemas/lead.py

    • Done when: ContactLeadIn(name="A", email="invalido", phone=None, message="ok") levanta ValidationError; ContactLeadIn(name="João", email="j@j.com", phone=None, message="Tenho interesse") passa; from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut importa sem erro.
  • T005 Adicionar rota GET /api/v1/properties/<slug> ao blueprint properties_bp: busca Property com slug=slug e is_active=True; retorna PropertyDetailOut.model_validate(p).model_dump(mode="json") com status 200, ou {"error": "Imóvel não encontrado"} com status 404 — backend/app/routes/properties.py

    • Done when: curl http://localhost:5000/api/v1/properties/slug-existente retorna 200 com JSON contendo photos, amenities, code, description; curl http://localhost:5000/api/v1/properties/slug-inexistente retorna 404; imóvel com is_active=False retorna 404 (não 403).
  • T006 Adicionar rota POST /api/v1/properties/<slug>/contact ao blueprint properties_bp: valida payload com ContactLeadIn (retorna 422 com {"error": "Dados inválidos", "details": {...}} se inválido); busca Property por slug + is_active=True (retorna 404 se não encontrado); cria e persiste ContactLead com property_id resolvido internamente; retorna ContactLeadCreatedOut com status 201 — backend/app/routes/properties.py

    • Done when: POST /api/v1/properties/<slug>/contact com payload válido retorna 201 {"id": N, "message": "Mensagem enviada com sucesso!"}; payload sem email retorna 422; slug inativo retorna 404; property_id do lead criado no banco corresponde ao imóvel (nunca aceito diretamente do cliente).
  • T007 Importar ContactLead em backend/app/models/__init__.py para que Flask-Migrate detecte o modelo na geração de migration — backend/app/models/__init__.py

    • Done when: from app.models import ContactLead importa sem erro; Flask-Migrate detecta a tabela contact_leads ao gerar nova migration.
  • T008 Gerar e aplicar migration Alembic cobrindo: (a) colunas code e description em properties; (b) tabela contact_leads com FK, índices e coluna TIMESTAMP WITH TIMEZONE — backend/migrations/versions/<hash>_add_contact_leads_and_property_detail_fields.py

    • Done when: uv run flask --app app db migrate -m "add contact_leads and property detail fields" cria arquivo de migration; revisão manual confirma presença de op.add_column("properties", ...) para code e description e op.create_table("contact_leads", ...); flask db upgrade executa sem erro; flask db downgrade -1 reverte sem erro; flask db upgrade re-aplica sem erro.

Checkpoint Phase 2: curl http://localhost:5000/api/v1/properties/<slug> retorna 200; POST /api/v1/properties/<slug>/contact com payload válido retorna 201 e grava no banco.


Phase 3: Frontend — Types & Services

Objetivo: Estender os tipos TypeScript e o serviço de propriedades para suportar detalhe de imóvel e envio de contato.

ID Complexidade Deps spec_ref
T009 S data-model.md §Types TypeScript
T010 S T009 spec.md §FR-F01, plan.md §frontend
T011 S T009 spec.md §US2, FR-F08
  • T009 [P] Adicionar interface PropertyDetail extends Property (campos address, code, description todos string | null) e interface ContactFormData (name, email, phone, message: todos string) ao arquivo de tipos — frontend/src/types/property.ts

    • Done when: import { PropertyDetail, ContactFormData } from '@/types/property' compila sem erro TypeScript; PropertyDetail inclui todos os campos de Property (base) mais address, code e description; ContactFormData tem exatamente os 4 campos do contrato da spec.
  • T010 [P] Adicionar função getProperty(slug: string): Promise<PropertyDetail> ao serviço de propriedades, chamando GET /api/v1/properties/${slug} via Axios; lança erro com status: 404 repassado para o caller — frontend/src/services/properties.ts

    • Done when: Chamada getProperty("slug-existente") retorna PropertyDetail tipada; chamada com slug inexistente propaga o erro 404 (não silencia); sem nenhuma hardcoded URL (usa instância api do src/services/api.ts).
  • T011 Adicionar função submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number; message: string }> ao serviço de propriedades, chamando POST /api/v1/properties/${slug}/contact via Axios — frontend/src/services/properties.ts

    • Done when: Função compila sem erro TypeScript; envia data como JSON body; propaga erros 4xx/5xx para o caller sem swallow silencioso.

Phase 4: Frontend — Componentes de Detalhe (US1, US2, US3)

Objetivo: Criar os componentes isolados de detalhe do imóvel. Todos os componentes seguem o design system definido em DESIGN.md (canvas #08090a, panel #0f1011, accent #7170ff, tipografia Inter Variable).

ID Complexidade Deps spec_ref
T012 M T009 spec.md §US1 cenários 24, FR-F02, FR-F03, FR-F04
T013 S T009 spec.md §US1 cenário 1, FR-F02
T014 S T009 spec.md §US3 cenários 12, FR-F07
T015 S T009 spec.md §US1 cenários 57, FR-F05
T016 M T009, T011 spec.md §US2 todos os cenários, FR-F08, FR-F09, FR-F10
T017 S T009 spec.md §US1 cenário 8, FR-F11
  • T012 [P] [US1] Criar componente PhotoCarousel recebendo photos: PropertyPhotoOut[] como prop; exibe foto ativa em tamanho grande + strip de miniaturas; miniatura ativa recebe destaque visual; suporta navegação por teclado (/ via keydown listener) quando o elemento está em foco; suporta swipe touchscreen via onTouchStart/onTouchEnd calculando delta >= 50px; se photos for array vazio exibe placeholder visual (div cinza com ícone ou texto "Sem fotos"); se photos.length === 1 oculta strip e botões de navegação — frontend/src/components/PropertyDetail/PhotoCarousel.tsx

    • Done when: Componente aceita photos: PropertyPhotoOut[]; clicar na miniatura da 3ª foto altera a foto principal; pressionar recua e avança; swipe horizontal muda a foto na direção do gesto; array vazio exibe placeholder sem erros de runtime; array com 1 elemento oculta strip e botões.
  • T013 [P] [US1] Criar componente StatsStrip recebendo bedrooms, bathrooms, parking_spots, area_m2 como props numéricas; exibe 4 cartões horizontais com ícone + valor + label ("Quartos", "Banheiros", "Vagas", "Área (m²)") usando tokens do design system — frontend/src/components/PropertyDetail/StatsStrip.tsx

    • Done when: Componente renderiza os 4 blocos de estatística; cada parking_spots = 0 ainda exibe o bloco (não ocultar com zero); usa classes Tailwind com tokens existentes no tailwind.config.ts.
  • T014 [P] [US3] Criar componente AmenitiesSection recebendo amenities: AmenityOut[] como prop; agrupa amenidades pelas chaves "caracteristica", "lazer", "condominio", "seguranca" com títulos "Características", "Lazer", "Condomínio", "Segurança"; renderiza cada grupo como seção com checklist; grupos sem amenidade não são renderizados; se amenities for array vazio o componente não renderiza nada (retorna null) — frontend/src/components/PropertyDetail/AmenitiesSection.tsx

    • Done when: Array com amenidades nos grupos "caracteristica" e "lazer" renderiza exatamente 2 seções; grupo "seguranca" ausente não gera seção vazia; array vazio retorna null (verificar com React DevTools ou teste visual).
  • T015 [P] [US1] Criar componente PriceBox recebendo price: string, condo_fee: string | null, listing_type: "venda" | "aluguel" como props; exibe label "Venda" ou "Aluguel" conforme listing_type; exibe price formatado em BRL; exibe linha de condomínio apenas se condo_fee não for null; em desktop (lg:) aplica sticky top-6 para o container — frontend/src/components/PropertyDetail/PriceBox.tsx

    • Done when: listing_type="aluguel" com condo_fee="650.00" exibe linha de condomínio; listing_type="venda" com condo_fee=null não exibe linha de condomínio; preço é formatado (ex: "R$ 850.000,00"); container tem classe lg:sticky lg:top-6.
  • T016 [P] [US2] Criar componente ContactSection recebendo slug: string e propertyTitle: string como props; exibe botão de WhatsApp que abre https://wa.me/${VITE_WHATSAPP_NUMBER}?text=<texto_codificado> em nova aba (texto menciona code e title); exibe formulário com campos name (obrigatório), email (obrigatório, validação de formato), phone (opcional), message (obrigatório); botão de envio fica desabilitado + spinner durante submitting; ao sucesso exibe "Mensagem enviada com sucesso!" e limpa o formulário; ao erro 5xx exibe "Erro ao enviar. Tente novamente mais tarde." preservando os dados digitados; VITE_WHATSAPP_NUMBER lido de import.meta.env.VITE_WHATSAPP_NUMBER (nunca hardcoded) — frontend/src/components/PropertyDetail/ContactSection.tsx

    • Done when: Formsubmit com campos em branco exibe erros nos campos obrigatórios sem fazer requisição; e-mail inválido exibe "E-mail inválido"; envio válido chama submitContactForm e exibe confirmação; botão fica desabilitado durante submitting; link WhatsApp abre wa.me com target="_blank" rel="noopener noreferrer"; número não está hardcoded no bundle.
  • T017 [P] [US1] Criar componente PropertyDetailSkeleton com placeholders animados (animate-pulse) para: bloco de carrossel (height ~400px), strip de estatísticas (4 blocos), caixa de preço e área de descrição — frontend/src/components/PropertyDetail/PropertyDetailSkeleton.tsx

    • Done when: Componente não recebe props; exibe placeholders com animate-pulse e bg-panel-dark (ou bg-surface-elevated) correspondendo ao layout geral da página; nenhum layout shift perceptível ao substituir pelo conteúdo real.

Checkpoint Phase 4: Todos os componentes renderizam isoladamente sem erros de TypeScript (npm run build passa).


Phase 5: Montagem da Página e Roteamento

Objetivo: Montar a PropertyDetailPage integrando todos os componentes, registrar a rota /imoveis/:slug no roteador e tornar o PropertyCard clicável.

ID Complexidade Deps spec_ref
T018 M T010, T012T017 spec.md §US1US3, FR-F01, FR-F11, FR-F12, FR-F13
T019 S T018 spec.md §FR-F01, plan.md §App.tsx
T020 S T019 spec.md §FR-B07
  • T018 [US1] Criar PropertyDetailPage com: estado property: PropertyDetail | null, notFound: boolean, loading: boolean; chama getProperty(slug) via useEffect ao montar (usando slug de useParams()); enquanto loading=true renderiza <PropertyDetailSkeleton />; se notFound=true renderiza estado "Imóvel não encontrado" com CTA <Link to="/imoveis">Ver todos os imóveis</Link>; quando property disponível renderiza: breadcrumb ("Imóveis > [Cidade] > [Bairro] > Título") + <PhotoCarousel photos={property.photos} /> + <StatsStrip ... /> + bloco de descrição + <AmenitiesSection amenities={property.amenities} /> + layout de 2 colunas (descrição + <PriceBox ... /> sticky) + <ContactSection slug={slug} propertyTitle={property.title} />; todos os links respeitam FR-F06frontend/src/pages/PropertyDetailPage.tsx

    • Done when: Acessar /imoveis/<slug-ativo> renderiza todos os blocos; loading exibe skeleton sem layout shift; slug com 404 exibe estado de não encontrado com link; breadcrumb exibe cidade e bairro quando disponíveis; npm run build passa sem erros TypeScript.
  • T019 Adicionar <Route path="/imoveis/:slug" element={<PropertyDetailPage />} /> ao roteador em App.tsx; importar PropertyDetailPagefrontend/src/App.tsx

    • Done when: Navegar para /imoveis/qualquer-slug não lança erro 404 de rota no frontend; npm run build compila sem erros.
  • T020 Envolver o elemento raiz retornado por PropertyCard com <Link to={/imoveis/${property.slug}}>...</Link> usando react-router-dom; garantir que o cursor mude para pointer e que não haja <a> aninhado — frontend/src/components/PropertyCard.tsx

    • Done when: Clicar em qualquer PropertyCard na listagem navega para /imoveis/<slug> sem reload de página; nenhum <a> aninhado dentro de outro <a> (inspecionar DOM); npm run build passa.

Checkpoint Final: Fluxo completo funcional — listagem /imoveis → clicar no card → /imoveis/<slug> com todos os blocos renderizados; formulário de contato grava lead no banco; botão WhatsApp abre link correto; 404 exibe estado amigável.


Dependency Graph

T001 ──┐
       ├── T005 (GET /slug) ──┐
T002 ──┘                      │
                              ├── T008 (migration) ── T018
T003 ──── T007 ───────────────┤
T004 ──── T006 (POST /contact)┘

T009 ──── T010 ──┐
       ── T011 ──┼── T016
                 │
T012 ──┐         │
T013 ──┤         │
T014 ──┼─────────┴── T018 ── T019 ── T020
T015 ──┤
T017 ──┘

Parallel Execution Examples

Backend (pode ser feito em paralelo com Frontend)

# Terminal 1 — Backend Phase 1+2
# T001 → T002 → T003/T004 (paralelo) → T005 → T006 → T007 → T008

# Terminal 2 — Frontend Phase 3
# T009 (types) → T010/T011 (services, mesmo arquivo: sequencial)

Frontend Components (todos paralelos entre si após T009)

# T012, T013, T014, T015, T016, T017 podem ser implementados em paralelo
# pois estão em arquivos distintos e dependem apenas de T009 (types)

Implementation Strategy (MVP Scope)

Prioridade User Stories Tarefas
MVP Mínimo US1 (visualização) T001T008 (backend) + T009T011 (services) + T013, T015, T017 (stats, price, skeleton) + T018T020 (page + routing)
Adição rápida US2 (contato) T016 (ContactSection) já no backend via T006
Complemento US3 (amenidades) T014 (AmenitiesSection) + T012 (PhotoCarousel com swipe)

Sugestão MVP: Implementar T001T020 na ordem recomendada. O carrossel completo (swipe + teclado) e a seção de amenidades podem ser entregues numa iteração posterior sem quebrar a page.


Verificações de Segurança

Risco Mitigação Tarefa
property_id aceito do cliente Backend resolve property_id via slug (nunca lê do body) T006
VITE_WHATSAPP_NUMBER hardcoded Lido de import.meta.env.VITE_WHATSAPP_NUMBER T016
SQL Injection via slug ORM SQLAlchemy com parâmetros vinculados (sem string concatenation) T005, T006
XSS via conteúdo do imóvel React escapa por padrão; sem dangerouslySetInnerHTML T018
Open Redirect via breadcrumb Links para /imoveis?city=... internos apenas (react-router Link) T018