feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Contact Button
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-17
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Non-Functional Requirements
- [x] No implementation details leak into specification
## Notes
- Endpoint `POST /api/v1/properties/<slug>/contact` já existe e está funcional — nenhuma migração de banco necessária.
- Configuração de WhatsApp via painel admin é marcada como Out of Scope; usa somente variável de ambiente `WHATSAPP_NUMBER` nesta entrega.
- Spec pronta para `/speckit.plan`.

View file

@ -0,0 +1,284 @@
# Implementation Plan: Contact Button (009)
**Feature Branch**: `009-contact-button`
**Spec**: [spec.md](./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`).
### 3.2 Estratégia WhatsApp Link
**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`):
```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})
```
**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 card**`PropertyRowCard` 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**:
```json
{ "whatsapp_number": "5516999998888" }
```
ou
```json
{ "whatsapp_number": null }
```
### `POST /api/v1/properties/{slug}/contact` *(já existe)*
- **Auth**: nenhuma
- **Body**:
```json
{
"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**:
```json
{
"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
}
```

View file

@ -0,0 +1,172 @@
# Feature Specification: Contact Button
**Feature Branch**: `[009-contact-button]`
**Created**: 2026-04-17
**Status**: Draft
**Input**: Adicionar um botão destacado 'Entre em Contato' em cada card de imóvel (PropertyRowCard e PropertyCard). Ao clicar, o cliente pode escolher entre preencher um formulário de contato (gera lead no painel admin) ou entrar em contato via WhatsApp com mensagem pré-preenchida com o código do imóvel. O número de WhatsApp do corretor deve vir de uma configuração (variável de ambiente ou admin).
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Acessar o Botão de Contato no Card do Imóvel (Priority: P1)
O visitante, ao visualizar qualquer card de imóvel (na listagem ou na seção de destaques), vê um botão destacado "Entre em Contato" e consegue acioná-lo com facilidade.
**Why this priority**: O botão é o ponto de entrada de toda a feature; sem ele, nenhum outro cenário é alcançável.
**Independent Test**: Verificar se o botão "Entre em Contato" está visível e clicável em todos os cards de imóvel (PropertyRowCard e PropertyCard).
**Acceptance Scenarios**:
1. **Given** um visitante na página de listagem de imóveis, **When** visualiza um `PropertyRowCard`, **Then** vê o botão "Entre em Contato" em destaque no card.
2. **Given** um visitante na página inicial (seção de destaques), **When** visualiza um `PropertyCard`, **Then** vê o botão "Entre em Contato" em destaque no card.
3. **Given** um visitante em dispositivo móvel, **When** visualiza qualquer card de imóvel, **Then** o botão "Entre em Contato" é visível, acessível e clicável sem necessitar de rolagem horizontal.
---
### User Story 2 - Escolher Canal de Contato (Priority: P1)
Ao clicar no botão "Entre em Contato", o visitante vê um modal/painel com duas opções claras: formulário de contato ou WhatsApp.
**Why this priority**: A escolha de canal é o fluxo central da feature; define qual caminho o usuário toma.
**Independent Test**: Verificar se o modal exibe as duas opções para cada imóvel diferente e que clicar fora do modal o fecha sem efeitos colaterais.
**Acceptance Scenarios**:
1. **Given** o visitante clica em "Entre em Contato" em um card, **When** o modal abre, **Then** vê duas opções claramente apresentadas: "Formulário de Contato" e "WhatsApp".
2. **Given** o modal está aberto, **When** o visitante clica fora da área do modal ou em um botão de fechar, **Then** o modal é fechado sem enviar nenhum dado.
3. **Given** o modal está aberto para o imóvel X, **When** o visitante fecha e abre o modal de um imóvel Y diferente, **Then** o modal exibe o código do imóvel Y corretamente.
---
### User Story 3 - Enviar Formulário de Contato (Priority: P1)
O visitante preenche e envia um formulário de contato com seu nome, e-mail, telefone (opcional) e uma mensagem pré-preenchida com o código do imóvel. O lead é registrado no sistema e visível no painel administrativo.
**Why this priority**: Captação de leads é um dos objetivos de negócio principais do sistema.
**Independent Test**: Verificar se um lead é criado no banco de dados com o `property_id` correto após o envio do formulário, e se o registro aparece no painel admin.
**Acceptance Scenarios**:
1. **Given** o visitante escolhe "Formulário de Contato", **When** o formulário é exibido, **Then** o campo de mensagem já contém o código do imóvel (ex.: "Tenho interesse no imóvel de código 2880602111.").
2. **Given** o formulário está aberto, **When** o visitante preenche nome, e-mail e mensagem e clica em "Enviar", **Then** os dados são enviados, o lead é salvo no sistema e o visitante vê uma mensagem de sucesso.
3. **Given** o visitante tenta enviar o formulário com campos obrigatórios vazios (nome ou e-mail), **When** clica em "Enviar", **Then** vê mensagens de erro claras nos campos inválidos e o envio é bloqueado.
4. **Given** o visitante preenche um e-mail em formato inválido, **When** clica em "Enviar", **Then** vê mensagem de erro específica sobre o e-mail.
5. **Given** o visitante clica "Enviar" e ocorre falha de rede ou erro do servidor, **When** a resposta retorna erro, **Then** vê mensagem de erro amigável e pode tentar novamente sem perder os dados preenchidos.
6. **Given** o formulário foi enviado com sucesso, **When** o admin acessa o painel de leads, **Then** vê o novo lead com o código/ID do imóvel associado, nome, e-mail, telefone e mensagem do visitante.
---
### User Story 4 - Contato via WhatsApp (Priority: P1)
O visitante escolhe a opção WhatsApp e é redirecionado para o WhatsApp com uma mensagem pré-preenchida contendo o código do imóvel. O número de destino pertence ao corretor responsável.
**Why this priority**: WhatsApp é o canal de comunicação predominante no mercado imobiliário brasileiro; é esperado como opção principal.
**Independent Test**: Verificar se o link gerado aponta para o número correto (configurado no sistema) e se a mensagem contém o código do imóvel.
**Acceptance Scenarios**:
1. **Given** o visitante escolhe a opção "WhatsApp" no modal, **When** confirma a ação, **Then** é redirecionado para o WhatsApp (web ou app) com a mensagem "Olá! Tenho interesse no imóvel de código [CÓDIGO]. Poderia me dar mais informações?" pré-preenchida.
2. **Given** o número de WhatsApp do corretor está configurado no sistema, **When** o visitante aciona a opção WhatsApp, **Then** o link aponta para esse número configurado.
3. **Given** o número de WhatsApp **não** está configurado, **When** o visitante tenta usar a opção WhatsApp, **Then** a opção é desabilitada ou exibe uma mensagem informando que o canal não está disponível no momento.
4. **Given** o visitante está em desktop, **When** clica em WhatsApp, **Then** o link abre o WhatsApp Web em nova aba; em dispositivos móveis, abre o aplicativo nativo do WhatsApp.
---
### User Story 5 - Configuração do Número de WhatsApp (Priority: P2)
O administrador pode configurar o número de WhatsApp do corretor que será usado nos links gerados nos cards de imóvel.
**Why this priority**: Sem configuração do número, a opção WhatsApp não funciona; mas a configuração em si pode ser feita antes do deploy via variável de ambiente.
**Independent Test**: Verificar se alternar o número de WhatsApp na configuração reflete imediatamente nos links gerados nos cards de imóvel.
**Acceptance Scenarios**:
1. **Given** o admin acessa as configurações do sistema, **When** define ou altera o número de WhatsApp, **Then** os novos links gerados nos cards de imóvel usam o número atualizado.
2. **Given** o número é definido via variável de ambiente `WHATSAPP_NUMBER`, **When** o sistema é iniciado, **Then** esse número é utilizado como padrão se não houver configuração salva no banco.
---
### User Story 6 - Visualizar Leads no Painel Admin (Priority: P2)
O administrador pode visualizar, filtrar e exportar a lista de leads gerados pelos formulários de contato, com informação do imóvel associado.
**Why this priority**: Sem visibilidade dos leads, o objetivo de negócio de captação não se completa.
**Independent Test**: Verificar se leads aparecem no painel admin com os dados corretos e se os filtros por imóvel e por data funcionam.
**Acceptance Scenarios**:
1. **Given** o admin acessa a seção de leads no painel, **When** visualiza a lista, **Then** vê todos os leads com: nome, e-mail, telefone, mensagem, código do imóvel associado e data de criação.
2. **Given** o admin deseja encontrar leads de um imóvel específico, **When** filtra pelo código ou ID do imóvel, **Then** apenas os leads daquele imóvel são exibidos.
3. **Given** o admin visualiza um lead, **When** clica para ver detalhes, **Then** vê todas as informações do lead e um link para o imóvel correspondente.
---
## Functional Requirements
1. O botão "Entre em Contato" deve ser exibido nos componentes `PropertyRowCard` e `PropertyCard`, com destaque visual adequado ao design existente (Linear dark theme).
2. Ao clicar no botão, um modal deve ser aberto apresentando duas opções: "Formulário de Contato" e "WhatsApp".
3. O formulário de contato deve conter os campos: nome (obrigatório), e-mail (obrigatório), telefone (opcional) e mensagem (obrigatório, pré-preenchida com o código do imóvel).
4. O envio do formulário deve criar um `ContactLead` no banco de dados com o `property_id` do imóvel correspondente, via endpoint existente `POST /api/v1/properties/<slug>/contact`.
5. O campo de mensagem deve ser pré-preenchido com o texto: "Tenho interesse no imóvel de código {CÓDIGO}. Poderia me dar mais informações?".
6. A opção WhatsApp deve gerar um link `wa.me/{NUMERO}?text={MENSAGEM_CODIFICADA}` que seja aberto em nova aba.
7. A mensagem pré-preenchida para o WhatsApp deve conter: "Olá! Tenho interesse no imóvel de código {CÓDIGO}. Poderia me dar mais informações?".
8. O número de WhatsApp deve ser lido prioritariamente de uma configuração no banco de dados; como fallback, da variável de ambiente `WHATSAPP_NUMBER`.
9. Quando o número de WhatsApp não estiver configurado, a opção WhatsApp deve ser desabilitada visualmente com mensagem informativa.
10. O backend deve expor um endpoint `GET /api/v1/config/whatsapp` (sem autenticação) que retorna o número de WhatsApp configurado (ou `null`), para que o frontend possa exibir ou desabilitar a opção.
11. O modal deve ser fechável clicando fora dele ou em um botão "X".
12. O formulário deve exibir mensagens de erro inline para campos inválidos, sem recarregar a página.
13. Após envio bem-sucedido, o formulário deve exibir mensagem de confirmação e fechar automaticamente após breve intervalo.
14. Em caso de erro no envio, o formulário deve exibir mensagem de erro amigável e manter os dados preenchidos.
15. O painel admin deve listar os leads captados com: nome, e-mail, telefone, mensagem, imóvel associado (código e link) e data de criação.
16. A listagem de leads no admin deve suportar paginação e filtro pelo imóvel associado.
---
## Non-Functional Requirements
- O modal deve abrir em até 200 ms após o clique no botão (experiência percebida como instantânea).
- O envio do formulário deve completar em até 3 segundos em condições normais de rede.
- O link do WhatsApp deve ser gerado no lado do cliente, sem round-trip ao servidor.
- O botão "Entre em Contato" deve ser acessível via teclado (foco visível, acionável com Enter/Space) e compatível com leitores de tela (atributo `aria-label` adequado).
- O modal deve ser responsivo em todos os breakpoints (mobile, tablet, desktop).
- Nenhum dado pessoal do visitante deve ser carregado ou exibido antes do visitante interagir com o formulário.
- O endpoint `GET /api/v1/config/whatsapp` deve retornar resposta em até 500 ms e pode ser cacheado no cliente por até 5 minutos.
---
## Out of Scope
- Envio de e-mail de confirmação ao visitante após submissão do formulário (pode ser feature futura).
- Integração com CRM externo para sincronização de leads.
- Notificação em tempo real (push ou e-mail) ao admin quando um novo lead é criado.
- Edição ou exclusão de leads pelo admin (somente leitura no escopo desta feature).
- Rastreamento analítico de cliques no botão ou escolha de canal (pode ser feature futura).
- Suporte a múltiplos números de WhatsApp por imóvel ou por corretor.
- Formulário de contato na página de detalhe do imóvel (já implementado separadamente).
---
## Key Entities
- **ContactLead**: id, property_id (FK → properties.id), name, email, phone, message, created_at — tabela `contact_leads` já existe.
- **Property**: id, code, slug, title — campo `code` usado como identificador legível na mensagem pré-preenchida.
- **WhatsApp Config**: número de telefone do corretor no formato internacional (ex.: `5516999998888`), armazenado em variável de ambiente `WHATSAPP_NUMBER` e/ou configuração no banco.
- **Modal de Contato**: componente React com estado local controlando qual canal está selecionado e o ciclo de vida do formulário (idle → submitting → success/error).
---
## Assumptions
- O endpoint `POST /api/v1/properties/<slug>/contact` já está implementado e funcional; esta feature apenas o consome no frontend.
- O `code` do imóvel (campo numérico) está disponível no objeto `Property` retornado pela API de listagem e já está presente nos props dos componentes de card.
- O design do botão e do modal seguirá o tema Linear dark já adotado no projeto.
- O número de WhatsApp será armazenado sem formatação, somente dígitos, no formato internacional sem `+` (ex.: `5516999998888`).
- A validação do formulário no frontend espelha as regras do schema Pydantic `ContactLeadIn` já existente (nome ≥ 2 chars, e-mail válido, mensagem ≥ 10 chars, etc.).
- A configuração do número de WhatsApp via admin (painel) será implementada como parte da feature 007 (Admin Panel) ou como extensão futura; neste escopo, o número é definido via variável de ambiente `WHATSAPP_NUMBER`.

View file

@ -0,0 +1,221 @@
# 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.