# Tasks: Contact Button (009) **Feature Branch**: `009-contact-button` **Spec**: [spec.md](./spec.md) **Plan**: [plan.md](./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": "" | null}`. Usar `jsonify`. Não criar tabela nem model — a configuração é exclusivamente via env var neste escopo. Esqueleto esperado: ```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}) ``` **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: ```json { "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=` 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: ```ts 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: ```ts 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 ` 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 `` 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 ` setContactOpen(false)} />` fora do ``, 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 `` ao final do JSX, fora do ``. **Acceptance**: Botão visível no `PropertyCard`. Comportamento idêntico ao da Task 8. Sem regressão no botão "Comparar" existente.