12 KiB
Implementation Plan: Contact Button (009)
Feature Branch: 009-contact-button
Spec: 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):
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:
ou{ "whatsapp_number": "5516999998888" }{ "whatsapp_number": null }
POST /api/v1/properties/{slug}/contact (já existe)
- Auth: nenhuma
- Body:
{ "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:
{ "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 }