- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint
migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
354 lines
14 KiB
Markdown
354 lines
14 KiB
Markdown
# Implementation Plan: Trabalhe Conosco
|
|
|
|
**Branch**: `028-trabalhe-conosco` | **Date**: 2026-04-21 | **Spec**: [spec.md](spec.md)
|
|
**Input**: Feature specification from `/specs/028-trabalhe-conosco/spec.md`
|
|
|
|
## Summary
|
|
|
|
Criar a página pública `/trabalhe-conosco` com hero section, seção de benefícios (3 cards estáticos) e formulário de candidatura. O formulário submete via `POST /api/v1/jobs/apply` (endpoint público sem auth). As candidaturas são persistidas na tabela `job_applications` e recuperáveis pelo administrador via `GET /api/v1/admin/jobs` (paginado, protegido por `@require_admin`). Links adicionados no footer (coluna "A Imobiliária") e em `AgentsPage.tsx`. Dois novos blueprints Flask, novo model SQLAlchemy, migration Alembic, schemas Pydantic e uma nova página React com serviço Axios.
|
|
|
|
---
|
|
|
|
## Technical Context
|
|
|
|
**Language/Version**: Python 3.12 (backend) / TypeScript 5.5 (frontend)
|
|
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT, Alembic (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
|
|
**Storage**: PostgreSQL 16 — nova tabela `job_applications`
|
|
**Testing**: pytest (backend)
|
|
**Target Platform**: Linux server (Docker container)
|
|
**Project Type**: web-service (Flask REST API) + SPA (React)
|
|
**Performance Goals**: página pública carrega em < 2s (SC-006); listagem admin paginada (20/página)
|
|
**Constraints**: sem upload real de arquivo (apenas `file_name` como texto); sem envio de e-mail; sem rate limiting nesta versão; múltiplas candidaturas do mesmo e-mail são permitidas
|
|
**Scale/Scope**: volume baixo de candidaturas; paginação padrão 20/página
|
|
|
|
---
|
|
|
|
## Constitution Check
|
|
|
|
| Princípio | Status | Observação |
|
|
|-----------|--------|------------|
|
|
| **I. Design-First** | ✅ PASS | Hero, cards de benefícios e formulário seguem design tokens dark do `DESIGN.md`; cores `#5e6ad2`, tipografia Inter, cards com `bg-panel border-borderSubtle` |
|
|
| **II. Separation of Concerns** | ✅ PASS | Flask retorna JSON puro; React SPA consome via Axios; zero lógica de renderização no backend |
|
|
| **III. Spec-Driven** | ✅ PASS | `spec.md` com user stories P1/P2/P3 e acceptance scenarios; plan derivado do spec |
|
|
| **IV. Data Integrity** | ✅ PASS | Migration Alembic (`i1j2k3l4m5n6`); Pydantic valida todos os inputs; `email: EmailStr`; sem raw SQL |
|
|
| **V. Security** | ✅ PASS | Endpoint admin protegido por `@require_admin` (JWT); endpoint público não expõe dados internos; sem exposição de stack traces em erro 500 |
|
|
| **VI. Simplicity First** | ✅ PASS | Sem upload binário (justificado na spec), sem e-mail transacional, sem rate limiting nesta versão; página de admin React adiada para iteração futura (conforme Assumptions da spec) |
|
|
|
|
**Veredicto**: Sem violações. Pode prosseguir com implementação.
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
### Documentação (esta feature)
|
|
|
|
```text
|
|
specs/028-trabalhe-conosco/
|
|
├── spec.md # Especificação de produto
|
|
├── data-model.md # Entidade JobApplication, migration, schemas
|
|
├── plan.md # Este arquivo
|
|
├── contracts/
|
|
│ └── jobs-api.md # Contratos dos 2 endpoints REST
|
|
└── tasks.md # (Phase 2 — gerado por /speckit.tasks)
|
|
```
|
|
|
|
### Código-fonte (raiz do repositório)
|
|
|
|
```text
|
|
backend/
|
|
├── app/
|
|
│ ├── models/
|
|
│ │ └── job_application.py # NOVO — modelo SQLAlchemy JobApplication
|
|
│ ├── schemas/
|
|
│ │ └── job_application.py # NOVO — JobApplicationIn, JobApplicationOut
|
|
│ ├── routes/
|
|
│ │ └── jobs.py # NOVO — jobs_public_bp + jobs_admin_bp
|
|
│ └── __init__.py # MODIFICAR — importar model + registrar blueprints
|
|
└── migrations/
|
|
└── versions/
|
|
└── i1j2k3l4m5n6_add_job_applications.py # NOVO — cria tabela + índices
|
|
|
|
frontend/
|
|
└── src/
|
|
├── types/
|
|
│ └── jobApplication.ts # NOVO — interface JobApplication
|
|
├── services/
|
|
│ └── jobs.ts # NOVO — submitApplication(), listApplications()
|
|
├── pages/
|
|
│ └── JobsPage.tsx # NOVO — página pública /trabalhe-conosco
|
|
├── App.tsx # MODIFICAR — adicionar rota /trabalhe-conosco
|
|
└── components/
|
|
└── Footer.tsx # MODIFICAR — link "Trabalhe Conosco" em "A Imobiliária"
|
|
└── pages/
|
|
└── AgentsPage.tsx # MODIFICAR — link/botão "Trabalhe Conosco"
|
|
```
|
|
|
|
---
|
|
|
|
## Backend: Arquitetura Técnica
|
|
|
|
### Model: `backend/app/models/job_application.py`
|
|
|
|
Entidade standalone com 9 campos. Ver [data-model.md](data-model.md) para schema completo e invariantes.
|
|
|
|
Campos principais:
|
|
- `id` — PK SERIAL
|
|
- `name`, `email`, `role_interest`, `message` — obrigatórios
|
|
- `phone`, `file_name` — opcionais
|
|
- `status` — default `"pending"` (extensível futuramente)
|
|
- `created_at` — server_default `now()`, imutável
|
|
|
|
### Schemas Pydantic: `backend/app/schemas/job_application.py`
|
|
|
|
**`JobApplicationIn`** (entrada do endpoint público):
|
|
- Valida `name` (não vazio, strip), `email` (EmailStr), `role_interest` (enum de 4 opções), `message` (não vazio, max 5000 chars)
|
|
- `phone` e `file_name` opcionais
|
|
|
|
**`JobApplicationOut`** (saída do endpoint admin):
|
|
- Retorna todos os campos incluindo `id`, `status` e `created_at`
|
|
- `model_config = ConfigDict(from_attributes=True)` para serialização ORM
|
|
|
|
### Blueprints: `backend/app/routes/jobs.py`
|
|
|
|
Dois blueprints no mesmo arquivo, seguindo o padrão de `routes/agents.py`:
|
|
|
|
```python
|
|
jobs_public_bp = Blueprint("jobs_public", __name__, url_prefix="/api/v1")
|
|
jobs_admin_bp = Blueprint("jobs_admin", __name__, url_prefix="/api/v1/admin")
|
|
```
|
|
|
|
**`POST /api/v1/jobs/apply`** (público, sem autenticação):
|
|
1. `request.get_json(silent=True) or {}`
|
|
2. `JobApplicationIn.model_validate(data)` → 422 com `exc.errors()` se inválido
|
|
3. Instanciar `JobApplication(...)` e `db.session.add` + `db.session.commit()`
|
|
4. Retornar `{"message": "Candidatura recebida com sucesso"}`, HTTP 201
|
|
|
|
**`GET /api/v1/admin/jobs`** (protegido por `@require_admin`):
|
|
1. Query params: `page` (default 1), `per_page` (default 20, max 100)
|
|
2. `JobApplication.query.order_by(JobApplication.created_at.desc()).paginate(page, per_page, error_out=False)`
|
|
3. Serializar com `JobApplicationOut` e retornar envelope paginado:
|
|
```json
|
|
{
|
|
"items": [...],
|
|
"total": 42,
|
|
"page": 1,
|
|
"per_page": 20,
|
|
"pages": 3
|
|
}
|
|
```
|
|
4. `@require_admin` dispara 401/403 automaticamente
|
|
|
|
### Registro em `backend/app/__init__.py`
|
|
|
|
Dois patches necessários:
|
|
|
|
```python
|
|
# Importar model (para Flask-Migrate detectar)
|
|
from app.models import job_application as _job_application_models # noqa: F401
|
|
|
|
# Importar e registrar blueprints
|
|
from app.routes.jobs import jobs_public_bp, jobs_admin_bp
|
|
app.register_blueprint(jobs_public_bp)
|
|
app.register_blueprint(jobs_admin_bp)
|
|
```
|
|
|
|
### Migration Alembic
|
|
|
|
Arquivo: `i1j2k3l4m5n6_add_job_applications.py`
|
|
- `down_revision = "h1i2j3k4l5m6"` (migration atual mais recente: `create_contact_config`)
|
|
- Cria tabela `job_applications` com 9 colunas
|
|
- Cria índices: `ix_job_applications_created_at`, `ix_job_applications_status`
|
|
- `downgrade()` desfaz índices e tabela
|
|
|
|
Ver código completo em [data-model.md](data-model.md).
|
|
|
|
---
|
|
|
|
## Frontend: Arquitetura Técnica
|
|
|
|
### Types: `frontend/src/types/jobApplication.ts`
|
|
|
|
```typescript
|
|
export interface JobApplicationPayload {
|
|
name: string
|
|
email: string
|
|
phone?: string
|
|
role_interest: string
|
|
message: string
|
|
file_name?: string
|
|
}
|
|
|
|
export interface JobApplication {
|
|
id: number
|
|
name: string
|
|
email: string
|
|
phone: string | null
|
|
role_interest: string
|
|
message: string
|
|
file_name: string | null
|
|
status: string
|
|
created_at: string
|
|
}
|
|
|
|
export interface JobApplicationsResponse {
|
|
items: JobApplication[]
|
|
total: number
|
|
page: number
|
|
per_page: number
|
|
pages: number
|
|
}
|
|
```
|
|
|
|
### Service: `frontend/src/services/jobs.ts`
|
|
|
|
```typescript
|
|
import api from './api'
|
|
import type { JobApplicationPayload, JobApplicationsResponse } from '../types/jobApplication'
|
|
|
|
export async function submitApplication(payload: JobApplicationPayload): Promise<void> {
|
|
await api.post('/api/v1/jobs/apply', payload)
|
|
}
|
|
|
|
export async function listApplications(
|
|
page = 1,
|
|
perPage = 20
|
|
): Promise<JobApplicationsResponse> {
|
|
const { data } = await api.get('/api/v1/admin/jobs', {
|
|
params: { page, per_page: perPage },
|
|
})
|
|
return data
|
|
}
|
|
```
|
|
|
|
### Página: `frontend/src/pages/JobsPage.tsx`
|
|
|
|
Estrutura da página (3 seções, de cima para baixo):
|
|
|
|
#### 1. Hero Section
|
|
```
|
|
bg-canvas | max-w-[1200px] mx-auto px-6 pt-16 pb-10
|
|
├── eyebrow: "Faça parte do nosso time" (text-[#5e6ad2] uppercase tracking-widest)
|
|
├── h1: "Trabalhe Conosco" (text-3xl md:text-4xl font-semibold text-textPrimary)
|
|
└── subtítulo: texto descritivo (text-textSecondary)
|
|
```
|
|
|
|
#### 2. Seção "Por que trabalhar conosco?" (3 cards estáticos)
|
|
```
|
|
max-w-[1200px] mx-auto px-6 py-10
|
|
├── h2: "Por que trabalhar conosco?" (text-xl font-semibold text-textPrimary mb-6)
|
|
└── grid grid-cols-1 md:grid-cols-3 gap-5
|
|
├── Card 1: ícone + "Ambiente Colaborativo" + descrição
|
|
├── Card 2: ícone + "Crescimento Profissional" + descrição
|
|
└── Card 3: ícone + "Remuneração Competitiva" + descrição
|
|
(cada card: bg-panel border border-borderSubtle rounded-2xl p-6)
|
|
```
|
|
|
|
#### 3. Formulário de Candidatura
|
|
```
|
|
max-w-[640px] mx-auto px-6 pb-20
|
|
├── h2: "Envie sua candidatura"
|
|
└── <form onSubmit={handleSubmit}>
|
|
├── name — input text, obrigatório
|
|
├── email — input email, obrigatório, validação RFC
|
|
├── phone — input tel, opcional
|
|
├── role_interest — select (4 opções), obrigatório
|
|
├── message — textarea, obrigatório, max 5000 chars, contador de chars
|
|
├── file (currículo) — input file, accept=".pdf", max 2MB (validação frontend only)
|
|
│ ao selecionar: setFileName(file.name), não envia binário
|
|
└── submit button "Enviar Candidatura"
|
|
```
|
|
|
|
**Gerenciamento de estado** (hooks locais, sem Redux/Context):
|
|
- `formData` — estado do formulário
|
|
- `fileName` — nome do arquivo selecionado (string | null)
|
|
- `errors` — Record<string, string> para mensagens por campo
|
|
- `submitting` — boolean, desabilita botão durante requisição
|
|
- `submitted` — boolean, exibe mensagem de sucesso e reseta form
|
|
- `serverError` — string | null, erro de rede/500
|
|
|
|
**Validação frontend** (antes de chamar `submitApplication`):
|
|
- `name`: obrigatório, trim
|
|
- `email`: obrigatório, regex RFC simples
|
|
- `role_interest`: obrigatório, não pode ser valor vazio/placeholder
|
|
- `message`: obrigatório, max 5000 chars
|
|
- `file`: se presente, extensão `.pdf` e tamanho ≤ 2 MB; apenas registra `file_name`
|
|
|
|
**Fluxo de submit**:
|
|
1. Validar campos → exibir erros por campo se inválido
|
|
2. `setSubmitting(true)`
|
|
3. `submitApplication({ name, email, phone, role_interest, message, file_name: fileName ?? undefined })`
|
|
4. Sucesso → `setSubmitted(true)`, resetar `formData`, `setFileName(null)`
|
|
5. Erro 422 → parsear `details` e mapear para `errors` por campo
|
|
6. Erro ≥ 500 ou rede → `setServerError("Erro ao enviar candidatura. Tente novamente.")`
|
|
7. `finally` → `setSubmitting(false)`
|
|
|
|
**Design tokens** (seguir padrão do projeto):
|
|
- Inputs: `w-full bg-panel border border-borderSubtle rounded-lg px-4 py-2.5 text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/50`
|
|
- Labels: `text-sm font-medium text-textSecondary mb-1.5`
|
|
- Erros: `text-xs text-red-400 mt-1`
|
|
- Botão submit: `w-full bg-[#5e6ad2] hover:bg-[#4f5bbf] text-white font-medium py-2.5 rounded-lg transition-colors duration-150 disabled:opacity-60`
|
|
- Mensagem de sucesso: `bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-6 text-center`
|
|
|
|
### Modificação: `frontend/src/App.tsx`
|
|
|
|
Adicionar a rota da nova página:
|
|
```tsx
|
|
import JobsPage from './pages/JobsPage'
|
|
// ...
|
|
<Route path="/trabalhe-conosco" element={<JobsPage />} />
|
|
```
|
|
|
|
### Modificação: `frontend/src/components/Footer.tsx`
|
|
|
|
Adicionar link na coluna "A Imobiliária" (após "Política de Privacidade"):
|
|
```tsx
|
|
<FooterLink to="/trabalhe-conosco">Trabalhe Conosco</FooterLink>
|
|
```
|
|
|
|
### Modificação: `frontend/src/pages/AgentsPage.tsx`
|
|
|
|
Adicionar banner/botão após o grid de corretores e antes do `<Footer />`:
|
|
|
|
```tsx
|
|
{/* CTA Trabalhe Conosco */}
|
|
<div className="max-w-[1200px] mx-auto px-6 pb-20">
|
|
<div className="bg-panel border border-borderSubtle rounded-2xl p-8 flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-textPrimary">Quer fazer parte do time?</h2>
|
|
<p className="text-textSecondary text-sm mt-1">Envie sua candidatura e venha crescer conosco.</p>
|
|
</div>
|
|
<Link
|
|
to="/trabalhe-conosco"
|
|
className="shrink-0 bg-[#5e6ad2] hover:bg-[#4f5bbf] text-white font-medium px-5 py-2.5 rounded-lg transition-colors duration-150 text-sm"
|
|
>
|
|
Trabalhe Conosco
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## Sequência de Implementação
|
|
|
|
1. **Migration** — criar `i1j2k3l4m5n6_add_job_applications.py` e rodar `flask db upgrade`
|
|
2. **Model** — criar `backend/app/models/job_application.py`
|
|
3. **Schemas** — criar `backend/app/schemas/job_application.py`
|
|
4. **Routes** — criar `backend/app/routes/jobs.py`
|
|
5. **Register** — modificar `backend/app/__init__.py` (model import + blueprints)
|
|
6. **Types** — criar `frontend/src/types/jobApplication.ts`
|
|
7. **Service** — criar `frontend/src/services/jobs.ts`
|
|
8. **Page** — criar `frontend/src/pages/JobsPage.tsx`
|
|
9. **Route** — modificar `frontend/src/App.tsx`
|
|
10. **Footer** — modificar `frontend/src/components/Footer.tsx`
|
|
11. **AgentsPage** — modificar `frontend/src/pages/AgentsPage.tsx`
|
|
|
|
---
|
|
|
|
## Complexity Tracking
|
|
|
|
Sem violações de constituição — seção não aplicável.
|
|
|
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
|-----------|------------|--------------------------------------|
|
|
| — | — | — |
|