feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
36
specs/009-contact-button/checklists/requirements.md
Normal file
36
specs/009-contact-button/checklists/requirements.md
Normal 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`.
|
||||
284
specs/009-contact-button/plan.md
Normal file
284
specs/009-contact-button/plan.md
Normal 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, 2–150 chars |
|
||||
| `email` | obrigatório, formato válido, ≤ 254 chars |
|
||||
| `phone` | opcional, ≤ 20 chars |
|
||||
| `message` | obrigatório, 10–2000 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
|
||||
}
|
||||
```
|
||||
172
specs/009-contact-button/spec.md
Normal file
172
specs/009-contact-button/spec.md
Normal 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`.
|
||||
221
specs/009-contact-button/tasks.md
Normal file
221
specs/009-contact-button/tasks.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue