feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
370
.specify/features/004-property-detail-page/quickstart.md
Normal file
370
.specify/features/004-property-detail-page/quickstart.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# Quickstart: Property Detail Page (004)
|
||||
|
||||
**Feature**: `004-property-detail-page`
|
||||
**Date**: 2026-04-13
|
||||
|
||||
Guia de implementação sequencial para desenvolvedores. Cada passo é independente; siga a ordem para evitar erros de dependência.
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Docker em execução (`docker compose ps` mostra backend + frontend + db up)
|
||||
- Python env ativo: `$env:DATABASE_URL = "postgresql://imob_user:imob_password_dev@localhost:5432/saas_imobiliaria"`
|
||||
- Frontend deps instaladas: `cd frontend && npm install`
|
||||
|
||||
---
|
||||
|
||||
## Passo 1 — Backend: atualizar modelo `Property` e criar `ContactLead`
|
||||
|
||||
**Arquivo**: `backend/app/models/property.py`
|
||||
|
||||
Adicionar ao modelo `Property`:
|
||||
```python
|
||||
code = db.Column(db.String(30), unique=True, nullable=True, index=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
```
|
||||
|
||||
Adicionar classe `ContactLead` no mesmo arquivo:
|
||||
```python
|
||||
class ContactLead(db.Model):
|
||||
__tablename__ = "contact_leads"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
property_id = db.Column(
|
||||
db.UUID(as_uuid=True),
|
||||
db.ForeignKey("properties.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(
|
||||
db.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
)
|
||||
|
||||
property = db.relationship("Property", foreign_keys=[property_id], lazy="select")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 2 — Backend: registrar o modelo em `__init__.py`
|
||||
|
||||
**Arquivo**: `backend/app/__init__.py`
|
||||
|
||||
Adicionar import para que Flask-Migrate detecte o modelo:
|
||||
```python
|
||||
from app.models import property as _property_models # já existe — ContactLead está no mesmo arquivo
|
||||
```
|
||||
|
||||
> `ContactLead` está em `property.py` — nenhuma linha nova necessária se já importa `property`.
|
||||
|
||||
---
|
||||
|
||||
## Passo 3 — Backend: gerar e aplicar migration
|
||||
|
||||
```powershell
|
||||
# Na raiz do backend com DATABASE_URL setado
|
||||
cd backend
|
||||
uv run flask db migrate -m "add contact_leads and property detail fields"
|
||||
uv run flask db upgrade
|
||||
```
|
||||
|
||||
**Verificar**: o arquivo gerado em `migrations/versions/` deve conter:
|
||||
- `op.create_table('contact_leads', ...)`
|
||||
- `op.add_column('properties', sa.Column('code', ...))`
|
||||
- `op.add_column('properties', sa.Column('description', ...))`
|
||||
|
||||
**Testar downgrade**:
|
||||
```powershell
|
||||
uv run flask db downgrade
|
||||
uv run flask db upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 4 — Backend: criar schemas Pydantic
|
||||
|
||||
**Arquivo**: `backend/app/schemas/property.py`
|
||||
|
||||
Adicionar ao final do arquivo:
|
||||
```python
|
||||
from pydantic import EmailStr
|
||||
from typing import Annotated
|
||||
from pydantic import Field
|
||||
|
||||
class PropertyDetailOut(PropertyOut):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
address: str | None = None
|
||||
code: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ContactLeadIn(BaseModel):
|
||||
name: Annotated[str, Field(min_length=2, max_length=150)]
|
||||
email: EmailStr
|
||||
phone: Annotated[str | None, Field(max_length=20)] = None
|
||||
message: Annotated[str, Field(min_length=10, max_length=2000)]
|
||||
|
||||
|
||||
class ContactLeadCreatedOut(BaseModel):
|
||||
id: int
|
||||
message: str
|
||||
```
|
||||
|
||||
> **Verificar**: `email-validator` está em `pyproject.toml`. Se não: `uv add email-validator`.
|
||||
|
||||
---
|
||||
|
||||
## Passo 5 — Backend: adicionar rotas ao `properties_bp`
|
||||
|
||||
**Arquivo**: `backend/app/routes/properties.py`
|
||||
|
||||
Adicionar as duas rotas ao blueprint existente:
|
||||
|
||||
```python
|
||||
from app.models.property import ContactLead
|
||||
from app.schemas.property import PropertyDetailOut, ContactLeadIn, ContactLeadCreatedOut
|
||||
from pydantic import ValidationError
|
||||
|
||||
@properties_bp.get("/properties/<string:slug>")
|
||||
def get_property(slug: str):
|
||||
prop = Property.query.filter_by(slug=slug, is_active=True).first()
|
||||
if prop is None:
|
||||
return jsonify({"error": "Imóvel não encontrado"}), 404
|
||||
return jsonify(PropertyDetailOut.model_validate(prop).model_dump(mode="json"))
|
||||
|
||||
|
||||
@properties_bp.post("/properties/<string:slug>/contact")
|
||||
def contact_property(slug: str):
|
||||
prop = Property.query.filter_by(slug=slug, is_active=True).first()
|
||||
if prop is None:
|
||||
return jsonify({"error": "Imóvel não encontrado"}), 404
|
||||
|
||||
try:
|
||||
payload = ContactLeadIn.model_validate(request.get_json(force=True) or {})
|
||||
except ValidationError as exc:
|
||||
details = {
|
||||
e["loc"][0]: [e["msg"]] for e in exc.errors() if e["loc"]
|
||||
}
|
||||
return jsonify({"error": "Dados inválidos", "details": details}), 422
|
||||
|
||||
lead = ContactLead(
|
||||
property_id=prop.id,
|
||||
name=payload.name,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
message=payload.message,
|
||||
)
|
||||
from app.extensions import db as _db
|
||||
_db.session.add(lead)
|
||||
_db.session.commit()
|
||||
|
||||
return jsonify(ContactLeadCreatedOut(id=lead.id, message="Mensagem enviada com sucesso!").model_dump()), 201
|
||||
```
|
||||
|
||||
> `properties_bp` já existe e está registrado em `__init__.py` — nenhuma alteração no factory necessária.
|
||||
|
||||
---
|
||||
|
||||
## Passo 6 — Backend: verificar rotas
|
||||
|
||||
```powershell
|
||||
# Com o docker compose rodando (ou DATABASE_URL local)
|
||||
Invoke-WebRequest "http://localhost:5173/api/v1/properties/apartamento-3-quartos-centro-123" -UseBasicParsing | Select-Object StatusCode
|
||||
# Esperado: 200
|
||||
Invoke-WebRequest "http://localhost:5173/api/v1/properties/slug-inexistente" -UseBasicParsing | Select-Object StatusCode
|
||||
# Esperado: 404
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 7 — Frontend: tipos TypeScript
|
||||
|
||||
**Arquivo**: `frontend/src/types/property.ts`
|
||||
|
||||
Adicionar ao final:
|
||||
```typescript
|
||||
export interface PropertyDetail extends Property {
|
||||
address: string | null
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface ContactFormData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 8 — Frontend: services
|
||||
|
||||
**Arquivo**: `frontend/src/services/properties.ts`
|
||||
|
||||
Adicionar ao final:
|
||||
```typescript
|
||||
import type { PropertyDetail, ContactFormData } from '../types/property'
|
||||
|
||||
export async function getProperty(slug: string): Promise<PropertyDetail> {
|
||||
const response = await api.get<PropertyDetail>(`/properties/${slug}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function submitContactForm(
|
||||
slug: string,
|
||||
data: ContactFormData,
|
||||
): Promise<{ id: number; message: string }> {
|
||||
const response = await api.post<{ id: number; message: string }>(
|
||||
`/properties/${slug}/contact`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 9 — Frontend: componentes novos
|
||||
|
||||
Criar os componentes nesta ordem (cada um é independente):
|
||||
|
||||
### 9a. `PropertyCarousel.tsx`
|
||||
- Props: `photos: PropertyPhoto[]`
|
||||
- State: `activeIndex: number`
|
||||
- Keyboard: `onKeyDown` no container com `tabIndex={0}`; teclas `ArrowLeft`/`ArrowRight`
|
||||
- Touch: `onTouchStart` salva `touchStartX`; `onTouchEnd` detecta delta > 50px → muda índice
|
||||
- Sem fotos: exibe placeholder `<div className="bg-panel ...">Sem fotos disponíveis</div>`
|
||||
- Uma foto: sem strip de miniaturas, sem botões de navegação
|
||||
|
||||
### 9b. `PropertyStatsStrip.tsx`
|
||||
- Props: `bedrooms, bathrooms, parking_spots, area_m2: number`
|
||||
- Layout: `flex gap-6 bg-panel border border-white/5 rounded-lg p-4`
|
||||
- Cada stat: ícone SVG + número + label em `text-text-quaternary`
|
||||
|
||||
### 9c. `Breadcrumb.tsx`
|
||||
- Props: `items: { label: string; href?: string }[]`
|
||||
- Último item: `text-text-primary`; demais: `text-text-quaternary`
|
||||
- Separador: `>` em `text-text-quaternary/50`
|
||||
|
||||
### 9d. `PriceBox.tsx`
|
||||
- Props: `price: string, condo_fee: string | null, type: "venda" | "aluguel"`
|
||||
- Sticky: `sticky top-24` em desktop (lg:)
|
||||
- Badge tipo: fundo `#5e6ad2/20` texto `#7170ff`
|
||||
- Preço em `text-3xl font-[510]`
|
||||
- Linha de condomínio: condicional se `condo_fee != null`
|
||||
|
||||
### 9e. `AmenitiesSection.tsx`
|
||||
- Props: `amenities: Amenity[]`
|
||||
- Agrupar por `group` → `Object.groupBy` ou `reduce`
|
||||
- Grid de checkmarks: `✓` em `text-[#7170ff]` + nome em `text-text-secondary`
|
||||
- Não renderizar se `amenities.length === 0`
|
||||
|
||||
### 9f. `ContactSection.tsx`
|
||||
- Props: `slug: string, propertyTitle: string, propertyCode: string | null`
|
||||
- State: `ContactFormData`, `submitting`, `submitStatus`, `fieldErrors`
|
||||
- WhatsApp button: `href=https://wa.me/${VITE_WHATSAPP_NUMBER}?text=...` em nova aba; fundo `#25D366`
|
||||
- `VITE_WHATSAPP_NUMBER` via `import.meta.env.VITE_WHATSAPP_NUMBER`
|
||||
- Inputs: `bg-[#111213] border border-white/[0.07] rounded-md`
|
||||
|
||||
### 9g. `PropertyDetailSkeleton.tsx`
|
||||
- Pulso com `animate-pulse bg-white/5`
|
||||
- Blocos: foto grande (h-96), stats strip, price box, linhas de texto
|
||||
|
||||
---
|
||||
|
||||
## Passo 10 — Frontend: `PropertyDetailPage.tsx`
|
||||
|
||||
```
|
||||
/imoveis/:slug
|
||||
└── loading → <PropertyDetailSkeleton />
|
||||
└── notFound → estado "Imóvel não encontrado" + Link para /imoveis
|
||||
└── loaded →
|
||||
<Navbar />
|
||||
<Breadcrumb items={[...]} />
|
||||
<main className="max-w-7xl mx-auto px-4">
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<PropertyCarousel photos={property.photos} />
|
||||
<h1>{property.title}</h1>
|
||||
<PropertyStatsStrip ... />
|
||||
{property.description && <p>{property.description}</p>}
|
||||
<AmenitiesSection amenities={property.amenities} />
|
||||
<ContactSection slug={slug} ... />
|
||||
</div>
|
||||
<aside>
|
||||
<PriceBox ... />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 11 — Frontend: `App.tsx`
|
||||
|
||||
Adicionar rota:
|
||||
```tsx
|
||||
import PropertyDetailPage from './pages/PropertyDetailPage'
|
||||
|
||||
// dentro de <Routes>
|
||||
<Route path="/imoveis/:slug" element={<PropertyDetailPage />} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 12 — Frontend: `PropertyCard.tsx`
|
||||
|
||||
Envolver o conteúdo do card com `Link to={/imoveis/${property.slug}}`:
|
||||
```tsx
|
||||
import { Link } from 'react-router-dom'
|
||||
// ...
|
||||
return (
|
||||
<Link to={`/imoveis/${property.slug}`} className="block ...">
|
||||
{/* conteúdo existente do card */}
|
||||
</Link>
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Passo 13 — Verificação Final
|
||||
|
||||
```powershell
|
||||
# Backend: rodar testes
|
||||
cd backend
|
||||
uv run pytest -v
|
||||
|
||||
# Frontend: build sem erros de tipo
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# Smoke test manual
|
||||
# 1. Acessar /imoveis → cards linkam para /imoveis/<slug>
|
||||
# 2. Acessar /imoveis/<slug-valido> → página renderiza com todos os blocos
|
||||
# 3. Acessar /imoveis/slug-invalido → estado "não encontrado"
|
||||
# 4. Preencher e enviar formulário → 201, mensagem de sucesso, form limpo
|
||||
# 5. Enviar sem nome/email → erros de campo exibidos
|
||||
# 6. Navegar carrossel com ← →
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Design (Princípio I)
|
||||
|
||||
- [ ] Canvas: `#08090a` (`bg-canvas`) em todos os backgrounds de página
|
||||
- [ ] Panels: `bg-panel` (`#0f1011`) com `border border-white/5`
|
||||
- [ ] Accent: `#7170ff` para checkmarks de amenidades e badge de tipo
|
||||
- [ ] WhatsApp button: `bg-[#25D366]` (não usar accent color)
|
||||
- [ ] Inputs de contato: `bg-[#111213] border border-white/[0.07]`
|
||||
- [ ] Tipografia: `font-inter` com `text-[510]` para preço e títulos principais
|
||||
- [ ] Skeleton: `animate-pulse bg-white/5` (mesma classe do `PropertyCardSkeleton`)
|
||||
- [ ] Breadcrumb: `text-text-quaternary` com último item `text-text-primary`
|
||||
Loading…
Add table
Add a link
Reference in a new issue