sass-imobiliaria/.specify/features/004-property-detail-page/spec.md

266 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, 2150 caracteres
- `email`: obrigatório, formato de e-mail válido
- `phone`: opcional, string, máximo 20 caracteres
- `message`: obrigatório, string, 102000 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)