teqo/src/lib/campaignPageChrome.ts
Francisco Solla f3c41d3382 feat(C101-ux): agenda mobile — navegação por teclado (região focável) + affordance do tap no título
- useAgendaKeyboardNavigation: ArrowLeft/Right → prev/next no mobile
  (FullCalendar v7 não tem setas no grid — verificado no bundle); skips
  para modificadores/dialog/menu/inputs; restore de foco na região
- Região de teclado mobile-only: tabIndex/role/aria-label gateados por
  isMobile (desktop mantém a toolbar e sua árvore a11y intacta)
- onTitleClick ganha icon?: LucideIcon (glyph da página, aria-hidden);
  agenda passa RotateCcw como affordance do 'voltar para hoje'
- E2E: teste de teclado (dia e mês), glyph no título, hardening do swipe
  CDP (pacing 25ms — burst era classificado como flick pelo Chromium e
  disparava back-nav ~35% das runs)
- Unit: 14 testes do hook/pura + 4 do renderer do glyph; impl plan
  docs/plans/c101-ux-pos-critique-impl.md (aprovado pelo humano)

Closes #512
2026-08-10 00:53:19 -03:00

294 lines
9.1 KiB
TypeScript

import type { LucideIcon } from 'lucide-react'
import type { Metadata } from 'next'
import { CAMPAIGN_HOME, CAMPAIGN_UPDATES_HREF, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { normalizePathname } from '@/lib/campaignQuickActionPaths'
import type { CampaignUser } from '@/payload-types'
export type CampaignPageChrome = {
title: string
subtitle?: string
/**
* C101 — page-owned action on the app-header title (mobile layout only).
* The agenda uses it to make the period label the "return to today"
* control, since the FullCalendar "Hoje" toolbar button is hidden on
* phones. Only client-rendered overrides set it — the catalog never does.
* The `hint` is the page's own copy (rendered as the button tooltip), so
* the shared chrome does not hardcode agenda wording.
* C101-ux — the optional `icon` is the page's own affordance glyph for the
* tap (rendered trailing the title, aria-hidden), for the same reason the
* copy is page-owned: the shared chrome stays generic.
*/
onTitleClick?: { action: () => void; hint: string; icon?: LucideIcon }
}
type CampaignRole = CampaignUser['role']
/** Client-safe vocabulary for shell chrome and tab titles (B123). */
export const campaignPageChromeCatalog = {
home: null,
quadro: {
title: 'Quadro',
},
municipios: {
title: 'Municípios',
},
territorios: {
title: 'Territórios de Identidade',
},
liderancas: {
title: 'Lideranças',
},
liderancasNova: {
title: 'Nova liderança',
},
dobradinhas: {
title: 'Dobradinhas',
},
dobradinhasNova: {
title: 'Nova dobradinha',
},
atividades: {
title: 'Atividades',
},
agenda: {
title: 'Agenda',
},
atividadesNova: {
title: 'Nova atividade',
},
atividadesGiros: {
title: 'Planejar giro',
},
demandas: {
title: 'Demandas',
},
demandasNova: {
title: 'Nova demanda',
},
apoiadores: {
title: 'Apoiadores',
},
apoiadoresNovo: {
title: 'Novo apoiador',
},
apoiadoresImportar: {
title: 'Importar apoiadores via CSV',
},
assessores: {
title: 'Assessores',
},
organizacoes: {
title: 'Organizações',
subtitle:
'Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e atividades.',
},
organizacoesNova: {
title: 'Nova organização',
subtitle:
'Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e atividades.',
},
conceitos: {
title: 'Conceitos de inteligência',
subtitle:
'O que cada número da campanha mede e como é calculado. Só o que o produto já calcula hoje — a lista cresce conforme novas análises entram.',
},
atualizacoes: {
title: 'Atualizações',
},
contatos: {
title: 'Contatos',
subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
},
perfil: {
title: 'Meu perfil',
subtitle: 'Gerencie sua foto, sua senha e a entrada por biometria.',
},
} as const satisfies Record<string, CampaignPageChrome | null>
const resolveCatalogEntry = (
entry: (typeof campaignPageChromeCatalog)[keyof typeof campaignPageChromeCatalog],
): CampaignPageChrome | null => {
if (!entry) return null
return 'subtitle' in entry && entry.subtitle
? { title: entry.title, subtitle: entry.subtitle }
: { title: entry.title }
}
type PathRule = {
match: (pathname: string) => boolean
resolve: () => CampaignPageChrome | null
}
const pathRules: PathRule[] = [
{
match: (pathname) => pathname === CAMPAIGN_HOME,
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/quadro',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.quadro),
},
{
match: (pathname) => pathname === '/campanha/municipios',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.municipios),
},
{
match: (pathname) => /^\/campanha\/municipios\/[^/]+\/editar$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
// B147 parallel v2 — soft-dep B145 for entity name in chrome; section title for now.
match: (pathname) => /^\/campanha\/municipio\/[^/]+\/v2$/.test(pathname),
resolve: () => ({ title: campaignPageChromeCatalog.municipios.title }),
},
{
match: (pathname) => pathname === '/campanha/territorios',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.territorios),
},
{
match: (pathname) => pathname === '/campanha/liderancas/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.liderancasNova),
},
{
match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/liderancas',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.liderancas),
},
{
match: (pathname) => pathname === '/campanha/dobradinhas/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhasNova),
},
{
match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/dobradinhas',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhas),
},
{
match: (pathname) => pathname === '/campanha/agenda',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.agenda),
},
{
match: (pathname) => pathname === '/campanha/atividades/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),
},
{
match: (pathname) => pathname === '/campanha/atividades/giros',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros),
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+\/editar$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/atividades',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividades),
},
{
match: (pathname) => pathname === '/campanha/demandas/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.demandasNova),
},
{
match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/demandas',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.demandas),
},
{
match: (pathname) => pathname === '/campanha/apoiadores/novo',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresNovo),
},
{
match: (pathname) => pathname === '/campanha/apoiadores/importar',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresImportar),
},
{
match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/apoiadores',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.apoiadores),
},
{
match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/assessores',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.assessores),
},
{
match: (pathname) => pathname === '/campanha/organizacoes/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.organizacoesNova),
},
{
match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/organizacoes',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.organizacoes),
},
{
match: (pathname) => pathname === '/campanha/conceitos',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.conceitos),
},
{
match: (pathname) => pathname === CAMPAIGN_UPDATES_HREF,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atualizacoes),
},
{
match: (pathname) => pathname === LEADER_CONTACTS_HOME,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.contatos),
},
{
match: (pathname) => pathname === '/campanha/perfil',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.perfil),
},
]
export const resolveCampaignPageChrome = (
pathname: string,
_role: CampaignRole,
): CampaignPageChrome | null => {
const normalized = normalizePathname(pathname)
for (const rule of pathRules) {
if (rule.match(normalized)) {
return rule.resolve()
}
}
return null
}
const noIndexMetadata: Pick<Metadata, 'robots'> = {
robots: { index: false, follow: false },
}
/** Tab title via `title.template` on `(campaign)/layout.tsx`. */
export const campaignPageMetadata = (chrome: CampaignPageChrome | null): Metadata => {
if (!chrome) {
return { title: 'Início', ...noIndexMetadata }
}
return { title: chrome.title, ...noIndexMetadata }
}
export const campaignPageMetadataFromCatalog = (
key: keyof typeof campaignPageChromeCatalog,
): Metadata => campaignPageMetadata(resolveCatalogEntry(campaignPageChromeCatalog[key]))