---
id: MOD-PUBLICACOES-TRABALHECONOSCO-PORTAL
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-PUBLICACOES-TRABALHECONOSCO]
fontes:
  - V2/Modules/Publicacoes/TrabalheConosco/
depende_de: [UI-PORTAL, SVC-SEO, SVC-CONTENTRICHEDITOR, SVC-MEDIA, SVC-STORAGE]
consumidores: [UI-PORTAL, SVC-SEO, SVC-SEARCH, SVC-FEEDRSS]
criticidade: media
dono: Trabalhe
---

# Publicações/TrabalheConosco — portal

> Anexo do `spec.md`. Três páginas públicas: lista de vagas, detalhe com formulário de
> candidatura e confirmação. O visual usa o layout do portal e classes Bootstrap já
> existentes (`team-section`, `team-box-items`, `breadcrumb-wrapper`); não há CSS/JS
> próprio. **Com a flag `trabalheconosco` desligada, as três páginas retornam 404**; o
> link do rodapé continua existindo.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Mostrar vagas abertas e permitir candidatura com currículo, com confirmação explícita e SEO por página. |
| Entrada | `GET /trabalhe-conosco`, `GET /trabalhe-conosco/{slug}`, `POST /trabalhe-conosco/{slug}/apply`; dados do formulário (6 campos + arquivo). |
| Saída | HTML semântico com hero/breadcrumb, cards de vaga, detalhe com descrição rica, capa, PDF e formulário; página de sucesso. |
| Efeitos colaterais | `SEOService::trackAccess()` em toda página (composer), candidatura + arquivo + e-mail no POST, `404` para vaga fechada. |
| Particularidade central | A página de detalhe renderiza o AST do editor como HTML (`{!! !!}`) e usa **três** fontes de SEO concorrentes: `pageTitle`/`pageDescription` da view, `setTitle` do controller e `metadata['seo']` — o `setTitle` sobrescreve o metadata, e o `seoTitle` do composer vence o `pageTitle`. |
| Como se integra | Views `v2-trabalheconosco::portal.*` estendendo `v2-portal::app-layout`; componentes `v2-service-media::components.picture` e o include de e-mail não aparece no portal; helper `v2_storage_url`. |
| Se removido, quebra | Link “Trabalhe conosco” do rodapé/home, resultados de busca e itens de feed que apontam para `/trabalhe-conosco/{slug}`, links do editor rico. |
| Superfície pública | Rotas `v2.trabalhe-conosco.portal.*`; classes CSS reutilizadas; IDs `name`, `email`, `phone`, `linkedin_url`, `cover_letter`, `resume`; schema `CollectionPage`. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

- Ver a lista de vagas com título, local, tipo, carga horária, faixa salarial, resumo de
  até 200 caracteres e link “Ver Detalhes” (`portal/index.blade.php:44-97`).
- Ver o detalhe: título, metadados, descrição rica, capa e botão de PDF
  (`portal/show.blade.php:26-78`).
- Candidatar-se preenchendo nome, e-mail, telefone, LinkedIn, carta e currículo
  (`portal/show.blade.php:81-140`).
- Receber a confirmação com CTA “Ver outras vagas” (`portal/success.blade.php:16-28`).

### 1.2 O que muda no sistema

| Ação | Efeito | Fonte |
|---|---|---|
| Abrir lista | `SEOService` recebe título/descrição/keywords; `trackAccess()` roda no composer | `TrabalheConoscoController.php:39-45`; `SEOServiceProvider.php:33-57` |
| Abrir detalhe | renderização do AST, `bindModel(metadata['seo'])`, título sobrescrito | `TrabalheConoscoController.php:56-70` |
| Enviar candidatura | arquivo em `storage/app/resumes`, linha `pending`, e-mail ao suporte | `TrabalheConoscoController.php:93-107` |
| Enviar candidatura inválida | `302` back; mensagens de validação (chaves cruas) | `TrabalheConoscoController.php:84-91` |
| Acessar com flag inativa | `404` sem renderizar nenhuma view | `ModuleAccessMiddleware.php:46-62` |

### 1.3 Contrato observável (entrada → saída)

| Entrada | Saída |
|---|---|
| `GET /trabalhe-conosco` com vagas | `200`; grid de cards; `<h1>Trabalhe Conosco</h1>`. |
| `GET /trabalhe-conosco` sem vagas | `200`; “Nenhuma vaga disponível no momento” + “Volte mais tarde para conferir novas oportunidades.” |
| `GET /trabalhe-conosco/{slug}` aberta | `200`; `<h1>{título}</h1>`, descrição HTML, capa, PDF e formulário. |
| `GET /trabalhe-conosco/{slug}` fechada/inexistente | `404`. |
| `POST .../apply` válido | `200` com “Candidatura enviada com sucesso!”. |
| `POST .../apply` sem currículo | `302` back; `<div class="invalid-feedback d-block">validation.required</div>` sob o campo. |
| `POST .../apply` com currículo `.png` | `302` back; erro `validation.mimes` sob o campo. |
| `POST .../apply` sem nome/e-mail | erro sob `name`/`email`, respectivamente. |
| Flag inativa | `404` em qualquer uma das três rotas. |

### 1.4 O que estas páginas NÃO fazem

- Não paginam, filtram, buscam nem ordenam (ordem fixa `published_at desc`).
- Não exibem data de publicação/encerramento, contagem de candidaturas ou “vaga
  urgente”.
- Não mostram vagas inativas/fechadas nem mensagem específica para vaga encerrada.
- Não permitem download do PDF se a mídia não existir; não exibem fallback de capa.
- Não enviam e-mail de confirmação ao candidato; a tela de sucesso é estática.
- Não têm CSS/JS próprios, nem GTM/eventos além de `gtag.page_context`.
- Não expõem JSON-LD `JobPosting` (só `CollectionPage` na lista).

## 2. Abordagem

### 2.1 Estratégia em uma página

Leitura direta de Eloquent + Blade, com SEO aplicado *antes* do `view()`. O formulário é
HTML puro com `csrf_field()` e `enctype="multipart/form-data"`; o erro de validação é
exibido apenas para `name`, `email` e `resume`. A página de sucesso é uma view sem dados.

```mermaid
flowchart LR
  R[GET /trabalhe-conosco] --> Q1[JobOpening::open orderBy published_at desc get]
  Q1 --> V1[portal.index]
  R2[GET /{slug}] --> Q2[where slug + open firstOrFail]
  Q2 --> REN[renderer.toHtml AST]
  REN --> SEO[bindModel + setTitle]
  SEO --> V2[portal.show]
  V2 -->|POST multipart| AP[apply]
  AP --> VAL[validate 6 regras]
  VAL --> FS[store resumes local]
  FS --> INS[INSERT application pending]
  INS --> MAIL[Mail::send best-effort]
  MAIL --> V3[portal.success]
```

### 2.2 Mecânica passo a passo

1. **Lista**: `JobOpening::open()->orderBy('published_at','desc')->get()`
   (`TrabalheConoscoController.php:39`).
2. `SEOService::setTitle('Trabalhe Conosco — '.config('app.name'))`, `setDescription`,
   `setKeywords` (`:41-43`).
3. `view('v2-trabalheconosco::portal.index', compact('openings'))` (`:45`).
4. **Detalhe**: `where('slug',$slug)->open()->firstOrFail()` (`:56`).
5. Se `description_detailed` não vazio, `renderer->toHtml(...)`; exceção → log e
   `$htmlDescription=''` (`:58-65`).
6. `seo->bindModel($opening)` aplica `metadata['seo']` (`:67`); em seguida
   `seo->setTitle($opening->title.' — '.config('app.name'))` sobrescreve o título (`:68`).
7. View monta breadcrumbs microdata, cabeçalho, descrição, capa (`picture`), PDF e form
   (`portal/show.blade.php:15-140`).
8. **Candidatura**: mesmos passos do `spec.md` §2.2 (validar, gravar arquivo, inserir,
   notificar, view de sucesso).
9. **Composer de SEO** injeta `$seo`, `$seoTitle`, `$seoDescription`, `$seoKeywords`,
   `$siteTitle` e chama `trackAccess()` em qualquer view do portal
   (`SEOServiceProvider.php:30-57`).
10. **Meta tags** usam `$seoTitle ?? ($pageTitle ?? '')` (`metadata/seo.blade.php:2-6`):
    como o controller sempre define título, o `pageTitle` da view não vence.

### 2.3 Decisões de design e por quê

- **Sem controller de “página”**: as três rotas são actions do mesmo controller
  (`index`, `show`, `apply`), sem FormRequest.
- **SEO no controller, não na view** `[C]`: `setTitle/setDescription/setKeywords` são
  chamados antes de renderizar; a view também passa `pageTitle/pageDescription`, mas o
  composer prioriza `seoTitle`.
- **Descrição rica renderizada no servidor** `[C]`: `{!! $htmlDescription !!}` sem escape;
  a confiança vem do editor do CMS.
- **Candidatura responde view, não redirect** `[C]`: evita perder o estado, mas permite
  reenvio por refresh.
- **Erros parciais** `[C]`: só três campos exibem `$errors`; telefone, LinkedIn e carta
  falham silenciosamente (ainda que suas regras sejam frouxas).
- **Classes do tema** `[C]`: reaproveita `team-box-items`/`team-content` da home, o que
  mantém o visual sem CSS novo.

### 2.4 Trechos de código pivô

```php
// V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php:54-71
// — detalhe: escopo, renderização tolerante a falha e SEO em duas etapas.
$opening = JobOpening::where('slug', $slug)->open()->firstOrFail();

$htmlDescription = '';
if (!empty($opening->description_detailed)) {
    try {
        $htmlDescription = $this->renderer->toHtml($opening->description_detailed);
    } catch (\Exception $e) {
        \Log::error('[TrabalheConosco] Render error: ' . $e->getMessage());
    }
}

$this->seo->bindModel($opening);
$this->seo->setTitle($opening->title . ' — ' . config('app.name'));

return view('v2-trabalheconosco::portal.show', compact('opening', 'htmlDescription'));
```

A vaga é resolvida com o escopo `open()`, não pelo id; logo, `/trabalhe-conosco/{slug}`
de vaga inativa responde 404. Falha de render não derruba a página: a seção “Descrição da
Vaga” desaparece porque a view testa `!empty($htmlDescription)`
(`portal/show.blade.php:49-56`). `bindModel` roda **antes** de `setTitle`, então o
`metadata['seo']['title']` é ignorado, mas `description`, `keywords`, `image_url` e
`json_ld` sobrevivem (`SEOService.php:29-58`).

```php
// .../TrabalheConoscoController.php:39-45 — lista e SEO.
$openings = JobOpening::open()->orderBy('published_at', 'desc')->get();

$this->seo->setTitle('Trabalhe Conosco — ' . config('app.name'));
$this->seo->setDescription('Confira as vagas disponíveis na ' . config('app.name') . ' e candidate-se.');
$this->seo->setKeywords('vagas, emprego, trabalhe conosco, ' . config('app.name'));

return view('v2-trabalheconosco::portal.index', compact('openings'));
```

Sem paginação: `get()` carrega todas as vagas abertas. O título efetivo no `<title>` é
`Trabalhe Conosco — {app.name}`: `setTitle` (`:41-43`) preenche `$seoTitle`, e o partial
usa `$seoTitle ?? (...)`; como o operador curto-circuita, `siteTitle` **não** é
concatenado (`metadata/seo.blade.php:2`).

```blade
{{-- .../Views/portal/index.blade.php:5-26 — props do layout e schema. --}}
@extends('v2-portal::app-layout', [
    'pageTitle'             => 'Trabalhe Conosco — ' . config('app.name'),
    'pageDescription'       => 'Confira as vagas disponíveis e candidate-se.',
    'gtag'                  => ['page_context' => 'trabalhe-conosco'],
    'schemas'               => [
        [
            '@context' => 'https://schema.org',
            '@type' => 'CollectionPage',
            'name' => 'Trabalhe Conosco',
            'description' => 'Confira as vagas disponíveis e candidate-se.',
            'url' => route('v2.trabalhe-conosco.portal.index', [], false)
        ]
    ],
    'fullWidthMain'         => true,
    'showHeader'            => false,
    // ...
])
```

Os flags de layout são **herdados**: `@extends` passa `get_defined_vars()` ao pai
(`vendor/laravel/framework/src/Illuminate/View/Compilers/Concerns/CompilesLayouts.php:26`),
e o `app-layout` os repassa ao `base`; `portal-content.blade.php` consome `showHeader`
(`:21`), `fullWidthMain` (`:32`), `showMainHeader` (`:41`), `showMainLeftArea` (`:57`),
`showMainRightArea` (`:75`), `showMainFooter` (`:91`) e `showFooter` (`:117`). Por isso a
lista renderiza em largura total e sem header/main-header/footer (o banner global
permanece). Exceção: `showAccessibilityBar`
não é lido — a barra depende de `$portalConfigs['accessibility_bar_enabled']`
(`portal-content.blade.php:7`). O schema é injetado como JSON-LD de fallback
(`metadata/seo.blade.php:46-51`).

```blade
{{-- .../Views/portal/index.blade.php:44-95 — grid de vagas e vazio. --}}
<section class="team-section section-padding section-bg">
  <div class="container">
    @if($openings->count() > 0)
      <div class="row g-4">
        @foreach($openings as $opening)
          <div class="col-md-6 col-lg-4">
            <div class="team-box-items mt-0 h-100">
              <div class="team-content p-4">
                <h3 class="h5 fw-bold mb-2">
                  <a href="{{ route('v2.trabalhe-conosco.portal.show', ['slug' => $opening->slug]) }}">
                    {{ $opening->title }}
                  </a>
                </h3>
                <div class="d-flex flex-wrap gap-2 mb-3 small text-muted">
                  @if($opening->location)
                    <span><i class="bi bi-geo-alt me-1" aria-hidden="true"></i>{{ $opening->location }}</span>
                  @endif
                  {{-- type e workload com ícones aria-hidden --}}
                </div>
                @if($opening->salary_range)
                  <span class="badge bg-success bg-opacity-10 text-success border border-success mb-3">
                    <i class="bi bi-currency-dollar me-1" aria-hidden="true"></i>{{ $opening->salary_range }}
                  </span>
                @endif
                <p class="text-muted small flex-grow-1">
                  {{ str_limit(strip_tags($opening->description_detailed ?: $opening->description ?: ''), 200) }}
                </p>
```

O resumo passa `strip_tags` no **JSON do AST**, então o texto do card contém chaves e
escapes do JSON, truncados em 200 caracteres — não é o texto limpo da descrição. O
fallback `$opening->description` é uma coluna legada, praticamente sempre nula (ver
`dados.md` §3.5). O link do título e o “Ver Detalhes” apontam para o mesmo destino.

```blade
{{-- .../Views/portal/show.blade.php:87-137 — o contrato do formulário. --}}
<form method="POST" action="{{ route('v2.trabalhe-conosco.portal.apply', ['slug' => $opening->slug]) }}"
      enctype="multipart/form-data">
    {{ csrf_field() }}

    <label for="name" class="form-label">Nome completo <span class="text-danger">*</span></label>
    <input type="text" id="name" name="name" class="form-control" required maxlength="255"
           value="{{ old('name') }}">
    @if ($errors->has('name'))
        <div class="invalid-feedback d-block">{{ $errors->first('name') }}</div>
    @endif

    <label for="email" class="form-label">E-mail <span class="text-danger">*</span></label>
    <input type="email" id="email" name="email" class="form-control" required maxlength="255"
           value="{{ old('email') }}">
    {{-- phone, linkedin_url e cover_letter sem bloco de erro --}}

    <label for="resume" class="form-label">Currículo (PDF, DOC ou DOCX, max 5MB) <span class="text-danger">*</span></label>
    <input type="file" id="resume" name="resume" class="form-control" required accept=".pdf,.doc,.docx">
```

O `accept` é apenas dica de cliente; a regra real é `mimes:pdf,doc,docx|max:5120`. O form
não tem `novalidate`, então os `required`/`maxlength` do HTML bloqueiam antes do servidor.
Telefone/LinkedIn/carta não têm bloco de erro; um `linkedin_url` inválido volta sem
mensagem visível.

```blade
{{-- .../Views/portal/show.blade.php:70-78 — PDF opcional da vaga. --}}
@if($opening->attachmentPdf)
<section class="mb-4">
    <a href="{{ v2_storage_url($opening->attachmentPdf->file_path) }}"
       class="btn btn-outline-primary rounded-pill"
       target="_blank" rel="noopener noreferrer">
        <i class="bi bi-filetype-pdf me-2" aria-hidden="true"></i> Baixar Edital / Ficha Técnica
    </a>
</section>
@endif
```

A relação é lazy: 1 query extra quando há PDF. O link usa `v2_storage_url` e
`target="_blank"` com `rel="noopener noreferrer"`. A capa usa
`v2-service-media::components.picture` com `variants` decodificadas e `alt` = título
(`portal/show.blade.php:58-68`), com fallback sem lazy.

### 2.5 Algoritmos, consultas e custos

- Lista: 1 query, sem eager loading; o card não toca relações.
- Detalhe: 1 query da vaga + 1 da capa (se houver) + 1 do PDF (se houver).
- POST: 1 query da vaga + escrita de arquivo + 1 insert + 1 render/envio de e-mail.
- `trackAccess()` roda em toda view do portal; pode gerar escrita/consulta de analytics
  (fora do escopo deste módulo).
- Sem cache de página (`page.cache`/`public.cache` excluem o prefixo).

## 3. Particularidades

### 3.1 O que só existe aqui

- **Página pública de escrita** em um módulo de Publicações.
- **Título SEO duplicado em três camadas** (`pageTitle` da view, `setTitle` do controller,
  `metadata['seo']`), com precedência contraintuitiva.
- **Resumo e busca construídos a partir de JSON de AST** com `strip_tags`.
- **Flags de layout mortas** na chamada do `app-layout`.
- **Schema `CollectionPage`** na lista; nenhum `JobPosting`.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-TRABALHECONOSCO-P01` | Visibilidade pública = `scopeOpen` (sem `published_at`). | Vaga futura aparece. | `TrabalheConoscoController.php:39,56`; `JobOpening.php:232-239` |
| `RN-TRABALHECONOSCO-P02` | O título SEO efetivo é `setTitle` do controller; `metadata['seo']['title']` é sobrescrito. | Editar SEO no banco não muda o `<title>` do detalhe. | `TrabalheConoscoController.php:67-68`; `SEOService.php:34-36` |
| `RN-TRABALHECONOSCO-P03` | `<title>` = `$seoTitle` quando definido; `siteTitle` só entraria se `$seoTitle` fosse nulo, e `pageTitle` não adiciona sufixo. | Título é exatamente `Trabalhe Conosco — {app.name}` / `{título} — {app.name}`, sem duplicar o site. | `metadata/seo.blade.php:2`; `SEOServiceProvider.php:47` |
| `RN-TRABALHECONOSCO-P04` | O resumo do card usa `description_detailed` (JSON) com `strip_tags`, 200 chars. | Texto do card não é legível como prosa. | `portal/index.blade.php:75-77` |
| `RN-TRABALHECONOSCO-P05` | Só `name`, `email` e `resume` exibem erro de validação. | Falhas em LinkedIn/carta são silenciosas. | `portal/show.blade.php:95-97,104-106,129-131` |
| `RN-TRABALHECONOSCO-P06` | Candidatura não redireciona (200). | Refresh duplica a candidatura. | `TrabalheConoscoController.php:109` |
| `RN-TRABALHECONOSCO-P07` | Páginas excluídas de cache e de indexação. | Sem `X-Cache-Status`; `robots.txt` desautoriza o prefixo. | `PageCacheMiddleware.php:12-19`; `PublicCacheMiddleware.php:27-32`; `SeoPortalController.php:45` |
| `RN-TRABALHECONOSCO-P08` | O link da vaga no feed/busca é `/trabalhe-conosco/{slug}`. | Se a flag desligar, esses links 404. | `SearchService.php:79-80`; `ContentRichLinkRenderer.php:69-70` |
| `RN-TRABALHECONOSCO-P09` | A descrição rica é impressa sem escape. | HTML do editor é confiável; conteúdo arbitrário no banco vira XSS. | `portal/show.blade.php:52-54` |
| `RN-TRABALHECONOSCO-P10` | A página de sucesso não recebe dados. | Não mostra o título da vaga nem o nome do candidato. | `portal/success.blade.php:1-28` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Vaga sem `description_detailed`**: a seção de descrição não é renderizada; restam
  capa/PDF/form.
- **Descrição inválida**: `toHtml` devolve HTML vazio ou parcial; a seção pode aparecer
  vazia (o teste é no `$htmlDescription`, não no conteúdo).
- **Vaga sem capa/PDF**: seções simplesmente ausentes, sem placeholder.
- **Capa com variantes vazias**: `json_decode(...) ?: []`; o componente usa `<img>`
  original.
- **Vaga única na lista**: grid fica com um card em 1/3 da largura (`col-lg-4`).
- **Nome/e-mail com 255 caracteres**: passa no HTML e no servidor (limite igual).
- **Carta gigante**: `text` no banco e sem `max` na validação; pode estourar o
  `post_max_size` e gerar 419/413 sem mensagem.
- **Upload com extensão maiúscula (`.PDF`)**: `mimes` valida por MIME/conteúdo e
  extensão inferida; o `accept` não bloqueia.
- **Vaga fechada durante o preenchimento**: 404 no POST; o formulário perdido.
- **Botão “Ver outras vagas”** sempre volta à lista, mesmo que a vaga tenha sido
  removida.
- **Título com HTML**: escapado no `<h1>`; o `alt` da capa também escapa.
- **Navegação por teclado**: o formulário é HTML nativo; os cards não são “clicáveis” por
  inteiro, só o link do título e o “Ver Detalhes”.

### 3.4 Armadilhas de reimplementação

- **Trocar `{!! $htmlDescription !!}` por `{{ }}`** exibe o HTML como texto; a decisão de
  segurança deve ser explícita (sanitizar na escrita, não na leitura).
- **Usar `pageTitle` esperando que ele vença** o `setTitle`: o composer prioriza
  `seoTitle`; para mudar o `<title>`, altere o controller/SEO.
- **Remover `published_at` da ordenação** ou incluir `published_at <= now()` muda o
  contrato de visibilidade.
- **Adicionar paginação** muda URLs e links de busca/feed.
- **Renderizar `description_detailed` como Markdown** (o seeder grava Markdown em
  `tt()`): o renderer espera AST JSON; conteúdo Markdown gera AST vazio.
- **Confiar no `accept`** do input file: a validação real é server-side.
- **Mover a lógica de SEO para a view**: o `bindModel` do controller depende de
  `metadata`, que não é editável no CMS.

### 3.5 Divergências documentação × código e dívidas

- **Flags de layout ignoradas** (`portal/index.blade.php:18-25`;
  `portal/show.blade.php:4-12` vs `app-layout.blade.php:1-7`).
- **`schemas` da lista** convivem com o JSON-LD do SEO; duplicação de `CollectionPage`
  quando `metadata['seo']['json_ld']` existir (a lista não tem model, então só o schema
  fixo).
- **`title` na view de sucesso** (`portal/success.blade.php:3`) não é lido pelo layout
  (que usa `pageTitle`).
- **Resumo do card** baseado em JSON (P04).
- **Nenhum `alt` para o ícone do hero**? Ícones são `aria-hidden`; a imagem de fundo do
  breadcrumb é CSS (`background-image`), sem alt — decorativa.
- **Link do rodapé** criado pelo seeder sem checar a flag.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `v2-trabalheconosco::portal.index` | view | `$openings` coleção | rota + flag | `TrabalheConoscoController@index` |
| `v2-trabalheconosco::portal.show` | view | `$opening`, `$htmlDescription` | vaga aberta | `show` |
| `v2-trabalheconosco::portal.success` | view | nenhum dado | POST válido | `apply` |
| `route('v2.trabalhe-conosco.portal.show', ['slug'=>...])` | rota | URL absoluta/relativa | slug válido | cards, feed, busca, editor |
| `route('v2.trabalhe-conosco.portal.apply', ['slug'=>...])` | rota | action do form | CSRF | formulário |
| `v2-portal::app-layout` | layout | `pageTitle`, `pageDescription`, `gtag`, `schemas` | — | as três views |
| `v2-service-media::components.picture` | componente | `originalPath`, `variants`, `alt`, `class`, `lazy` | mídia existente | detalhe |

### 4.2 Contrato de UI/JS (quando aplicável)

- Não há JS do módulo; o portal carrega `portal-init.js` (Flatpickr etc.) globalmente,
  sem uso aqui.
- IDs/names do formulário: `name`, `email`, `phone`, `linkedin_url`, `cover_letter`,
  `resume`; labels associados por `for`.
- Único atributo de dados: `style` do hero e classes do tema; nenhum `data-*` próprio.
- O `gtag` recebe `page_context='trabalhe-conosco'` na lista (não no detalhe/sucesso).

### 4.3 Ordem e ciclo de vida

1. Rota resolvida após o boot dos módulos, antes do fallback `/{slug}`.
2. Controller define SEO antes de `view()`; o composer do SEO roda ao renderizar e pode
   sobrescrever valores com `metadata`.
3. `trackAccess()` executa uma vez por view renderizada do portal.
4. A view de detalhe depende do `ContentRichEditorRendererService` (singleton do serviço)
   e das relações de mídia resolvidas no render.
5. A tela de sucesso não faz consultas.

### 4.4 Receitas de uso

**1. Linkar uma vaga a partir de outro módulo:**

```php
$url = route('v2.trabalhe-conosco.portal.show', ['slug' => $opening->slug]);
// absoluta; use [], false para relativa
```

**2. Renderizar a descrição em outro contexto (ex.: feed) com o mesmo renderer:**

```php
$html = app(\V2\Services\ContentRichEditor\Services\ContentRichEditorRendererService::class)
    ->toHtml($opening->description_detailed);
```

**3. Incluir um bloco JSON-LD `JobPosting` sem duplicar o `CollectionPage`:**

```blade
@push('json-ld')
<script type="application/ld+json">
{!! json_encode([
  '@context' => 'https://schema.org',
  '@type' => 'JobPosting',
  'title' => $opening->title,
  'datePosted' => optional($opening->published_at)->toIso8601String(),
  'validThrough' => optional($opening->closes_at)->toIso8601String(),
  'jobLocation' => ['@type' => 'Place', 'address' => $opening->location],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endpush
```

**4. Exibir a lista em outra página (sem duplicar a query):**

```php
$openings = \V2\Modules\Publicacoes\TrabalheConosco\Models\JobOpening::open()
    ->orderByDesc('published_at')->get();
return view('v2-trabalheconosco::portal.index', compact('openings'));
```

### 4.5 Anti-uso

- **Usar `$opening->description` (legado) como fonte principal**: quase sempre nulo; a
  fonte é `description_detailed`.
- **Esquecer o `csrf_field()`**: o POST é rejeitado com 419 antes do controller.
- **Assumir que `pageTitle` controla o `<title>`**: o SEO do controller vence; a
  expectativa quebra testes de metatag.

### 4.6 Custos e limites da integração

- Sem cache HTTP: cada visita executa queries e `trackAccess`.
- Sem paginação: o custo cresce com o número de vagas abertas.
- Upload de até 5 MB no POST; o PHP pode cortar antes.
- Sem rate limit: candidaturas automatizadas são possíveis com CSRF válido.
- E-mail síncrono: latência do SMTP entra no tempo de resposta do POST.

## 5. Rotas e contratos HTTP

Ver `rotas.md` §5.1. Resumo:

| Método | URI | Nome | Aceita | Responde |
|---|---|---|---|---|
| GET | `/trabalhe-conosco` | `...portal.index` | — | 200/404 (flag) |
| GET | `/trabalhe-conosco/{slug}` | `...portal.show` | slug | 200/404 |
| POST | `/trabalhe-conosco/{slug}/apply` | `...portal.apply` | multipart + CSRF | 200/302/404/419/500 |

## 6. Dados e modelo de domínio

Não se aplica em detalhe: ver `dados.md`. O portal lê `title`, `slug`, `type`,
`workload`, `location`, `salary_range`, `description_detailed`, `description` (fallback),
`published_at`, `closes_at`, `coverImage`, `attachmentPdf`, `metadata`; escreve
`v2_job_applications` e um arquivo no disco `local`.

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Lista
  Lista --> Detalhe: clicar no título / Ver Detalhes
  Detalhe --> Detalhe: erro de validação (302 back, old())
  Detalhe --> Sucesso: POST válido (200)
  Sucesso --> Lista: Ver outras vagas
  Lista --> NaoEncontrado: flag inativa (404)
  Detalhe --> NaoEncontrado: vaga fechada/inexistente (404)
```

## 8. UI CMS

Não se aplica a este anexo (ver `cms.md`).

## 9. UI Portal

### 9.1 Lista — `GET /trabalhe-conosco`

- **Layout**: `v2-portal::app-layout`; hero/breadcrumb com imagem
  `assets/sucesu/img/hero/breadcrumb.png`, “Home › Trabalhe Conosco” e `<h1>Trabalhe
  Conosco</h1>`.
- **Seção**: `team-section section-padding section-bg`, container, grid
  `row g-4` com cards `col-md-6 col-lg-4`.
- **Card**: `<h3 class="h5">` com link; linha de metadados (local, tipo, carga) com ícones
  `bi-geo-alt`, `bi-tag`, `bi-clock` (todos `aria-hidden`); badge de salário; resumo de
  200 chars; link “Ver Detalhes” com seta.
- **Vazio**: ícone `bi-briefcase display-1`, `<h2>Nenhuma vaga disponível no momento</h2>`
  e parágrafo “Volte mais tarde para conferir novas oportunidades.”
- **SEO**: `CollectionPage` JSON-LD com `name`, `description` e `url` relativa
  (`route(..., [], false)`); `seoTitle`/`seoDescription`/`seoKeywords` do controller
  quando o SEO global não os sobrescreve.
- **a11y**: um `<h1>`; cards têm `<h3>`; ícones decorativos com `aria-hidden`; links com
  texto visível; contraste do badge depende do tema.

### 9.2 Detalhe — `GET /trabalhe-conosco/{slug}`

- **Breadcrumbs**: microdata `BreadcrumbList` com posições 2 (lista) e 3 (título), último
  com `aria-current="page"` (`portal/show.blade.php:15-24`).
- **Cabeçalho**: `<h1 class="display-6">{título}</h1>` e metadados (local, tipo, carga,
  salário) em `small text-muted` (`:26-44`).
- **Coluna esquerda (7/12)**:
  - “Descrição da Vaga” (`<h2 class="h5">`) com HTML do editor (`:49-56`);
  - capa via `picture` (`originalPath`, `variants`, `alt` = título, `class=img-fluid
    rounded shadow-sm`, `lazy=true`) (`:58-68`);
  - botão “Baixar Edital / Ficha Técnica” (`:70-78`).
- **Coluna direita (5/12)**: card sticky com “Candidate-se” e formulário (`:82-140`).
- **Formulário**: ver §9.3; labels com asterisco vermelho em nome/e-mail/currículo;
  erros como `div.invalid-feedback.d-block`; botão “Enviar Candidatura”.
- **SEO**: `bindModel` (description/keywords/image/json_ld), `setTitle(título — app.name)`;
  `pageDescription` = `str_limit(strip_tags(description_detailed ?: title), 160)`
  (`portal/show.blade.php:3`) — em JSON, não em prosa. `og:type` é sempre `website`
  (`metadata/seo.blade.php:8`), não `article`.
- **a11y**: `<h1>` único, `<h2>` na descrição, labels/for em todos os campos, erros
  textuais, ícones `aria-hidden`, card sticky sem armadilha de foco; o link do PDF abre
  nova aba com `rel`.

### 9.3 Formulário de candidatura (campos)

| Campo | HTML | Obrigatório | Restrição cliente | Restrição servidor | Erro exibido |
|---|---|---|---|---|---|
| `name` | `text` | sim | `required maxlength=255` | `required|string|max:255` | sim |
| `email` | `email` | sim | `required maxlength=255` | `required|email|max:255` | sim |
| `phone` | `text` | não | `maxlength=50` | `nullable|string|max:50` | não |
| `linkedin_url` | `url` | não | `maxlength=255` | `nullable|url|max:255` | não |
| `cover_letter` | `textarea rows=4` | não | — | `nullable|string` | não |
| `resume` | `file` | sim | `required accept=".pdf,.doc,.docx"` | `required|file|mimes:pdf,doc,docx|max:5120` | sim |

- **Prefill**: `old()` em todos os campos (o arquivo não volta).
- **CSRF**: `csrf_field()`; `enctype="multipart/form-data"`.
- **Consentimento (requisito de reimplementação)**: além dos campos legados, o formulário
  MUST incluir um checkbox obrigatório, **não pré-marcado**, com texto específico para o
  tratamento de dados e currículo e link para a política de privacidade; o servidor MUST
  rejeitar o POST sem consentimento, registrar data/hora e versão do texto aceito junto
  à candidatura e permitir revogação/expurgo (ver `spec.md` §3.4). Sem esse registro, a
  candidatura não deve ser aceita.
- **Botão**: `btn btn-primary w-100`, ícone `bi-send aria-hidden`.

### 9.4 Sucesso — view pós-POST

- Ícone `bi-check-circle-fill` 4rem, `<h2>Candidatura enviada com sucesso!</h2>`,
  parágrafo “Agradecemos o seu interesse. Analisaremos seu currículo e entraremos em
  contato caso seu perfil seja compatível com a vaga.” e botão “Ver outras vagas”.
- SEO: `pageTitle='Candidatura enviada — {app.name}'`,
  `pageDescription='Sua candidatura foi enviada com sucesso.'`; sem schema.
- Não exibe dados da vaga/candidato; refresh reenvia o POST.

### 9.5 Cache, indexação e integrações

- `page.cache` exclui `trabalhe-conosco*` (`PageCacheMiddleware.php:12-19`); `public.cache`
  exclui `/trabalhe-conosco` (`PublicCacheMiddleware.php:27-32`) — sem cache HTTP.
- `robots.txt` gerado por `SeoPortalController` contém `Disallow: /trabalhe-conosco`
  (`SeoPortalController.php:45`).
- Busca pública mapeia `module='trabalhe-conosco'` para `/trabalhe-conosco/{slug}`
  (`SearchService.php:79-80`); o índice é alimentado pelos hooks.
- Editor rico mapeia links internos `trabalhe-conosco` para a mesma URL
  (`ContentRichLinkRenderer.php:69-70`).
- O link do rodapé/home é semeado (`SucesuHomepageSeeder.php:115`).

## 10. Configuração, permissões e flags

- **Sem autenticação**: as três rotas são públicas; a única checagem é a flag.
- **Flag**: `module_key='trabalheconosco'`, `active=false`; com ela falsa, o middleware
  aborta 404 antes do controller — nenhuma view é renderizada e nenhum SEO é aplicado.
- **Config usada**: `app.name` (títulos), `filesystems.default`/`media_disk` (URLs de
  mídia), `v2.support.email` (só no POST).
- **Permissões**: não se aplica ao portal (não há `auth`/`can`).

## 11. Observabilidade, erros e limites

- **Logs**: `[TrabalheConosco] Render error: ...` (falha de AST) e
  `[TrabalheConosco] Falha ao enviar notificação: ...` (SMTP).
- **HTTP**: 200, 302 (validação), 404 (vaga/flag), 419 (CSRF), 500 (upload/banco).
- **Limites**: 5 MB e três extensões no currículo; sem `max` para `cover_letter`; sem
  throttle.
- **Dados sensíveis**: o formulário coleta dados pessoais e currículo; não há aviso de
  privacidade/LGPD na página, nem consentimento explícito. A reimplementação MUST
  adicionar consentimento obrigatório e registrar sua versão, e MUST NOT expor currículos
  em `/storage` público — download apenas por rota autenticada ou URL assinada curta
  (`spec.md` §3.4).

## 12. Seeds e dados iniciais

- O seed demo cria 6 vagas; a última fica inativa/vencida e não aparece na lista; as
  demais têm `closes_at` futuro (`DemoContentSeeder.php:1155-1163`).
- Candidaturas demo não afetam o portal.
- O link “Trabalhe conosco” do rodapé é criado por `SucesuHomepageSeeder` e aponta para
  `/trabalhe-conosco` mesmo com a flag desligada (`SucesuHomepageSeeder.php:115`).

## 13. Lacunas

- `LG-TRABALHECONOSCO-P01` — Formato real do AST em produção: o seeder grava Markdown
  convertido por `tt()`; não foi possível verificar se todo conteúdo histórico é AST
  válido. Impacto: descrição vazia.
- `LG-TRABALHECONOSCO-P02` — SEO esperado para vagas: não há `JobPosting` nem campos de
  SEO no CMS; a origem de `metadata['seo']` é apenas o seeder.
- `LG-TRABALHECONOSCO-P03` — Peso/UX do JSON no resumo do card: não há evidência de
  tratamento no front; confirmar em produção.
- `LG-TRABALHECONOSCO-P04` — Aviso de privacidade/consentimento: inexistente no código;
  requisito jurídico exige adicionar checkbox obrigatório, registrar versão do
  consentimento e prever revogação/expurgo (não há texto aprovado no repositório —
  validar com jurídico).
- `LG-TRABALHECONOSCO-P05` — Acessibilidade do hero (imagem de fundo) e contraste dos
  badges: depende do CSS do tema, não medido.

## 14. Checklist de paridade

- [ ] `PC-TRABALHECONOSCO-58` — Lista mostra apenas vagas abertas, ordenadas por `published_at desc`, com os campos e links descritos. Fonte: `TrabalheConoscoController.php:39`; `portal/index.blade.php:44-97`.
- [ ] `PC-TRABALHECONOSCO-59` — Lista vazia exibe exatamente os dois textos do §9.1. Fonte: `portal/index.blade.php:89-95`.
- [ ] `PC-TRABALHECONOSCO-60` — Detalhe de vaga inativa/fechada/inexistente retorna 404. Fonte: `TrabalheConoscoController.php:56`.
- [ ] `PC-TRABALHECONOSCO-61` — Detalhe renderiza AST como HTML e oculta a seção quando vazio/inválido. Fonte: `TrabalheConoscoController.php:58-65`; `portal/show.blade.php:49-56`.
- [ ] `PC-TRABALHECONOSCO-62` — Capa e PDF aparecem somente quando as relações resolvem; PDF abre em nova aba com `rel`. Fonte: `portal/show.blade.php:58-78`.
- [ ] `PC-TRABALHECONOSCO-63` — Formulário envia multipart com CSRF e os seis campos; erros visíveis em nome/e-mail/currículo. Fonte: `portal/show.blade.php:87-137`.
- [ ] `PC-TRABALHECONOSCO-64` — Currículo `.png` ou >5 MB é rejeitado; `.pdf/.doc/.docx` aceito. Fonte: `TrabalheConoscoController.php:90`.
- [ ] `PC-TRABALHECONOSCO-65` — Sucesso exibe o texto e o botão “Ver outras vagas”. Fonte: `portal/success.blade.php:16-28`.
- [ ] `PC-TRABALHECONOSCO-66` — `<title>` do detalhe é `{título} — {app.name}`, sem sufixo `| {titulo_site}`, e o `metadata['seo']['title']` é ignorado. Fonte: `TrabalheConoscoController.php:67-68`; `metadata/seo.blade.php:2`.
- [ ] `PC-TRABALHECONOSCO-67` — Lista injeta JSON-LD `CollectionPage` com URL relativa. Fonte: `portal/index.blade.php:9-17`; `metadata/seo.blade.php:46-51`.
- [ ] `PC-TRABALHECONOSCO-68` — Nenhuma das três rotas recebe cabeçalho de cache de página. Fonte: `PageCacheMiddleware.php:12-19`; `PublicCacheMiddleware.php:27-32`.
- [ ] `PC-TRABALHECONOSCO-69` — Com a flag inativa, as três rotas retornam 404 e não renderizam SEO. Fonte: `ModuleAccessMiddleware.php:46-62`.
- [ ] `PC-TRABALHECONOSCO-70` — `robots.txt` contém `Disallow: /trabalhe-conosco`. Fonte: `SeoPortalController.php:45`.

## 15. Evidências consultadas

- `V2/Modules/Publicacoes/TrabalheConosco/Views/portal/index.blade.php:1-98`
- `V2/Modules/Publicacoes/TrabalheConosco/Views/portal/show.blade.php:1-142`
- `V2/Modules/Publicacoes/TrabalheConosco/Views/portal/success.blade.php:1-29`
- `V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php:37-137`
- `V2/Modules/Publicacoes/TrabalheConosco/Models/JobOpening.php:232-239`
- `V2/layouts/portal/app-layout.blade.php:1-30`
- `V2/layouts/portal/metadata/seo.blade.php:1-50`
- `V2/Services/SEO/Providers/SEOServiceProvider.php:30-58`
- `V2/Services/SEO/Services/SEOService.php:29-58,293-321`
- `V2/Services/SEO/Controllers/SeoPortalController.php:35-55`
- `V2/Core/Middleware/PageCacheMiddleware.php:10-92`
- `V2/Core/Middleware/PublicCacheMiddleware.php:25-70`
- `V2/Services/Search/Services/SearchService.php:20-90`
- `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichLinkRenderer.php:60-76`
- `V2/Seeds/BasicPages/SucesuHomepageSeeder.php:110-118`
- `V2/Services/Media/Views/components/picture.blade.php:1-40`
