---
id: MOD-CONEXOES-ASSOCIADOS-PORTAL
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-CONEXOES-ASSOCIADOS]
fontes:
  - V2/Modules/Conexoes/Associados/
depende_de: [UI-PORTAL, SVC-SEO, SVC-SEARCH, SVC-MEDIA, SVC-STORAGE, SVC-CONTENTRICHEDITOR, CORE-RUNTIME]
consumidores: [UI-PORTAL, SVC-SEARCH]
criticidade: alta
dono: Associados
---

# Conexões/Associados — portal

> Três saídas públicas: listagem `/associados` (busca + tags + regional, com
> agrupamento por tag primária quando há filtro), fragmento `/associados/detalhe/{id}`
> para o modal e página `/associado/{slug}` para SEO. Todas leem somente
> `is_active = true` e são cacheadas por 5 min no Redis + 60 s no HTTP.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Exibir as instituições conveniadas ativas, permitindo filtrar por nome, área temática (tags) e unidade regional, e abrir o convênio em modal ou página dedicada. |
| Entrada | `GET /associados?search=&tags[]=&regional=`, `GET /associados/detalhe/{id}`, `GET /associado/{slug}`. |
| Saída | HTML com hero, formulário de filtros, contagem, grid de cards, paginação, modal vazio; fragmento HTML; página completa com JSON-LD. |
| Efeitos colaterais | Cache Redis `page:*` (5 min), `Cache-Control: public, max-age=60`, `X-Cache-Status`, consultas ao índice? **Não** — a listagem não usa `v2_public_search_index`, consulta o banco diretamente. |
| Particularidade central | Com **qualquer** filtro a lista deixa de paginar (`get()`) e passa a agrupar pela **tag primária** — não pela tag filtrada; sem filtro, pagina 12 e não agrupa. |
| Como se integra | `v2-portal::app-layout` com props `pageTitle`, `pageDescription`, `schemas`, `fullWidthMain`, flags de header/footer; partials `v2-portal::partials.pagination`; `v2-service-media::components.picture`; `SEOService`. |
| Se removido, quebra | Menu do portal para "Associados", links do CMS ("Ver no portal"), modal de detalhe e a projeção de busca do módulo (o índice continua, mas a página some). |
| Superfície pública | 3 rotas, 4 blades, IDs `associados-search`, `associados-regional`, `associadoModal`, `associadoModalBody`, `galleryModal`. |

## 1. Efeito

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

- Ver todas as instituições ativas em cards com logo, nome, badge de desconto e até 3 tags.
- Buscar por nome, marcar N tags (auto-submit), escolher regional (auto-submit) e limpar filtros com contador.
- Abrir o detalhe em modal (logo, selo "Associado à SIGLA"/"Associado Institucional", badge, descrição, conteúdo Tiptap, benefícios, galeria, site e redes).
- Abrir a página dedicada pelo slug, com galeria em lightbox e links de regional.

### 1.2 O que muda no sistema

| Efeito | Onde | Fonte |
|---|---|---|
| Lista só ativas e ordena por `sort_order`, `name` | `index()` | `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:47,85` |
| Filtro de tag por IDs validados como UUID | `index()` | `:34-58` |
| Filtro de regional por `whereHas` | `index()` | `:62-66` |
| Agrupamento por tag primária quando há filtro | blade | `instituicoes.blade.php:155-181` |
| Detalhe por id em fragmento | `detalhe()` | `:122-158` |
| Página por slug com SEO | `show()` | `:160-198` |
| Hero configurável por `theme_associados_bg` | `index()` | `:96-102` |
| Cache Redis/HTTP | middlewares | `V2/Core/Middleware/PageCacheMiddleware.php:25-90`; `PublicCacheMiddleware.php:45-96` |

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

| Entrada | Saída |
|---|---|
| `GET /associados` | `200`; 12 cards; sem agrupamento; sem contagem de resultados |
| `GET /associados?search=foo` | `200`; todos os matches (sem paginação); agrupados por primária; contagem `N resultado(s) encontrado(s) para "foo"` |
| `GET /associados?tags[]=<uuid>` | idem, ordenado por número de tags casadas |
| `GET /associados?tags[]=lixo` | filtro ignorado; lista como sem filtro (12/página) |
| `GET /associados?regional=<uuid>` | filtra por unidade regional via pivô |
| `GET /associados` sem resultados (sem filtro) | `Nenhuma instituição encontrada` + `Volte mais tarde para conferir as instituições conveniadas.` |
| `GET /associados?search=x` sem resultados | `Nenhuma instituição encontrada` + `Tente ajustar os filtros ou limpar a busca.` |
| `GET /associados/detalhe/{id}` ativo | `200` fragmento (sem `<html>`), com spinner substituído |
| `GET /associados/detalhe/{id}` inativo/inexistente | `404` |
| `GET /associado/{slug}` ativo | `200` página com `<title>`, OG/Twitter, canonical, JSON-LD `Product` |
| `GET /associado/{slug}` inativo/inexistente | `404` |

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

- Não usa o índice público de busca (`v2_public_search_index`) na listagem.
- Não filtra por regional do visitante (não há tenancy no portal).
- Não tem busca textual na descrição/conteúdo — só `name`.
- Não tem paginação quando há filtro.
- Não tem avaliação, contato, formulário ou "favoritar".
- Não tem campo de SEO editável; usa fallbacks e `metadata.seo`.

## 2. Abordagem

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

```mermaid
flowchart LR
  Q[query string] --> S[sanitize tags]
  S --> B[AssociadoInstituicao::active]
  B --> F1[search ilike name]
  B --> F2[whereHas tags]
  B --> F3[whereHas unidadesRegionais]
  F1 & F2 & F3 --> D{hasFilters?}
  D -->|sim| G[withCount + get + groupBy primária]
  D -->|não| P[ordered + paginate 12]
  G & P --> V[instituicoes.blade]
```

### 2.2 Mecânica passo a passo

1. `index()` lê `search`, `tags[]` e `regional`; sanitiza tags por regex UUID e reconstrói `$activeTags`.
2. Monta `AssociadoInstituicao::active()`; aplica `where('name','ilike','%termo%')`.
3. Se há tags válidas, `whereHas('tags', whereIn v2_associado_tags.id)`.
4. Se há regional, `whereHas('unidadesRegionais', where id = ...)`.
5. `$hasFilters` só é `true` se algum filtro real sobrou.
6. Eager loading de `tags`, `primaryTag`, `gallery`.
7. Com filtros e tags: `withCount('tags as matched_tags_count')`, `orderBy desc`, `orderBy sort_order`, `orderBy name`, `get()`.
8. Sem filtros: `ordered()->paginate(12)`.
9. Carrega todas as tags e todas as regionais ativas para os controles.
10. Seta SEO, resolve o hero, devolve a view.
11. `detalhe()` busca ativo por id, achata `benefits_list`, renderiza Tiptap e devolve o fragmento.
12. `show()` busca ativo por slug, seta SEO, faz `bindModel`, achata benefícios, renderiza Tiptap e devolve a página.
13. O layout do portal monta `<title>`, metas OG/Twitter, canonical e JSON-LD a partir de `SEOService` + props `schemas`.

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

- **Banco em vez de índice** `[C]`: a listagem precisa de relação com tags/regional e de agrupamento; o índice público é usado pelo `/busca`, não por esta página.
- **Sanitização por regex** `[C]`: evita `whereIn` com valores arbitrários e mantém `$activeTags` consistente para o re-render.
- **Duas estratégias de listagem** `[C]`: sem filtro, paginação estável; com filtro, ranking por aderência e visão completa agrupada.
- **Fragmento para modal** `[C]`: reduz duplicação de HTML entre modal e página; o JS injeta o retorno do XHR.
- **Fallback de descrição em JSON** `[I]`: `strip_tags` sobre `convenio_description` não remove JSON; gera meta description ruim quando `description` está vazia (ver §3.3).
- **Sem SEO por registro** `[C]`: `bindModel` só usa `metadata.seo`; nenhum campo no CMS o preenche.

### 2.4 Trechos de código pivô

```php
// V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:76-86 — duas estratégias
if ($hasFilters) {
    if (!empty($safeTags)) {
        $query->withCount(['tags as matched_tags_count' => function ($q) use ($safeTags) {
            $q->whereIn('v2_associado_tags.id', $safeTags);
        }]);
        $query->orderBy('matched_tags_count', 'desc');
    }
    $query->orderBy('sort_order', 'asc')->orderBy('name', 'asc');
    $instituicoes = $query->get();
} else {
    $instituicoes = $query->ordered()->paginate(12);
}
```

O `get()` com filtro pode devolver todo o catálogo; a ordenação por `matched_tags_count` coloca primeiro quem casa com mais tags, e `sort_order`/`name` desempatam.

```blade
{{-- V2/Modules/Conexoes/Associados/Views/portal/instituicoes.blade.php:155-181 — agrupamento por tag primária --}}
@php
    $shouldGroup = $hasFilters;
    if ($shouldGroup) {
        $grouped = $instituicoes->groupBy(function ($item) {
            $primaryTag = $item->primaryTag->first();
            return $primaryTag ? $primaryTag->name : 'Outros';
        });
    }
@endphp
...
@if($shouldGroup)
    @foreach($grouped as $tagName => $items)
        <div class="mb-5">
            <h2 class="h4 fw-bold mb-0 me-3">{{ $tagName }}</h2>
            <span class="badge bg-primary bg-opacity-10 text-primary rounded-pill">{{ $items->count() }}</span>
            <div class="row g-4 associados-grid-row">
                @foreach($items as $instituicao)
                    @include('v2-associados::portal._card', ['instituicao' => $instituicao])
                @endforeach
            </div>
        </div>
    @endforeach
@endif
```

O agrupamento é pela tag **primária**, então filtrar por uma tag secundária mostra o card sob o grupo da primária — comportamento que pode surpreender.

```blade
{{-- V2/Modules/Conexoes/Associados/Views/portal/_card.blade.php:7-14,34-45 — card como gatilho do modal --}}
<div class="team-box-items mt-0 associado-card d-flex flex-column h-100 w-100"
     data-id="{{ $instituicao->id }}"
     data-bs-toggle="modal"
     data-bs-target="#associadoModal"
     role="button"
     tabindex="0"
     aria-label="Ver detalhes de {{ $instituicao->name }}">
...
@foreach($instituicao->tags->take(3) as $tag)
    <span class="badge bg-secondary bg-opacity-25 text-dark rounded-pill small">
        @if($tag->pivot->is_primary)<i class="bi bi-star-fill text-warning me-1" aria-hidden="true"></i>@endif
        {{ $tag->name }}
    </span>
@endforeach
<p class="flex-grow-1">{{ $instituicao->description ? str_limit($instituicao->description, 250) : 'Instituição associada regional oferecendo benefícios exclusivos para associados SUCESU.' }}</p>
```

O card inteiro é o botão (Bootstrap modal via `data-bs-*`), com `role="button"` e `tabindex="0"`; o JS adiciona Enter/Espaço.

```js
// V2/Modules/Conexoes/Associados/Views/portal/instituicoes.blade.php:281-320 — carga do fragmento no modal
modalEl.addEventListener('show.bs.modal', function(event) {
    const trigger = event.relatedTarget;
    if (!trigger) return;
    const id = trigger.getAttribute('data-id');
    if (!id) return;

    const url = detalheUrlBase.replace('__ID__', id);
    modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status">...</div></div>';

    var xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.onload = function() {
        if (xhr.status >= 200 && xhr.status < 300) {
            modalBody.innerHTML = xhr.responseText;
            var firstFocusable = modalBody.querySelector('button, a, input, [tabindex]:not([tabindex="-1"])');
            if (firstFocusable) setTimeout(function() { firstFocusable.focus(); }, 200);
        } else {
            modalBody.innerHTML = '<div class="text-center py-4 text-danger">...Erro ao carregar detalhes do associado.</div>';
        }
    };
    xhr.send();
});
```

O XHR **não** envia `X-Requested-With`, então o `PageCacheMiddleware` não o reconhece como AJAX e cacheia o fragmento pela URL. O foco é movido para o primeiro elemento focável após a injeção.

```blade
{{-- V2/Modules/Conexoes/Associados/Views/portal/instituicao.blade.php:63-72 — capa com variantes --}}
@if(!empty($instituicao->cover_image))
    $coverMedia = \V2\Services\Gallery\Models\GalleryMedia::where('file_path', $instituicao->cover_image)->first();
    if ($coverMedia && !empty($coverMedia->variants)) {
        $coverBgVariants = json_decode($coverMedia->variants, true) ?: [];
    }
@endif
$coverBg = $instituicao->cover_image
    ? v2_bg_image_css($instituicao->cover_image, $coverBgVariants)
    : 'linear-gradient(135deg, var(--tenant-primary), var(--tenant-secondary))';
```

A capa é resolvida por uma query extra a `GalleryMedia` usando o path (não o id), e o helper escolhe a maior variante WebP; sem capa, usa o gradiente do tema.

### 2.5 Algoritmos, consultas e custos

- Sem filtro: `count(*)` + `select ... limit 12 offset` com dois eager loads.
- Com filtro e tags: `select` completo + subquery de contagem + `exists` de tags + `exists` de regional; sem `limit`.
- `primaryTag` é uma segunda relação N:N carregada por eager load: `tags` e `primaryTag` custam 2 queries cada (pivô + tags) e `gallery` custa 1.
- `gallery` é carregado para cards, mas o card não o usa (custo desperdiçado).
- `show()` faz 1 query + eager loads + 1 query de `GalleryMedia` da capa; cada item da galeria dispara um `belongsTo` para `galleryMedia` (N+1), e o `picture` usa as variantes já carregadas.
- `detalhe()` não carrega `unidadesRegionais` no eager inicial (faz `load` depois) — 1 query extra.
- Cache: 5 min Redis por URL; HIT devolve conteúdo e headers sem `Set-Cookie`; escrita no CMS apaga tudo.

## 3. Particularidades

### 3.1 O que só existe aqui

- **Hero de listagem configurável** por `theme_associados_bg` com fallback `assets/img/hero/breadcrumb.png`.
- **Agrupamento por tag primária** apenas quando filtrado.
- **Selo regional** com link para `v2.unidade-regional.portal.show` (quando há regional).
- **Lightbox próprio** (`#galleryModal`) com teclado (setas) e botões anterior/próximo.

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-ASSOCIADOS-P01` | Somente `is_active = true` é público | arquivadas/inativas dão 404 e somem da lista | `AssociadosPortalController.php:47,124,162`; `Model:348-351` |
| `RN-ASSOCIADOS-P02` | Tags inválidas são descartadas | não há erro 500 por query string | `:34-44` |
| `RN-ASSOCIADOS-P03` | `hasFilters` exige filtro real | tag inválida não ativa o modo "sem paginação" | `:69` |
| `RN-ASSOCIADOS-P04` | Com filtro não pagina e agrupa pela primária | listas grandes; grupo ≠ tag filtrada | `:76-86`; `instituicoes.blade.php:155-181` |
| `RN-ASSOCIADOS-P05` | Sem filtro pagina 12 | navegação por `links('v2-portal::partials.pagination')` | `:85`; `instituicoes.blade.php:190-194` |
| `RN-ASSOCIADOS-P06` | Busca só por nome | descrição/conteúdo não são pesquisados | `:51` |
| `RN-ASSOCIADOS-P07` | Regional do filtro é aplicada ao pivô sem checar `status` | vínculo inativo continua exibindo | `Model:74-82`; `:63-65` |
| `RN-ASSOCIADOS-P08` | Detalhe por id e página por slug | URLs distintas para o mesmo conteúdo | `Routes/web.php:19-27` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Descrição vazia + `convenio_description` JSON**: `show()` usa `strip_tags($convenio_description)` como meta description; o resultado é o JSON cru (`{"type":"doc",...}`) truncado em 160 chars (`:168`; `instituicao.blade.php:6`). O `pageDescription` do layout recebe esse valor.
- **Sem descrição nenhuma**: controller usa `Instituição associada regional SUCESU` para a meta, mas a blade calcula `''` para `pageDescription`; prevalece o service (`$seoDescription`).
- **`metadata.seo.title`** (seeder/API) sobrescreve o título montado no controller, porque `bindModel` roda depois de `setTitle` (`:167-170`; `SEOService.php:29-58`).
- **Duplicação de JSON-LD**: o layout imprime `$seoSchemas` (de `bindModel`) e também `$schemas` (prop da página); a página por slug sempre injeta um `Product`, e a listagem um `CollectionPage` (`metadata/seo.blade.php:36-49`).
- **`hasFilters` e resultado vazio**: a seção de contagem só aparece com filtro, mesmo quando `0`.
- **Cards com 0 tags** mostram apenas nome/descrição; o card não exibe selo regional.
- **`$portalStorage` é passado ao card e não usado**; o card usa `v2_storage_url`.
- **Modal em página cacheada**: se o cache está quente, o detalhe pode refletir estado anterior por até 5 min.
- **`str_limit`/`str_plural`** são helpers legados do Laravel 5.5; o texto do card é cortado em 250.

### 3.4 Armadilhas de reimplementação

- **Paginar também quando há filtro** muda o contrato observável e quebra a contagem/agrupamento.
- **Agrupar pela tag filtrada** muda a ordem visual dos grupos.
- **Usar `$request->input('tags')` sem sanitizar** permite valores arbitrários em `whereIn` e ativa o modo sem paginação.
- **Trocar o XHR por `fetch` com `X-Requested-With`** faz o `PageCacheMiddleware` deixar de cachear o fragmento (mudança de comportamento).
- **Assumir que o índice público alimenta esta página** leva a implementar busca em tabela errada.
- **Remover `role="button"`/`tabindex`** quebra o suporte a teclado do card.

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

- **SEO sem fonte editorial**: não há campos no CMS; `metadata.seo` só existe em seeds/API.
- **Fallback de descrição para JSON**: dívida de qualidade de meta description.
- **`gallery` carregado na listagem sem uso**: custo desnecessário.
- **`$portalStorage` não usado** no card e no detalhe.
- **`show()` faz uma query extra por capa** procurando por path em vez de usar o id já guardado em `metadata.cover_image_id`.
- **Duas fontes de JSON-LD** podem duplicar `@type` se ambas existirem.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Assinatura | Retorno | Consumidores |
|---|---|---|---|
| `AssociadosPortalController::index()` | — | `View` | rota `v2.associados.portal.index` |
| `::detalhe($id)` | string | `View` (fragmento) | XHR do modal |
| `::show($slug)` | string | `View` | links externos, CMS |
| `v2-associados::portal._card` | partial | HTML de card | listagem |
| `v2-associados::portal.detalhe` | view | fragmento | modal |
| `v2-associados::portal.instituicao` | view | página | rota show |
| `v2-associados::portal.instituicoes` | view | página | rota index |

### 4.2 Contrato de UI/JS

- **IDs**: `associados-search`, `associados-regional`, `associadoModal`, `associadoModalLabel`, `associadoModalBody`, `galleryModal`, `gallery-thumb`, `gallery-prev`, `gallery-next`, `gallery-modal-img`.
- **Classes de comportamento**: `.associados-filter-form`, `.associado-tag-check .btn-check`, `.associado-card[role="button"]`, `.gallery-thumb`.
- **Eventos**: `change` nos checkboxes/select (auto-submit), `keydown` (Enter/Espaço no card; setas no lightbox), `show.bs.modal` (Bootstrap), `click` nos thumbs.
- **Sem JS**: os filtros só funcionam pelo botão `Buscar`; marcar tag/regional não envia; o card não abre o modal (mas tem link textual "Ver Detalhes" sem href).
- **CSS**: `public/assets/css/modules/associados/portal-associados.css` (com `?v=filemtime`).

### 4.3 Ordem e ciclo de vida

1. Rota pública → `MaintenanceMiddleware` → `page.cache` → `public.cache` → controller.
2. Controller resolve SEO e dados; view inclui layout do portal.
3. `DOMContentLoaded` registra os listeners do filtro, do card e do modal.
4. Lightbox só é registrado quando há galeria.

### 4.4 Receitas de uso

**(a) Linkar a página de uma instituição**

```blade
<a href="{{ route('v2.associados.portal.show', ['slug' => $instituicao->slug]) }}">
    {{ $instituicao->name }}
</a>
```

**(b) Embutir a listagem em outra página**

```blade
{{-- a view espera as variáveis do controller; prefira a rota --}}
<a href="{{ route('v2.associados.portal.index') }}">Associados Regionais</a>
```

**(c) Consumir o fragmento de detalhe**

```js
fetch('/associados/detalhe/' + id)   // sem X-Requested-With, para manter o cache de página
  .then(r => r.text())
  .then(html => document.getElementById('associadoModalBody').innerHTML = html);
```

**(d) Forçar atualização do hero após trocar a imagem**

```bash
# a escrita do CMS já invalida page:*; alternativamente:
php artisan v2:clear-page-cache
```

### 4.5 Anti-uso

- **Adicionar `X-Requested-With: XMLHttpRequest` no XHR do modal** esperando melhor performance: você desativa o cache de página para o fragmento e aumenta o custo no banco.
- **Usar `tags[]` sem o padrão UUID** em links compartilhados: o filtro pode ser descartado silenciosamente e o usuário ver a lista sem filtro.
- **Depender de paginação com filtro**: não existe; um link para `?page=2&search=x` mostra todos os resultados.
- **Editar SEO pelo CMS**: não há campo; alterações exigem banco/seeder.

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

- Lista sem filtro: 12 itens + eager loads; com filtro: sem limite.
- Cache Redis: 5 min por URL (query string incluída); invalidação global por escrita.
- HTTP: 60 s de cache público com revalidação.
- Modal: 1 requisição XHR por abertura (pode ser HIT do cache).
- Página por slug: 1 query extra para a capa + **1 query por item da galeria** (`galleryMedia` não é eager loaded por `with('gallery')`).

## 5. Rotas e contratos HTTP

| Método | URI | Nome | Query | Resposta |
|---|---|---|---|---|
| GET | `/associados` | `v2.associados.portal.index` | `search`, `tags[]` (UUID), `regional` (UUID), `page` | HTML; `X-Cache-Status`; `Cache-Control: public, max-age=60, must-revalidate` |
| GET | `/associados/detalhe/{id}` | `v2.associados.portal.detalhe` | — | fragmento HTML; `404` se inativo |
| GET | `/associado/{slug}` | `v2.associados.portal.show` | — | HTML completo; `404` se inativo |

Sem autenticação, sem CSRF (GET), sem throttle. `{id}`/`{slug}` sem `where`.

## 6. Dados e modelo de domínio

Consultas: `active()`, `ordered()`, `whereHas(tags)`, `whereHas(unidadesRegionais)`, `with(tags, primaryTag, gallery)`, `withCount(tags as matched_tags_count)`. Colunas e relações em [dados.md](dados.md).

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Lista
  Lista --> ListaFiltrada: submit/auto-submit
  ListaFiltrada --> Lista: limpar filtros
  Lista --> Modal: clique no card
  Modal --> Lista: fechar
  Lista --> PaginaSlug: link direto
  PaginaSlug --> Lightbox: clique na miniatura
```

`FL-ASSOCIADOS-P01` — listagem: sanitização → query → (paginação|agrupamento) → view → cache.
`FL-ASSOCIADOS-P02` — modal: clique → `show.bs.modal` → XHR → injeção → foco.
`FL-ASSOCIADOS-P03` — página: slug → model + SEO + Tiptap → layout → JSON-LD.

## 8. UI CMS

Ver [cms.md](cms.md).

## 9. UI Portal

### 9.1 Estrutura da listagem

1. **Hero/breadcrumb** com `background-image` do config e `<h1>Associados Regionais</h1>`.
2. **Filtros** (`section` com `aria-label="Filtros de busca"`): busca, checkboxes de tags (`btn-check` + label pill), select de regional, link "Limpar filtros" com badge de contagem.
3. **Contagem** (só com filtro, `aria-live="polite"`): `N resultados encontrados[ para "termo"]`.
4. **Grid** (`section.team-section`): agrupado por primária (com filtro) ou plano paginado.
5. **Vazio**: ícone, título, texto e link de limpar.
6. **Modal**: spinner → fragmento; erro com ícone e `Erro ao carregar detalhes do associado.`

### 9.2 Card (`_card.blade.php`)

- Coluna `col-md-6`.
- Logo 185×185 (`object-fit: contain`, lazy, `decoding=async`, `width/height`), fallback `bi-building`.
- Nome em `<h3>`, badge de desconto, até 3 tags (estrela na primária).
- Descrição truncada em 250 ou texto padrão.
- "Ver Detalhes" com ícone de link externo.
- Card inteiro abre o modal; `aria-label="Ver detalhes de NOME"`.

### 9.3 Detalhe (`detalhe.blade.php`)

- Cabeçalho centralizado: logo (max 80 px), nome (`h2`), selo regional/institucional, badge.
- Descrição, "Sobre o Convênio" (HTML do Tiptap), "Benefícios" (linhas de `benefits_list` + `metadata.benefits` com link), "Galeria" (grid 3 colunas, `picture` com variantes, link para o original), site e redes sociais (ícones), tags.
- Não tem galeria modal neste fragmento (o link abre a imagem).

### 9.4 Página (`instituicao.blade.php`)

- Hero com capa (variantes WebP) e breadcrumb `Home › Associados Regionais › NOME`.
- Coluna principal: descrição (`lead`), conteúdo, benefícios, galeria (grid 3/2 colunas) com lightbox.
- Sidebar: "Informações" (site e redes), "Tags".
- Lightbox: modal `modal-xl` escuro, setas, teclado, `alt="Galeria"`.
- Aviso? Não; é página final.

### 9.5 SEO

| Item | Origem | Fonte |
|---|---|---|
| `<title>` | `SEOService` (setTitle/bindModel) ou `pageTitle` | `metadata/seo.blade.php:2-3` |
| `description` | `SEOService::setDescription` ou `pageDescription` | `:4` |
| `keywords` | `SEOService::setKeywords` | `:5` |
| OG (`og:type/url/title/description/image`) | layout + `SEOService::getCoverImage` | `:8-21` |
| Twitter (`summary_large_image`) | layout | `:24-28` |
| canonical | `SEOService::getCanonical` ou URL atual | `:31` |
| JSON-LD | `bindModel` (`metadata.seo.json_ld`) + prop `schemas` | `:36-49` |
| `robots` | não definido (default do layout) | — |

- Listagem injeta `CollectionPage` com `name`, `description`, `url`.
- Página injeta `Product` com `name`, `description`, `url`.
- `coverImage` é `null` nas duas; o OG image cai na logo principal do layout (`SEOService.php:375-396`).

### 9.6 Acessibilidade e responsividade

- `aria-label` nos filtros, no card e nos links externos ("abre em nova aba").
- Checkboxes reais (`btn-check`) com label associado; teclado funciona.
- Foco movido para o primeiro focável do modal após o carregamento; não há focus trap explícito (Bootstrap modal cobre).
- Lightbox: botões com `aria-label`, setas do teclado; sem `aria-live`.
- Imagens: `alt` com o nome da instituição; galeria usa `alt="Galeria NOME"`; capa é CSS (sem alt).
- Grid responsivo: `col-md-6` (cards), `col-4` (galeria modal), `col-lg-8/4` (página), `col-md-4 col-6` (galeria da página).
- `print`: sem CSS específico; o layout do portal define o comportamento global.

### 9.7 Cache

- Redis `page:sha1(fullUrl)` por 5 min; `X-Cache-Status`.
- HTTP `public, max-age=60, must-revalidate` + `Vary: Accept-Encoding`.
- Invalidação: qualquer escrita no CMS (`page:*`), ou `v2:clear-page-cache`.
- O fragmento do modal também é cacheado (XHR sem header AJAX).

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

- Público: sem permissão.
- `theme_associados_bg` em `v2_portal_configs` (sem default; fallback `assets/img/hero/breadcrumb.png`).
- `app.name` nos títulos.
- Flag do módulo: **não** se aplica às rotas públicas (o provider só adiciona `module.active` ao grupo CMS); desligar `associados` esconde o menu do CMS, mas `/associados` continua respondendo (`V2/Core/Providers/V2ServiceProvider.php:297-306`).

## 11. Observabilidade, erros e limites

- Erros de render Tiptap são logados (`[AssociadosPortal] Erro ao renderizar descrição`) e o conteúdo cai para vazio.
- `404` para id/slug inexistente ou inativo.
- Cache HIT/MISS via header; falha do Redis é fail-open (log warning).
- Sem paginação com filtro: risco de resposta grande.
- `keys()` na invalidação é O(N) no Redis.

## 12. Seeds e dados iniciais

O portal exibe o que o seeder/API criar. `DemoContentSeeder` cria 10 instituições ativas com tags e regionais (ver [dados.md](dados.md)). As tags dos checkboxes vêm do catálogo inteiro, mesmo as sem uso.

## 13. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| `LG-ASSOCIADOS-P01` | Não há definição de `robots`/`noindex` para listas filtradas. | Risco de SEO duplicado em URLs com query. | layout do portal |
| `LG-ASSOCIADOS-P02` | Não há tratamento visual/SEO para descrição ausente além do fallback. | Cards e meta podem ficar genéricos. | decisão editorial |
| `LG-ASSOCIADOS-P03` | O CSS do módulo não foi auditado (breakpoints, contraste). | Paridade visual/acessibilidade. | `portal-associados.css` |
| `LG-ASSOCIADOS-P04` | Não há teste de volume para a lista sem paginação. | Risco de timeout com catálogo grande. | produção |

## 14. Checklist de paridade

- [ ] `PC-ASSOCIADOS-P01` — `/associados` sem filtro pagina 12 e ordena por `sort_order`, `name`. Fonte: `AssociadosPortalController.php:85`.
- [ ] `PC-ASSOCIADOS-P02` — qualquer filtro real desativa paginação e agrupa por tag primária. Fonte: `:69-86`; `instituicoes.blade.php:155-181`.
- [ ] `PC-ASSOCIADOS-P03` — tags fora do padrão UUID são ignoradas. Fonte: `:34-44`.
- [ ] `PC-ASSOCIADOS-P04` — busca é `ilike` apenas em `name`. Fonte: `:50-52`.
- [ ] `PC-ASSOCIADOS-P05` — filtro regional usa o pivô. Fonte: `:62-66`.
- [ ] `PC-ASSOCIADOS-P06` — card abre modal e recebe foco no primeiro elemento. Fonte: `instituicoes.blade.php:281-320`.
- [ ] `PC-ASSOCIADOS-P07` — detalhe e página retornam 404 para inativos. Fonte: `:124-126,162-165`.
- [ ] `PC-ASSOCIADOS-P08` — listagem injeta `CollectionPage`; página injeta `Product`. Fonte: `instituicoes.blade.php:19-27`; `instituicao.blade.php:39-49`.
- [ ] `PC-ASSOCIADOS-P09` — cache Redis 5 min + HTTP 60 s, com `X-Cache-Status`. Fonte: `PageCacheMiddleware.php:11-14,35-56`; `PublicCacheMiddleware.php:85-96`.
- [ ] `PC-ASSOCIADOS-P10` — lightbox da página navega por setas e teclado. Fonte: `instituicao.blade.php:294-322`.
- [ ] `PC-ASSOCIADOS-P11` — o fragmento do modal também é cacheado (XHR sem `X-Requested-With`). Fonte: `instituicoes.blade.php:296-298`; `PageCacheMiddleware.php:74-77`.

## 15. Evidências consultadas

- `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:28-198`
- `V2/Modules/Conexoes/Associados/Views/portal/instituicoes.blade.php:1-323`
- `V2/Modules/Conexoes/Associados/Views/portal/_card.blade.php:1-53`
- `V2/Modules/Conexoes/Associados/Views/portal/detalhe.blade.php:1-182`
- `V2/Modules/Conexoes/Associados/Views/portal/instituicao.blade.php:1-327`
- `V2/layouts/portal/metadata/seo.blade.php:1-50`; `metadata/gtag.blade.php:1-19`
- `V2/Services/SEO/Services/SEOService.php:29-58,293-401`
- `V2/Core/Middleware/PageCacheMiddleware.php:1-90`; `PublicCacheMiddleware.php:1-120`
- `V2/Core/Helpers/helpers.php:259-301` (`v2_storage_url`, `v2_bg_image_css`)
- `V2/Services/Media/Views/components/picture.blade.php` (variantes/lazy)
- `public/assets/css/modules/associados/portal-associados.css` (referência de classes)
