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

217 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `Property``backend/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 | None``backend/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_at``backend/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-F06``frontend/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 `PropertyDetailPage``frontend/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)
```bash
# 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)
```bash
# 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 |