sass-imobiliaria/specs/009-contact-button/plan.md

284 lines
12 KiB
Markdown
Raw Permalink 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.

# 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
}
```