sass-imobiliaria/.specify/features/001-homepage/plan.md

1148 lines
36 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.

# Implementation Plan: Homepage (Página Inicial)
**Branch**: `001-homepage` | **Date**: 2026-04-13 | **Spec**: `spec.md`
**Input**: Feature specification from `spec.md`
---
## Summary
Implementar a homepage pública do SaaS Imobiliária — uma landing page com hero configurável, grade de imóveis em destaque, seção Sobre, seção CTA e rodapé. O conteúdo dinâmico (headline, imóveis em destaque) é servido por uma API Flask REST. O frontend é uma SPA React (TypeScript + Vite) estilizada com Tailwind alinhado ao design system Linear-inspired documentado em `DESIGN.md`.
Abordagem técnica: Flask para API backend (sem SSR), React SPA para todos os rendering, PostgreSQL via SQLAlchemy com Flask-Migrate, Pydantic v2 para validação de input/output nas rotas.
---
## Technical Context
**Language/Version**: Python 3.12 (backend) / TypeScript ~5.4 com React 18 (frontend)
**Primary Dependencies**: Flask 3.x, Flask-SQLAlchemy, Flask-Migrate, Pydantic v2, Flask-CORS; React 18, Vite 5, Tailwind CSS 3, Axios
**Storage**: PostgreSQL 16 (desenvolvimento local via Docker)
**Testing**: pytest (backend); Vitest + React Testing Library (frontend)
**Target Platform**: Web — browser moderno (desktop + mobile)
**Project Type**: Web Application (backend REST API + frontend SPA)
**Performance Goals**: LCP < 2,5s; API response < 500ms (NFR-001, NFR-002)
**Constraints**: Imagens de card 300 KB (NFR-003); suporte a breakpoints 320px1440px (NFR-004)
**Scale/Scope**: MVP; homepage pública + painel admin básico para configuração de conteúdo
---
## Constitution Check
*Verificado em 2026-04-13 — todos os gates passam.*
| # | Princípio | Status | Nota |
|---|-----------|--------|------|
| I | Design-First | PASS | Tailwind config mapeará todos os tokens de `DESIGN.md`; nenhum valor inline fora do config |
| II | Separation of Concerns | PASS | Flask emite JSON puro; React é SPA; CORS configurado explicitamente |
| III | Spec-Driven Development | PASS | `spec.md` existe com user stories e acceptance scenarios; este plano precede a implementação |
| IV | Data Integrity | PASS | Todos os inputs validados com Pydantic; migrações via Flask-Migrate; Numeric para `price` |
| V | Security | PASS | Rotas admin protegidas com autenticação; secrets em variáveis de ambiente; sem credenciais no frontend |
| VI | Simplicity First | PASS | Sem abstrações prematuras; sem padrão Repository; SQLAlchemy ORM diretamente nas rotas |
---
## Project Structure
### Source Code (repository root)
```text
backend/
├── app/
│ ├── __init__.py # Flask app factory (create_app)
│ ├── config.py # Config classes (Dev, Prod, Test) — lê de env vars
│ ├── extensions.py # db, migrate, cors (instâncias únicas)
│ ├── models/
│ │ ├── __init__.py
│ │ ├── property.py # Property, PropertyPhoto
│ │ └── homepage.py # HomepageConfig
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── property.py # PropertyOut, PropertyPhotoOut
│ │ └── homepage.py # HomepageConfigOut, HomepageConfigIn
│ └── routes/
│ ├── __init__.py
│ ├── homepage.py # GET /api/v1/homepage-config
│ └── properties.py # GET /api/v1/properties
├── migrations/ # Alembic — gerenciado pelo Flask-Migrate
├── seeds/
│ └── seed.py # Dados de exemplo para desenvolvimento
├── tests/
│ ├── conftest.py
│ ├── test_homepage.py
│ └── test_properties.py
├── .env.example
├── pyproject.toml # uv project + dependências
└── uv.lock # Commitável
frontend/
├── src/
│ ├── components/
│ │ ├── Navbar.tsx
│ │ ├── HeroSection.tsx
│ │ ├── FeaturedProperties.tsx
│ │ ├── PropertyCard.tsx
│ │ ├── PropertyCardSkeleton.tsx
│ │ ├── AboutSection.tsx
│ │ ├── CTASection.tsx
│ │ └── Footer.tsx
│ ├── pages/
│ │ └── HomePage.tsx
│ ├── services/
│ │ ├── api.ts # Instância Axios + interceptors
│ │ ├── homepage.ts # getHomepageConfig()
│ │ └── properties.ts # getFeaturedProperties()
│ ├── types/
│ │ ├── homepage.ts # HomepageConfig interface
│ │ └── property.ts # Property, PropertyPhoto interfaces
│ ├── App.tsx
│ └── main.tsx
├── public/
│ └── placeholder-property.jpg
├── tailwind.config.ts # Tokens alinhados ao DESIGN.md
├── index.html
├── vite.config.ts
├── tsconfig.json
└── package.json
```
---
## Phase 1 — Project Structure Setup
**Objetivo**: Scaffolding do backend Flask e frontend React com todas as ferramentas configuradas, banco conectado e build passando. Nenhuma lógica de negócio ainda.
### 1.1 Backend — Inicialização
**Arquivos a criar**:
| Arquivo | Responsabilidade |
|---------|-----------------|
| `backend/pyproject.toml` | Configuração do projeto uv com dependências fixadas |
| `backend/app/__init__.py` | `create_app()` factory registrando blueprints e extensões |
| `backend/app/config.py` | `DevelopmentConfig`, `ProductionConfig`, `TestingConfig` lendo `DATABASE_URL`, `SECRET_KEY`, `CORS_ORIGINS` de env vars |
| `backend/app/extensions.py` | Instâncias de `db = SQLAlchemy()`, `migrate = Migrate()`, `cors = CORS()` inicializadas em `create_app()` |
| `backend/.env.example` | Template com variáveis obrigatórias (sem valores reais) |
**Dependências** (`pyproject.toml`):
```toml
[project]
name = "saas-imobiliaria-backend"
requires-python = ">=3.12"
dependencies = [
"flask>=3.0",
"flask-sqlalchemy>=3.1",
"flask-migrate>=4.0",
"flask-cors>=4.0",
"pydantic>=2.7",
"psycopg2-binary>=2.9",
"python-dotenv>=1.0",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-flask>=1.3",
]
```
**Variáveis de ambiente obrigatórias** (`.env.example`):
```
DATABASE_URL=postgresql://user:password@localhost:5432/saas_imobiliaria
SECRET_KEY=change-me-in-production
CORS_ORIGINS=http://localhost:5173
FLASK_ENV=development
```
**Config pattern** (`app/config.py`):
```python
import os
class BaseConfig:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(BaseConfig):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
class ProductionConfig(BaseConfig):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
config = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}
```
**Critério de sucesso**: `uv run flask --app app shell` abre sem erros; `uv run pytest` passa (0 testes, setup ok).
---
### 1.2 Banco de Dados — PostgreSQL + SQLAlchemy
**Passos**:
1. Criar container Docker para PostgreSQL local:
```bash
docker run -d \
--name saas_imob_db \
-e POSTGRES_USER=imob \
-e POSTGRES_PASSWORD=imob_dev \
-e POSTGRES_DB=saas_imobiliaria \
-p 5432:5432 \
postgres:16-alpine
```
2. Inicializar o repositório de migrações: `uv run flask --app app db init`
3. Confirmar conexão via `uv run flask --app app shell` → `db.engine.connect()`
**Critério de sucesso**: Banco acessível; `flask db init` gera pasta `migrations/` sem erros.
---
### 1.3 Frontend — Inicialização
**Comando**:
```bash
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install axios
```
**Arquivos a criar/editar**:
| Arquivo | Ação |
|---------|------|
| `frontend/tailwind.config.ts` | Estender com todos os tokens de `DESIGN.md` (ver Phase 3) |
| `frontend/src/index.css` | Importar Tailwind; configurar Inter Variable via `@font-face` |
| `frontend/vite.config.ts` | Configurar proxy `/api` → `http://localhost:5000` para desenvolvimento |
**Configuração do proxy Vite** (`vite.config.ts`):
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})
```
**Critério de sucesso**: `npm run dev` sobe em `localhost:5173`; `npm run build` passa sem erros de TypeScript.
---
## Phase 2 — Backend API (Flask)
**Objetivo**: Modelos de banco, migrações, rotas REST e seeder com dados de exemplo. Ao final desta phase, `GET /api/v1/homepage-config` e `GET /api/v1/properties?featured=true` retornam JSON válido.
**Rastreabilidade**: US-2 (grade de imóveis), US-4 (config admin), FR-005, FR-006, FR-008, FR-009.
---
### 2.1 Models
#### `Property` (`app/models/property.py`)
```python
import uuid
from decimal import Decimal
from app.extensions import db
class PropertyType(db.Enum):
VENDA = "venda"
ALUGUEL = "aluguel"
class Property(db.Model):
__tablename__ = "properties"
id = db.Column(db.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(220), unique=True, nullable=False)
address = db.Column(db.String(300), nullable=True)
price = db.Column(db.Numeric(12, 2), nullable=False)
type = db.Column(db.Enum("venda", "aluguel", name="property_type"), nullable=False)
bedrooms = db.Column(db.Integer, nullable=False)
bathrooms = db.Column(db.Integer, nullable=False)
area_m2 = db.Column(db.Integer, nullable=False)
is_featured = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
photos = db.relationship(
"PropertyPhoto",
backref="property",
order_by="PropertyPhoto.display_order",
cascade="all, delete-orphan",
)
class PropertyPhoto(db.Model):
__tablename__ = "property_photos"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
property_id = db.Column(
db.UUID(as_uuid=True),
db.ForeignKey("properties.id", ondelete="CASCADE"),
nullable=False,
)
url = db.Column(db.String(500), nullable=False)
alt_text = db.Column(db.String(200), nullable=False, default="")
display_order = db.Column(db.Integer, nullable=False, default=0)
```
**Regras de integridade**:
- `price` usa `Numeric(12, 2)` — nunca `Float` (princípio IV)
- `property_type` é enum nativo do PostgreSQL — rejeita valores inválidos no DB
- `ondelete="CASCADE"` nas fotos — sem fotos órfãs
#### `HomepageConfig` (`app/models/homepage.py`)
```python
from app.extensions import db
class HomepageConfig(db.Model):
__tablename__ = "homepage_config"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
hero_headline = db.Column(db.String(120), nullable=False)
hero_subheadline = db.Column(db.String(240), nullable=True)
hero_cta_label = db.Column(db.String(40), nullable=False, default="Ver Imóveis")
hero_cta_url = db.Column(db.String(200), nullable=False, default="/imoveis")
featured_properties_limit = db.Column(db.Integer, nullable=False, default=6)
updated_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
onupdate=db.func.now(),
)
```
**Constraints**:
- `hero_headline` NOT NULL — FR-016 (validation gate no schema Pydantic)
- `featured_properties_limit` padrão 6, máximo 12 — aplicado na rota (não no DB, para evitar complexidade desnecessária)
- Tabela usa row única (id=1); upsert gerenciado pela rota admin
---
### 2.2 Pydantic Schemas
#### `app/schemas/property.py`
```python
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, field_validator
from typing import Literal
class PropertyPhotoOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
url: str
alt_text: str
display_order: int
class PropertyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
slug: str
price: Decimal
type: Literal["venda", "aluguel"]
bedrooms: int
bathrooms: int
area_m2: int
is_featured: bool
photos: list[PropertyPhotoOut]
```
#### `app/schemas/homepage.py`
```python
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, field_validator
class HomepageConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
hero_headline: str
hero_subheadline: str | None
hero_cta_label: str
hero_cta_url: str
featured_properties_limit: int
class HomepageConfigIn(BaseModel):
hero_headline: str
hero_subheadline: str | None = None
hero_cta_label: str = "Ver Imóveis"
hero_cta_url: str = "/imoveis"
featured_properties_limit: int = 6
@field_validator("hero_headline")
@classmethod
def headline_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("hero_headline não pode ser vazio")
return v
@field_validator("featured_properties_limit")
@classmethod
def limit_in_range(cls, v: int) -> int:
if not (1 <= v <= 12):
raise ValueError("featured_properties_limit deve estar entre 1 e 12")
return v
```
---
### 2.3 Rotas
#### `GET /api/v1/homepage-config` (`app/routes/homepage.py`)
```python
from flask import Blueprint, jsonify
from app.models.homepage import HomepageConfig
from app.schemas.homepage import HomepageConfigOut
homepage_bp = Blueprint("homepage", __name__, url_prefix="/api/v1")
@homepage_bp.get("/homepage-config")
def get_homepage_config():
config = HomepageConfig.query.first()
if config is None:
return jsonify({"error": "Homepage config not found"}), 404
return jsonify(HomepageConfigOut.model_validate(config).model_dump())
```
**Response shape**:
```json
{
"hero_headline": "Encontre o imóvel dos seus sonhos",
"hero_subheadline": "Mais de 200 imóveis disponíveis em toda a região",
"hero_cta_label": "Ver Imóveis",
"hero_cta_url": "/imoveis",
"featured_properties_limit": 6
}
```
#### `GET /api/v1/properties` (`app/routes/properties.py`)
```python
from flask import Blueprint, jsonify, request
from app.models.property import Property
from app.schemas.property import PropertyOut
properties_bp = Blueprint("properties", __name__, url_prefix="/api/v1")
@properties_bp.get("/properties")
def list_properties():
featured_param = request.args.get("featured", "").lower()
query = Property.query.filter_by(is_active=True)
if featured_param == "true":
# Busca o limite configurado
from app.models.homepage import HomepageConfig
config = HomepageConfig.query.first()
limit = config.featured_properties_limit if config else 6
query = (
query
.filter_by(is_featured=True)
.order_by(Property.created_at.desc())
.limit(limit)
)
properties = query.all()
return jsonify([PropertyOut.model_validate(p).model_dump(mode="json") for p in properties])
```
**Response shape** (`?featured=true`):
```json
[
{
"id": "uuid",
"title": "Apartamento 3 quartos — Centro",
"slug": "apartamento-3-quartos-centro",
"price": "750000.00",
"type": "venda",
"bedrooms": 3,
"bathrooms": 2,
"area_m2": 98,
"is_featured": true,
"photos": [
{ "url": "https://...", "alt_text": "Sala de estar", "display_order": 0 }
]
}
]
```
---
### 2.4 Migração Inicial
```bash
# Gera a migração a partir dos models
uv run flask --app app db migrate -m "initial schema: properties, property_photos, homepage_config"
# Aplica ao banco
uv run flask --app app db upgrade
```
Verificar que o downgrade funciona antes de commitar:
```bash
uv run flask --app app db downgrade base
uv run flask --app app db upgrade
```
---
### 2.5 Seeder (`seeds/seed.py`)
```python
"""
Popula o banco com dados de exemplo para desenvolvimento.
Uso: uv run python seeds/seed.py
"""
from app import create_app
from app.extensions import db
from app.models.homepage import HomepageConfig
from app.models.property import Property, PropertyPhoto
def seed():
app = create_app()
with app.app_context():
db.session.query(PropertyPhoto).delete()
db.session.query(Property).delete()
db.session.query(HomepageConfig).delete()
config = HomepageConfig(
hero_headline="Encontre o imóvel dos seus sonhos",
hero_subheadline="Mais de 200 imóveis disponíveis em toda a região",
hero_cta_label="Ver Imóveis",
hero_cta_url="/imoveis",
featured_properties_limit=6,
)
db.session.add(config)
sample_properties = [
{
"title": "Apartamento 3 quartos — Centro",
"slug": "apartamento-3-quartos-centro",
"price": "750000.00",
"type": "venda",
"bedrooms": 3,
"bathrooms": 2,
"area_m2": 98,
"is_featured": True,
},
# ... mais 5 imóveis para cobrir o limite padrão de 6
]
for data in sample_properties:
prop = Property(**data)
db.session.add(prop)
db.session.commit()
print("Seed concluído.")
if __name__ == "__main__":
seed()
```
**Critérios de sucesso da Phase 2**:
- `GET /api/v1/homepage-config` → `200 OK` com JSON válido
- `GET /api/v1/properties?featured=true` → array com até 6 imóveis
- `GET /api/v1/properties?featured=true` sem imóveis em destaque → `[]` (sem erro 500)
- `uv run pytest` passa em `tests/test_homepage.py` e `tests/test_properties.py`
---
## Phase 3 — Frontend (React + TypeScript)
**Objetivo**: Implementar todos os componentes da homepage estilizados conforme `DESIGN.md`, consumindo a API backend. Ao final, a homepage pública está visualmente completa e responsiva.
**Rastreabilidade**: US-1 (hero/nav), US-2 (imóveis em destaque), US-3 (sobre/CTA/rodapé), FR-001FR-014, NFR-004NFR-010.
---
### 3.1 Tailwind Config (`tailwind.config.ts`)
Mapeamento completo dos tokens de `DESIGN.md`:
```typescript
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Backgrounds
'mkt-black': '#08090a',
'panel-dark': '#0f1011',
'surface-elevated': '#191a1b',
'surface-secondary': '#28282c',
// Text
'text-primary': '#f7f8f8',
'text-secondary': '#d0d6e0',
'text-tertiary': '#8a8f98',
'text-quaternary': '#62666d',
// Brand
'brand-indigo': '#5e6ad2',
'accent-violet': '#7170ff',
'accent-hover': '#828fff',
// Borders (solid)
'border-primary': '#23252a',
'border-secondary': '#34343a',
'border-tertiary': '#3e3e44',
'line-tint': '#141516',
// Status
'status-green': '#27a644',
'status-emerald': '#10b981',
},
fontFamily: {
sans: [
'"Inter Variable"',
'SF Pro Display',
'-apple-system',
'system-ui',
'Segoe UI',
'Roboto',
'sans-serif',
],
mono: [
'"Berkeley Mono"',
'ui-monospace',
'SF Mono',
'Menlo',
'monospace',
],
},
fontWeight: {
light: '300',
normal: '400',
medium: '510', // Linear signature weight
semibold: '590',
},
letterSpacing: {
'display-xl': '-1.584px', // 72px hero
'display-lg': '-1.408px', // 64px
display: '-1.056px', // 48px
h1: '-0.704px', // 32px
h2: '-0.288px', // 24px
h3: '-0.24px', // 20px
body: '-0.165px', // 15-18px
},
backdropBlur: {
navbar: '12px',
},
},
},
plugins: [],
} satisfies Config
```
**Inter Variable** — adicionar ao `src/index.css`:
```css
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
/* Ou via @font-face com arquivo local se disponível */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-feature-settings: "cv01", "ss03";
}
body {
@apply bg-mkt-black text-text-primary font-sans antialiased;
}
}
```
---
### 3.2 Types (`src/types/`)
#### `src/types/homepage.ts`
```typescript
export interface HomepageConfig {
hero_headline: string
hero_subheadline: string | null
hero_cta_label: string
hero_cta_url: string
featured_properties_limit: number
}
```
#### `src/types/property.ts`
```typescript
export interface PropertyPhoto {
url: string
alt_text: string
display_order: number
}
export interface Property {
id: string
title: string
slug: string
price: string // Decimal serializado como string
type: 'venda' | 'aluguel'
bedrooms: number
bathrooms: number
area_m2: number
is_featured: boolean
photos: PropertyPhoto[]
}
```
---
### 3.3 API Service Layer (`src/services/`)
#### `src/services/api.ts`
```typescript
import axios from 'axios'
export const api = axios.create({
baseURL: '/api/v1',
timeout: 8000,
headers: { 'Content-Type': 'application/json' },
})
```
#### `src/services/homepage.ts`
```typescript
import { api } from './api'
import type { HomepageConfig } from '../types/homepage'
export async function getHomepageConfig(): Promise<HomepageConfig> {
const { data } = await api.get<HomepageConfig>('/homepage-config')
return data
}
```
#### `src/services/properties.ts`
```typescript
import { api } from './api'
import type { Property } from '../types/property'
export async function getFeaturedProperties(): Promise<Property[]> {
const { data } = await api.get<Property[]>('/properties', {
params: { featured: 'true' },
})
return data
}
```
---
### 3.4 Componentes
#### `Navbar` (`src/components/Navbar.tsx`)
**Especificação visual**:
- Background: `rgba(8,9,10,0.85)` + `backdrop-blur-navbar`
- Sticky no topo, `z-50`
- Logotipo à esquerda (texto ou SVG, `font-medium text-text-primary`)
- Links à direita: "Imóveis", "Sobre", "Contato" (`text-sm text-text-secondary hover:text-text-primary`)
- Mobile (<768px): hamburger menu — links em dropdown/drawer
- Border bottom: `border-b border-white/5`
**Requisitos**: FR-001, FR-002, FR-003, NFR-008
```tsx
// Estrutura semântica obrigatória (NFR-010)
<header role="banner">
<nav aria-label="Navegação principal">
{/* logo + links + hamburger */}
</nav>
</header>
```
---
#### `HeroSection` (`src/components/HeroSection.tsx`)
**Props**:
```typescript
interface HeroSectionProps {
headline: string
subheadline: string | null
ctaLabel: string
ctaUrl: string
}
```
**Especificação visual**:
- Background: `#08090a` com gradiente sutil radial centralizado (`rgba(94,106,210,0.08)`)
- Headline: `text-[72px] leading-none tracking-display-xl font-medium text-text-primary` no desktop
- Tablet (768px): `text-[48px] tracking-display`
- Mobile (320px428px): `text-[40px]`
- Subheadline (quando presente): `text-xl text-text-secondary` com `font-light`
- Botão CTA: background `#5e6ad2`, hover `#828fff`, texto branco, padding `-y-3 -x-6`, `rounded-md`
- Focus ring visível para acessibilidade (NFR-008)
- Subheadline ausente → elemento não renderizado (não string vazia) — FR-edge case
**Estado de carregamento**: Skeleton para headline e subheadline (3 linhas animadas).
**Requisitos**: FR-004, FR-005, US-1 acceptance scenarios 15
---
#### `PropertyCard` (`src/components/PropertyCard.tsx`)
**Props**:
```typescript
interface PropertyCardProps {
property: Property
}
```
**Especificação visual**:
- Container: `bg-surface-elevated rounded-xl border border-white/5 overflow-hidden cursor-pointer`
hover: `border-white/8 shadow-lg transition-all duration-200`
- Foto: `aspect-[4/3] w-full object-cover`
- Placeholder (`placeholder-property.jpg`) quando `photos.length === 0` (FR-011)
- Atributo `alt` = `property.title` (NFR-007)
- Badge de tipo: `rounded-full text-xs font-medium px-2.5 py-1`
- Venda: `bg-brand-indigo/20 text-accent-violet`
- Aluguel: `bg-status-green/20 text-status-green`
- Preço: Formatado com `Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })`
Fonte: `text-lg font-medium text-text-primary`
- Stats (quartos/banheiros/área): ícones SVG inline + `text-sm text-text-secondary`
- Clique navega para `/imoveis/{slug}` (FR-007, US-2 scenario 3)
---
#### `PropertyCardSkeleton` (`src/components/PropertyCardSkeleton.tsx`)
**Especificação visual**:
- Mesma estrutura de card com `animate-pulse`
- Blocos cinza (`bg-surface-secondary rounded`) nos lugares de foto, badge, preço e stats
- Exibido durante loading de `getFeaturedProperties()` (spec edge case: estado de carregamento)
---
#### `FeaturedProperties` (`src/components/FeaturedProperties.tsx`)
**Estado interno**: `loading | success | error | empty`
**Comportamentos**:
- `loading`: renderiza 3 `PropertyCardSkeleton` (NFR edge case)
- `success` com dados: grade de cards (`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6`)
- `success` sem dados (`[]`): mensagem "Nenhum imóvel em destaque no momento" (FR-010)
- `error`: fallback estático — não lança exceção para o usuário (spec edge case: API indisponível)
**Responsivo**: 1 col mobile → 2 col tablet → 3 col desktop (NFR-005)
**Requisitos**: FR-006, FR-008, FR-009, FR-010, US-2
---
#### `AboutSection` (`src/components/AboutSection.tsx`)
**Conteúdo estático** (MVP — não configurável via API nesta feature):
- Seção com nome da agência e parágrafo de descrição
- Background alternado: `bg-panel-dark`
- Título: `text-3xl font-medium tracking-h1 text-text-primary`
- Corpo: `text-base text-text-secondary leading-relaxed`
**Requisitos**: FR-012, US-3 scenario 1
---
#### `CTASection` (`src/components/CTASection.tsx`)
**Conteúdo estático** (MVP):
- Convite ao contato com número de telefone e/ou e-mail
- Botão/link clicável como elemento acionável obrigatório (FR-013)
- Background: `bg-surface-elevated` com border top `border-t border-white/5`
**Requisitos**: FR-013, US-3 scenario 2
---
#### `Footer` (`src/components/Footer.tsx`)
**Conteúdo**:
- Informações de contato da agência (e-mail ou telefone) — FR-014
- Links de navegação: Imóveis, Sobre, Contato
- Copyright
**Estrutura semântica**: `<footer role="contentinfo">` (NFR-010)
**Requisitos**: FR-014, US-3 scenario 3
---
### 3.5 Page (`src/pages/HomePage.tsx`)
```tsx
import React from 'react'
import { useEffect, useState } from 'react'
import Navbar from '../components/Navbar'
import HeroSection from '../components/HeroSection'
import FeaturedProperties from '../components/FeaturedProperties'
import AboutSection from '../components/AboutSection'
import CTASection from '../components/CTASection'
import Footer from '../components/Footer'
import { getHomepageConfig } from '../services/homepage'
import type { HomepageConfig } from '../types/homepage'
// Conteúdo fallback estático para quando a API falhar (spec edge case)
const FALLBACK_CONFIG: HomepageConfig = {
hero_headline: 'Encontre o imóvel dos seus sonhos',
hero_subheadline: null,
hero_cta_label: 'Ver Imóveis',
hero_cta_url: '/imoveis',
featured_properties_limit: 6,
}
export default function HomePage() {
const [config, setConfig] = useState<HomepageConfig>(FALLBACK_CONFIG)
useEffect(() => {
getHomepageConfig()
.then(setConfig)
.catch(() => { /* usa fallback silenciosamente */ })
}, [])
return (
<main>
<Navbar />
<HeroSection
headline={config.hero_headline}
subheadline={config.hero_subheadline}
ctaLabel={config.hero_cta_label}
ctaUrl={config.hero_cta_url}
/>
<FeaturedProperties />
<AboutSection />
<CTASection />
<Footer />
</main>
)
}
```
**Estrutura semântica**: `<main>` envolve o conteúdo da página (NFR-010)
---
### 3.6 App Router (`src/App.tsx`)
```tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import HomePage from './pages/HomePage'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
{/* /imoveis e /imoveis/:slug serão adicionados em features futuras */}
</Routes>
</BrowserRouter>
)
}
```
Adicionar `react-router-dom`:
```bash
npm install react-router-dom
npm install -D @types/react-router-dom
```
**Critérios de sucesso da Phase 3**:
- `npm run build` sem erros de TypeScript
- Homepage renderiza em todos os breakpoints de 320px a 1440px (NFR-004)
- Grades responsivas: 1→2→3 colunas conforme NFR-005
- Tipografia do hero escala: 40px mobile → 48px tablet → 72px desktop (NFR-006)
- Todas as imagens com `alt` descritivo (NFR-007)
- Todos os botões navegáveis por teclado com foco visível (NFR-008)
---
## Phase 4 — Integration & Polish
**Objetivo**: Conectar frontend ao backend real, tratar todos os estados de borda especificados, ajuste visual fino e verificação de contraste WCAG AA.
**Rastreabilidade**: NFR-001NFR-009, spec edge cases, US-1 scenario 4.
---
### 4.1 Integração API → Frontend
**Checklist de integração**:
- [ ] Hero exibe conteúdo real de `GET /api/v1/homepage-config`
- [ ] Grade de imóveis exibe dados reais de `GET /api/v1/properties?featured=true`
- [ ] Quando headline atualizado via admin → reload da homepage reflete novo texto (US-4 scenario 1)
- [ ] `is_featured` toggleado via admin → grade atualiza no próximo reload (US-4 scenario 2)
- [ ] Subheadline `null` → elemento não exibido (não string vazia) — spec edge case
- [ ] Headline 120+ caracteres → seção hero sem overflow (spec edge case)
**CORS**: Confirmar que `flask-cors` aceita apenas origin `http://localhost:5173` em dev e o domínio de produção em prod. Nunca wildcard `*` em produção (princípio V).
---
### 4.2 Loading States & Error Handling
| Estado | Componente | Comportamento |
|--------|-----------|---------------|
| Carregando config | `HeroSection` | Skeleton de 23 linhas animadas substituindo headline/subheadline |
| Carregando imóveis | `FeaturedProperties` | 3 `PropertyCardSkeleton` side-by-side |
| API config falha | `HomePage` | Fallback `FALLBACK_CONFIG` silencioso — página não quebra |
| API imóveis falha | `FeaturedProperties` | Estado `error` — mensagem amigável, sem stack trace visível |
| Imóvel sem foto | `PropertyCard` | Imagem placeholder (`/placeholder-property.jpg`) com `alt="Imóvel sem foto"` |
| Grade vazia | `FeaturedProperties` | Mensagem "Nenhum imóvel em destaque no momento" — seção não colapsa |
---
### 4.3 Visual Polish
**Checklist de conformidade com `DESIGN.md`**:
- [ ] Background da página: `#08090a` em toda a extensão vertical
- [ ] Navbar sticky com `backdrop-blur` e `rgba(8,9,10,0.85)` — não opaca
- [ ] Headline do hero com `letter-spacing: -1.584px` em 72px desktop
- [ ] Border dos cards: `rgba(255,255,255,0.05)` no estado default; `rgba(255,255,255,0.08)` no hover
- [ ] Botão CTA: background `#5e6ad2`, hover `#828fff`, transição `duration-200`
- [ ] Badges de tipo com `bg-opacity` — usando cores definidas no Tailwind config (sem inline)
- [ ] Nenhum valor de cor ou espaçamento inline que não esteja no Tailwind config
**Verificação de contraste WCAG 2.1 AA** (NFR-009):
| Par | Ratio mínimo | Verificar |
|-----|-------------|-----------|
| `#f7f8f8` sobre `#08090a` | > 21:1 | ✅ Texto primário/hero |
| `#d0d6e0` sobre `#08090a` | ~13:1 | ✅ Texto secundário |
| `#d0d6e0` sobre `#191a1b` | ~9:1 | ✅ Texto em cards |
| `#7170ff` sobre `#191a1b` | verificar tool | Badge violation risk — usar `#828fff` se necessário |
| Branco sobre `#5e6ad2` | verificar tool | Botão CTA |
Ferramenta recomendada: [whocanuse.com](https://whocanuse.com) ou browser DevTools Accessibility panel.
---
### 4.4 Performance
**Checklist NFR-001NFR-003**:
- [ ] Imagens do seeder ≤ 300 KB cada (NFR-003) — usar imagens de desenvolvimento de `picsum.photos` redimensionadas
- [ ] LCP medido via Lighthouse em rede 4G slow: deve ser < 2,5s (NFR-001)
- [ ] Resposta de API medida via DevTools: deve ser < 500ms (NFR-002)
- [ ] Build Vite com `npm run build` + `npm run preview` para teste de produção
---
### 4.5 Acessibilidade Final
**Checklist NFR-007NFR-010**:
- [ ] `<nav aria-label="...">`, `<main>`, `<footer role="contentinfo">`, `<header role="banner">` presentes
- [ ] Tab order lógico: Navbar → Hero CTA → Cards → CTA → Footer links
- [ ] Cada card de imóvel é focável e acionável via `Enter`/`Space`
- [ ] Imagens com `alt` descritivo — não `alt=""` exceto se puramente decorativas
- [ ] Focus ring visível em todos os elementos interativos (não removido com `outline-none` sem substituto)
- [ ] Screen reader: verificar com NVDA/VoiceOver que a sequência de headings é lógica (h1 → h2 → h3)
**Critérios de sucesso da Phase 4**:
- Lighthouse Score: Performance ≥ 90, Accessibility ≥ 95, Best Practices ≥ 95
- Nenhum console error em dev ou prod
- Verificação visual em Chrome DevTools: 375px (iPhone SE), 768px (iPad), 1280px (desktop)
- Admin atualiza headline → homepage pública reflete sem redeploy (US-4 scenario 1)
---
## Dependencies Summary
### Backend
| Pacote | Versão mínima | Razão |
|--------|-------------|-------|
| `flask` | 3.0 | Framework REST |
| `flask-sqlalchemy` | 3.1 | ORM (princípio IV) |
| `flask-migrate` | 4.0 | Migrações Alembic (princípio IV) |
| `flask-cors` | 4.0 | CORS explícito (princípio II/V) |
| `pydantic` | 2.7 | Validação de input/output (princípio IV) |
| `psycopg2-binary` | 2.9 | Driver PostgreSQL |
| `python-dotenv` | 1.0 | Loading de `.env` (princípio V) |
| `pytest` | 8.0 | Testes (dev) |
| `pytest-flask` | 1.3 | Fixtures Flask para testes (dev) |
### Frontend
| Pacote | Versão mínima | Razão |
|--------|-------------|-------|
| `react` / `react-dom` | 18 | Framework SPA |
| `typescript` | 5.4 | Type safety |
| `vite` | 5 | Build tool (stack constraint) |
| `tailwindcss` | 3 | Styling com tokens DESIGN.md (stack constraint) |
| `axios` | 1.6 | HTTP client com interceptors |
| `react-router-dom` | 6 | Roteamento SPA |
---
## Sequência de Execução
```
Phase 1.1 (backend scaffold)
→ Phase 1.2 (banco de dados)
→ Phase 2.1 (models)
→ Phase 2.4 (migração) ← pode ser paralelizado com Phase 1.3
→ Phase 2.2 (schemas)
→ Phase 2.3 (rotas)
→ Phase 2.5 (seeder)
→ Phase 1.3 (frontend scaffold)
→ Phase 3.1 (tailwind config)
→ Phase 3.2 (types)
→ Phase 3.3 (services)
→ Phase 3.4 (componentes — ordem: PropertyCard → FeaturedProperties → HeroSection → Navbar → AboutSection → CTASection → Footer)
→ Phase 3.5 (HomePage)
→ Phase 3.6 (App router)
→ Phase 4 (integração, polish, a11y, performance)
```
---
## Artifacts Gerados
| Arquivo | Phase | Descrição |
|---------|-------|-----------|
| `plan.md` | — | Este arquivo |
| `backend/pyproject.toml` | 1.1 | Dependências Python fixadas |
| `backend/app/__init__.py` | 1.1 | App factory Flask |
| `backend/app/config.py` | 1.1 | Configuração por ambiente |
| `backend/app/extensions.py` | 1.1 | Instâncias db/migrate/cors |
| `backend/app/models/property.py` | 2.1 | Modelos Property e PropertyPhoto |
| `backend/app/models/homepage.py` | 2.1 | Modelo HomepageConfig |
| `backend/app/schemas/property.py` | 2.2 | Pydantic schemas de imóvel |
| `backend/app/schemas/homepage.py` | 2.2 | Pydantic schemas de config |
| `backend/app/routes/homepage.py` | 2.3 | GET /api/v1/homepage-config |
| `backend/app/routes/properties.py` | 2.3 | GET /api/v1/properties |
| `backend/seeds/seed.py` | 2.5 | Seeder de dados de exemplo |
| `frontend/tailwind.config.ts` | 3.1 | Tokens DESIGN.md → Tailwind |
| `frontend/src/types/` | 3.2 | TypeScript interfaces |
| `frontend/src/services/` | 3.3 | Camada de API (axios) |
| `frontend/src/components/` | 3.4 | 8 componentes da homepage |
| `frontend/src/pages/HomePage.tsx` | 3.5 | Página principal |
---
*Próximo passo: executar `/speckit.tasks` para decompor este plano em tasks implementáveis.*