18 KiB
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 (US1–US3)
- 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) edescription(TEXT, nullable) ao modeloProperty—backend/app/models/property.py- Done when:
from app.models.property import Propertyimporta sem erro;Property.codeeProperty.descriptionsão atributosdb.Columndeclarados exatamente como emdata-model.md §Property;codetemunique=True, nullable=True;descriptiontemnullable=True, type_=db.Text.
- Done when:
-
T002 Adicionar
PropertyDetailOut(PropertyOut)ao schema de propriedades com camposaddress: str | None,code: str | None,description: str | None—backend/app/schemas/property.py- Done when:
from app.schemas.property import PropertyDetailOutimporta sem erro;PropertyDetailOut.model_validate(property_instance)serializa corretamente incluindoaddress,codeedescription;model_config = ConfigDict(from_attributes=True)herdado dePropertyOut.
- Done when:
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
ContactLeadcom camposid(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 índiceix_contact_leads_created_at—backend/app/models/lead.py- Done when:
from app.models.lead import ContactLeadimporta sem erro;ContactLead.__tablename__ == "contact_leads";property_idFK temondelete="SET NULL"enullable=True; índiceix_contact_leads_property_iddeclarado viaindex=Truena coluna.
- Done when:
-
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) eContactLeadCreatedOut(id: int, message: str) —backend/app/schemas/lead.py- Done when:
ContactLeadIn(name="A", email="invalido", phone=None, message="ok")levantaValidationError;ContactLeadIn(name="João", email="j@j.com", phone=None, message="Tenho interesse")passa;from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOutimporta sem erro.
- Done when:
-
T005 Adicionar rota
GET /api/v1/properties/<slug>ao blueprintproperties_bp: buscaPropertycomslug=slugeis_active=True; retornaPropertyDetailOut.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-existenteretorna 200 com JSON contendophotos,amenities,code,description;curl http://localhost:5000/api/v1/properties/slug-inexistenteretorna 404; imóvel comis_active=Falseretorna 404 (não 403).
- Done when:
-
T006 Adicionar rota
POST /api/v1/properties/<slug>/contactao blueprintproperties_bp: valida payload comContactLeadIn(retorna 422 com{"error": "Dados inválidos", "details": {...}}se inválido); buscaPropertyporslug+is_active=True(retorna 404 se não encontrado); cria e persisteContactLeadcomproperty_idresolvido internamente; retornaContactLeadCreatedOutcom status 201 —backend/app/routes/properties.py- Done when:
POST /api/v1/properties/<slug>/contactcom payload válido retorna 201{"id": N, "message": "Mensagem enviada com sucesso!"}; payload sememailretorna 422; slug inativo retorna 404;property_iddo lead criado no banco corresponde ao imóvel (nunca aceito diretamente do cliente).
- Done when:
-
T007 Importar
ContactLeadembackend/app/models/__init__.pypara que Flask-Migrate detecte o modelo na geração de migration —backend/app/models/__init__.py- Done when:
from app.models import ContactLeadimporta sem erro; Flask-Migrate detecta a tabelacontact_leadsao gerar nova migration.
- Done when:
-
T008 Gerar e aplicar migration Alembic cobrindo: (a) colunas
codeedescriptionemproperties; (b) tabelacontact_leadscom 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 deop.add_column("properties", ...)paracodeedescriptioneop.create_table("contact_leads", ...);flask db upgradeexecuta sem erro;flask db downgrade -1reverte sem erro;flask db upgradere-aplica sem erro.
- Done when:
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(camposaddress,code,descriptiontodosstring | null) e interfaceContactFormData(name, email, phone, message: todosstring) ao arquivo de tipos —frontend/src/types/property.ts- Done when:
import { PropertyDetail, ContactFormData } from '@/types/property'compila sem erro TypeScript;PropertyDetailinclui todos os campos deProperty(base) maisaddress,codeedescription;ContactFormDatatem exatamente os 4 campos do contrato da spec.
- Done when:
-
T010 [P] Adicionar função
getProperty(slug: string): Promise<PropertyDetail>ao serviço de propriedades, chamandoGET /api/v1/properties/${slug}via Axios; lança erro comstatus: 404repassado para o caller —frontend/src/services/properties.ts- Done when: Chamada
getProperty("slug-existente")retornaPropertyDetailtipada; chamada com slug inexistente propaga o erro 404 (não silencia); sem nenhuma hardcoded URL (usa instânciaapidosrc/services/api.ts).
- Done when: Chamada
-
T011 Adicionar função
submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number; message: string }>ao serviço de propriedades, chamandoPOST /api/v1/properties/${slug}/contactvia Axios —frontend/src/services/properties.ts- Done when: Função compila sem erro TypeScript; envia
datacomo JSON body; propaga erros 4xx/5xx para o caller sem swallow silencioso.
- Done when: Função compila sem erro TypeScript; envia
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 2–4, 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 1–2, FR-F07 |
| T015 | S | T009 | spec.md §US1 cenários 5–7, 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
PhotoCarouselrecebendophotos: PropertyPhotoOut[]como prop; exibe foto ativa em tamanho grande + strip de miniaturas; miniatura ativa recebe destaque visual; suporta navegação por teclado (←/→viakeydownlistener) quando o elemento está em foco; suporta swipe touchscreen viaonTouchStart/onTouchEndcalculando delta >= 50px; sephotosfor array vazio exibe placeholder visual (div cinza com ícone ou texto "Sem fotos"); sephotos.length === 1oculta 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.
- Done when: Componente aceita
-
T013 [P] [US1] Criar componente
StatsStriprecebendobedrooms,bathrooms,parking_spots,area_m2como 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 = 0ainda exibe o bloco (não ocultar com zero); usa classes Tailwind com tokens existentes notailwind.config.ts.
- Done when: Componente renderiza os 4 blocos de estatística; cada
-
T014 [P] [US3] Criar componente
AmenitiesSectionrecebendoamenities: 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; seamenitiesfor array vazio o componente não renderiza nada (retornanull) —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).
- 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
-
T015 [P] [US1] Criar componente
PriceBoxrecebendoprice: string,condo_fee: string | null,listing_type: "venda" | "aluguel"como props; exibe label "Venda" ou "Aluguel" conformelisting_type; exibepriceformatado em BRL; exibe linha de condomínio apenas secondo_feenão fornull; em desktop (lg:) aplicasticky top-6para o container —frontend/src/components/PropertyDetail/PriceBox.tsx- Done when:
listing_type="aluguel"comcondo_fee="650.00"exibe linha de condomínio;listing_type="venda"comcondo_fee=nullnão exibe linha de condomínio; preço é formatado (ex: "R$ 850.000,00"); container tem classelg:sticky lg:top-6.
- Done when:
-
T016 [P] [US2] Criar componente
ContactSectionrecebendoslug: stringepropertyTitle: stringcomo props; exibe botão de WhatsApp que abrehttps://wa.me/${VITE_WHATSAPP_NUMBER}?text=<texto_codificado>em nova aba (texto mencionacodeetitle); exibe formulário com camposname(obrigatório),email(obrigatório, validação de formato),phone(opcional),message(obrigatório); botão de envio fica desabilitado + spinner durantesubmitting; 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_NUMBERlido deimport.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
submitContactForme exibe confirmação; botão fica desabilitado durantesubmitting; link WhatsApp abrewa.mecomtarget="_blank" rel="noopener noreferrer"; número não está hardcoded no bundle.
- 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
-
T017 [P] [US1] Criar componente
PropertyDetailSkeletoncom 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-pulseebg-panel-dark(oubg-surface-elevated) correspondendo ao layout geral da página; nenhum layout shift perceptível ao substituir pelo conteúdo real.
- Done when: Componente não recebe props; exibe placeholders com
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, T012–T017 | spec.md §US1–US3, 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
PropertyDetailPagecom: estadoproperty: PropertyDetail | null,notFound: boolean,loading: boolean; chamagetProperty(slug)viauseEffectao montar (usandoslugdeuseParams()); enquantoloading=truerenderiza<PropertyDetailSkeleton />; senotFound=truerenderiza estado "Imóvel não encontrado" com CTA<Link to="/imoveis">Ver todos os imóveis</Link>; quandopropertydisponí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 respeitamFR-F06—frontend/src/pages/PropertyDetailPage.tsx- Done when: Acessar
/imoveis/<slug-ativo>renderiza todos os blocos;loadingexibe 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 buildpassa sem erros TypeScript.
- Done when: Acessar
-
T019 Adicionar
<Route path="/imoveis/:slug" element={<PropertyDetailPage />} />ao roteador emApp.tsx; importarPropertyDetailPage—frontend/src/App.tsx- Done when: Navegar para
/imoveis/qualquer-slugnão lança erro 404 de rota no frontend;npm run buildcompila sem erros.
- Done when: Navegar para
-
T020 Envolver o elemento raiz retornado por
PropertyCardcom<Link to={/imoveis/${property.slug}}>...</Link>usandoreact-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
PropertyCardna listagem navega para/imoveis/<slug>sem reload de página; nenhum<a>aninhado dentro de outro<a>(inspecionar DOM);npm run buildpassa.
- Done when: Clicar em qualquer
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) | T001–T008 (backend) + T009–T011 (services) + T013, T015, T017 (stats, price, skeleton) + T018–T020 (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 T001–T020 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 |