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

221 lines
9.6 KiB
Markdown

# 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": "<número>" | 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=<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:
```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 `<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.