# 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 `` | | `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=(opcional) Header: Authorization: Bearer ← { 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 `` 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 `
`, 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 `` pai** — ambos os cards envolvem o conteúdo em ``, 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 ` (`@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 } ```