# 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 { await api.post('/api/v1/jobs/apply', payload) } export async function listApplications( page = 1, perPage = 20 ): Promise { 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" └──
├── 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 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' // ... } /> ``` ### Modificação: `frontend/src/components/Footer.tsx` Adicionar link na coluna "A Imobiliária" (após "Política de Privacidade"): ```tsx Trabalhe Conosco ``` ### Modificação: `frontend/src/pages/AgentsPage.tsx` Adicionar banner/botão após o grid de corretores e antes do `