1148 lines
36 KiB
Markdown
1148 lines
36 KiB
Markdown
# 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 320px–1440px (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-001–FR-014, NFR-004–NFR-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 (320px–428px): `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 1–5
|
||
|
||
---
|
||
|
||
#### `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-001–NFR-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 2–3 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-001–NFR-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-007–NFR-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.*
|