266 lines
16 KiB
Markdown
266 lines
16 KiB
Markdown
# Feature Specification: Página de Detalhe do Imóvel
|
||
|
||
**Feature Branch**: `004-property-detail-page`
|
||
**Created**: 2026-04-13
|
||
**Status**: Draft
|
||
|
||
## Contexto
|
||
|
||
Página pública `/imoveis/<slug>` que exibe todas as informações de um imóvel específico: galeria de fotos em carrossel, dados técnicos, preço, diferenciais e formas de contato. É o ponto de conversão do funil — o visitante que chegou via listagem ou link direto deve encontrar tudo que precisa para solicitar uma visita ou mais informações.
|
||
|
||
## User Stories
|
||
|
||
### US1 — Visitante visualiza o imóvel em detalhe (P1)
|
||
|
||
**Given** o visitante acessa `/imoveis/apartamento-3-quartos-centro-123`, **When** a página carrega com sucesso, **Then** vê o carrossel de fotos, título, código do imóvel, breadcrumb de localização, estatísticas-chave (quartos, banheiros, vagas, área), caixa de preço com label "Venda" ou "Aluguel", e a descrição completa do imóvel.
|
||
|
||
**Why this priority**: É o núcleo da feature — sem visualização nenhum outro story faz sentido.
|
||
|
||
**Independent Test**: Acessar `/imoveis/<slug>` de um imóvel ativo com fotos e verificar que todos os blocos de informação são renderizados corretamente.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** um imóvel ativo com 5 fotos e todos os campos preenchidos, **When** o visitante acessa a URL do imóvel, **Then** a foto de índice 0 (`display_order` mais baixo) aparece como principal, as demais aparecem como miniaturas, e todas as informações do imóvel são exibidas.
|
||
2. **Given** o carrossel está exibindo a primeira foto, **When** o visitante clica na miniatura da terceira foto, **Then** a foto principal muda para a terceira foto e a miniatura ativa recebe destaque visual.
|
||
3. **Given** o carrossel está em foco, **When** o visitante pressiona a tecla `←` ou `→`, **Then** a foto principal avança ou recua junto às miniaturas.
|
||
4. **Given** o visitante está em um dispositivo móvel, **When** faz swipe horizontal no carrossel, **Then** a foto principal muda na direção do gesto.
|
||
5. **Given** um imóvel do tipo `aluguel`, **When** a página carrega, **Then** a caixa de preço exibe o label "Aluguel", o valor principal, e — se `condo_fee` não for nulo — o valor de condomínio separado abaixo.
|
||
6. **Given** um imóvel do tipo `venda`, **When** a página carrega, **Then** o label exibe "Venda" e não há linha de condomínio se `condo_fee` for nulo.
|
||
7. **Given** o visitante está em desktop, **When** rola a página para baixo, **Then** a caixa de preço permanece visível (sticky) ao lado do conteúdo principal.
|
||
8. **Given** a página está carregando, **When** os dados ainda não chegaram, **Then** esqueletos de carregamento ocupam as áreas de foto, estatísticas e preço (sem layout shift).
|
||
|
||
---
|
||
|
||
### US2 — Visitante solicita contato pelo formulário ou WhatsApp (P2)
|
||
|
||
**Given** o visitante está na página de detalhe de um imóvel, **When** rola até a seção de contato, **Then** vê dois caminhos: botão de WhatsApp com número da imobiliária e formulário de contato (nome, e-mail, telefone, mensagem).
|
||
|
||
**Why this priority**: Conversão é o objetivo do negócio. O formulário captura leads que não usam WhatsApp.
|
||
|
||
**Independent Test**: Preencher e enviar o formulário; verificar que o lead aparece no banco de dados com `property_id` correto. Clicar no botão de WhatsApp e verificar que o link `wa.me` abre com texto pré-preenchido referenciando o imóvel.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** o formulário de contato está visível, **When** o visitante preenche nome, e-mail válido, telefone e mensagem e clica em "Enviar", **Then** o sistema registra o lead na tabela `contact_leads`, exibe mensagem de confirmação "Mensagem enviada com sucesso!", e o formulário é limpo.
|
||
2. **Given** o formulário está exibido, **When** o visitante tenta enviar sem preencher nome ou e-mail, **Then** os campos obrigatórios são destacados com mensagem de erro e o envio é bloqueado no próprio frontend.
|
||
3. **Given** o e-mail informado tem formato inválido (ex: "joao@"), **When** o visitante tenta enviar, **Then** o campo de e-mail exibe mensagem "E-mail inválido" e o envio é bloqueado.
|
||
4. **Given** o botão de WhatsApp está visível, **When** o visitante clica nele, **Then** uma nova aba abre com `https://wa.me/<numero>?text=...`, onde o texto pré-preenchido menciona o código e o título do imóvel.
|
||
5. **Given** o backend retorna erro 5xx ao tentar salvar o lead, **When** o envio falha, **Then** o formulário exibe mensagem "Erro ao enviar. Tente novamente mais tarde." sem apagar os dados já digitados.
|
||
6. **Given** o formulário está sendo enviado, **When** a requisição está em andamento, **Then** o botão de envio fica desabilitado com indicador de carregamento para evitar duplo envio.
|
||
|
||
---
|
||
|
||
### US3 — Visitante consulta diferenciais e localização (P3)
|
||
|
||
**Given** o visitante está na página de detalhe, **When** rola até as seções de diferenciais e mapa, **Then** vê a lista de amenidades agrupadas por categoria (características, lazer, condomínio, segurança) e um mapa com marcador na localização do imóvel.
|
||
|
||
**Why this priority**: Informações de apoio à decisão — importantes mas não bloqueantes para o MVP mínimo funcional.
|
||
|
||
**Independent Test**: Acessar a página de um imóvel com amenidades em múltiplos grupos e verificar que cada grupo tem seu título e checklist. Verificar mapa embutido com o endereço correto.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** um imóvel possui amenidades nos grupos "caracteristica", "lazer" e "segurança", **When** a página carrega, **Then** cada grupo é exibido como seção distinta com título ("Características", "Lazer", "Segurança") e a lista de amenidades correspondente.
|
||
2. **Given** um imóvel não possui nenhuma amenidade cadastrada, **When** a página carrega, **Then** a seção de diferenciais não é renderizada (sem seção vazia).
|
||
3. **Given** o imóvel possui endereço cadastrado, **When** a seção de localização é exibida, **Then** um mapa embutido mostra o pin na localização aproximada do imóvel.
|
||
4. **Given** o breadcrumb está exibido, **When** o visitante clica em "Imóveis", **Then** é redirecionado para `/imoveis`. Clicar na cidade aplica o filtro de cidade na listagem. Clicar no bairro aplica o filtro de bairro.
|
||
|
||
---
|
||
|
||
### Edge Cases
|
||
|
||
- **Imóvel não encontrado**: Se o `slug` não corresponder a nenhum imóvel na base, o backend retorna `404` e o frontend exibe página de "Imóvel não encontrado" com link de volta para `/imoveis`.
|
||
- **Imóvel inativo**: Se `is_active = false`, o backend retorna `404` na rota pública (o imóvel não existe para visitantes). Não retornar `403` para não vazar informação sobre existência.
|
||
- **Sem fotos**: Se o imóvel não tiver nenhuma `PropertyPhoto`, o carrossel exibe um placeholder visual (imagem genérica de imóvel) sem quebrar o layout.
|
||
- **Uma única foto**: O carrossel exibe a foto principal sem strip de miniaturas e sem os botões de navegação.
|
||
- **Campo `condo_fee` nulo em aluguel**: A linha de condomínio não é renderizada na caixa de preço.
|
||
- **Endereço sem coordenadas precisas**: O mapa pode usar geocoding por endereço completo; se falhar, a seção de mapa é omitida silenciosamente.
|
||
- **Envio duplicado de lead**: O backend não deduplica — cada envio gera um novo `ContactLead`. O bloqueio de UI durante envio (US2, cenário 6) é suficiente para o MVP.
|
||
- **Slug com caracteres especiais**: A rota aceita slugs no formato `[a-z0-9-]+` apenas; outros formatos retornam `404`.
|
||
|
||
## API Contract
|
||
|
||
### Endpoint: GET /api/v1/properties/<slug>
|
||
|
||
Retorna o detalhe completo de um imóvel ativo pelo slug.
|
||
|
||
**Resposta 200 OK**:
|
||
```json
|
||
{
|
||
"id": 42,
|
||
"title": "Apartamento 3 quartos no Centro",
|
||
"slug": "apartamento-3-quartos-centro-123",
|
||
"code": "AP-00042",
|
||
"description": "Excelente apartamento com vista para o jardim...",
|
||
"address": "Rua das Flores, 100, Centro",
|
||
"price": "850000.00",
|
||
"condo_fee": "650.00",
|
||
"listing_type": "venda",
|
||
"bedrooms": 3,
|
||
"bathrooms": 2,
|
||
"parking_spots": 2,
|
||
"area_m2": 95.0,
|
||
"is_featured": true,
|
||
"subtype": { "id": 10, "name": "Apartamento", "slug": "apartamento" },
|
||
"city": { "id": 5, "name": "Franca", "slug": "franca", "state": "SP" },
|
||
"neighborhood": { "id": 12, "name": "Jardim São Luiz", "slug": "jardim-sao-luiz" },
|
||
"photos": [
|
||
{ "id": 1, "url": "https://...", "alt_text": "Sala de estar", "display_order": 0 },
|
||
{ "id": 2, "url": "https://...", "alt_text": "Quarto principal", "display_order": 1 }
|
||
],
|
||
"amenities": [
|
||
{ "id": 3, "name": "Aceita animais", "slug": "aceita-animais", "group": "caracteristica" },
|
||
{ "id": 7, "name": "Piscina", "slug": "piscina", "group": "lazer" }
|
||
]
|
||
}
|
||
```
|
||
|
||
**Resposta 404 Not Found** (imóvel inexistente ou inativo):
|
||
```json
|
||
{ "error": "Imóvel não encontrado" }
|
||
```
|
||
|
||
---
|
||
|
||
### Endpoint: POST /api/v1/properties/<slug>/contact
|
||
|
||
Registra um lead de contato vinculado ao imóvel.
|
||
|
||
**Request body**:
|
||
```json
|
||
{
|
||
"name": "João Silva",
|
||
"email": "joao@email.com",
|
||
"phone": "(16) 99999-0000",
|
||
"message": "Tenho interesse no imóvel, gostaria de agendar uma visita."
|
||
}
|
||
```
|
||
|
||
**Validações no backend**:
|
||
- `name`: obrigatório, string, 2–150 caracteres
|
||
- `email`: obrigatório, formato de e-mail válido
|
||
- `phone`: opcional, string, máximo 20 caracteres
|
||
- `message`: obrigatório, string, 10–2000 caracteres
|
||
|
||
**Resposta 201 Created**:
|
||
```json
|
||
{
|
||
"id": 88,
|
||
"message": "Mensagem enviada com sucesso!"
|
||
}
|
||
```
|
||
|
||
**Resposta 404 Not Found** (slug não encontrado ou imóvel inativo):
|
||
```json
|
||
{ "error": "Imóvel não encontrado" }
|
||
```
|
||
|
||
**Resposta 422 Unprocessable Entity** (validação falhou):
|
||
```json
|
||
{
|
||
"error": "Dados inválidos",
|
||
"details": {
|
||
"email": ["E-mail inválido"],
|
||
"message": ["Campo obrigatório"]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Modelos necessários
|
||
|
||
### ContactLead (novo)
|
||
|
||
Registra cada solicitação de contato feita por um visitante em relação a um imóvel.
|
||
|
||
| Campo | Tipo | Restrições |
|
||
|---|---|---|
|
||
| `id` | SERIAL PK | — |
|
||
| `property_id` | FK → Property | NOT NULL |
|
||
| `name` | VARCHAR(150) | NOT NULL |
|
||
| `email` | VARCHAR(254) | NOT NULL |
|
||
| `phone` | VARCHAR(20) | nullable |
|
||
| `message` | TEXT | NOT NULL |
|
||
| `created_at` | TIMESTAMP WITH TIME ZONE | NOT NULL, default NOW() |
|
||
|
||
> Não há relação de exclusão em cascata com `Property` — leads são preservados mesmo se o imóvel for deletado (para histórico de negócio). A FK deve ser SET NULL ou restrita por política — para o MVP: ON DELETE SET NULL é suficiente.
|
||
|
||
---
|
||
|
||
## Requisitos Funcionais
|
||
|
||
### Backend
|
||
|
||
- **FR-B01**: O sistema DEVE expor `GET /api/v1/properties/<slug>` retornando o imóvel ativo com fotos e amenidades aninhadas.
|
||
- **FR-B02**: A rota `GET /api/v1/properties/<slug>` DEVE retornar `404` para imóveis com `is_active = false` ou slug inexistente.
|
||
- **FR-B03**: O sistema DEVE expor `POST /api/v1/properties/<slug>/contact` que valida o payload com Pydantic e persiste um `ContactLead` no banco.
|
||
- **FR-B04**: A criação de `ContactLead` DEVE usar `property_id` resolvido via `slug`; nunca aceitar `property_id` diretamente do cliente.
|
||
- **FR-B05**: As rotas públicas de detalhe e contato NÃO requerem autenticação.
|
||
- **FR-B06**: A migração Alembic para a tabela `contact_leads` DEVE ser criada antes de qualquer deploy.
|
||
- **FR-B07**: O `PropertyCard` na listagem `/imoveis` DEVE tornarse clicável, linkando para `/imoveis/<slug>`.
|
||
|
||
### Frontend
|
||
|
||
- **FR-F01**: A aplicação DEVE renderizar a rota `/imoveis/:slug` como `PropertyDetailPage`.
|
||
- **FR-F02**: O carrossel DEVE exibir a foto ativa em tamanho grande e um strip de miniaturas abaixo (ou lateral); a foto ativa é destacada no strip.
|
||
- **FR-F03**: O carrossel DEVE suportar navegação por teclado (teclas `←` e `→`) quando em foco.
|
||
- **FR-F04**: O carrossel DEVE suportar swipe touchscreen (dispositivos móveis).
|
||
- **FR-F05**: Em telas desktop (≥ 1024px), a caixa de preço DEVE ser sticky durante o scroll da página.
|
||
- **FR-F06**: O breadcrumb DEVE exibir: "Imóveis > [Cidade] > [Bairro] > [Título do imóvel]", onde "Imóveis" linka para `/imoveis`, cidade e bairro linkam para `/imoveis` com filtros pré-aplicados.
|
||
- **FR-F07**: As amenidades DEVEM ser agrupadas pelas categorias: "Características" (group=caracteristica), "Lazer" (group=lazer), "Condomínio" (group=condominio), "Segurança" (group=seguranca). Grupos sem amenidade NÃO são renderizados.
|
||
- **FR-F08**: O formulário de contato DEVE validar `name` e `email` como obrigatórios e `email` como formato válido antes de enviar a requisição.
|
||
- **FR-F09**: O botão de WhatsApp DEVE abrir `https://wa.me/<NUMERO>?text=<texto_codificado>` em nova aba, onde o texto menciona o código e título do imóvel.
|
||
- **FR-F10**: O número de WhatsApp DEVE ser configurável via variável de ambiente no frontend (`VITE_WHATSAPP_NUMBER`).
|
||
- **FR-F11**: A página DEVE exibir skeleton loaders durante o carregamento dos dados.
|
||
- **FR-F12**: Ao receber `404` do backend, o frontend DEVE renderizar um estado de "Imóvel não encontrado" com CTA para `/imoveis`.
|
||
- **FR-F13**: Todos os componentes DEVEM seguir o design system Linear dark definido em `DESIGN.md` (canvas `#08090a`, panel `#0f1011`, accent `#7170ff`, tipografia Inter Variable).
|
||
|
||
---
|
||
|
||
## Key Entities
|
||
|
||
- **Property** (existente): imóvel com fotos (1:M) e amenidades (M:M). Nenhum campo novo necessário.
|
||
- **PropertyPhoto** (existente): foto vinculada ao imóvel com `display_order` para ordenação do carrossel.
|
||
- **Amenity** (existente): diferencial com `group` para agrupamento visual.
|
||
- **ContactLead** (novo): registro de interesse de um visitante por um imóvel específico.
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
- **SC-001**: Um visitante consegue acessar a página de detalhe a partir de um card na listagem e visualizar todas as informações em menos de 3 segundos em conexão padrão.
|
||
- **SC-002**: 100% dos campos do imóvel (fotos, preço, estatísticas, descrição) são exibidos sem erros de layout para imóveis com dados completos.
|
||
- **SC-003**: O visitante consegue preencher e enviar o formulário de contato em menos de 2 minutos; a confirmação de envio é visível imediatamente após o sucesso.
|
||
- **SC-004**: Os leads enviados pelo formulário ficam registrados no banco de dados com `property_id`, `name`, `email` e `created_at` corretos.
|
||
- **SC-005**: Acessar o slug de um imóvel inexistente ou inativo nunca resulta em página em branco ou erro 500 — sempre exibe estado de "não encontrado".
|
||
- **SC-006**: O carrossel de fotos é navegável por teclado e por swipe em 100% dos testes de interação.
|
||
|
||
---
|
||
|
||
## Assumptions
|
||
|
||
- O campo `listing_type` em `Property` usa os valores `"venda"` e `"aluguel"` (já implementado).
|
||
- O slug é único por imóvel e imutável após criação (não há redirecionamento de slugs antigos no MVP).
|
||
- O número de WhatsApp da imobiliária é único e configurado por variável de ambiente (`VITE_WHATSAPP_NUMBER`); não há múltiplos corretores no MVP.
|
||
- O mapa embutido usa o serviço de mapas via endereço textual (geocoding pelo Google Maps Embed ou similar); coordenadas geográficas não são armazenadas no modelo `Property` no MVP.
|
||
- Não há sistema de autenticação de visitante — o formulário de contato é anônimo, e o campo `phone` é opcional.
|
||
- `PropertyCard` na listagem (`/imoveis`) já renderiza `slug` nos dados retornados pela API existente `GET /api/v1/properties`.
|
||
- Rate limiting no endpoint de contato está fora do escopo do MVP (será tratado em feature de segurança dedicada).
|
||
- Notificação por e-mail para a imobiliária ao receber um lead está fora do escopo do MVP (apenas persistência em DB).
|
||
- O IPTU não está modelado em `Property` atualmente; a exibição de IPTU está fora do escopo desta feature.
|
||
|
||
---
|
||
|
||
## Out of Scope
|
||
|
||
- Painel administrativo para visualizar leads recebidos (feature futura)
|
||
- Notificação por e-mail ou push ao receber novo lead
|
||
- Imóveis similares / "Veja também"
|
||
- Compartilhamento em redes sociais
|
||
- Favoritar imóvel (requer autenticação de visitante)
|
||
- Comparador de imóveis
|
||
- Tour virtual / vídeo embutido
|
||
- IPTU na caixa de preço (campo não modelado em `Property`)
|
||
- Múltiplos corretores com contato individualizado
|
||
- Slug redirect (slugs antigos não são preservados)
|