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,284 @@
# Implementation Plan: Contact Button (009)
**Feature Branch**: `009-contact-button`
**Spec**: [spec.md](./spec.md)
**Status**: Ready for implementation
**Date**: 2026-04-17
---
## 1. Arquitetura da Solução
### 1.1 Visão Geral
```
Browser (React)
├── PropertyRowCard.tsx [MODIFICAR] — adicionar botão + integrar ContactModal
├── PropertyCard.tsx [MODIFICAR] — adicionar botão + integrar ContactModal
├── ContactModal.tsx [CRIAR] — modal com seleção de canal
├── ContactForm.tsx [CRIAR] — formulário de lead (submódul do modal)
└── services/contact.ts [CRIAR] — getWhatsappConfig()
Backend (Flask)
├── routes/config.py [CRIAR] — GET /api/v1/config/whatsapp
└── routes/admin.py [MODIFICAR] — GET /api/v1/admin/leads (listagem paginada)
```
### 1.2 Componentes Novos
| Componente | Caminho | Responsabilidade |
|---|---|---|
| `ContactModal` | `frontend/src/components/ContactModal.tsx` | Orquestra o modal (overlay + escolha de canal) |
| `ContactForm` | `frontend/src/components/ContactForm.tsx` | Formulário de lead com validação inline |
| `contact.ts` | `frontend/src/services/contact.ts` | `getWhatsappConfig()` — GET /api/v1/config/whatsapp |
| `config.py` | `backend/app/routes/config.py` | Blueprint `config_bp`, endpoint público de configuração |
### 1.3 Componentes Modificados
| Arquivo | Mudança |
|---|---|
| `frontend/src/components/PropertyRowCard.tsx` | Adiciona estado `modalOpen`, botão "Entre em Contato", renderiza `<ContactModal>` |
| `frontend/src/components/PropertyCard.tsx` | Idem acima |
| `frontend/src/types/property.ts` | Adiciona campo `code?: string \| null` na interface `Property` (já consumido em runtime pelo `PropertyRowCard`) |
| `backend/app/routes/__init__.py` | Registra o novo `config_bp` |
| `backend/app/routes/admin.py` | Adiciona `GET /api/v1/admin/leads` com paginação e filtro por `property_id` |
---
## 2. Fluxo de Dados
### 2.1 Inicialização do Modal (carregamento do número WhatsApp)
```
Componente monta
→ useEffect → GET /api/v1/config/whatsapp
← { whatsapp_number: "5516999998888" } | { whatsapp_number: null }
→ salva em estado local: whatsappNumber
```
**Estratégia**: a chamada é feita uma única vez quando o modal abre (lazy load), não ao montar o card. Pode ser cacheado via `localStorage` por 5 minutos para evitar requisições repetidas.
### 2.2 Envio do Formulário de Contato
```
Usuário clica "Entre em Contato"
→ ContactModal abre (estado local no card)
→ Usuário seleciona "Formulário de Contato"
→ ContactForm renderiza com mensagem pré-preenchida:
"Tenho interesse no imóvel de código {property.code}. Poderia me dar mais informações?"
→ Usuário preenche nome, e-mail, telefone(opt) e confirma mensagem
→ Validação frontend (espelha ContactLeadIn do Pydantic)
→ POST /api/v1/properties/{property.slug}/contact
Body: { name, email, phone?, message }
← 201 { id, message: "Mensagem enviada com sucesso!" }
→ Estado: success → exibe confirmação → fecha modal após 2 s
← 422 { error, details } → exibe erros inline por campo
← 5xx / network error → exibe banner de erro; mantém dados no form
```
### 2.3 Redirecionamento WhatsApp
```
Usuário seleciona "WhatsApp" no modal
→ Link gerado 100% no cliente:
url = `https://wa.me/${whatsappNumber}?text=${encodeURIComponent(msg)}`
msg = "Olá! Tenho interesse no imóvel de código {property.code}.
Poderia me dar mais informações?"
→ window.open(url, '_blank', 'noopener,noreferrer')
→ Modal fecha (opcional: fechar após 500 ms)
```
Se `whatsappNumber` for `null`: botão WhatsApp renderiza desabilitado com tooltip "Canal indisponível no momento".
### 2.4 Listagem de Leads no Admin
```
Admin acessa /admin/leads
→ GET /api/v1/admin/leads?page=1&per_page=20&property_id=<uuid>(opcional)
Header: Authorization: Bearer <token>
← { items: [...], total, page, per_page, pages }
```
---
## 3. Decisões de Design
### 3.1 Modal vs. Página
**Decisão**: Modal (overlay) em vez de navegação para nova página.
**Rationale**:
- O usuário está navegando na listagem; um modal preserva o contexto (ele não abandona a lista).
- A spec exige fechamento por clique externo, o que é nativo de modal.
- O formulário de contato é simples (4 campos) — não justifica uma rota dedicada.
- Consistente com o padrão já adotado nos cards (HeartButton, Comparar) que operam com estado local.
**Implementação**: Portal React (`ReactDOM.createPortal`) montado em `document.body` para evitar z-index conflicts com o overflow hidden dos cards (`overflow-hidden` no `article` de `PropertyRowCard`).
### 3.2 Estratégia WhatsApp Link
**Decisão**: Geração do link inteiramente no cliente, sem round-trip ao backend.
**Rationale**: O link `wa.me` é uma URL pública que não expõe dados sensíveis. Gerar no cliente elimina latência e simplifica o backend.
**Formato do link**:
```
https://wa.me/{número_sem_+}?text={mensagem_codificada}
```
Desktop → abre `web.whatsapp.com` em nova aba. Mobile → redireciona para o app nativo via deep link.
**Nota de segurança**: usar `window.open(url, '_blank', 'noopener,noreferrer')` para prevenir Tabnabbing.
### 3.3 Configuração do Número de WhatsApp
**Decisão**: env var `WHATSAPP_NUMBER` no backend, exposta via endpoint público `GET /api/v1/config/whatsapp`.
**Rationale**:
- Não há sistema de configuração em banco (fora do escopo).
- A spec confirma explicitamente: "o número é definido via variável de ambiente `WHATSAPP_NUMBER`".
- Um endpoint dedicado permite que o frontend seja agnóstico sobre a origem da config e facilita futura migração para configuração em banco (feature 007).
**Implementação do endpoint** (`backend/app/routes/config.py`):
```python
import os
from flask import Blueprint, jsonify
config_bp = Blueprint("config", __name__, url_prefix="/api/v1/config")
@config_bp.get("/whatsapp")
def get_whatsapp_config():
number = os.environ.get("WHATSAPP_NUMBER") or None
return jsonify({"whatsapp_number": number})
```
**Cache no cliente**: `localStorage` com TTL de 5 minutos. Chave: `whatsapp_config_cache`. Estrutura: `{ number, fetchedAt }`.
### 3.4 Validação do Formulário
**Decisão**: Validação no frontend espelha exatamente o schema Pydantic `ContactLeadIn`.
| Campo | Regra |
|---|---|
| `name` | obrigatório, 2150 chars |
| `email` | obrigatório, formato válido, ≤ 254 chars |
| `phone` | opcional, ≤ 20 chars |
| `message` | obrigatório, 102000 chars |
Erros do backend (422) são mapeados campo a campo e exibidos inline abaixo de cada input.
### 3.5 Posicionamento do Botão nos Cards
**`PropertyRowCard`**: botão "Entre em Contato" posicionado na seção `Footer` (ao lado do botão "Comparar"), substituindo o `span` "Ver detalhes →" ou adjacente a ele. Deve usar `e.preventDefault()` + `e.stopPropagation()` pois está dentro do `<Link>` pai.
**`PropertyCard`**: botão adicionado à `div.mt-auto.space-y-3`, abaixo do botão "Comparar" (que já ocupa full-width). Botão também full-width.
### 3.6 Estilo Visual
Tema Linear dark existente. Referência de classes para o botão principal:
```
bg-brand text-white rounded-lg px-3 py-1.5 text-xs font-medium
hover:bg-brand/90 transition border border-brand/20
```
Modal: overlay `bg-black/60 backdrop-blur-sm`, painel `bg-panel border border-white/10 rounded-2xl`.
---
## 4. Riscos e Mitigações
| # | Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|---|
| R1 | **`property.code` ausente no tipo `Property`** — campo existe em runtime (retornado pela API e já usado no JSX) mas não está declarado na interface TypeScript, causando erro de compilação ao adicionar o botão | Alta | Médio | Adicionar `code?: string \| null` na interface `Property` em `types/property.ts` como primeira tarefa |
| R2 | **z-index do modal vs. overflow hidden do card**`PropertyRowCard` usa `overflow-hidden` no `<article>`, o que poderia clipar o modal | Alta | Alto | Usar `ReactDOM.createPortal` para montar o modal diretamente em `document.body`, fora da hierarquia do card |
| R3 | **Clique no botão propaga para o `<Link>` pai** — ambos os cards envolvem o conteúdo em `<Link>`, causando navegação indesejada ao clicar no botão | Alta | Alto | Chamar `e.preventDefault()` e `e.stopPropagation()` no handler do botão (padrão já aplicado no botão "Comparar") |
| R4 | **`WHATSAPP_NUMBER` não configurado em produção** — a opção WhatsApp fica desabilitada silenciosamente sem alertar o admin | Média | Médio | Endpoint retorna `null` explicitamente; frontend desabilita o botão com mensagem "Canal indisponível"; adicionar alerta na documentação de deploy |
| R5 | **Spam de leads via formulário** — endpoint público sem rate limit pode ser abusado | Média | Médio | Implementar rate limiting por IP no Flask (ex.: `Flask-Limiter`) ou via proxy nginx antes do deploy em produção; fora do escopo desta feature mas deve ser registrado como dívida técnica |
| R6 | **Inconsistência entre validação frontend e Pydantic** — mensagens de erro diferentes confundem o usuário | Baixa | Baixo | Manter a validação frontend alinhada ao `ContactLeadIn`; erros do backend (422) são re-exibidos inline field-by-field |
| R7 | **`admin.py` sem endpoint de leads** — a spec exige listagem no painel admin (US6), mas não há rota implementada | Alta | Médio | Implementar `GET /api/v1/admin/leads` com `@require_admin`, paginação e filtro por `property_id` como parte desta feature |
| R8 | **Cache de config do WhatsApp desatualizado** — após mudança da env var, cliente continua usando número antigo por até 5 min | Baixa | Baixo | TTL de 5 minutos é aceitável conforme NFR da spec; documentar comportamento esperado |
---
## 5. Checklist de Arquivos Afetados
```
backend/
app/
routes/
config.py ← CRIAR (Blueprint config_bp)
admin.py ← MODIFICAR (GET /admin/leads)
__init__.py ← MODIFICAR (registrar config_bp)
frontend/
src/
types/
property.ts ← MODIFICAR (adicionar code? a Property)
components/
ContactModal.tsx ← CRIAR
ContactForm.tsx ← CRIAR
services/
contact.ts ← CRIAR (getWhatsappConfig)
components/
PropertyRowCard.tsx ← MODIFICAR (botão + modal)
PropertyCard.tsx ← MODIFICAR (botão + modal)
```
---
## 6. Contratos de API
### `GET /api/v1/config/whatsapp`
- **Auth**: nenhuma
- **Cache**: pode ser cacheado por 5 min (cliente)
- **Response 200**:
```json
{ "whatsapp_number": "5516999998888" }
```
ou
```json
{ "whatsapp_number": null }
```
### `POST /api/v1/properties/{slug}/contact` *(já existe)*
- **Auth**: nenhuma
- **Body**:
```json
{
"name": "string (2-150)",
"email": "string (email válido)",
"phone": "string (≤20) | null",
"message": "string (10-2000)"
}
```
- **Response 201**: `{ "id": int, "message": "Mensagem enviada com sucesso!" }`
- **Response 422**: `{ "error": "Dados inválidos", "details": [...] }`
### `GET /api/v1/admin/leads` *(a criar)*
- **Auth**: `Bearer <admin_token>` (`@require_admin`)
- **Query params**: `page` (default 1), `per_page` (default 20), `property_id` (UUID, opcional)
- **Response 200**:
```json
{
"items": [
{
"id": 1,
"name": "João Silva",
"email": "joao@email.com",
"phone": "16999998888",
"message": "Tenho interesse...",
"property_id": "uuid",
"property_code": "2880602111",
"property_slug": "apartamento-centro",
"created_at": "2026-04-17T10:00:00Z"
}
],
"total": 42,
"page": 1,
"per_page": 20,
"pages": 3
}
```