284 lines
12 KiB
Markdown
284 lines
12 KiB
Markdown
# 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, 2–150 chars |
|
||
| `email` | obrigatório, formato válido, ≤ 254 chars |
|
||
| `phone` | opcional, ≤ 20 chars |
|
||
| `message` | obrigatório, 10–2000 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
|
||
}
|
||
```
|