feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -0,0 +1,217 @@
# 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 |