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

9.6 KiB

Tasks: Contact Button (009)

Feature Branch: 009-contact-button Spec: spec.md Plan: plan.md Generated: 2026-04-17


Task 1 — Adicionar campo code na interface Property

Depends on: none Files:

  • frontend/src/types/property.ts

Description: A interface Property base (linha ~11) não possui o campo code; ele existe apenas em PropertyDetail. Como PropertyRowCard e PropertyCard recebem Property, o TypeScript emitirá erro ao tentar ler property.code no botão e no modal. Adicionar code?: string | null na interface Property, após o campo slug.

Acceptance: tsc --noEmit não reporta erro sobre code nas interfaces de Property. O campo code fica acessível tipado em componentes que recebem Property.


Task 2 — Criar endpoint GET /api/v1/config/whatsapp

Depends on: none Files:

  • backend/app/routes/config.py (novo)

Description: Criar blueprint config_bp com prefixo /api/v1/config. Implementar GET /whatsapp sem autenticação que lê os.environ.get("WHATSAPP_NUMBER") e retorna {"whatsapp_number": "<número>" | null}. Usar jsonify. Não criar tabela nem model — a configuração é exclusivamente via env var neste escopo.

Esqueleto esperado:

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

Acceptance: GET /api/v1/config/whatsapp retorna HTTP 200 com {"whatsapp_number": null} quando env var ausente, e {"whatsapp_number": "5516999998888"} quando definida.


Task 3 — Registrar config_bp no app Flask

Depends on: Task 2 Files:

  • backend/app/__init__.py

Description: Importar config_bp de app.routes.config e registrá-lo via app.register_blueprint(config_bp) logo após o registro dos demais blueprints (linha ~60). O prefixo /api/v1/config já está definido no próprio blueprint, não passar url_prefix aqui.

Acceptance: O Flask resolve a rota GET /api/v1/config/whatsapp sem erro 404. Verificar com flask routes ou via request HTTP.


Task 4 — Adicionar GET /api/v1/admin/leads em admin.py

Depends on: none Files:

  • backend/app/routes/admin.py

Description: Adicionar rota protegida por @require_admin que lista ContactLead com paginação e filtro opcional por property_id:

  • Query params: page (int, default 1), per_page (int, default 20, max 100), property_id (UUID string, opcional).
  • Fazer JOIN com Property para retornar property_code e property_slug junto ao lead.
  • Retornar JSON no formato:
    {
      "items": [{ "id", "name", "email", "phone", "message", "created_at", "property_id", "property_code", "property_slug" }],
      "total": 42,
      "page": 1,
      "per_page": 20,
      "pages": 3
    }
    
  • Importar ContactLead de app.models.lead e Property de app.models.property.
  • created_at deve ser serializado como ISO 8601 string.

Acceptance: GET /api/v1/admin/leads com token de admin retorna HTTP 200 com estrutura paginada. Filtro ?property_id=<uuid> retorna apenas leads daquele imóvel. Sem token retorna 401.


Task 5 — Criar services/contact.ts

Depends on: Task 1 Files:

  • frontend/src/services/contact.ts (novo)

Description: Criar módulo de serviço com duas funções exportadas:

  1. getWhatsappConfig(): Promise<{ whatsapp_number: string | null }> — faz GET /api/v1/config/whatsapp. Implementar cache em localStorage com TTL de 5 minutos (chave whatsapp_config_cache, estrutura { number: string | null, fetchedAt: number }).

  2. submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number, message: string }> — faz POST /api/v1/properties/${slug}/contact com Content-Type: application/json. Em caso de resposta 422, lançar erro com details mapeados por campo. Em caso de erro de rede ou 5xx, lançar erro genérico preservando status.

Usar axios (já disponível no projeto) ou fetch nativo — preferir consistência com o padrão já adotado nos outros services. Importar ContactFormData de ../types/property.

Acceptance: getWhatsappConfig() retorna número correto e não refaz request antes de expirar o TTL. submitContactForm() entrega payload correto ao endpoint e propaga erros 422 com details por campo.


Task 6 — Criar componente ContactForm.tsx

Depends on: Task 5 Files:

  • frontend/src/components/ContactForm.tsx (novo)

Description: Componente de formulário de lead com as seguintes props:

interface Props {
  propertySlug: string
  propertyCode: string | null
  onSuccess: () => void
  onBack: () => void
}

Comportamento:

  • Campo message pré-preenchido com "Tenho interesse no imóvel de código ${propertyCode}. Poderia me dar mais informações?".
  • Campos: name (obrigatório), email (obrigatório), phone (opcional), message (obrigatório, textarea).
  • Validação inline antes de submeter (espelha ContactLeadIn): name ≥ 2 chars, email formato válido, message ≥ 10 chars.
  • Erros do backend (422) mapeados campo a campo e exibidos abaixo do input correspondente.
  • Estado submitting desabilita o botão e exibe indicador de carregamento.
  • Em sucesso (201): exibe mensagem "Mensagem enviada com sucesso! Entraremos em contato em breve." e chama onSuccess() após 2 s.
  • Em erro de rede/5xx: exibe banner de erro no topo do form sem perder os dados preenchidos.
  • Botão "Voltar" chama onBack().
  • Estilo: classes Tailwind do tema Linear dark do projeto. Botão de submit: classe bg-brand text-white.

Acceptance: Formulário valida campos antes de submeter. Erros de backend aparecem inline por campo. Sucesso exibe confirmação e chama onSuccess. Dados não são perdidos em caso de erro.


Task 7 — Criar componente ContactModal.tsx

Depends on: Task 5, Task 6 Files:

  • frontend/src/components/ContactModal.tsx (novo)

Description: Modal orquestrador montado via ReactDOM.createPortal em document.body. Props:

interface Props {
  property: Property     // importar de ../types/property
  isOpen: boolean
  onClose: () => void
}

Estrutura interna (estado view: 'select' | 'form'):

Overlay: div com bg-black/60 backdrop-blur-sm fixed inset-0 z-50, clique nele chama onClose().

Painel: div com bg-[#1a1a1a] border border-white/10 rounded-2xl centralizado. Contém botão "X" (aria-label="Fechar modal") que chama onClose().

View select (tela inicial):

  • Título: "Como prefere entrar em contato?"
  • Botão "Formulário de Contato" → muda view para 'form'.
  • Botão "WhatsApp" → se whatsappNumber não nulo, chama window.open(waUrl, '_blank', 'noopener,noreferrer') e chama onClose(); se nulo, botão desabilitado com tooltip/texto "Canal indisponível no momento".
  • URL do WhatsApp: `https://wa.me/${whatsappNumber}?text=${encodeURIComponent(`Olá! Tenho interesse no imóvel de código ${property.code}. Poderia me dar mais informações?`)}`.
  • Carregar whatsappNumber via getWhatsappConfig() em useEffect quando isOpen === true.

View form:

  • Renderiza <ContactForm propertySlug={property.slug} propertyCode={property.code} onSuccess={onClose} onBack={() => setView('select')} />.

Acessibilidade: fechar com tecla Escape (listener em keydown), stopPropagation no clique do painel para não propagar ao overlay.

Acceptance: Modal abre/fecha corretamente. Clicar fora ou pressionar Esc fecha sem erro. Opção WhatsApp desabilitada quando número é null. Link WhatsApp contém número e código do imóvel corretos. Navegação entre views funciona sem perda de estado.


Task 8 — Adicionar botão "Entre em Contato" em PropertyRowCard.tsx

Depends on: Task 7 Files:

  • frontend/src/components/PropertyRowCard.tsx

Description:

  1. Importar ContactModal de ./ContactModal e useState do React.
  2. Adicionar estado: const [contactOpen, setContactOpen] = useState(false).
  3. Adicionar botão "Entre em Contato" na seção footer do card (próximo ao botão "Comparar"). O botão deve:
    • Chamar e.preventDefault() e e.stopPropagation() no onClick (necessário pois está dentro do <Link> pai).
    • Acionar setContactOpen(true).
    • Classes sugeridas: bg-brand text-white rounded-lg px-3 py-1.5 text-xs font-medium hover:bg-brand/90 transition border border-brand/20.
    • Atributo aria-label="Entrar em contato sobre este imóvel".
  4. Renderizar <ContactModal property={property} isOpen={contactOpen} onClose={() => setContactOpen(false)} /> fora do <Link>, ao final do JSX retornado pelo componente.

Acceptance: Botão visível no card. Clique abre o ContactModal sem navegar para a página do imóvel. Modal fecha ao clicar em X, fora do painel ou pressionar Esc. Botão acessível via teclado.


Task 9 — Adicionar botão "Entre em Contato" em PropertyCard.tsx

Depends on: Task 7 Files:

  • frontend/src/components/PropertyCard.tsx

Description: Aplicar as mesmas mudanças da Task 8 em PropertyCard.tsx:

  1. Importar ContactModal e useState.
  2. Adicionar estado contactOpen.
  3. Adicionar botão "Entre em Contato" na div.mt-auto (abaixo do botão "Comparar"), full-width. Handler deve usar e.preventDefault() + e.stopPropagation().
  4. Renderizar <ContactModal> ao final do JSX, fora do <Link>.

Acceptance: Botão visível no PropertyCard. Comportamento idêntico ao da Task 8. Sem regressão no botão "Comparar" existente.