feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- 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)
This commit is contained in:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
36
specs/028-trabalhe-conosco/checklists/requirements.md
Normal file
36
specs/028-trabalhe-conosco/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Specification Quality Checklist: Página "Trabalhe Conosco"
|
||||
|
||||
**Purpose**: Validar completude e qualidade da especificação antes de avançar para o planejamento
|
||||
**Created**: 2026-04-21
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Upload real de arquivo está explicitamente fora do escopo (Assumption documentada)
|
||||
- Listagem no painel admin cobre apenas a API; UI React de `/admin/jobs` pode ser entregue em iteração futura
|
||||
- Spec pronta para `/speckit.plan`
|
||||
210
specs/028-trabalhe-conosco/contracts/jobs-api.md
Normal file
210
specs/028-trabalhe-conosco/contracts/jobs-api.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# API Contracts: Trabalhe Conosco
|
||||
|
||||
**Feature**: 028-trabalhe-conosco
|
||||
**Phase**: 1 — Design & Contracts
|
||||
**Base URL**: `/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Método | Path | Auth | Descrição |
|
||||
|--------|-------------------------|--------------|------------------------------------------|
|
||||
| POST | `/jobs/apply` | Nenhuma | Submeter candidatura (público) |
|
||||
| GET | `/admin/jobs` | `@require_admin` (JWT) | Listar candidaturas paginadas (admin) |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/jobs/apply
|
||||
|
||||
Endpoint público. Recebe os dados textuais da candidatura e persiste na tabela `job_applications`.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers**
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body** (JSON)
|
||||
|
||||
| Campo | Tipo | Obrigatório | Validações |
|
||||
|-----------------|----------|-------------|-------------------------------------------------------------------------|
|
||||
| `name` | string | Sim | Não pode ser vazio ou apenas espaços; strip aplicado |
|
||||
| `email` | string | Sim | Formato de e-mail válido (RFC-5321 via `pydantic.EmailStr`) |
|
||||
| `phone` | string | Não | Qualquer string; sem validação de formato nesta versão |
|
||||
| `role_interest` | string | Sim | Deve ser exatamente um de: `"Corretor(a)"`, `"Assistente Administrativo"`, `"Estagiário(a)"`, `"Outro"` |
|
||||
| `message` | string | Sim | Não pode ser vazio; máximo 5000 caracteres |
|
||||
| `file_name` | string | Não | Nome do arquivo de currículo; sem conteúdo binário |
|
||||
|
||||
**Exemplo de request**
|
||||
```json
|
||||
{
|
||||
"name": "Ana Lima",
|
||||
"email": "ana.lima@email.com",
|
||||
"phone": "(11) 98765-4321",
|
||||
"role_interest": "Corretor(a)",
|
||||
"message": "Tenho 5 anos de experiência no mercado imobiliário e gostaria de integrar a equipe.",
|
||||
"file_name": "curriculo-ana-lima.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
#### 201 Created — Candidatura registrada com sucesso
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Candidatura recebida com sucesso"
|
||||
}
|
||||
```
|
||||
|
||||
#### 422 Unprocessable Entity — Dados inválidos
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos",
|
||||
"details": [
|
||||
{
|
||||
"type": "value_error",
|
||||
"loc": ["role_interest"],
|
||||
"msg": "Value error, role_interest deve ser um de: Assistente Administrativo, Corretor(a), Estagiário(a), Outro",
|
||||
"input": "Diretor",
|
||||
"url": "https://errors.pydantic.dev/..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 400 Bad Request — Body ausente ou não é JSON válido
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Dados inválidos",
|
||||
"details": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/jobs
|
||||
|
||||
Endpoint protegido. Retorna listagem paginada de todas as candidaturas em ordem decrescente de `created_at`.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers**
|
||||
```
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Query Parameters**
|
||||
|
||||
| Parâmetro | Tipo | Default | Restrições | Descrição |
|
||||
|------------|---------|---------|-----------------|------------------------|
|
||||
| `page` | integer | `1` | ≥ 1 | Número da página |
|
||||
| `per_page` | integer | `20` | 1 – 100 | Registros por página |
|
||||
|
||||
**Exemplo de request**
|
||||
```
|
||||
GET /api/v1/admin/jobs?page=1&per_page=20
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
#### 200 OK — Lista retornada com sucesso
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Ana Lima",
|
||||
"email": "ana.lima@email.com",
|
||||
"phone": "(11) 98765-4321",
|
||||
"role_interest": "Corretor(a)",
|
||||
"message": "Tenho 5 anos de experiência no mercado imobiliário...",
|
||||
"file_name": "curriculo-ana-lima.pdf",
|
||||
"status": "pending",
|
||||
"created_at": "2026-04-21T14:35:00"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Carlos Souza",
|
||||
"email": "carlos@email.com",
|
||||
"phone": null,
|
||||
"role_interest": "Estagiário(a)",
|
||||
"message": "Estudante de Administração em busca do primeiro emprego.",
|
||||
"file_name": null,
|
||||
"status": "pending",
|
||||
"created_at": "2026-04-20T09:12:00"
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"pages": 3
|
||||
}
|
||||
```
|
||||
|
||||
**Schema do item** (`JobApplicationOut`)
|
||||
|
||||
| Campo | Tipo | Nullable | Descrição |
|
||||
|-----------------|------------------|----------|----------------------------------|
|
||||
| `id` | integer | Não | Identificador único |
|
||||
| `name` | string | Não | Nome completo do candidato |
|
||||
| `email` | string | Não | E-mail do candidato |
|
||||
| `phone` | string \| null | Sim | Telefone (opcional) |
|
||||
| `role_interest` | string | Não | Cargo de interesse selecionado |
|
||||
| `message` | string | Não | Mensagem/apresentação |
|
||||
| `file_name` | string \| null | Sim | Nome do arquivo de currículo |
|
||||
| `status` | string | Não | Estado: `"pending"` (padrão) |
|
||||
| `created_at` | string (ISO 8601)| Não | Data/hora do envio |
|
||||
|
||||
**Schema de paginação**
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
|------------|---------|-------------------------------------|
|
||||
| `total` | integer | Total de candidaturas no sistema |
|
||||
| `page` | integer | Página atual |
|
||||
| `per_page` | integer | Registros retornados nesta página |
|
||||
| `pages` | integer | Total de páginas |
|
||||
|
||||
#### 200 OK — Nenhuma candidatura registrada
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"pages": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### 401 Unauthorized — Token ausente ou inválido
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Token inválido ou ausente"
|
||||
}
|
||||
```
|
||||
|
||||
#### 403 Forbidden — Usuário autenticado sem permissão de admin
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Acesso negado"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notas de Implementação
|
||||
|
||||
- O endpoint `POST /api/v1/jobs/apply` **não** possui autenticação — qualquer cliente pode submeter.
|
||||
- O endpoint `GET /api/v1/admin/jobs` usa o decorator `@require_admin` já existente no projeto, que valida o JWT e verifica a flag de administrador.
|
||||
- O campo `created_at` é serializado pelo Pydantic como ISO 8601 sem timezone (`TIMESTAMP WITHOUT TIME ZONE` no PostgreSQL).
|
||||
- `per_page` deve ser limitado a 100 no backend para evitar queries excessivamente grandes.
|
||||
- Campos ausentes no body do POST são tratados pelo Pydantic: obrigatórios geram erro 422, opcionais recebem `None`.
|
||||
215
specs/028-trabalhe-conosco/data-model.md
Normal file
215
specs/028-trabalhe-conosco/data-model.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# Data Model: Trabalhe Conosco
|
||||
|
||||
**Feature**: 028-trabalhe-conosco
|
||||
**Phase**: 1 — Design & Contracts
|
||||
**Source**: spec.md
|
||||
|
||||
---
|
||||
|
||||
## Entidade: JobApplication (Candidatura)
|
||||
|
||||
### Tabela: `job_applications`
|
||||
|
||||
| Coluna | Tipo SQL | Nullable | Default | Restrições |
|
||||
|------------------|---------------------------------|----------|------------------|--------------------------------------------|
|
||||
| `id` | `SERIAL` (INTEGER PK) | NOT NULL | auto-increment | PRIMARY KEY |
|
||||
| `name` | `VARCHAR(150)` | NOT NULL | — | campo obrigatório |
|
||||
| `email` | `VARCHAR(254)` | NOT NULL | — | formato e-mail válido (validado no backend)|
|
||||
| `phone` | `VARCHAR(30)` | NULL | — | opcional conforme spec |
|
||||
| `role_interest` | `VARCHAR(100)` | NOT NULL | — | enum: Corretor(a), Assistente Administrativo, Estagiário(a), Outro |
|
||||
| `message` | `TEXT` | NOT NULL | — | apresentação/mensagem do candidato |
|
||||
| `file_name` | `VARCHAR(255)` | NULL | — | nome do arquivo de currículo (sem upload) |
|
||||
| `status` | `VARCHAR(50)` | NOT NULL | `'pending'` | estado da candidatura (pending / reviewed) |
|
||||
| `created_at` | `TIMESTAMP WITHOUT TIME ZONE` | NOT NULL | `now()` (server) | imutável após criação |
|
||||
|
||||
### Índices
|
||||
|
||||
| Índice | Colunas | Motivo |
|
||||
|------------------------------------|-----------------|------------------------------------------------|
|
||||
| `ix_job_applications_created_at` | `created_at` | ordenação DESC na listagem admin |
|
||||
| `ix_job_applications_status` | `status` | filtragem futura por estado |
|
||||
|
||||
### Invariantes
|
||||
|
||||
1. `name`, `email`, `role_interest` e `message` nunca são deixados em branco (validação Pydantic).
|
||||
2. `email` deve ser validado com `pydantic.EmailStr` — formato RFC-5321.
|
||||
3. `role_interest` deve ser um dos valores permitidos: `"Corretor(a)"`, `"Assistente Administrativo"`, `"Estagiário(a)"`, `"Outro"`.
|
||||
4. `message` não pode ultrapassar 5000 caracteres (validação frontend + Pydantic `max_length`).
|
||||
5. `phone` é opcional — sem validação de formato nesta versão.
|
||||
6. `file_name` armazena apenas o nome do arquivo informado, sem conteúdo binário.
|
||||
7. Múltiplas candidaturas do mesmo `email` são permitidas (sem deduplicação nesta versão).
|
||||
8. Nenhum `DELETE` físico é exposto; o campo `status` permite rastreabilidade futura.
|
||||
|
||||
### Diagrama ER
|
||||
|
||||
```
|
||||
job_applications
|
||||
├── id PK SERIAL
|
||||
├── name VARCHAR(150) NOT NULL
|
||||
├── email VARCHAR(254) NOT NULL
|
||||
├── phone VARCHAR(30) NULL
|
||||
├── role_interest VARCHAR(100) NOT NULL
|
||||
├── message TEXT NOT NULL
|
||||
├── file_name VARCHAR(255) NULL
|
||||
├── status VARCHAR(50) NOT NULL DEFAULT 'pending'
|
||||
└── created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
Sem relacionamentos com outras tabelas nesta versão. Entidade standalone.
|
||||
|
||||
---
|
||||
|
||||
## Modelo SQLAlchemy: `backend/app/models/job_application.py`
|
||||
|
||||
```python
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplication(db.Model):
|
||||
__tablename__ = "job_applications"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
role_interest = db.Column(db.String(100), nullable=False)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
file_name = db.Column(db.String(255), nullable=True)
|
||||
status = db.Column(db.String(50), nullable=False, default="pending")
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, server_default=db.func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<JobApplication id={self.id} email={self.email!r}>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Alembic: `backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py`
|
||||
|
||||
```python
|
||||
"""add job_applications table
|
||||
|
||||
Revision ID: i1j2k3l4m5n6
|
||||
Revises: h1i2j3k4l5m6
|
||||
Create Date: 2026-04-21 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "i1j2k3l4m5n6"
|
||||
down_revision = "h1i2j3k4l5m6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"job_applications",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(length=150), nullable=False),
|
||||
sa.Column("email", sa.String(length=254), nullable=False),
|
||||
sa.Column("phone", sa.String(length=30), nullable=True),
|
||||
sa.Column("role_interest", sa.String(length=100), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("file_name", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_job_applications_created_at", "job_applications", ["created_at"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_job_applications_status", "job_applications", ["status"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_job_applications_status", table_name="job_applications")
|
||||
op.drop_index("ix_job_applications_created_at", table_name="job_applications")
|
||||
op.drop_table("job_applications")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schemas Pydantic: `backend/app/schemas/job_application.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||
|
||||
VALID_ROLES = {"Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"}
|
||||
|
||||
|
||||
class JobApplicationIn(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
phone: str | None = None
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("name não pode ser vazio")
|
||||
return v
|
||||
|
||||
@field_validator("role_interest")
|
||||
@classmethod
|
||||
def role_must_be_valid(cls, v: str) -> str:
|
||||
if v not in VALID_ROLES:
|
||||
raise ValueError(f"role_interest deve ser um de: {', '.join(sorted(VALID_ROLES))}")
|
||||
return v
|
||||
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def message_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("message não pode ser vazia")
|
||||
if len(v) > 5000:
|
||||
raise ValueError("message não pode ultrapassar 5000 caracteres")
|
||||
return v
|
||||
|
||||
|
||||
class JobApplicationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: str | None
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
```
|
||||
354
specs/028-trabalhe-conosco/plan.md
Normal file
354
specs/028-trabalhe-conosco/plan.md
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
# 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 |
|
||||
|-----------|------------|--------------------------------------|
|
||||
| — | — | — |
|
||||
150
specs/028-trabalhe-conosco/spec.md
Normal file
150
specs/028-trabalhe-conosco/spec.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Feature Specification: Página "Trabalhe Conosco"
|
||||
|
||||
**Feature Branch**: `028-trabalhe-conosco`
|
||||
**Created**: 2026-04-21
|
||||
**Status**: Draft
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
O site imobiliário atualmente não oferece um canal formal para que candidatos manifestem interesse em trabalhar na empresa. Esse contato ocorre de maneira informal — por telefone, e-mail avulso ou presencialmente — sem rastreabilidade e sem uma experiência consistente para o candidato.
|
||||
|
||||
Esta spec cobre a criação de uma página pública "/trabalhe-conosco" com formulário de candidatura, armazenamento das submissões em banco de dados e listagem das candidaturas no painel administrativo. A página também deve ser acessível via links no footer e na página de equipe, tornando o recrutamento um ponto de contato organizado e profissional.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 — Candidato Envia Formulário de Candidatura (Priority: P1)
|
||||
|
||||
Um candidato interessado em trabalhar na imobiliária acessa a página "/trabalhe-conosco", preenche o formulário com seus dados e envia sua candidatura.
|
||||
|
||||
**Why this priority**: É o núcleo da feature. Toda a proposta de valor gira em torno dessa ação — sem ela, a página é apenas um conteúdo estático sem utilidade.
|
||||
|
||||
**Independent Test**: Acessar `/trabalhe-conosco` sem autenticação, preencher todos os campos obrigatórios (nome, e-mail, telefone, cargo de interesse, mensagem) e submeter. Verificar que uma mensagem de sucesso é exibida e que a candidatura aparece na listagem do painel admin em `GET /api/v1/admin/jobs`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um visitante não autenticado na página `/trabalhe-conosco`, **When** ele preenche todos os campos obrigatórios e clica em "Enviar Candidatura", **Then** a candidatura é registrada no sistema e uma mensagem de confirmação é exibida ao candidato.
|
||||
2. **Given** o formulário preenchido corretamente, **When** o campo de cargo de interesse é "Corretor(a)", **Then** o valor enviado e armazenado reflete exatamente a opção selecionada.
|
||||
3. **Given** o formulário preenchido corretamente com um arquivo PDF informado, **When** o candidato submete, **Then** o nome do arquivo é registrado junto com a candidatura, mesmo que o conteúdo do arquivo não seja armazenado nesta versão.
|
||||
4. **Given** o formulário submetido com sucesso, **When** o candidato tenta submeter novamente sem recarregar a página, **Then** o formulário é limpo/resetado após o sucesso, prevenindo envios duplicados acidentais.
|
||||
5. **Given** falha de rede durante o envio, **When** a requisição não é completada, **Then** uma mensagem de erro informativa é exibida e o candidato pode tentar novamente sem perder os dados preenchidos.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Visitante Descobre a Oportunidade Pelo Site (Priority: P1)
|
||||
|
||||
Um visitante que navega pelo footer ou pela página de equipe (/corretores) encontra o link "Trabalhe Conosco" e acessa a página de candidatura.
|
||||
|
||||
**Why this priority**: Sem pontos de entrada adequados, a página não é encontrada organicamente dentro do site, tornando o canal de recrutamento inacessível na prática.
|
||||
|
||||
**Independent Test**: Acessar o footer do site e verificar a presença do link "Trabalhe Conosco" na coluna "A Imobiliária". Acessar `/corretores` e verificar a presença do link/botão "Trabalhe Conosco". Clicar em cada link e confirmar que navega para `/trabalhe-conosco`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um visitante em qualquer página do site, **When** ele visualiza o footer, **Then** o link "Trabalhe Conosco" está visível na coluna "A Imobiliária".
|
||||
2. **Given** um visitante na página `/corretores`, **When** ele visualiza a página de equipe, **Then** existe um elemento (link ou botão) com o texto "Trabalhe Conosco" que leva a `/trabalhe-conosco`.
|
||||
3. **Given** um visitante clicando no link "Trabalhe Conosco" a partir do footer, **When** a navegação ocorre, **Then** ele é direcionado para `/trabalhe-conosco` com a página completa carregada.
|
||||
4. **Given** um visitante em dispositivo móvel, **When** ele visualiza o footer ou a página `/corretores`, **Then** o link "Trabalhe Conosco" é igualmente acessível e funcional.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Administrador Visualiza as Candidaturas Recebidas (Priority: P2)
|
||||
|
||||
O administrador da imobiliária acessa o painel admin e visualiza uma listagem paginada de todas as candidaturas enviadas pelos candidatos.
|
||||
|
||||
**Why this priority**: Sem visibilidade das candidaturas, o canal de recrutamento não entrega valor operacional. A listagem é o produto final que o administrador consume para iniciar o processo seletivo.
|
||||
|
||||
**Independent Test**: Com candidaturas já enviadas via formulário público, autenticar como administrador e consultar `GET /api/v1/admin/jobs`. Verificar que a resposta inclui os dados dos candidatos (nome, e-mail, cargo, data de envio) com paginação funcional.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um administrador autenticado, **When** ele acessa o endpoint de listagem de candidaturas, **Then** a resposta inclui uma lista paginada com nome, e-mail, telefone, cargo de interesse, data de envio e nome do arquivo informado para cada candidatura.
|
||||
2. **Given** mais de 20 candidaturas no sistema, **When** o administrador consulta a segunda página, **Then** os resultados são diferentes da primeira página e o total de candidaturas é informado na resposta.
|
||||
3. **Given** um usuário não autenticado tentando acessar o endpoint de listagem, **When** a requisição é enviada, **Then** o sistema retorna erro de acesso não autorizado (HTTP 401).
|
||||
4. **Given** um token de usuário comum (não administrador), **When** ele tenta acessar o endpoint de listagem, **Then** o sistema retorna erro de permissão insuficiente (HTTP 403).
|
||||
5. **Given** nenhuma candidatura registrada, **When** o administrador consulta a listagem, **Then** o sistema retorna uma lista vazia com o total zerado, sem erro.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Candidato Vê a Página com Conteúdo Institucional (Priority: P3)
|
||||
|
||||
Um candidato acessa `/trabalhe-conosco` e, além do formulário, encontra uma apresentação institucional da imobiliária como empregadora, com destaque para benefícios de trabalhar na empresa.
|
||||
|
||||
**Why this priority**: Enriquece a experiência do candidato e posiciona a imobiliária como empregadora, mas não bloqueia o funcionamento do recrutamento em si.
|
||||
|
||||
**Independent Test**: Acessar `/trabalhe-conosco` e verificar que a página contém: uma hero section com título e subtítulo, uma seção "Por que trabalhar conosco?" com exatamente 3 cards de benefícios, e o formulário de candidatura.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** qualquer visitante acessando `/trabalhe-conosco`, **When** a página carrega, **Then** uma hero section com título principal e subtítulo descritivo é exibida no topo.
|
||||
2. **Given** a página carregada, **When** o visitante rola a tela, **Then** uma seção "Por que trabalhar conosco?" com 3 cards de benefícios é visível antes do formulário.
|
||||
3. **Given** a página carregada, **When** o visitante acessa em dispositivo móvel, **Then** hero section, cards de benefícios e formulário se adaptam ao layout vertical sem perda de conteúdo ou sobreposição visual.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece se o candidato enviar o formulário com um e-mail em formato inválido? A validação deve ocorrer no frontend antes do envio, e o backend deve rejeitar com HTTP 422 e mensagem descritiva.
|
||||
- O que acontece se o campo de mensagem ultrapassar o limite de caracteres? O sistema deve validar e informar o candidato antes de enviar.
|
||||
- O que acontece se o candidato tentar enviar o mesmo e-mail múltiplas vezes? Por padrão, múltiplas candidaturas do mesmo e-mail são permitidas (sem deduplicação nesta versão).
|
||||
- O que acontece se o candidato selecionar um arquivo que não seja PDF ou que exceda 2 MB? O frontend deve bloquear o envio e exibir mensagem de erro clara. Nesta versão, apenas o nome do arquivo é registrado — não há upload real de arquivo.
|
||||
- O que acontece se o backend retornar erro 500 durante o envio? O frontend deve exibir mensagem genérica de erro sem expor detalhes técnicos.
|
||||
- Como o endpoint público `POST /api/v1/jobs/apply` se comporta em caso de sobrecarga? Por padrão, o endpoint não possui rate limiting nesta versão — isso pode ser adicionado futuramente.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE disponibilizar a rota pública `/trabalhe-conosco` no frontend, acessível sem autenticação.
|
||||
- **FR-002**: A página DEVE conter uma hero section com título e subtítulo configurados estaticamente.
|
||||
- **FR-003**: A página DEVE conter uma seção "Por que trabalhar conosco?" com 3 cards de benefícios (conteúdo estático).
|
||||
- **FR-004**: A página DEVE conter um formulário de candidatura com os campos: nome completo, e-mail, telefone, cargo de interesse (select), mensagem/apresentação e seleção de arquivo de currículo.
|
||||
- **FR-005**: O campo de cargo de interesse DEVE oferecer as opções: Corretor(a), Assistente Administrativo, Estagiário(a), Outro.
|
||||
- **FR-006**: O formulário DEVE validar campos obrigatórios (nome, e-mail, cargo, mensagem) antes do envio, exibindo mensagens de erro por campo.
|
||||
- **FR-007**: O campo de e-mail DEVE validar formato de e-mail válido no frontend antes do envio.
|
||||
- **FR-008**: O campo de arquivo DEVE aceitar apenas arquivos PDF e rejeitar arquivos acima de 2 MB, com mensagem de erro clara — a validação ocorre no frontend; nesta versão, apenas o nome do arquivo é enviado ao backend.
|
||||
- **FR-009**: Após envio bem-sucedido, o formulário DEVE exibir uma mensagem de confirmação ao candidato e limpar os campos.
|
||||
- **FR-010**: O sistema DEVE disponibilizar o endpoint público `POST /api/v1/jobs/apply` que receba e persista os dados textuais da candidatura (sem autenticação).
|
||||
- **FR-011**: O backend DEVE validar os dados recebidos no endpoint de candidatura e retornar HTTP 422 com detalhes para dados inválidos.
|
||||
- **FR-012**: O sistema DEVE armazenar as candidaturas em uma tabela `job_applications` com os campos: nome completo, e-mail, telefone, cargo de interesse, mensagem, nome do arquivo informado e data/hora do envio.
|
||||
- **FR-013**: O sistema DEVE disponibilizar o endpoint protegido `GET /api/v1/admin/jobs` que retorne uma listagem paginada das candidaturas, acessível apenas por administradores autenticados.
|
||||
- **FR-014**: O endpoint de listagem DEVE retornar para cada candidatura: nome, e-mail, telefone, cargo de interesse, mensagem, nome do arquivo e data de envio.
|
||||
- **FR-015**: O endpoint de listagem DEVE suportar paginação com parâmetros `page` e `per_page`, retornando o total de registros.
|
||||
- **FR-016**: O footer do site DEVE conter o link "Trabalhe Conosco" na coluna "A Imobiliária", navegando para `/trabalhe-conosco`.
|
||||
- **FR-017**: A página `/corretores` DEVE conter um link ou botão "Trabalhe Conosco" navegando para `/trabalhe-conosco`.
|
||||
- **FR-018**: O design da página DEVE seguir os design tokens existentes do projeto (cores, tipografia Inter, estilo de cards limpos).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **JobApplication**: Registro de candidatura enviada por um candidato. Atributos principais: identificador único, nome completo do candidato, e-mail, telefone, cargo de interesse selecionado, texto de apresentação/mensagem, nome do arquivo de currículo informado (opcional), data e hora do envio. Sem relacionamento com outras entidades nesta versão.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Um candidato consegue localizar e acessar a página "Trabalhe Conosco" a partir do footer ou da página de equipe em no máximo 2 cliques.
|
||||
- **SC-002**: Um candidato consegue preencher e enviar o formulário de candidatura completo em menos de 3 minutos.
|
||||
- **SC-003**: 100% das candidaturas enviadas com dados válidos são armazenadas e recuperáveis pelo administrador via painel admin.
|
||||
- **SC-004**: Tentativas de acesso não autorizado ao endpoint de listagem de candidaturas são bloqueadas em 100% dos casos.
|
||||
- **SC-005**: O formulário exibe mensagem de erro específica para cada campo inválido sem necessidade de recarregar a página.
|
||||
- **SC-006**: A página carrega e exibe todo o conteúdo estático (hero, benefícios, formulário) em menos de 2 segundos em conexões de banda larga padrão.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- O upload real do arquivo de currículo (armazenamento binário no servidor ou serviço de storage) está fora do escopo desta versão; apenas o nome do arquivo informado pelo candidato é salvo como texto.
|
||||
- Não há deduplicação de candidaturas por e-mail nesta versão — múltiplas submissões do mesmo endereço são permitidas.
|
||||
- Os 3 benefícios exibidos na seção "Por que trabalhar conosco?" são conteúdo estático definido em tempo de desenvolvimento; não há interface de gerenciamento para esse conteúdo.
|
||||
- Não há envio de e-mail de confirmação ao candidato nem notificação por e-mail ao administrador nesta versão.
|
||||
- O endpoint público de candidatura não possui rate limiting nesta versão.
|
||||
- A listagem de candidaturas no painel admin é acessível via API; a interface visual no painel admin (página React de `/admin/jobs`) pode ser entregue em iteração futura, não sendo requisito desta spec.
|
||||
- O padrão de autenticação de administrador já implementado no projeto (`require_admin`) é suficiente e será reutilizado para proteger o endpoint de listagem.
|
||||
- O campo de telefone é opcional para o candidato, mas recomendado — a validação de formato não é obrigatória nesta versão.
|
||||
217
specs/028-trabalhe-conosco/tasks.md
Normal file
217
specs/028-trabalhe-conosco/tasks.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
---
|
||||
description: "Task list para a feature 028 - Trabalhe Conosco"
|
||||
---
|
||||
|
||||
# Tasks: Trabalhe Conosco (028)
|
||||
|
||||
**Input**: Design documents de `specs/028-trabalhe-conosco/`
|
||||
**Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/jobs-api.md ✅
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description — arquivo`
|
||||
|
||||
- **[P]**: Pode executar em paralelo (arquivo diferente, sem bloqueadores incompletos)
|
||||
- **[Story]**: User story correspondente (US1, US2, US3, US4)
|
||||
- Arquivo exato indicado em cada task
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational — Backend (Bloqueador de tudo)
|
||||
|
||||
**Purpose**: Migration, model, schemas e rotas Flask precisam existir antes que qualquer integração frontend possa ser testada contra o servidor real.
|
||||
|
||||
**⚠️ CRÍTICO**: Nenhuma fase de user story pode começar até esta fase estar completa.
|
||||
|
||||
- [ ] T001 Criar migration Alembic `i1j2k3l4m5n6` em `backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py` com `down_revision = "h1i2j3k4l5m6"` — implementar `upgrade()` criando a tabela `job_applications` (9 colunas conforme data-model.md: id SERIAL PK, name VARCHAR(150) NOT NULL, email VARCHAR(254) NOT NULL, phone VARCHAR(30) NULL, role_interest VARCHAR(100) NOT NULL, message TEXT NOT NULL, file_name VARCHAR(255) NULL, status VARCHAR(50) NOT NULL server_default `'pending'`, created_at TIMESTAMP NOT NULL server_default `now()`) + 2 índices (`ix_job_applications_created_at` em `created_at`, `ix_job_applications_status` em `status`); `downgrade()` remove índices e tabela na ordem inversa
|
||||
|
||||
- [ ] T002 Criar modelo SQLAlchemy `JobApplication` em `backend/app/models/job_application.py` — classe com `__tablename__ = "job_applications"`, 9 colunas mapeando o schema da tabela (status com `default="pending"`, created_at com `server_default=db.func.now()`), constante `ROLE_INTEREST_OPTIONS = ["Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"]` e `__repr__` com id + email
|
||||
|
||||
- [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/job.py` — definir 3 classes:
|
||||
- `JobApplicationIn(BaseModel)`: campos `name: str` (strip, não vazio), `email: EmailStr`, `phone: str | None = None`, `role_interest: str` (validado contra `ROLE_INTEREST_OPTIONS` via `@field_validator`), `message: str` (max_length=5000, não vazio), `file_name: str | None = None`
|
||||
- `JobApplicationOut(BaseModel)`: todos os campos de `JobApplicationIn` + `id: int`, `status: str`, `created_at: datetime`; `model_config = ConfigDict(from_attributes=True)`
|
||||
- `PaginatedJobApplications(BaseModel)`: `items: list[JobApplicationOut]`, `total: int`, `page: int`, `per_page: int`, `pages: int`
|
||||
|
||||
- [ ] T004 Criar rotas em `backend/app/routes/jobs.py` com dois blueprints:
|
||||
- `jobs_public_bp = Blueprint("jobs_public", __name__)`: endpoint `POST /jobs/apply` público — valida body via `JobApplicationIn` (retorna 422 com `{"error": "Dados inválidos", "details": ...}` em ValidationError), cria e salva `JobApplication` via `db.session`, retorna `{"message": "Candidatura recebida com sucesso"}` com status 201
|
||||
- `jobs_admin_bp = Blueprint("jobs_admin", __name__)`: endpoint `GET /jobs` decorado com `@require_admin` — lê query params `page` (default 1, ≥ 1) e `per_page` (default 20, clamp 1–100), consulta `JobApplication.query.order_by(JobApplication.created_at.desc()).paginate(...)`, serializa via `PaginatedJobApplications` e retorna JSON 200
|
||||
|
||||
- [ ] T005 Registrar model e blueprints em `backend/app/__init__.py`:
|
||||
- Na seção de imports de models, adicionar `from app.models import job_application as _job_application_models`
|
||||
- Registrar `jobs_public_bp` com `url_prefix="/api/v1"` e `jobs_admin_bp` com `url_prefix="/api/v1/admin"` na função `create_app()`
|
||||
|
||||
- [ ] T006 Aplicar migration no container e verificar schema: `docker-compose exec backend flask db upgrade` → confirmar tabela com `docker-compose exec db psql -U postgres -d saas_imobiliaria -c "\d job_applications"`
|
||||
|
||||
**Checkpoint**: `curl -X POST http://localhost:5000/api/v1/jobs/apply` com body válido retorna 201. `GET /api/v1/admin/jobs` sem token retorna 401.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Candidato Envia Formulário (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Página `/trabalhe-conosco` com formulário funcional que submete via `POST /api/v1/jobs/apply`, exibe confirmação de sucesso e mantém dados em caso de erro de rede.
|
||||
|
||||
**Independent Test**: Acessar `/trabalhe-conosco` sem autenticação, preencher todos os campos obrigatórios (nome, e-mail, cargo de interesse, mensagem) e clicar em "Enviar Candidatura". Verificar que uma mensagem de confirmação é exibida e que `GET /api/v1/admin/jobs` (com token admin) lista a candidatura recebida.
|
||||
|
||||
- [ ] T007 [P] [US1] Criar interface TypeScript em `frontend/src/types/jobApplication.ts`:
|
||||
```typescript
|
||||
export interface JobApplicationPayload {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role_interest: string;
|
||||
message: string;
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
export const ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
] as const;
|
||||
```
|
||||
|
||||
- [ ] T008 [P] [US1] Criar `frontend/src/services/jobsService.ts` com função `submitApplication(data: JobApplicationPayload): Promise<void>` — chama `api.post("/api/v1/jobs/apply", data)` via instância Axios do projeto e relança o erro para tratamento no componente
|
||||
|
||||
- [ ] T009 [US1] Criar `frontend/src/pages/JobsPage.tsx` com formulário de candidatura:
|
||||
- Campos controlados com `useState`: `name`, `email`, `phone`, `role_interest` (select com `ROLE_INTEREST_OPTIONS`), `message` (textarea, contador de caracteres até 5000), `file_name` (input file decorativo — apenas registra `e.target.files?.[0]?.name`)
|
||||
- Validação frontend antes do submit: e-mail formato válido, campos obrigatórios não vazios, message ≤ 5000 chars, arquivo (se presente) deve ser PDF e ≤ 2 MB
|
||||
- Estado `submitting: boolean` para desabilitar o botão durante o envio
|
||||
- Submit: chama `submitApplication()`, em sucesso exibe mensagem de confirmação e limpa o formulário; em erro de rede exibe mensagem genérica sem apagar os dados preenchidos
|
||||
- Estilo: dark theme do projeto (`bg-panel`, `border-borderSubtle`, accent `#5e6ad2`, tipografia Inter, Tailwind CSS)
|
||||
|
||||
- [ ] T010 [US1] Adicionar rota `/trabalhe-conosco` em `frontend/src/App.tsx`: importar `JobsPage` e inserir `<Route path="/trabalhe-conosco" element={<JobsPage />} />` entre as rotas públicas
|
||||
|
||||
**Checkpoint**: Formulário em `/trabalhe-conosco` envia candidatura, recebe 201 e exibe confirmação. Erro de rede exibe mensagem sem apagar campos.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Visitante Descobre a Oportunidade (Priority: P1)
|
||||
|
||||
**Goal**: Links "Trabalhe Conosco" no footer (coluna "A Imobiliária") e na página `/corretores` tornam a página de candidatura descobrível organicamente.
|
||||
|
||||
**Independent Test**: Acessar qualquer página e verificar que o footer contém o link "Trabalhe Conosco" na coluna "A Imobiliária". Acessar `/corretores` e verificar que existe elemento com texto "Trabalhe Conosco" que navega para `/trabalhe-conosco`.
|
||||
|
||||
- [ ] T011 [P] [US2] Adicionar link "Trabalhe Conosco" em `frontend/src/components/Footer.tsx` — localizar a coluna "A Imobiliária" e inserir `<Link to="/trabalhe-conosco">Trabalhe Conosco</Link>` seguindo o mesmo padrão visual dos demais links da coluna
|
||||
|
||||
- [ ] T012 [P] [US2] Adicionar link/botão "Trabalhe Conosco" em `frontend/src/pages/AgentsPage.tsx` — inserir elemento (link `<Link>` ou botão secundário) com texto "Trabalhe Conosco" e `href`/`to="/trabalhe-conosco"` em posição visível na página (ex.: ao final da seção de equipe ou como chamada à ação após o grid de corretores)
|
||||
|
||||
**Checkpoint**: Footer exibe link em todas as páginas. `/corretores` exibe elemento que navega para `/trabalhe-conosco`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Administrador Visualiza Candidaturas (Priority: P2)
|
||||
|
||||
**Goal**: Endpoint `GET /api/v1/admin/jobs` (implementado na Phase 1) retorna listagem paginada e corretamente serializada. Serviço Axios disponível para consumo futuro no painel admin.
|
||||
|
||||
**Independent Test**: Autenticar como admin e consultar `GET /api/v1/admin/jobs?page=1&per_page=20`. Verificar: resposta 200 com campos `items`, `total`, `page`, `per_page`, `pages`; cada item contém id, name, email, phone, role_interest, message, file_name, status, created_at. Sem token: 401. Token não-admin: 403.
|
||||
|
||||
- [ ] T013 [US3] Adicionar função `listApplications(page?: number, perPage?: number): Promise<PaginatedJobApplications>` em `frontend/src/services/jobsService.ts` — chama `api.get("/api/v1/admin/jobs", { params: { page, per_page: perPage } })` com header Authorization via instância autenticada do Axios; adicionar tipo `PaginatedJobApplications` em `frontend/src/types/jobApplication.ts` espelhando o schema do contrato (`items: JobApplicationItem[]`, `total`, `page`, `per_page`, `pages`)
|
||||
|
||||
**Checkpoint**: `listApplications()` pode ser chamado do console do browser (após login admin) e retorna dados paginados com a estrutura correta.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 — Conteúdo Institucional (Priority: P3)
|
||||
|
||||
**Goal**: Página `/trabalhe-conosco` enriquecida com hero section e seção "Por que trabalhar conosco?" com 3 cards de benefícios, posicionados acima do formulário.
|
||||
|
||||
**Independent Test**: Acessar `/trabalhe-conosco` e verificar: hero section com título principal e subtítulo no topo; seção "Por que trabalhar conosco?" com exatamente 3 cards de benefícios antes do formulário; layout responsivo sem sobreposição em mobile.
|
||||
|
||||
- [ ] T014 [US4] Adicionar hero section no topo de `frontend/src/pages/JobsPage.tsx` — bloco com título principal (ex.: "Faça parte da nossa equipe") e subtítulo descritivo; seguir design tokens dark (`text-primary`, `text-secondary`, fundo com gradiente sutil ou `bg-surface`); posicionar acima dos cards de benefícios e do formulário
|
||||
|
||||
- [ ] T015 [US4] Adicionar seção "Por que trabalhar conosco?" em `frontend/src/pages/JobsPage.tsx` com 3 cards de benefícios estáticos — cada card tem ícone SVG, título e descrição; layout em grid responsivo (`grid-cols-1 md:grid-cols-3`); estilo `bg-panel border border-borderSubtle rounded-xl`; posicionar entre o hero e o formulário de candidatura. Sugestão de conteúdo dos cards: "Crescimento Profissional" / "Ambiente Colaborativo" / "Comissões Competitivas"
|
||||
|
||||
**Checkpoint**: `/trabalhe-conosco` exibe hero → 3 benefit cards → formulário nessa ordem. Em mobile (375 px) os cards empilham verticalmente sem overflow horizontal.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Verificação Final
|
||||
|
||||
- [ ] T016 Executar verificação end-to-end manualmente:
|
||||
1. `GET /api/v1/admin/jobs` sem token → 401
|
||||
2. `POST /api/v1/jobs/apply` com body válido → 201, candidatura registrada
|
||||
3. `POST /api/v1/jobs/apply` com e-mail inválido → 422 com `details`
|
||||
4. `GET /api/v1/admin/jobs?page=1` com token admin → 200 com a candidatura enviada
|
||||
5. Browser: `/trabalhe-conosco` renderiza hero + 3 cards + formulário
|
||||
6. Browser: footer → link "Trabalhe Conosco" → navega para `/trabalhe-conosco`
|
||||
7. Browser: `/corretores` → link "Trabalhe Conosco" → navega para `/trabalhe-conosco`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependências entre fases
|
||||
|
||||
```
|
||||
Phase 1 (Foundational Backend)
|
||||
│
|
||||
├──→ Phase 2 (US1 — Formulário) ──→ Phase 4 (US3 — Admin service)
|
||||
│ │
|
||||
│ └──→ Phase 3 (US2 — Links de entrada)
|
||||
│
|
||||
└──→ Phase 5 (US4 — Conteúdo institucional, extensão da Phase 2)
|
||||
│
|
||||
└──→ Phase 6 (Polish)
|
||||
```
|
||||
|
||||
- **Phase 1**: Sem dependências — começa imediatamente
|
||||
- **Phase 2**: T007 e T008 podem começar em paralelo com Phase 1 (sem necessidade do backend para criar os arquivos TS); T009 depende de T007 + T008; T010 depende de T009
|
||||
- **Phase 3**: T011 e T012 são paralelos entre si e independentes do backend; dependem apenas de T010 (rota já existir no App.tsx)
|
||||
- **Phase 4**: T013 depende de T007/T008 (padrão do serviço) e do endpoint já implementado em T004
|
||||
- **Phase 5**: T014 e T015 são modificações em JobsPage.tsx criado em T009 — devem ser feitas sequencialmente em relação a T009
|
||||
- **Phase 6**: Depende de todas as fases anteriores
|
||||
|
||||
### Dependências por task
|
||||
|
||||
| Task | Depende de | Pode ir em paralelo com |
|
||||
|------|----------------|------------------------|
|
||||
| T001 | — | T003 |
|
||||
| T002 | T001 | T003 |
|
||||
| T003 | — | T001, T002 |
|
||||
| T004 | T002, T003 | — |
|
||||
| T005 | T002, T004 | — |
|
||||
| T006 | T005 | — |
|
||||
| T007 | — | T001–T006, T008, T011, T012 |
|
||||
| T008 | T007 | T011, T012 |
|
||||
| T009 | T007, T008 | T011, T012 |
|
||||
| T010 | T009 | T011, T012 |
|
||||
| T011 | T010 | T012 |
|
||||
| T012 | T010 | T011 |
|
||||
| T013 | T007, T008 | T011, T012 |
|
||||
| T014 | T009 | T013 |
|
||||
| T015 | T014 | T013 |
|
||||
| T016 | T006, T015 | — |
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Fluxo MVP (US1 apenas — Phase 1 + Phase 2)
|
||||
|
||||
```
|
||||
Stream A (Backend): T001 → T002 → T004 → T005 → T006
|
||||
Stream B (Schemas): T003 (paralelo a T001-T002)
|
||||
Stream C (Frontend): T007 → T008 → T009 → T010
|
||||
```
|
||||
|
||||
### Fluxo completo
|
||||
|
||||
```
|
||||
Stream A (Backend): T001 → T002 → T004 → T005 → T006
|
||||
Stream B (Schemas): T003
|
||||
Stream C (Frontend): T007 → T008 → T009 → T010 → T014 → T015
|
||||
Stream D (Links): T011 (paralelo após T010)
|
||||
Stream E (Links): T012 (paralelo após T010)
|
||||
Stream F (Admin svc): T013 (paralelo após T008)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
**MVP Scope** (Phase 1 + Phase 2): Formulário público funcional com persistência — entrega o núcleo da feature (US1 P1).
|
||||
|
||||
**Incremento 2** (Phase 3): Links de descoberta — sem novos arquivos backend, apenas modificações pontuais em Footer e AgentsPage (US2 P1).
|
||||
|
||||
**Incremento 3** (Phase 4): Serviço admin no frontend — prepara consumo da listagem (US3 P2); página admin React adiada para iteração futura.
|
||||
|
||||
**Incremento 4** (Phase 5): Conteúdo institucional (hero + cards) sobre a base já existente de JobsPage (US4 P3).
|
||||
Loading…
Add table
Add a link
Reference in a new issue