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
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
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue