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

16 KiB
Raw Blame History

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/

Retorna o detalhe completo de um imóvel ativo pelo slug.

Resposta 200 OK:

{
  "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):

{ "error": "Imóvel não encontrado" }

Endpoint: POST /api/v1/properties//contact

Registra um lead de contato vinculado ao imóvel.

Request body:

{
  "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:

{
  "id": 88,
  "message": "Mensagem enviada com sucesso!"
}

Resposta 404 Not Found (slug não encontrado ou imóvel inativo):

{ "error": "Imóvel não encontrado" }

Resposta 422 Unprocessable Entity (validação falhou):

{
  "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)