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

12 KiB
Raw Blame History

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).

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, 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 cardPropertyRowCard 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:
    { "whatsapp_number": "5516999998888" }
    
    ou
    { "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
    }