---
id: MOD-INSTITUCIONAL-ASSOCIESE-PORTAL
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-INSTITUCIONAL-ASSOCIESE]
fontes:
  - V2/Modules/Institucional/AssocieSe/
depende_de: [UI-PORTAL, SVC-SEO, SVC-MAILSERVICE, SVC-MEDIA, SVC-CONTENTRICHEDITOR, SVC-STORAGE]
consumidores: [UI-PORTAL, DATA-SEEDS-SLE]
criticidade: media
dono: InstitucionalB
---

# Institucional/AssocieSe — portal e e-mail

> Anexo do módulo [`spec.md`](spec.md). Duas páginas e um e-mail: `/associe-se` (hero,
> intro, planos, formulário, faixa de benefícios), `/associe-se/sucesso` (agradecimento
> estático) e `v2-associese::emails.new-lead` (HTML para o suporte). Sem header/footer
> do portal, sem cache, sem JS de validação; o único JS é um contorno para o
> `nice-select` do tema.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Apresentar planos ativos/vigentes e captar um lead, redirecionando para uma página de sucesso; notificar o suporte por e-mail em HTML. |
| Entrada | `GET /associe-se`; `POST /associe-se` com 8 campos; `GET /associe-se/sucesso`. |
| Saída | HTML no layout `v2-portal::app-layout`; e-mail HTML; `302`; `$errors` de validação; flash (não renderizada no portal). |
| Efeitos colaterais | 4 queries de página; `INSERT` de lead; `activity_log`; envio SMTP + `v2_mail_logs`; nenhum cache; sem confirmação ao visitante. |
| Particularidade central | A página ignora `associese_enabled` e `associese_success_message`; o destinatário do e-mail ignora `associese_notify_email`; e o formulário declara `novalidate`, então a validação é só do servidor. |
| Como se integra | `AssocieSeController`; namespace `v2-associese`; `SEOService`; `ContentRichEditorRendererService`; `MailService`; `v2-service-media::components.picture`. |
| Se removido, quebra | O link “Associe-se” do header, o funil de captação e o e-mail de novos leads. |
| Superfície pública | `GET/POST /associe-se`, `GET /associe-se/sucesso`; `Views/portal/index.blade.php`, `Views/portal/success.blade.php`, `Views/emails/new-lead.blade.php`. |

## 1. Efeito

### 1.1 O que o visitante consegue fazer

- Ver o hero com breadcrumb `Home › Associe-se`, `<h1>` configurável, texto
  introdutório, até todos os planos ativos/vigentes em cards (nome, imagem, preço,
  ciclo, descrição rica, até 4 miniaturas + `+N`) e uma faixa fixa de benefícios.
- Ver e preencher o formulário: nome*, e-mail*, telefone, empresa, estado (27 UFs),
  cidade, plano (opcional) e mensagem.
- Enviar e, em caso de sucesso, ver `/associe-se/sucesso` com a mensagem “Obrigado pelo
  seu interesse!” e a lista “O que acontece agora?”.
- Em caso de erro de validação, voltar ao formulário com os campos preservados e
  mensagens em inglês abaixo de cada campo + resumo “Corrija os campos destacados acima
  antes de enviar.”.
- Em caso de erro de persistência, voltar com input e uma flash que **não é exibida**
  pelo layout do portal.

### 1.2 O que muda no sistema

| Ação | Efeito | Fonte |
|---|---|---|
| `GET /associe-se` | SEO, 4+ queries, render | `AssocieSeController.php:48-83` |
| `POST /associe-se` válido | `INSERT` lead (`pending`), auditoria, e-mail, redirect | `AssocieSeController.php:104-141` |
| `POST /associe-se` com plano | `metadata` no `INSERT` → erro; nenhum lead | `AssocieSeController.php:115-147` |
| `POST /associe-se` inválido | `302` back com `$errors`; nada gravado | validação `:93-102` |
| `GET /associe-se/sucesso` | nada persistido | `:155-158` |
| E-mail | `v2_mail_logs` `sent`/`failed`; log | `MailService.php:121-133,360-380` |

### 1.3 Contrato observável

| Entrada | Resultado |
|---|---|
| `GET /associe-se` com planos | cards renderizados; formulário com select de planos. |
| `GET /associe-se` sem planos | seção de planos omitida; select de plano omitido. |
| `GET /associe-se` com `associese_show_plans='0'` | seção omitida, mas o select de plano **continua** se houver planos. |
| `GET /associe-se` sem `v2_portal_configs` | `500`. |
| `POST` sem `name` | `302` back; `The name field is required.`. |
| `POST` sem `email` | `The email field is required.`. |
| `POST` com e-mail inválido | `The email must be a valid email address.`. |
| `POST` com `phone` > 50 | `The phone may not be greater than 50 characters.`. |
| `POST` com `state` > 2 | `The state may not be greater than 2 characters.`. |
| `POST` válido sem plano | `302 /associe-se/sucesso`; lead criado; e-mail best-effort. |
| `POST` com plano | `302` back + flash `Erro ao enviar solicitação. Tente novamente.`; sem lead. |
| `GET /associe-se/sucesso` | `200`; texto fixo, independente de config. |
| Manutenção ativa | `503` nas duas páginas para visitante; sessão Sentinel logada passa (vê o banner). |

### 1.4 O que o portal NÃO faz

- Não usa `associese_enabled` para esconder/desligar a página.
- Não usa `associese_form_subtitle` nem `associese_success_message`.
- Não usa `associese_notify_email`.
- Não envia confirmação ao visitante; só ao suporte.
- Não cria conta, associação ou cobrança.
- Não valida no cliente (`novalidate`), não tem captcha/honeypot/throttle.
- Não impede duplicidade de lead.
- Não tem página de detalhe de plano nem link individual.
- Não tem cache de página/HTTP.
- Não exibe a flash de erro de persistência (o layout não inclui `flash::message`).
- Não define OG/Twitter/JSON-LD próprios; herda o que o layout/SEO global fornecer.

## 2. Abordagem

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

O controller prepara três coisas e entrega à view: planos (Eloquent com galeria),
configs (array chave→valor) e SEO (estado no `SEOService`). A view é uma página de
marketing estática com loops Blade; a descrição rica é convertida antes (não na view).
O POST é um formulário clássico; o controller valida, monta o model, salva e tenta
notificar. A página de sucesso é independente do resultado (não há checagem de flash).

```mermaid
flowchart LR
  A[GET /associe-se] --> B[SEOService]
  A --> C[MembershipPlan active+valid]
  C --> D[eager gallery.galleryMedia]
  A --> E[configs via DB]
  A --> F[render portal.index]
  G[POST /associe-se] --> H[validate]
  H --> I[MembershipLead save]
  I --> J[activity_log]
  I --> K[render emails.new-lead]
  K --> L[MailService::send]
  L --> M[v2_mail_logs]
  I --> N[302 /associe-se/sucesso]
```

### 2.2 Mecânica passo a passo

1. **SEO**: `setTitle('Associe-se — '.config('app.name'))`,
   `setDescription('Torne-se um associado da <app>. Preencha o formulário e entraremos
   em contato.')`, `setKeywords('associe-se, associado, <app>')`
   (`AssocieSeController.php:50-52`).
2. **Planos**: `MembershipPlan::with('gallery.galleryMedia')->active()->valid()
   ->orderBy('price','asc')->get()` (`:55-59`).
3. **Descrição**: para cada plano, decodifica o JSON e chama o renderer; em erro de
   JSON, usa `nl2br(e(...))`; em exceção do renderer, loga e faz o mesmo fallback
   (`:61-77`). O resultado vai em `$plan->htmlDescription`.
4. **Configs**: `loadConfigs()` lê seis chaves de `v2_portal_configs` (não lê
   `associese_notify_email`) e devolve array (`:195-216`).
5. **Render**: `v2-associese::portal.index` com `$plans`, `$configs`.
6. **POST**: valida 8 campos (`:93-102`); cria lead com UUID; se `plan_id` presente e o
   plano existir, atribui `metadata`; status `pending`; `save()` (`:104-128`).
7. **Pós-save**: auditoria (`:130-135`), `sendAdminNotification` (`:137`), flash de
   sucesso (`:139`), redirect para `v2.associe-se.portal.success` (`:141`).
8. **Falha**: `catch (\Exception)` loga `[AssocieSe] Erro ao salvar lead`, flash
   `Erro ao enviar solicitação. Tente novamente.` e `redirect()->back()->withInput()`
   (`:142-147`).
9. **Sucesso**: `success()` só renderiza a view (`:155-158`).

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

- **Descrição renderizada no controller** `[C]`: evita lógica na view, mas duplica o
  acessor `getHtmlDescriptionAttribute` do model. O HTML é impresso cru.
- **Configs em array simples** `[C]`: a view usa `$configs['chave'] ?? default`; não há
  objeto/DTO.
- **Plano opcional no formulário** `[C]`: o select só aparece se houver planos, mas não
  é obrigatório; o valor vira metadata.
- **Página de sucesso sem estado** `[C]`: qualquer pessoa pode acessá-la; não há
  verificação de que um lead acabou de ser criado.
- **Formulário `novalidate`** `[C]`: os atributos `required`, `type=email` e
  `maxlength` são ignorados pelo navegador; a validação é 100% server-side.
- **Contorno do nice-select** `[C]`: o tema envolve `<select>` em um wrapper; o script
  remove o wrapper e força o select nativo visível, com timeouts de 500/1500 ms
  (`Views/portal/index.blade.php:286-311`).
- **E-mail sem template global** `[C]`: `use_template=false`; o corpo é o Blade do
  módulo, com CSS inline.
- **Sem cache** `[C]`: as listas de exceção dos middlewares cobrem o prefixo.

### 2.4 Trechos de código pivô

```php
// V2/Modules/Institucional/AssocieSe/Controllers/AssocieSeController.php:54-59
// — a consulta pública: ativos, vigentes, mais baratos primeiro.
$plans = MembershipPlan::with('gallery.galleryMedia')
    ->active()
    ->valid()
    ->orderBy('price', 'asc')
    ->get();
```

**Efeito.** Um `SELECT` em `v2_membership_plans` + eager de galeria + eager de mídia.
Planos inativos ou fora da vigência não chegam à view. A ordem é por preço crescente,
sem desempate.

```php
// V2/Modules/Institucional/AssocieSe/Controllers/AssocieSeController.php:61-77
// — JSON rich-content vira HTML; fallback para texto escapado.
foreach ($plans as $plan) {
    $plan->htmlDescription = '';
    if (!empty($plan->description)) {
        try {
            $decoded = json_decode($plan->description, true);
            if (json_last_error() === JSON_ERROR_NONE) {
                $plan->htmlDescription = $this->renderer->toHtml($decoded);
            } else {
                $plan->htmlDescription = nl2br(e($plan->description));
            }
        } catch (\Exception $e) {
            \Log::error('[AssocieSe] Render error: ' . $e->getMessage());
            $plan->htmlDescription = nl2br(e($plan->description));
        }
    }
}
```

**Efeito.** Texto puro com quebras vira HTML escapado (`<br>`); JSON válido passa pelo
renderer (que decide a sanitização). A view imprime com `{!! !!}`
(`Views/portal/index.blade.php:91-94`), então a segurança depende do renderer.

```blade
{{-- V2/Modules/Institucional/AssocieSe/Views/portal/index.blade.php:46-58 — planos --}}
@if(($configs['associese_show_plans'] ?? '1') === '1' && $plans->count() > 0)
    <section class="section-padding section-bg">
        <div class="container">
            <div class="text-center mb-5">
                <h2 class="fw-bold mb-2">Nossos Planos</h2>
                <p class="text-muted">Escolha o plano ideal para você</p>
            </div>
            <div class="row g-4 justify-content-center">
                @foreach($plans as $plan)
                    <div class="col-lg-4 col-md-6">
                        <div class="team-box-items mt-0 h-100">
                            @if($plan->gallery->count() > 0 && $plan->gallery->first()->galleryMedia)
```

**Efeito.** A seção só aparece com a config exatamente `'1'` e ao menos um plano. O
card usa a primeira mídia da galeria; se não houver mídia resolvida, cai no ícone
`bi-person-badge`. Se houver galeria mas a primeira mídia não for imagem, **não** há
fallback (o `@if($isImage)` não tem `@else`), deixando o topo do card vazio.

```blade
{{-- V2/Modules/Institucional/AssocieSe/Views/portal/index.blade.php:137-157 — form --}}
<form method="POST" action="{{ route('v2.associe-se.portal.store') }}" novalidate class="associese-form">
    {{ csrf_field() }}
    <div class="row g-3">
        <div class="col-md-6">
            <label for="name" class="form-label fw-semibold">Nome completo <span class="text-danger">*</span></label>
            <input type="text" id="name" name="name"
                   class="form-control{{ $errors->has('name') ? ' is-invalid' : '' }}"
                   required maxlength="255" value="{{ old('name') }}" placeholder="Seu nome">
            @if ($errors->has('name'))
                <div class="text-danger small mt-1">{{ $errors->first('name') }}</div>
            @endif
        </div>
```

**Efeito.** O `novalidate` desliga a validação nativa; o `required`/`maxlength` são
apenas semânticos. O erro é renderizado logo abaixo do campo e a classe `is-invalid`
aplica a borda vermelha via CSS local (`:324-329`).

```blade
{{-- V2/Modules/Institucional/AssocieSe/Views/portal/index.blade.php:286-311 — nice-select --}}
@push('scripts')
<script>
if (typeof jQuery !== 'undefined' && jQuery.fn && jQuery.fn.niceSelect) {
    (function() {
        function fixSelects() {
            document.querySelectorAll('.associese-form .nice-select').forEach(function(wrapper) {
                var native = wrapper.querySelector('select');
                if (native) {
                    wrapper.parentNode.replaceChild(native, wrapper);
                }
            });
        }
        fixSelects();
        document.addEventListener('DOMContentLoaded', fixSelects);
        setTimeout(fixSelects, 500);
    })();
}
</script>
@endpush
```

**Efeito.** O script só roda se o plugin existir. Ele substitui o wrapper
`.nice-select` pelo `<select>` nativo, garantindo que o campo de UF e o de plano sejam
utilizáveis. O CSS `@push('head-styles')` esconde `.nice-select` e força
`select.form-select` visível (`:314-331`).

```php
// V2/Modules/Institucional/AssocieSe/Controllers/AssocieSeController.php:176-184
// — corpo renderizado no request e enviado sem template global.
$body = View::make('v2-associese::emails.new-lead', ['lead' => $lead])->render();
$subject = '[Associe-se] Novo lead: ' . $lead->name;

$this->mailService->send($adminEmail, $subject, $body, [
    'to_name'      => 'Administrador',
    'use_template' => false,
    'is_html'      => true,
]);
```

**Efeito.** O `MailService` aplica a config SMTP do profile `default` (ou o fallback de
`config/mail.php`), envia e grava `v2_mail_logs`. A falha é engolida pelo serviço; o
`try/catch` externo só cobre a renderização do Blade/erros não tratados.

### 2.5 Algoritmos, consultas e custos

- `GET /associe-se`: 1 `SELECT` de planos + 1 de galeria + 1 de mídia + 1 de configs.
  `orderBy('price')` sem índice; a tabela é pequena. O eager de mídia pode falhar se
  houver UUID em `gallery_media_id` (ver [`dados.md`](dados.md) §3).
- `POST /associe-se`: 1 `SELECT` de plano (se `plan_id`), 1 `INSERT` de lead, 1 insert
  de atividade, 1 render de e-mail, 1 envio SMTP, 1 insert de log. Sem transação.
- O e-mail é síncrono: a latência do SMTP entra no tempo de resposta.
- Não há cache; cada visita executa as consultas.
- A página de sucesso não consulta banco (exceto layout/SEO global).

## 3. Particularidades

### 3.1 O que só existe aqui

- **Formulário público com `novalidate`** e mensagens em inglês.
- **Página de sucesso independente de estado**.
- **Contorno de `nice-select`** específico do tema SUCESU.
- **Faixa de benefícios hardcoded** (“Benefícios Exclusivos”, “Suporte Prioritário”,
  “Networking”) sem config.
- **E-mail com `use_template=false`**, diferente de outros módulos que usam o template
  global do `MailService`.

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-ASSOCIESE-P01` | `associese_show_plans` precisa ser exatamente `'1'`. | `'true'`/vazio escondem a seção. | `Views/portal/index.blade.php:46` |
| `RN-ASSOCIESE-P02` | O select de plano não depende de `show_plans`. | Planos ocultos continuam selecionáveis no formulário. | `Views/portal/index.blade.php:215-231` |
| `RN-ASSOCIESE-P03` | O formulário é `novalidate`. | `required`/`type=email` não bloqueiam o submit. | `Views/portal/index.blade.php:137` |
| `RN-ASSOCIESE-P04` | `state` só é validado por `max:2`, não pela lista de UFs. | Um POST adulterado aceita qualquer sigla de 2 caracteres. | `AssocieSeController.php:97` |
| `RN-ASSOCIESE-P05` | `message` não tem limite. | Texto longo é aceito. | `AssocieSeController.php:101` |
| `RN-ASSOCIESE-P06` | O lead nasce `pending` e não recebe `converted_at`. | Conversão é manual no CMS. | `AssocieSeController.php:127` |
| `RN-ASSOCIESE-P07` | O e-mail vai para `config('v2.support.email')`. | `associese_notify_email` é ignorada. | `AssocieSeController.php:169` |
| `RN-ASSOCIESE-P08` | O e-mail não usa o template global. | O HTML do módulo é o corpo final. | `AssocieSeController.php:180-184` |
| `RN-ASSOCIESE-P09` | A página de sucesso ignora `associese_success_message`. | Texto é hardcoded. | `Views/portal/success.blade.php:40-43` |
| `RN-ASSOCIESE-P10` | O portal não tem cache. | Alterações aparecem na hora. | `PageCacheMiddleware.php:16-23`; `PublicCacheMiddleware.php:31-36` |
| `RN-ASSOCIESE-P11` | O layout desliga header/footer/barras. | A página é “full width” sem chrome do portal. | `Views/portal/index.blade.php:1-12`; `Views/portal/success.blade.php:1-12` |
| `RN-ASSOCIESE-P12` | O SEO é definido via `SEOService`, não na view. | Título/description/keywords vêm do controller. | `AssocieSeController.php:50-52` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Sem planos**: formulário sem select de plano; ainda é possível enviar.
- **Planos ocultos por config**: o select continua; o usuário pode escolher um plano que
  não vê na página.
- **Plano selecionado**: erro de persistência; a flash não aparece (layout sem flash).
- **Plano inexistente**: lead sem metadata; sucesso normal.
- **E-mail vazio no config**: `sendAdminNotification` retorna cedo, sem log e sem
  `v2_mail_logs` (o lead é criado).
- **SMTP indisponível**: `MailService` registra `failed`; o visitante vê sucesso.
- **Render do e-mail falha**: log `[AssocieSe] Falha ao enviar notificação`; sucesso.
- **Falha de auditoria**: o lead já foi salvo; o visitante vê erro genérico.
- **Duplo clique**: dois POSTs criam dois leads (sem idempotência).
- **Refresh no sucesso**: não reenvia (GET).
- **Acesso direto ao sucesso**: funciona; não há checagem de origem.
- **Manutenção**: ambas as páginas respondem `503` (middleware).
- **`v2_portal_configs` ausente**: `500` no GET; o POST não depende dela, mas o
  redirect para sucesso (GET) falharia depois.
- **UF inválida via POST**: aceita; aparece como texto no CMS/e-mail.
- **Mensagem com HTML**: o e-mail e o CMS escapam (`{{ }}`); não há injeção.
- **Descrição rica com HTML perigoso**: depende do renderer; a view não reescapa.
- **Imagem de plano com variantes**: o partial `picture` recebe `variants` decodificado;
  se o JSON for inválido, passa `[]`.
- **Primeira mídia PDF**: card sem imagem e sem fallback.
- **Galeria > 4**: mostra 4 miniaturas + badge `+N`.
- **nice-select ausente**: o select nativo já funciona; o script não roda.
- **JS desabilitado**: o formulário funciona; só o enfeite do select não é aplicado.

### 3.4 Armadilhas de reimplementação

- **Não troque `=== '1'` por truthy** sem decidir o que fazer com valores legados.
- **Não remova o `novalidate`** sem também remover a dependência de mensagens do
  servidor; o layout de erros é server-side.
- **Não use `associese_enabled`** para bloquear a página sem uma decisão de produto: a
  chave nunca foi lida.
- **Não use `associese_notify_email`** sem migrar a config; hoje o destinatário é fixo.
- **Não exiba a flash no portal** esperando o texto de erro atual: o layout não tem
  `flash::message`; a mensagem fica só na sessão.
- **Não confie no fallback de imagem**: para mídia não-imagem, o card fica vazio.
- **Não reescape `htmlDescription`** na view sem mudar o contrato do renderer; hoje é
  `{!! !!}`.
- **Não remova o timeout do nice-select**: o plugin pode reaplicar o wrapper após o
  `DOMContentLoaded`.
- **Não envie e-mail em fila** esperando o mesmo registro em `v2_mail_logs`: o serviço
  atual grava no request.
- **Não use `created_at` do lead antes do `save()`** no e-mail: o template formata
  `$lead->created_at` e depende do timestamp preenchido.

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

- O plano do módulo fala em notificar “admin” sem citar `v2.support.email`.
- O plano sugere e-mail de confirmação ao visitante; não existe.
- O plano não menciona a página de sucesso estática nem o nice-select.
- O plano fala em “formulário responsivo”; a view usa Bootstrap, mas o `novalidate`
  reduz a acessibilidade nativa.
- O `associese_success_message` é editável e nunca lido.
- O `associese_form_subtitle` é editável e nunca lido.
- O `associese_enabled` é editável e nunca lido.
- `showAccessibilityBar=false` é passado pelas duas views, mas o partial não consulta
  esse parâmetro: a barra depende de `$portalConfigs['accessibility_bar_enabled']`
  (`V2/layouts/portal/partials/portal-content.blade.php:7-12`).
- A faixa de benefícios é hardcoded.
- O link do e-mail para o CMS não é assinado.
- O SEO não define canonical/OG/JSON-LD no módulo; depende do layout global.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura/Chave | Precondição | Consumidores |
|---|---|---|---|---|
| `GET /associe-se` | rota | `v2.associe-se.portal.index` | tabelas migradas | header, links externos |
| `POST /associe-se` | rota | `v2.associe-se.portal.store` | CSRF | formulário |
| `GET /associe-se/sucesso` | rota | `v2.associe-se.portal.success` | — | redirect do controller |
| `AssocieSeController@index` | método | `index(): View` | `v2_portal_configs` | view |
| `sendAdminNotification` | método protegido | `(MembershipLead $lead): void` | `config('v2.support.email')` | `store` |
| `loadConfigs` | método protegido | `(): array` | tabela de configs | `index` |

### 4.2 Contrato de UI/JS

| Elemento | Contrato |
|---|---|
| Layout | `v2-portal::app-layout` com `pageTitle`, `pageDescription`, `fullWidthMain=true`, `showHeader=false`, `showMainHeader=false`, `showMainFooter=false`, `showFooter=false`, `showAccessibilityBar=false` (não lido pelo partial), `showMainLeftArea=false`, `showMainRightArea=false` |
| Seção | `@section('main-section-area')` (o layout renderiza em `<main id="main-content">`) |
| Formulário | `.associese-form`, `novalidate`, `POST`, `csrf_field()` |
| Selects | `#state` (27 UFs), `#plan_id` (opcional) |
| JS | `fixSelects()` dentro de `@push('scripts')`; CSS em `@push('head-styles')` |
| Imagens | `v2-service-media::components.picture` para a primeira mídia; `<img>` para miniaturas com `v2_storage_url` |
| Classes | `breadcrumb-wrapper`, `section-padding`, `section-bg`, `team-box-items`, `team-content`, `card`, `form-control`, `form-select`, `is-invalid`, `text-danger` |

### 4.3 Ordem e ciclo de vida

1. O provider aplica `MaintenanceMiddleware` antes de qualquer controller; em manutenção,
   `503`.
2. `page.cache`/`public.cache` entram depois, mas ignoram o prefixo.
3. O controller define SEO antes de renderizar; o layout lê o estado no `<head>`.
4. O controller consulta planos/configs antes de renderizar.
5. No POST, a validação acontece antes de qualquer escrita.
6. O e-mail é renderizado depois do `save()` (o `created_at` já existe).
7. O redirect para o sucesso é um novo GET; não há flash de sucesso necessária.

### 4.4 Receitas de uso

**1. Criar um link para a página**

```blade
<a href="{{ route('v2.associe-se.portal.index') }}">Associe-se</a>
```

**2. Consultar a config com fallback**

```blade
{{ $configs['associese_form_title'] ?? 'Associe-se' }}
```

**3. Renderizar a descrição rica**

```blade
@if(!empty($plan->htmlDescription))
    <div class="mb-3 small text-muted flex-grow-1">{!! $plan->htmlDescription !!}</div>
@endif
```

**4. Enviar o formulário por HTTP**

```http
POST /associe-se HTTP/1.1
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&name=Fulano&email=fulano%40exemplo.com&phone=61999999999&state=DF&city=Brasilia&company=ACME&message=Tenho%20interesse
```

Resposta esperada sem `plan_id`: `302 Location: /associe-se/sucesso`.

**5. Notificar manualmente um lead existente**

```php
app(\V2\Modules\Institucional\AssocieSe\Controllers\AssocieSeController::class)
    ->sendAdminNotification($lead); // método protegido: use o fluxo do store ou um job
```

Na prática, o método é `protected`; a receita canônica é reutilizar o `MailService`
diretamente com o mesmo template.

### 4.5 Anti-uso

- **Adicionar validação só no HTML**: `novalidate` a ignora; o servidor precisa validar.
- **Imprimir `{!! $plan->description !!}`**: o valor é JSON bruto, não HTML.
- **Usar `associese_show_plans` como booleano PHP**: a comparação é estrita com `'1'`.
- **Enviar `plan_id` esperando vínculo**: não há FK; o metadata quebra no schema atual.
- **Depender de flash no portal**: o layout não a renderiza.
- **Cachear `/associe-se`**: os middlewares excluem, mas um proxy externo pode cachear;
  não há headers explícitos de `no-store` no módulo.

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

- Sem throttle: spam de leads e de e-mails.
- Sem captcha/honeypot: bots podem criar leads.
- Sem limite de `message`: payloads grandes.
- E-mail síncrono: latência do SMTP.
- Sem cache: 4 queries por visita.
- Sem fila: falha de SMTP não é retentada.
- Sem confirmação ao visitante: não há double opt-in.
- Sem página de detalhe: o plano não tem URL própria.

## 5. Rotas e contratos HTTP

| ID | Método | URI | Nome | Middleware | Efeito |
|---|---|---|---|---|---|
| `R-ASSOCIESE-15` | GET | `/associe-se` | `v2.associe-se.portal.index` | `web; MaintenanceMiddleware; page.cache; public.cache` (cache excluído) | `200` com planos/configs; `500` sem configs; `503` em manutenção |
| `R-ASSOCIESE-16` | POST | `/associe-se` | `v2.associe-se.portal.store` | idem + CSRF | `302` sucesso ou back com erros; `419` sem token |
| `R-ASSOCIESE-17` | GET | `/associe-se/sucesso` | `v2.associe-se.portal.success` | idem | `200` estático; `503` em manutenção |

Detalhes de validação e mensagens em [`rotas.md`](rotas.md) §5.1 e §5.3.

## 6. Dados e modelo de domínio

O portal lê `MembershipPlan` (ativos/válidos), `MembershipPlanGallery` +
`GalleryMedia`, e `v2_portal_configs`. Escreve `MembershipLead` (e `activity_log`,
`v2_mail_logs`). O e-mail lê todos os campos do lead e tenta `metadata`. Ver
[`dados.md`](dados.md) §6.

## 7. Fluxos e estados

```mermaid
sequenceDiagram
  participant V as Visitante
  participant C as Controller
  participant DB as Banco
  participant M as MailService
  V->>C: GET /associe-se
  C->>DB: planos + galeria + configs
  C-->>V: HTML
  V->>C: POST /associe-se
  C->>C: validate
  alt inválido
    C-->>V: 302 back + $errors
  else válido com plano
    C->>DB: INSERT (metadata) -> erro
    C-->>V: 302 back + flash
  else válido sem plano
    C->>DB: INSERT lead
    C->>DB: INSERT activity_log
    C->>M: send()
    M->>DB: INSERT v2_mail_logs
    C-->>V: 302 /associe-se/sucesso
  end
```

## 8. UI CMS

Não se aplica ao portal; ver [`cms.md`](cms.md).

## 9. UI Portal

### 9.1 Estrutura da página

1. **Global banner** (incondicional): o partial `portal-content` inclui
   `v2-portal::base-struct.global-banner` mesmo com `showHeader=false`
   (`V2/layouts/portal/partials/portal-content.blade.php:21-30`).
2. **Hero breadcrumb** com background `assets/sucesu/img/hero/breadcrumb.png`, lista
   `Home › Associe-se` e `<h1>` = `associese_form_title`.
3. **Intro** (opcional) com `associese_intro_text` em `<p class="lead text-center">`.
4. **Planos** (se `show_plans` e houver planos): `<h2>Nossos Planos</h2>`, grid de cards.
5. **Formulário**: card com cabeçalho “Formulário de Interesse” e 8 campos.
6. **Benefícios**: três colunas com ícones e textos fixos.
7. **Scripts/estilos**: correção do nice-select.

### 9.2 Planos

- Card: imagem de capa 200px (`object-fit: cover`, lazy) ou ícone `bi-person-badge`;
  nome em `<h3 class="h5">`; preço `R$ 0,00` em `<span class="h2">`; sufixo `/mês` ou
  `/ano`; descrição rica; até 4 miniaturas 40×40 e badge `+N`.
- Imagem principal via `v2-service-media::components.picture` com
  `originalPath`, `variants`, `alt=$plan->name`, `lazy=true`.
- Miniaturas via `v2_storage_url($item->galleryMedia->file_path)`, `alt="Galeria
  <nome>"`, `loading="lazy"`, `decoding="async"`.
- Se a primeira mídia não for `image/*`, nenhuma imagem é renderizada (sem fallback).
- O card inteiro é estático; não há link, botão “escolher” ou detalhe.

### 9.3 Formulário

| Campo | Tipo | Obrigatório | Limite | Observação |
|---|---|---|---|---|
| `name` | text | sim | 255 | erro abaixo |
| `email` | email | sim | 255 | validação `email` no servidor |
| `phone` | text | não | 50 | placeholder de máscara |
| `company` | text | não | 255 | — |
| `state` | select | não | 2 | 27 UFs hardcoded |
| `city` | text | não | 255 | — |
| `plan_id` | select | não | 36 | só se houver planos |
| `message` | textarea | não | sem limite | rows=4 |

- O formulário tem `novalidate`; os `required`/`maxlength` não bloqueiam.
- Erros: `is-invalid` + `<div class="text-danger small">`; resumo com `role="alert"`.
- Botão “Enviar Solicitação” com ícone `bi-send`.
- CSRF via `csrf_field()`.
- O select de plano mostra `nome — R$ preço/ciclo`, com `old('plan_id')`.

### 9.4 Página de sucesso

- Título “Solicitação enviada — <app>”, descrição estática.
- Hero “Solicitação enviada com sucesso!”.
- Card com ícone `bi-check-circle-fill`, “Obrigado pelo seu interesse!”, texto fixo e
  lista de 4 passos.
- Botão “Voltar para o início”.
- Não lê config, banco, flash ou query.
- Herda o mesmo layout sem header/footer.

### 9.5 SEO

| Item | Valor/origem |
|---|---|
| `title` | `Associe-se — <app.name>` via `SEOService::setTitle`; layout também recebe `pageTitle` equivalente |
| `description` | “Torne-se um associado da <app.name>. Preencha o formulário e entraremos em contato.” |
| `keywords` | “associe-se, associado, <app.name>” |
| canonical | não definido pelo módulo; depende do layout/SEO global |
| OG/Twitter | não definidos pelo módulo; dependem do layout |
| JSON-LD | não definido pelo módulo |
| robots | não definido pelo módulo |
| sucesso | só `pageTitle`/`pageDescription` no `@extends`; sem `SEOService` |

### 9.6 Mídia, responsividade e acessibilidade

- Imagens: `loading=lazy`, `decoding=async`, variantes WebP via `picture`; `alt` com o
  nome do plano.
- Grid Bootstrap: planos `col-lg-4 col-md-6`; formulário `col-lg-8`, campos
  `col-md-6`; benefícios `col-md-4`.
- `fullWidthMain=true` remove o container do layout; a view usa `.container` próprio.
- A11y: `<h1>` único no hero; `<h2>` de seção; `<h3>` por plano; labels `for` em todos
  os campos; erros textuais; ícones `aria-hidden`; resumo `role="alert"`.
- Lacunas de a11y: breadcrumb em `<ul>` sem `<nav aria-label>`; erros sem
  `aria-describedby`/`aria-live`; asterisco de obrigatório só visual; `state` depende do
  nice-select (se o fix falhar, o plugin pode manter o select nativo oculto); contraste
  do texto
  `text-muted` sobre `section-bg` não é verificado.
- Print: sem regra própria; o layout pode ocultar as barras laterais (não presentes).

### 9.7 Cache

- `PageCacheMiddleware`: `except` inclui `associe-se*`; portanto não há HIT/MISS.
- `PublicCacheMiddleware`: `excludedPrefixes` inclui `/associe-se`; não há
  `Cache-Control: public, max-age=60`.
- `MaintenanceMiddleware` continua valendo.
- Não há invalidação porque não há cache do módulo; proxies externos podem cachear sem
  header explícito.

### 9.8 E-mail `new-lead`

- **Assunto**: `[Associe-se] Novo lead: <nome>`.
- **Remetente**: config SMTP do `MailService` (profile `default`).
- **Destinatário**: `config('v2.support.email')`, `to_name='Administrador'`.
- **Formato**: HTML, `use_template=false`, CSS inline, `<table>` com
  `aria-label="Dados do lead"`.
- **Conteúdo**: nome, e-mail, telefone (se), empresa (se), estado (se), cidade (se),
  plano (se metadata), mensagem (se), data `d/m/Y H:i:s`, botão “Visualizar Lead no
  CMS”.
- **Sem metadata**: a linha de plano nunca aparece no schema atual.
- **Sem confirmação**: não há cópia para o visitante.
- **Falha**: silenciosa para o visitante; `v2_mail_logs` registra `failed` e o log
  `[MailService] Erro ao enviar e-mail para ...`.

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

- O portal é público; não há RBAC nem flag de módulo.
- `MaintenanceMiddleware` é a única barreira (503).
- Configs lidas: `associese_form_title`, `associese_intro_text`,
  `associese_show_plans`; as demais são ignoradas.
- `config('v2.support.email')` define o destinatário; `config('app.name')` aparece no
  SEO e no e-mail.
- Não há throttle, captcha ou assinatura.

## 11. Observabilidade, erros e limites

- Logs: `[AssocieSe] Render error: ...`, `[AssocieSe] Erro ao salvar lead: ...`,
  `[AssocieSe] Falha ao enviar notificação: ...`; `MailService` loga falhas de envio.
- Erros HTTP: validação `302`; persistência `302` (flash); CSRF `419`; manutenção `503`;
  configs ausentes `500`.
- Limites: sem throttle; sem limite de mensagem; 255 para nome/e-mail/empresa/cidade;
  50 para telefone; 2 para UF; 36 para plan_id.
- Dados pessoais em claro no banco e no e-mail; sem retenção definida.
- Sem métricas/analytics próprios.

## 12. Seeds e dados iniciais

- Nenhuma chave `associese_*` semeada; a página usa fallbacks.
- Nenhum plano semeado.
- O link “Associe-se” do header é semeado em `V2/Seeds/SleHeaderSeeder.php:58`.
- A tabela global de configs é semeada por `SupportConfigs`, sem chaves do módulo.

## 13. Lacunas

| ID | Lacuna | Onde procurar | Impacto |
|---|---|---|---|
| `LG-ASSOCIESE-P01` | Não há confirmação ao visitante; não se sabe se é intencional. | Produto. | UX e deliverability. |
| `LG-ASSOCIESE-P02` | Não há tratamento visual para falha de persistência (flash não renderizada). | Layout do portal. | Usuário vê o form voltar sem explicação. |
| `LG-ASSOCIESE-P03` | O comportamento desejado para `associese_enabled` não é conhecido. | Produto. | Página não desliga. |
| `LG-ASSOCIESE-P04` | O uso de `associese_notify_email` não é claro. | Produto. | Destinatário fixo. |
| `LG-ASSOCIESE-P05` | A sanitização do rich-content depende do renderer; não há teste de XSS. | `SVC-CONTENTRICHEDITOR`. | Segurança. |
| `LG-ASSOCIESE-P06` | Não há regra de deduplicação/retenção de leads. | Produto/LGPD. | Dados pessoais. |
| `LG-ASSOCIESE-P07` | O fallback visual para mídia não-imagem não existe. | UX. | Card vazio. |

## 14. Checklist de paridade

- [ ] `PC-ASSOCIESE-P01` — `GET /associe-se` retorna `200` com hero, intro (se
  configurada), planos e formulário. Fonte: `Views/portal/index.blade.php:14-261`.
- [ ] `PC-ASSOCIESE-P02` — planos são apenas `is_active` e vigentes, ordenados por preço.
  Fonte: `AssocieSeController.php:55-59`.
- [ ] `PC-ASSOCIESE-P03` — `associese_show_plans='0'` remove a seção, mas mantém o
  select de plano. Fonte: `Views/portal/index.blade.php:46,215`.
- [ ] `PC-ASSOCIESE-P04` — o formulário tem `novalidate`, 8 campos e CSRF. Fonte:
  `Views/portal/index.blade.php:137-255`.
- [ ] `PC-ASSOCIESE-P05` — `POST` sem plano cria lead e redireciona ao sucesso. Fonte:
  `AssocieSeController.php:104-141`.
- [ ] `PC-ASSOCIESE-P06` — `POST` com plano existente falha e volta com input. Fonte:
  `AssocieSeController.php:115-147`.
- [ ] `PC-ASSOCIESE-P07` — a página de sucesso não lê config/banco e mostra o texto
  fixo. Fonte: `Views/portal/success.blade.php:1-75`.
- [ ] `PC-ASSOCIESE-P08` — SEO define title, description e keywords via `SEOService`.
  Fonte: `AssocieSeController.php:50-52`.
- [ ] `PC-ASSOCIESE-P09` — `/associe-se*` não recebe `X-Cache-Status` nem
  `Cache-Control: public`. Fonte: `PageCacheMiddleware.php:16-23`;
  `PublicCacheMiddleware.php:31-36`.
- [ ] `PC-ASSOCIESE-P10` — e-mail vai para `v2.support.email` com assunto
  `[Associe-se] Novo lead: <nome>` e `use_template=false`. Fonte:
  `AssocieSeController.php:169-184`.
- [ ] `PC-ASSOCIESE-P11` — falha de SMTP não altera a resposta ao visitante. Fonte:
  `MailService.php:126-134`; `AssocieSeController.php:175-187`.
- [ ] `PC-ASSOCIESE-P12` — o JS do nice-select só roda com jQuery/plugin e força o
  select nativo visível. Fonte: `Views/portal/index.blade.php:286-331`.
- [ ] `PC-ASSOCIESE-P13` — o layout desliga header/footer/barras e usa
  `main-section-area`. Fonte: `Views/portal/index.blade.php:1-14`;
  `V2/layouts/portal/partials/portal-content.blade.php:21-33`.

## 15. Evidências consultadas

- `V2/Modules/Institucional/AssocieSe/Controllers/AssocieSeController.php:43-216`.
- `V2/Modules/Institucional/AssocieSe/Views/portal/index.blade.php:1-333`;
  `Views/portal/success.blade.php:1-75`.
- `V2/Modules/Institucional/AssocieSe/Views/emails/new-lead.blade.php:1-54`.
- `V2/Modules/Institucional/AssocieSe/Routes/web.php:1-21`.
- `V2/layouts/portal/app-layout.blade.php:1-32`;
  `V2/layouts/portal/partials/portal-content.blade.php:1-100`.
- `V2/Services/SEO/Services/SEOService.php` (uso de `setTitle/setDescription/setKeywords`).
- `V2/Services/MailService/MailService.php:36-135,360-422`.
- `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:14-35`.
- `V2/Services/Media/Views/components/picture.blade.php` (uso no card).
- `V2/Core/Middleware/PageCacheMiddleware.php:16-86`;
  `V2/Core/Middleware/PublicCacheMiddleware.php:31-76`;
  `V2/Core/Middleware/MaintenanceMiddleware.php:41-52`.
- `V2/Seeds/SleHeaderSeeder.php:58`.
