# 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/` 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/` 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/?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/ 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//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/` retornando o imóvel ativo com fotos e amenidades aninhadas. - **FR-B02**: A rota `GET /api/v1/properties/` DEVE retornar `404` para imóveis com `is_active = false` ou slug inexistente. - **FR-B03**: O sistema DEVE expor `POST /api/v1/properties//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/`. ### 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/?text=` 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)