sass-imobiliaria/specs/028-trabalhe-conosco/plan.md
MatheusAlves96 cf5603243c
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s
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)
2026-04-22 22:35:17 -03:00

14 KiB

Implementation Plan: Trabalhe Conosco

Branch: 028-trabalhe-conosco | Date: 2026-04-21 | Spec: 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)

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)

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 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:

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:
    {
      "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:

# 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.


Frontend: Arquitetura Técnica

Types: frontend/src/types/jobApplication.ts

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

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. finallysetSubmitting(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:

import JobsPage from './pages/JobsPage'
// ...
<Route path="/trabalhe-conosco" element={<JobsPage />} />

Adicionar link na coluna "A Imobiliária" (após "Política de Privacidade"):

<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 />:

{/* 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