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
Propertypara retornarproperty_codeeproperty_slugjunto 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
ContactLeaddeapp.models.leadePropertydeapp.models.property. created_atdeve 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:
-
getWhatsappConfig(): Promise<{ whatsapp_number: string | null }>— fazGET /api/v1/config/whatsapp. Implementar cache emlocalStoragecom TTL de 5 minutos (chavewhatsapp_config_cache, estrutura{ number: string | null, fetchedAt: number }). -
submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number, message: string }>— fazPOST /api/v1/properties/${slug}/contactcomContent-Type: application/json. Em caso de resposta 422, lançar erro comdetailsmapeados 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
messagepré-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,emailformato válido,message≥ 10 chars. - Erros do backend (422) mapeados campo a campo e exibidos abaixo do input correspondente.
- Estado
submittingdesabilita 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
viewpara'form'. - Botão "WhatsApp" → se
whatsappNumbernão nulo, chamawindow.open(waUrl, '_blank', 'noopener,noreferrer')e chamaonClose(); 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
whatsappNumberviagetWhatsappConfig()emuseEffectquandoisOpen === 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:
- Importar
ContactModalde./ContactModaleuseStatedo React. - Adicionar estado:
const [contactOpen, setContactOpen] = useState(false). - 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()ee.stopPropagation()noonClick(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".
- Chamar
- 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:
- Importar
ContactModaleuseState. - Adicionar estado
contactOpen. - Adicionar botão "Entre em Contato" na
div.mt-auto(abaixo do botão "Comparar"), full-width. Handler deve usare.preventDefault()+e.stopPropagation(). - 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.