feat(potencial): página pública, formulário e story 1080×1920

A rota estática /potencial informa UF/município/zona/seção do título e
mostra as três leituras da seção com o ganho em p.p., rotulado como cenário
hipotético, com a prévia do story (canvas real) e baixar/compartilhar. A
ilha trata carregando, resultado, não encontrada e sem dado; o combobox de
município busca a partir de 3 letras pelo normalizador dos pickers; foco
amarelo+separador escuro e alvos ≥44px. Arimo/Exo 2 passam a viver em
fonts.ts (owner único) para o canvas receber a família. E2E novo
frontendPotencial (projeto + manifest + prewarm no setup).
This commit is contained in:
Francisco Solla 2026-10-07 19:04:42 -03:00
parent 62b674cfba
commit 67d889661a
15 changed files with 1588 additions and 6 deletions

View file

@ -198,6 +198,16 @@ export default defineConfig({
dependencies: isProdMode ? [] : ['frontend'], dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' }, use: { ...devices['Desktop Chrome'], channel: 'chromium' },
}, },
{
// S46 — the `/potencial` public page (TSE section query + story PNG
// download). Reads only the committed artifact and owns no DB rows, so
// it serializes behind frontend in dev (shared dev-server boot) and runs
// parallel to it in prod.
name: 'frontendPotencial',
testMatch: /frontendPotencial\.e2e\.spec\.ts/,
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{ {
// S27 — the Central de Conteúdos public page (catalogue, lazy media, // S27 — the Central de Conteúdos public page (catalogue, lazy media,
// vote share, kill switch). Owns its content piece rows only, so it // vote share, kill switch). Owns its content piece rows only, so it

View file

@ -101,6 +101,23 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'], prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'],
specs: ['frontend', 'campaignNewsletter'], specs: ['frontend', 'campaignNewsletter'],
}, },
{
// S46 — the public `/potencial` surface: the section query endpoints over
// the committed TSE shards, the pure calculator and the story canvas
// export. The artifact itself (`public/dados/…`) and its build script are
// outside `src/` — the manifest only maps src diffs; a data-only rebuild
// rides the deploy verify's full suite.
prefixes: [
'src/app/(frontend)/(home)/potencial',
'src/app/(frontend)/api/potencial',
'src/app/(frontend)/fonts.ts',
'src/components/potencial',
'src/lib/sectionPotential',
'src/lib/sectionStoryRender',
'src/utilities/potencialSections',
],
specs: ['frontendPotencial'],
},
{ {
// S19/S29 — the share-link surface: the `[type]` branch serves the OG card, // S19/S29 — the share-link surface: the `[type]` branch serves the OG card,
// the instant handoff and the announcement page (`.ics` included), and the // the instant handoff and the announcement page (`.ics` included), and the
@ -510,6 +527,7 @@ export const E2E_AFFECTED_MANIFEST = [
'campaignSpeechCut', 'campaignSpeechCut',
'campaignReel', 'campaignReel',
'frontendFotosSelfie', 'frontendFotosSelfie',
'frontendPotencial',
], ],
}, },
{ {

View file

@ -0,0 +1,89 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { campaignTextFont } from '@/app/(frontend)/fonts'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { SectionPotentialStudio } from '@/components/potencial/SectionPotentialStudio'
import {
POTENTIAL_HEADLINE,
POTENTIAL_INTRO,
POTENTIAL_PATH,
} from '@/components/potencial/sectionPotentialCopy'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
/**
* S46 — a página pública do "potencial da sua seção": informa UF, município,
* zona e seção do título e sai com o story 1080×1920. Sem login, sem cadastro
* e sem coleta de contato; os números vêm do artefato TSE commitado, servido
* pelas rotas `/api/potencial/*`. Indexável com título e descrição próprios.
*/
export async function generateMetadata(): Promise<Metadata> {
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, POTENTIAL_PATH)
const { url: imageUrl } = await resolveOgImage(null)
const title = `${POTENTIAL_HEADLINE} | ${siteName}`
const images = imageUrl ? [{ url: imageUrl, alt: POTENTIAL_HEADLINE }] : []
return {
title,
description: POTENTIAL_INTRO,
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: POTENTIAL_INTRO,
images,
},
twitter: {
card: 'summary_large_image',
title,
description: POTENTIAL_INTRO,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function PotencialPage() {
const [showJingles, showConteudos, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
return (
<>
<CampaignPageHeader />
<div className="border-b border-(--campaign-line) bg-white">
<div className="mx-auto w-full max-w-6xl px-5 py-2.5 text-sm text-(--campaign-muted) sm:px-8">
<Link
href="/"
className="rounded-sm font-bold text-(--pt-red) no-underline underline-offset-4 hover:underline"
>
Início
</Link>
<span className="px-1 text-[#c9c2bd]">/</span> Potencial da sua seção
</div>
</div>
<main className="w-full bg-white text-black">
<SectionPotentialStudio fontFamily={campaignTextFont.style.fontFamily} />
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos={showFotos}
/>
</>
)
}

View file

@ -1,3 +1,4 @@
import { Arimo, Exo_2 } from 'next/font/google'
import localFont from 'next/font/local' import localFont from 'next/font/local'
/** /**
@ -16,3 +17,12 @@ export const brexterBold = localFont({
// loads it on demand via `document.fonts.load`). // loads it on demand via `document.fonts.load`).
preload: false, preload: false,
}) })
/**
* Campanha 2026 — Exo 2 display (títulos/CTAs) e Arimo (texto), as duas faces
* OFL self-hosted do site. Vivem aqui (não no layout) desde o S46: a página
* pública do story passa a família ao canvas do cliente, e o `(frontend)`
* layout consome as mesmas instâncias — uma só, sem preload duplicado.
*/
export const campaignDisplayFont = Exo_2({ subsets: ['latin'], variable: '--font-exo2' })
export const campaignTextFont = Arimo({ subsets: ['latin'], variable: '--font-arimo' })

View file

@ -7,15 +7,12 @@ import { resolveSiteMetadata } from '@/utilities/seo'
import { Analytics } from '@vercel/analytics/next' import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next' import { SpeedInsights } from '@vercel/speed-insights/next'
import { Metadata } from 'next' import { Metadata } from 'next'
import { Arimo, Exo_2, Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import React from 'react' import React from 'react'
import { campaignDisplayFont, campaignTextFont } from './fonts'
import './styles.css' import './styles.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
// Campanha 2026: Exo 2 para display (títulos/CTAs) e Arimo para cards/tags —
// mesmo uso das peças do Penpot e do santinho. OFL, self-hosted pelo next/font.
const exo2 = Exo_2({ subsets: ['latin'], variable: '--font-exo2' })
const arimo = Arimo({ subsets: ['latin'], variable: '--font-arimo' })
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const payload = await getCachedGlobal('metadata')() const payload = await getCachedGlobal('metadata')()
@ -69,7 +66,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
return ( return (
<html <html
lang="pt-BR" lang="pt-BR"
className={`${inter.variable} ${exo2.variable} ${arimo.variable} w-screen h-screen scrollbar-hide`} className={`${inter.variable} ${campaignDisplayFont.variable} ${campaignTextFont.variable} w-screen h-screen scrollbar-hide`}
style={{ colorScheme: 'light' }} style={{ colorScheme: 'light' }}
suppressHydrationWarning suppressHydrationWarning
> >

View file

@ -0,0 +1,211 @@
'use client'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import {
filterPotentialMunicipalities,
type PotentialMunicipalityOption,
type PotentialUf,
} from '@/lib/sectionPotential'
import { cn } from '@/lib/utils'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
POTENTIAL_FIELD_INPUT,
POTENTIAL_FIELD_LABEL,
POTENTIAL_FOCUS,
POTENTIAL_NOTE,
} from './sectionPotentialClasses'
import {
POTENTIAL_MUNICIPALITY_EMPTY,
POTENTIAL_MUNICIPALITY_ERROR,
POTENTIAL_MUNICIPALITY_HELP,
POTENTIAL_MUNICIPALITY_PICK,
} from './sectionPotentialCopy'
type MunicipalityComboboxProps = {
uf: PotentialUf
value: string
onValueChange: (value: string) => void
onSelect: (municipality: PotentialMunicipalityOption) => void
}
/**
* S46 — o campo Município (design, detalhe do combobox): busca por nome a
* partir de 3 letras, accent-insensitive, só municípios da UF escolhida; sem
* texto livre — a consulta exige uma opção conhecida (o `code` TSE).
*/
export const MunicipalityCombobox = ({
uf,
value,
onValueChange,
onSelect,
}: MunicipalityComboboxProps) => {
const listId = useId()
const cache = useRef(new Map<string, PotentialMunicipalityOption[]>())
const [items, setItems] = useState<PotentialMunicipalityOption[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
let alive = true
const cached = cache.current.get(uf)
if (cached) {
setItems(cached)
setLoadState('ready')
return
}
setLoadState('loading')
fetch(`/api/potencial/municipios?uf=${uf}`, { headers: { accept: 'application/json' } })
.then((response) => {
if (!response.ok) throw new Error(`municipios:${response.status}`)
return response.json() as Promise<{
ok: boolean
municipalities?: PotentialMunicipalityOption[]
}>
})
.then((body) => {
if (!alive) return
if (!body.ok || !body.municipalities) throw new Error('municipios:body')
cache.current.set(uf, body.municipalities)
setItems(body.municipalities)
setLoadState('ready')
})
.catch(() => {
if (alive) setLoadState('error')
})
return () => {
alive = false
}
}, [uf])
const matches = useMemo(() => filterPotentialMunicipalities(items, value), [items, value])
const showList = open && loadState === 'ready' && value.trim().length >= 3
// Nome exato de um município carregado = seleção válida; a ajuda some (o
// "Escolha um município da lista." contradizia o campo preenchido).
const selectedExact =
value.trim().length >= 3 &&
items.some(
(municipality) => normalizeSearchPhrase(municipality.name) === normalizeSearchPhrase(value),
)
useEffect(() => {
setActiveIndex(0)
}, [value, uf])
const select = (municipality: PotentialMunicipalityOption) => {
onSelect(municipality)
setOpen(false)
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!showList || matches.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, matches.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
select(matches[activeIndex])
} else if (event.key === 'Escape') {
setOpen(false)
}
}
const helper =
loadState === 'error'
? POTENTIAL_MUNICIPALITY_ERROR
: selectedExact
? null
: value.trim().length < 3
? POTENTIAL_MUNICIPALITY_HELP
: showList && matches.length === 0
? POTENTIAL_MUNICIPALITY_EMPTY
: POTENTIAL_MUNICIPALITY_PICK
return (
<div className="relative">
<label className={POTENTIAL_FIELD_LABEL} htmlFor={`${listId}-input`}>
Município
</label>
<div className={POTENTIAL_FIELD_INPUT}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0 text-(--campaign-muted)"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id={`${listId}-input`}
type="text"
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={showList}
aria-controls={listId}
aria-activedescendant={
showList && matches[activeIndex] ? `${listId}-${matches[activeIndex].code}` : undefined
}
aria-describedby={`${listId}-help`}
value={value}
onChange={(event) => {
onValueChange(event.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)}
/>
</div>
{showList ? (
<ul
id={listId}
role="listbox"
aria-label="Municípios"
className="absolute z-20 mt-1.5 max-h-64 w-full list-none overflow-auto rounded-xl border border-(--campaign-line) bg-white p-0 shadow-[0_12px_32px_rgb(0_0_0/12%)]"
// Mantém o foco no input enquanto o clique seleciona a opção.
onMouseDown={(event) => event.preventDefault()}
>
{matches.map((municipality, index) => (
<li
key={municipality.code}
id={`${listId}-${municipality.code}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => select(municipality)}
className={cn(
'cursor-pointer px-3 py-2.5 text-sm',
index === activeIndex ? 'bg-(--campaign-cream) font-bold text-(--pt-red)' : '',
)}
>
{municipality.name}
</li>
))}
</ul>
) : null}
{helper ? (
<p
id={`${listId}-help`}
className={cn(POTENTIAL_NOTE, 'mt-1.5', loadState === 'error' && 'text-(--destructive)')}
>
{helper}
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,214 @@
'use client'
import {
POTENTIAL_UFS,
type PotentialMunicipalityOption,
type PotentialUf,
} from '@/lib/sectionPotential'
import { cn } from '@/lib/utils'
import { MunicipalityCombobox } from './MunicipalityCombobox'
import {
POTENTIAL_FIELD_INPUT,
POTENTIAL_FIELD_INPUT_ERROR,
POTENTIAL_FIELD_LABEL,
POTENTIAL_FOCUS,
POTENTIAL_NOTE,
POTENTIAL_OUTLINE_BUTTON,
POTENTIAL_PRIMARY_BUTTON,
} from './sectionPotentialClasses'
import { POTENTIAL_CHECK_LIST, TSE_SELF_SERVICE_URL } from './sectionPotentialCopy'
type SectionPotentialFormProps = {
uf: PotentialUf
onUfChange: (uf: PotentialUf) => void
municipalityText: string
onMunicipalityTextChange: (value: string) => void
onMunicipalitySelect: (municipality: PotentialMunicipalityOption) => void
zone: string
onZoneChange: (value: string) => void
section: string
onSectionChange: (value: string) => void
fieldsInvalid: boolean
submitLabel: string
onSubmit: () => void
}
/**
* S46 — o formulário do título (design, cena 3a): UF, município, zona e seção
* em duas colunas, com a lista de conferência e o caminho para o
* autoatendimento do TSE quando a seção não aparece. Zona e seção ficam
* marcadas quando a combinação não existe.
*/
export const SectionPotentialForm = ({
uf,
onUfChange,
municipalityText,
onMunicipalityTextChange,
onMunicipalitySelect,
zone,
onZoneChange,
section,
onSectionChange,
fieldsInvalid,
submitLabel,
onSubmit,
}: SectionPotentialFormProps) => (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={POTENTIAL_FIELD_LABEL} htmlFor="potencial-uf">
UF
</label>
<div className={POTENTIAL_FIELD_INPUT}>
<select
id="potencial-uf"
value={uf}
onChange={(event) => onUfChange(event.target.value as PotentialUf)}
className={cn(
'w-full min-w-0 appearance-none bg-transparent text-base',
POTENTIAL_FOCUS,
)}
>
{POTENTIAL_UFS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0 text-(--campaign-muted)"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
<MunicipalityCombobox
uf={uf}
value={municipalityText}
onValueChange={onMunicipalityTextChange}
onSelect={onMunicipalitySelect}
/>
<div>
<label className={POTENTIAL_FIELD_LABEL} htmlFor="potencial-zona">
Zona
</label>
<div className={cn(POTENTIAL_FIELD_INPUT, fieldsInvalid && POTENTIAL_FIELD_INPUT_ERROR)}>
<input
id="potencial-zona"
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={4}
aria-invalid={fieldsInvalid || undefined}
aria-describedby={fieldsInvalid ? 'potencial-fields-error' : undefined}
value={zone}
onChange={(event) => onZoneChange(event.target.value.replace(/\D/g, ''))}
className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)}
/>
</div>
</div>
<div>
<label className={POTENTIAL_FIELD_LABEL} htmlFor="potencial-secao">
Seção
</label>
<div className={cn(POTENTIAL_FIELD_INPUT, fieldsInvalid && POTENTIAL_FIELD_INPUT_ERROR)}>
<input
id="potencial-secao"
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={4}
aria-invalid={fieldsInvalid || undefined}
aria-describedby={fieldsInvalid ? 'potencial-fields-error' : undefined}
value={section}
onChange={(event) => onSectionChange(event.target.value.replace(/\D/g, ''))}
className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)}
/>
</div>
</div>
</div>
{fieldsInvalid ? (
<p
id="potencial-fields-error"
className="mt-1.5 text-xs leading-relaxed text-(--destructive)"
>
Zona e seção não conferem entre si. Revise os números no título.
</p>
) : null}
<div className="mt-5 rounded-xl border border-(--campaign-line) bg-white p-4">
<p className="m-0 text-[13px] font-extrabold">No título de eleitor, confira:</p>
<ul className="mt-2 list-none space-y-1.5 p-0 text-[13px] leading-5 text-(--campaign-muted)">
{POTENTIAL_CHECK_LIST.map((item) => (
<li key={item}>· {item}</li>
))}
</ul>
</div>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<button type="submit" className={cn(POTENTIAL_PRIMARY_BUTTON, 'flex-1')}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
{submitLabel}
</button>
<a
href={TSE_SELF_SERVICE_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(POTENTIAL_OUTLINE_BUTTON, 'flex-1')}
>
Consultar meu título no TSE
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</a>
</div>
<p className={cn(POTENTIAL_NOTE, 'mt-3')}>
A base usa os dados do 1º turno de 2026 publicados pelo TSE. Seção recém-criada ou transferida
pode ainda não aparecer.
</p>
</form>
)

View file

@ -0,0 +1,191 @@
'use client'
import {
formatPotentialPercent,
formatPotentialPoints,
type PotentialSectionNumbers,
type SectionPotential,
} from '@/lib/sectionPotential'
import { SECTION_STORY_HYPOTHESIS } from '@/lib/sectionStoryRender'
import { cn } from '@/lib/utils'
import {
POTENTIAL_EYEBROW,
POTENTIAL_FOCUS,
POTENTIAL_GHOST_BUTTON,
POTENTIAL_NOTE,
POTENTIAL_STAT_LABEL,
POTENTIAL_STAT_VALUE,
} from './sectionPotentialClasses'
import {
POTENTIAL_PRIVACY_NOTE,
POTENTIAL_SOURCE_PREFIX,
TSE_RESULTS_URL,
} from './sectionPotentialCopy'
type SectionPotentialResultProps = {
numbers: PotentialSectionNumbers
potential: SectionPotential
onChangeSection: () => void
}
/**
* S46 — o painel de resultado (design, cenas 1/2/3c): número-herói do ganho
* total, os dois ganhos, as três leituras da mesma seção, o "Como calculamos"
* com a legenda X₁/X₂ e os guardrails (cenário hipotético + fonte TSE).
*/
export const SectionPotentialResult = ({
numbers,
potential,
onChangeSection,
}: SectionPotentialResultProps) => (
<div>
<div className="flex items-center justify-between gap-4 rounded-2xl border border-(--campaign-line) bg-white px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-(--campaign-cream) text-(--pt-red)">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>
</span>
<div className="min-w-0">
<p className="m-0 truncate text-sm font-bold">
<span className="sm:hidden">
{numbers.uf} · {numbers.municipalityName}
</span>
<span className="hidden sm:inline">
{numbers.uf} · {numbers.municipalityName} · Zona {numbers.zone} · Seção{' '}
{numbers.section}
</span>
</p>
<p className="m-0 text-xs text-(--campaign-muted)">
<span className="sm:hidden">
Zona {numbers.zone} · Seção {numbers.section}
</span>
<span className="hidden sm:inline">Dados do título de eleitor</span>
</p>
</div>
</div>
<button
type="button"
className={cn(POTENTIAL_GHOST_BUTTON, 'shrink-0')}
onClick={onChangeSection}
>
Trocar seção
</button>
</div>
<div className="mt-4 rounded-2xl border border-(--campaign-line) bg-(--campaign-cream) p-5 sm:p-7">
<p className={POTENTIAL_EYEBROW}>Potencial nesta seção</p>
<div className="mt-3 flex flex-wrap items-end gap-x-4 gap-y-1">
<p className="m-0 font-[family-name:var(--font-exo2)] text-[56px] leading-none font-black text-(--pt-red)">
{formatPotentialPoints(potential.gainTotalPp)}
</p>
<p className="m-0 pb-1 text-sm leading-5 text-(--campaign-muted)">
de ganho total entre turnos
<br />
no cenário com todos os votos disponíveis
</p>
</div>
<p className="m-0 mt-2 text-sm text-(--campaign-muted)">
Só com os votos imediatos (X₁):{' '}
<b className="text-(--campaign-ink)">{formatPotentialPoints(potential.gainImmediatePp)}</b>
</p>
<div className="mt-6 grid grid-cols-3 overflow-hidden rounded-xl border border-(--campaign-line) bg-white">
<div className="p-2.5 sm:p-4">
<p className={POTENTIAL_STAT_LABEL}>1º turno</p>
<p className={POTENTIAL_STAT_VALUE}>{formatPotentialPercent(potential.firstRoundPct)}</p>
<p className="mt-0.5 hidden text-[11px] text-(--campaign-muted) sm:block">
Lula na seção
</p>
</div>
<div className="border-l border-(--campaign-line) p-2.5 sm:p-4">
<p className={POTENTIAL_STAT_LABEL}>
<span className="sm:hidden">2º · imediato</span>
<span className="hidden sm:inline">2º turno · imediato</span>
</p>
<p className={POTENTIAL_STAT_VALUE}>{formatPotentialPercent(potential.immediatePct)}</p>
<p className="mt-0.5 hidden text-[11px] text-(--campaign-muted) sm:block">só X₁</p>
</div>
<div className="border-l border-(--campaign-line) p-2.5 sm:p-4">
<p className={POTENTIAL_STAT_LABEL}>
<span className="sm:hidden">2º · total</span>
<span className="hidden sm:inline">2º turno · total</span>
</p>
<p className={POTENTIAL_STAT_VALUE}>{formatPotentialPercent(potential.totalPct)}</p>
<p className="mt-0.5 hidden text-[11px] text-(--campaign-muted) sm:block">
X₁ + faltantes
</p>
</div>
</div>
<details className="group mt-4 rounded-xl border border-(--campaign-line) bg-white p-4">
<summary
className={cn(
'flex min-h-11 cursor-pointer items-center gap-2 text-sm font-extrabold',
POTENTIAL_FOCUS,
)}
>
Como calculamos
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="transition-transform duration-150 group-open:rotate-180 motion-reduce:transition-none"
>
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div className="mt-3 space-y-2 text-[13px] leading-5 text-(--campaign-muted)">
<p className="m-0">1º turno: % do Lula = votos do Lula ÷ votos válidos da seção × 100.</p>
<p className="m-0">2º turno: (LULA + X) ÷ (LULA + X + FLÁVIO) × 100.</p>
<p className="m-0">Ganho: 2º − 1º, em pontos percentuais.</p>
<p className="m-0 rounded-lg bg-(--campaign-band) px-3 py-2">
<b className="text-(--campaign-ink)">X₁</b> = nulos + brancos + votos de terceiros
(disponíveis já no 1º turno). <b className="text-(--campaign-ink)">X₂</b> = X₁ +
faltantes.
</p>
</div>
</details>
<p className={cn(POTENTIAL_NOTE, 'mt-4')}>
<b className="text-(--campaign-ink)">
Cenário hipotético — não é previsão nem promessa de resultado.
</b>{' '}
Hipótese: na seção, só Lula e Flávio disputam o 2º turno; {SECTION_STORY_HYPOTHESIS}
</p>
<p className={cn(POTENTIAL_NOTE, 'mt-2')}>
{POTENTIAL_SOURCE_PREFIX}{' '}
<a
href={TSE_RESULTS_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'rounded-sm font-bold text-(--pt-red) underline-offset-4 hover:underline',
POTENTIAL_FOCUS,
)}
>
Arquivos de Urna
</a>
. {POTENTIAL_PRIVACY_NOTE}
</p>
</div>
</div>
)

View file

@ -0,0 +1,297 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
formatPotentialPercent,
formatPotentialPoints,
sectionStoryFileName,
type PotentialSectionNumbers,
type SectionPotential,
} from '@/lib/sectionPotential'
import { STORY_HEIGHT, STORY_WIDTH, type SectionStoryInput } from '@/lib/sectionStoryRender'
import { cn } from '@/lib/utils'
import {
POTENTIAL_CHIP,
POTENTIAL_NOTE,
POTENTIAL_OUTLINE_BUTTON,
POTENTIAL_PRIMARY_BUTTON,
} from './sectionPotentialClasses'
import {
POTENTIAL_STORY_EXPORT_FAILED,
POTENTIAL_STORY_PREVIEW_NOTE,
POTENTIAL_STORY_READY,
POTENTIAL_STORY_SHARE_NOTE,
} from './sectionPotentialCopy'
import {
canvasToPngBlob,
downloadBlob,
drawStoryOnCanvas,
ensureStoryFont,
} from './sectionStoryCanvas'
type SectionPotentialStoryPreviewProps = {
numbers: PotentialSectionNumbers
potential: SectionPotential
fontFamily: string
}
const DownloadIcon = () => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
)
const ShareIcon = () => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
</svg>
)
const storyInput = (
numbers: PotentialSectionNumbers,
potential: SectionPotential,
fontFamily: string,
): SectionStoryInput => ({
municipalityName: numbers.municipalityName,
uf: numbers.uf,
zone: numbers.zone,
section: numbers.section,
firstRound: formatPotentialPercent(potential.firstRoundPct),
immediate: formatPotentialPercent(potential.immediatePct),
total: formatPotentialPercent(potential.totalPct),
gainImmediate: formatPotentialPoints(potential.gainImmediatePp),
gainTotal: formatPotentialPoints(potential.gainTotalPp),
fontFamily,
})
/**
* S46 — a prévia do story (design, cenas 1/2/3d): o MESMO canvas que vira o
* PNG 1080×1920. "Baixar imagem" é o piso universal; "Compartilhar" abre a
* folha do sistema com o arquivo quando o aparelho suporta (cancelar não baixa)
* e cai no download quando não — o contrato do share de conteúdo. Uma falha de
* export nunca fica silenciosa: o visitante vê a mensagem honesta.
*/
export const SectionPotentialStoryPreview = ({
numbers,
potential,
fontFamily,
}: SectionPotentialStoryPreviewProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [previewReady, setPreviewReady] = useState(false)
const [generated, setGenerated] = useState(false)
const [exportFailed, setExportFailed] = useState(false)
const [busy, setBusy] = useState(false)
const fileName = sectionStoryFileName(numbers)
useEffect(() => {
let alive = true
setPreviewReady(false)
void (async () => {
// A fonte é o gate da medição do canvas; se o load falhar, desenha mesmo
// assim com o fallback da família (a página já carrega a Arimo).
await ensureStoryFont(fontFamily)
if (!alive || !canvasRef.current) return
drawStoryOnCanvas(canvasRef.current, storyInput(numbers, potential, fontFamily))
if (alive) setPreviewReady(true)
})()
return () => {
alive = false
}
}, [numbers, potential, fontFamily])
const exportBlob = async (): Promise<Blob | null> => {
const canvas = canvasRef.current
if (!canvas) return null
try {
return await canvasToPngBlob(canvas)
} catch {
return null
}
}
const download = async () => {
const blob = await exportBlob()
if (!blob) {
setExportFailed(true)
return
}
setExportFailed(false)
downloadBlob(blob, fileName)
setGenerated(true)
}
const onDownload = async () => {
if (busy) return
setBusy(true)
try {
await download()
} finally {
setBusy(false)
}
}
const onShare = async () => {
if (busy) return
setBusy(true)
let fellBack = false
try {
const blob = await exportBlob()
if (!blob) {
setExportFailed(true)
return
}
const file = new File([blob], fileName, { type: 'image/png' })
if (typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: 'Potencial de Lula na minha seção' })
setGenerated(true)
return
} catch (error) {
// Cancelar a folha não é falha: nunca baixa no lugar.
if (error instanceof DOMException && error.name === 'AbortError') return
}
}
fellBack = true
} finally {
setBusy(false)
}
if (fellBack) await onDownload()
}
const ariaLabel = [
`Prévia do story 1080 por 1920 da seção ${numbers.section} de ${numbers.municipalityName} (${numbers.uf}), cenário hipotético.`,
`Lula no 1º turno: ${formatPotentialPercent(potential.firstRoundPct)}.`,
`Lula no 2º turno só com os votos imediatos (X₁): ${formatPotentialPercent(potential.immediatePct)}.`,
`Lula no 2º turno com todos os votos (X₂): ${formatPotentialPercent(potential.totalPct)}.`,
`Ganho imediato: ${formatPotentialPoints(potential.gainImmediatePp)}; ganho total: ${formatPotentialPoints(potential.gainTotalPp)}.`,
].join(' ')
return (
<div className="rounded-2xl border border-(--campaign-line) bg-white p-5">
<div className="flex items-center justify-between gap-3">
<p className="m-0 text-sm font-bold">Prévia do story</p>
<span className={POTENTIAL_CHIP}>1080 × 1920</span>
</div>
{generated ? (
<div
role="status"
className="mt-4 flex items-center gap-2 rounded-xl bg-support-engaged px-4 py-3 text-[13px] font-black text-support-engaged-foreground"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{POTENTIAL_STORY_READY}
</div>
) : null}
<div className="relative mt-4">
<canvas
ref={canvasRef}
width={STORY_WIDTH}
height={STORY_HEIGHT}
role="img"
aria-label={ariaLabel}
aria-busy={!previewReady}
className="block h-auto w-full rounded border border-(--campaign-line)"
/>
{!previewReady ? (
<p className="absolute inset-0 grid place-items-center bg-white/70 text-xs font-bold text-(--campaign-muted)">
Gerando prévia…
</p>
) : null}
</div>
<p className={cn(POTENTIAL_NOTE, 'mt-3')}>{POTENTIAL_STORY_PREVIEW_NOTE}</p>
<div className="mt-4 flex flex-col gap-2">
{generated ? (
<>
<button
type="button"
className={POTENTIAL_PRIMARY_BUTTON}
onClick={onShare}
disabled={busy || !previewReady}
>
<ShareIcon />
Compartilhar
</button>
<button
type="button"
className={POTENTIAL_OUTLINE_BUTTON}
onClick={onDownload}
disabled={busy || !previewReady}
>
<DownloadIcon />
Baixar de novo
</button>
</>
) : (
<>
<button
type="button"
className={POTENTIAL_PRIMARY_BUTTON}
onClick={onDownload}
disabled={busy || !previewReady}
>
<DownloadIcon />
Baixar imagem
</button>
<button
type="button"
className={POTENTIAL_OUTLINE_BUTTON}
onClick={onShare}
disabled={busy || !previewReady}
>
<ShareIcon />
Compartilhar
</button>
</>
)}
</div>
{exportFailed ? (
<p role="alert" className="mt-3 text-xs leading-relaxed font-bold text-(--destructive)">
{POTENTIAL_STORY_EXPORT_FAILED}
</p>
) : null}
<p className={cn(POTENTIAL_NOTE, 'mt-3')}>{POTENTIAL_STORY_SHARE_NOTE}</p>
</div>
)
}

View file

@ -0,0 +1,307 @@
'use client'
import { useRef, useState } from 'react'
import {
computeSectionPotential,
type PotentialMunicipalityOption,
type PotentialSectionNumbers,
type PotentialUf,
type SectionPotential,
} from '@/lib/sectionPotential'
import { cn } from '@/lib/utils'
import { SectionPotentialForm } from './SectionPotentialForm'
import { SectionPotentialResult } from './SectionPotentialResult'
import { SectionPotentialStoryPreview } from './SectionPotentialStoryPreview'
import { POTENTIAL_NOTE } from './sectionPotentialClasses'
import {
POTENTIAL_FORM_VALIDATION_MESSAGE,
POTENTIAL_HEADLINE,
POTENTIAL_INTRO,
POTENTIAL_LOADING_MESSAGE,
POTENTIAL_LOADING_NOTE,
POTENTIAL_NOT_FOUND_EYEBROW,
POTENTIAL_NOT_FOUND_HINT,
POTENTIAL_NOT_FOUND_MESSAGE,
POTENTIAL_NOT_FOUND_TITLE,
POTENTIAL_NO_DATA_MESSAGE,
POTENTIAL_PAGE_EYEBROW,
POTENTIAL_THROTTLED_MESSAGE,
POTENTIAL_UNAVAILABLE_MESSAGE,
} from './sectionPotentialCopy'
type StudioState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; numbers: PotentialSectionNumbers; potential: SectionPotential }
| {
status: 'error'
reason: 'not-found' | 'no-data' | 'unavailable'
message: string
hint: string | null
fieldsInvalid: boolean
}
const SectionPotentialSkeleton = () => (
<div aria-hidden="true" className="animate-pulse motion-reduce:animate-none">
<div className="h-3 w-40 rounded bg-(--campaign-band)" />
<div className="mt-5 rounded-2xl border border-(--campaign-line) bg-white p-6">
<div className="h-3 w-40 rounded bg-(--campaign-band)" />
<div className="mt-4 h-12 w-52 rounded bg-(--campaign-band)" />
<div className="mt-3 flex gap-3">
<div className="h-4 w-32 rounded bg-(--campaign-band)" />
<div className="h-4 w-24 rounded bg-(--campaign-band)" />
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
<div className="h-24 rounded bg-(--campaign-band)" />
<div className="h-24 rounded bg-(--campaign-band)" />
<div className="h-24 rounded bg-(--campaign-band)" />
</div>
<div className="mt-4 h-12 w-full rounded bg-(--campaign-band)" />
</div>
</div>
)
/**
* S46 — a ilha do `/potencial`: formulário do título → consulta à API pública
* → painel + prévia do story. O cálculo vem do módulo puro compartilhado
* (`computeSectionPotential`), então a tela e a imagem usam os mesmos números.
* Nenhum dado pessoal é coletado; nada é persistido no navegador.
*/
export const SectionPotentialStudio = ({ fontFamily }: { fontFamily: string }) => {
const [uf, setUf] = useState<PotentialUf>('BA')
const [municipalityText, setMunicipalityText] = useState('')
const [municipality, setMunicipality] = useState<PotentialMunicipalityOption | null>(null)
const [zone, setZone] = useState('')
const [section, setSection] = useState('')
const [formMessage, setFormMessage] = useState<string | null>(null)
const [state, setState] = useState<StudioState>({ status: 'idle' })
const requestSeq = useRef(0)
const onUfChange = (nextUf: PotentialUf) => {
setUf(nextUf)
setMunicipality(null)
setMunicipalityText('')
}
const submit = async () => {
const zoneNumber = Number(zone)
const sectionNumber = Number(section)
if (
!municipality ||
!Number.isInteger(zoneNumber) ||
zoneNumber < 1 ||
!Number.isInteger(sectionNumber) ||
sectionNumber < 1
) {
setFormMessage(POTENTIAL_FORM_VALIDATION_MESSAGE)
return
}
setFormMessage(null)
setState({ status: 'loading' })
const requestId = ++requestSeq.current
try {
const response = await fetch(
`/api/potencial/secao?uf=${uf}&codigo=${municipality.code}&zona=${zoneNumber}&secao=${sectionNumber}`,
{ headers: { accept: 'application/json' } },
)
if (requestId !== requestSeq.current) return
if (response.status === 404) {
setState({
status: 'error',
reason: 'not-found',
message: POTENTIAL_NOT_FOUND_MESSAGE,
hint: POTENTIAL_NOT_FOUND_HINT,
fieldsInvalid: true,
})
return
}
if (response.status === 429) {
setState({
status: 'error',
reason: 'unavailable',
message: POTENTIAL_THROTTLED_MESSAGE,
hint: null,
fieldsInvalid: false,
})
return
}
if (!response.ok) throw new Error(`potencial:${response.status}`)
const body = (await response.json()) as {
ok: boolean
section?: PotentialSectionNumbers
}
if (!body.ok || !body.section) throw new Error('potencial:body')
const result = computeSectionPotential(body.section)
if (!result.available) {
setState({
status: 'error',
reason: 'no-data',
message: POTENTIAL_NO_DATA_MESSAGE,
hint: POTENTIAL_NOT_FOUND_HINT,
fieldsInvalid: false,
})
return
}
setState({ status: 'ready', numbers: body.section, potential: result.potential })
} catch {
if (requestId !== requestSeq.current) return
setState({
status: 'error',
reason: 'unavailable',
message: POTENTIAL_UNAVAILABLE_MESSAGE,
hint: null,
fieldsInvalid: false,
})
}
}
const isError = state.status === 'error'
const notFound = isError && state.reason === 'not-found'
// A validação local vence o alerta do estado: depois de um 404, um resubmit
// com campo vazio precisa mostrar o que falta, não a mensagem antiga.
const alertMessage = formMessage ?? (isError ? state.message : null)
return (
<div className="mx-auto w-full max-w-[1160px] px-5 pt-8 pb-12 sm:px-8 lg:px-10">
<div className="text-center">
<p className={POTENTIAL_PAGE_EYEBROW}>
{notFound ? POTENTIAL_NOT_FOUND_EYEBROW : POTENTIAL_PAGE_EYEBROW}
</p>
<h1
className={cn(
'm-0 mt-2 font-[family-name:var(--font-exo2)] leading-[1.12] font-black tracking-[-0.02em] text-balance',
notFound ? 'text-[28px] leading-[1.15]' : 'text-[27px] sm:text-[34px] lg:text-[40px]',
)}
>
{notFound ? POTENTIAL_NOT_FOUND_TITLE : POTENTIAL_HEADLINE}
</h1>
{state.status === 'idle' ? (
<p className="mx-auto mt-3 max-w-[680px] text-[15px] leading-6 text-(--campaign-muted)">
{POTENTIAL_INTRO}
</p>
) : null}
</div>
{state.status === 'loading' ? (
<div className="mt-8" role="status" aria-live="polite">
<p className="m-0 flex items-center gap-2 text-sm font-black">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="var(--pt-red)"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
className="animate-spin motion-reduce:animate-none"
>
<path d="M21 12a9 9 0 1 1-6.22-8.56" />
</svg>
{POTENTIAL_LOADING_MESSAGE}
</p>
<p className={cn(POTENTIAL_NOTE, 'mt-1')}>{POTENTIAL_LOADING_NOTE}</p>
<div className="mt-5 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_396px]">
<SectionPotentialSkeleton />
<div className="rounded-2xl border border-(--campaign-line) bg-white p-5">
<div className="h-4 w-32 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 aspect-[1080/1920] w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 h-12 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-2 h-12 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
</div>
</div>
</div>
) : null}
{state.status === 'idle' || isError ? (
<div className="mx-auto mt-6 w-full max-w-[680px]">
{alertMessage ? (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-[rgb(180_35_24/40%)] bg-[#fef3f2] p-4 text-(--destructive)"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mt-0.5 shrink-0"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<div>
<p className="m-0 text-sm font-black">{alertMessage}</p>
{isError && state.hint ? (
<p className="m-0 mt-1 text-[13px] leading-5">{state.hint}</p>
) : null}
</div>
</div>
) : null}
<div className="mt-6">
<SectionPotentialForm
uf={uf}
onUfChange={onUfChange}
municipalityText={municipalityText}
onMunicipalityTextChange={(value) => {
setMunicipalityText(value)
if (municipality && value !== municipality.name) setMunicipality(null)
}}
onMunicipalitySelect={(selected) => {
setMunicipality(selected)
setMunicipalityText(selected.name)
}}
zone={zone}
onZoneChange={setZone}
section={section}
onSectionChange={setSection}
fieldsInvalid={isError && state.fieldsInvalid}
submitLabel={isError ? 'Tentar de novo' : 'Ver o potencial da minha seção'}
onSubmit={() => {
void submit()
}}
/>
</div>
</div>
) : null}
{state.status === 'ready' ? (
<div
className="mt-8 grid grid-cols-1 items-start gap-8 lg:grid-cols-[minmax(0,1fr)_396px]"
aria-live="polite"
>
<SectionPotentialResult
numbers={state.numbers}
potential={state.potential}
onChangeSection={() => setState({ status: 'idle' })}
/>
<SectionPotentialStoryPreview
numbers={state.numbers}
potential={state.potential}
fontFamily={fontFamily}
/>
</div>
) : null}
{state.status === 'idle' || isError ? (
<p className={cn(POTENTIAL_NOTE, 'mt-6 text-center')}>
A consulta não pede nome, e-mail ou telefone — e nada fica salvo no seu aparelho.
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,49 @@
import { cn } from '@/lib/utils'
/** S46 — class contracts of the public `/potencial` surface (design cena 1–3d). */
/** Foco da campanha: amarelo + separador escuro (a superfície é branca/creme). */
export const POTENTIAL_FOCUS =
'focus-visible:outline-[3px] focus-visible:outline-offset-2 focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(16_42_67/90%)]'
export const POTENTIAL_PRIMARY_BUTTON = cn(
'inline-flex min-h-12 items-center justify-center gap-2 rounded-[10px] border-2 border-(--pt-red) bg-(--pt-red) px-[18px] font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white',
'transition-[filter] duration-150 ease-out hover:brightness-95 motion-reduce:transition-none',
POTENTIAL_FOCUS,
)
export const POTENTIAL_OUTLINE_BUTTON = cn(
'inline-flex min-h-12 items-center justify-center gap-2 rounded-[10px] border-2 border-[rgb(162_28_28/28%)] bg-white px-[18px] font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)',
'transition-colors duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
POTENTIAL_FOCUS,
)
export const POTENTIAL_GHOST_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center rounded-lg px-2.5 text-xs font-extrabold text-(--pt-red)',
'transition-colors duration-150 ease-out hover:bg-(--campaign-band) motion-reduce:transition-none',
POTENTIAL_FOCUS,
)
export const POTENTIAL_CHIP =
'inline-flex min-h-6 items-center gap-1.5 rounded-full bg-(--campaign-band) px-2.5 py-0.5 text-[11px] font-bold text-[#3f3a37]'
export const POTENTIAL_FIELD_LABEL = 'block text-[13px] font-bold text-(--campaign-ink)'
export const POTENTIAL_FIELD_INPUT = cn(
'mt-1.5 flex min-h-12 w-full items-center gap-2 rounded-[10px] border border-[rgb(0_0_0/18%)] bg-white px-3 text-base text-(--campaign-ink)',
'focus-within:border-(--pt-red) focus-within:shadow-[0_0_0_3px_rgb(162_28_28/18%)]',
)
export const POTENTIAL_FIELD_INPUT_ERROR =
'border-(--danger) shadow-[0_0_0_3px_rgb(180_35_24/12%)] focus-within:border-(--danger)'
export const POTENTIAL_NOTE = 'text-xs leading-relaxed text-(--campaign-muted)'
export const POTENTIAL_EYEBROW =
'font-[family-name:var(--font-exo2)] text-xs font-extrabold tracking-[0.1em] text-(--pt-red) uppercase'
export const POTENTIAL_STAT_LABEL =
'text-[9px] font-extrabold tracking-[0.04em] whitespace-nowrap text-(--campaign-muted) uppercase sm:text-[11px] sm:tracking-[0.06em]'
export const POTENTIAL_STAT_VALUE =
'mt-1 font-[family-name:var(--font-exo2)] text-lg leading-none font-extrabold whitespace-nowrap tabular-nums text-(--campaign-ink) sm:text-[26px]'

View file

@ -0,0 +1,63 @@
import { SECTION_STORY_TITLE } from '@/lib/sectionStoryRender'
/**
* S46 — copy e links do `/potencial` (pt-BR; a página não coleta dado pessoal).
* O link dos resultados aponta para o Portal de Dados Abertos do TSE — a mesma
* fonte do artefato commitado; o autoatendimento é o caminho honesto de quem
* não encontra a seção. O título e a hipótese são os MESMOS do story (dono:
* `sectionStoryRender`), para a tela e a imagem nunca divergirem.
*/
export const POTENTIAL_PATH = '/potencial'
export const POTENTIAL_PAGE_EYEBROW = '2º turno · Eleição 2026'
export const POTENTIAL_NOT_FOUND_EYEBROW = 'Potencial da sua seção'
export const POTENTIAL_HEADLINE = SECTION_STORY_TITLE
export const POTENTIAL_INTRO =
'Informe os dados do seu título de eleitor — UF, município, zona e seção — e veja o potencial de crescimento em pontos percentuais, do 1º para o 2º turno.'
export const TSE_RESULTS_URL = 'https://dadosabertos.tse.jus.br/dataset/resultados-2026'
export const TSE_SELF_SERVICE_URL =
'https://www.tse.jus.br/servicos-eleitorais/autoatendimento-eleitoral'
export const POTENTIAL_SOURCE_PREFIX = 'Fonte: dados oficiais do TSE — 1º turno 2026 ·'
export const POTENTIAL_PRIVACY_NOTE = 'Sem cadastro: a consulta não pede nome, e-mail ou telefone.'
export const POTENTIAL_NOT_FOUND_TITLE = 'Não encontramos essa seção'
export const POTENTIAL_NOT_FOUND_MESSAGE =
'Essa combinação de zona e seção não aparece nos dados do TSE.'
export const POTENTIAL_NOT_FOUND_HINT =
'Confira os quatro dados no seu título de eleitor — é comum trocar a zona pela seção.'
export const POTENTIAL_NO_DATA_MESSAGE =
'Essa seção não teve votos válidos para presidente no 1º turno de 2026.'
export const POTENTIAL_UNAVAILABLE_MESSAGE =
'Não foi possível consultar os dados agora. Tente de novo em instantes.'
export const POTENTIAL_THROTTLED_MESSAGE =
'Muitas consultas em sequência. Aguarde um instante e tente de novo.'
export const POTENTIAL_FORM_VALIDATION_MESSAGE =
'Preencha UF, município, zona e seção — os quatro dados estão no seu título de eleitor.'
export const POTENTIAL_MUNICIPALITY_HELP =
'Digite ao menos 3 letras. A lista mostra só municípios da UF escolhida.'
export const POTENTIAL_MUNICIPALITY_PICK = 'Escolha um município da lista.'
export const POTENTIAL_MUNICIPALITY_EMPTY = 'Nenhum município encontrado nesta UF.'
export const POTENTIAL_MUNICIPALITY_ERROR =
'Não foi possível carregar a lista de municípios. Recarregue a página.'
export const POTENTIAL_LOADING_MESSAGE = 'Consultando os dados oficiais do TSE…'
export const POTENTIAL_LOADING_NOTE = 'Pode levar alguns segundos. Não feche a página.'
export const POTENTIAL_STORY_PREVIEW_NOTE =
'A imagem final sai em 1080 × 1920 px (PNG), pronta para o story.'
export const POTENTIAL_STORY_SHARE_NOTE =
'No celular, o compartilhamento abre a folha do sistema (Instagram, WhatsApp e outros). No computador, a imagem vai para a pasta de downloads — poste pelo app do Instagram.'
export const POTENTIAL_STORY_READY = 'Imagem pronta — 1080 × 1920 px'
export const POTENTIAL_STORY_EXPORT_FAILED =
'Não foi possível gerar a imagem neste aparelho. Tente de novo.'
export const POTENTIAL_CHECK_LIST = [
'UF e município do seu local de votação;',
'zona eleitoral — 2 a 4 dígitos;',
'seção — até 4 dígitos;',
'se os números não estão invertidos (zona × seção).',
] as const

View file

@ -0,0 +1,40 @@
import { canvasToPngBlob, downloadBlob } from '@/components/cards/cardCanvas'
import {
drawSectionStory,
STORY_HEIGHT,
STORY_WIDTH,
type SectionStoryInput,
} from '@/lib/sectionStoryRender'
/**
* S46 — browser adapters of the story canvas: the font gate (the renderer
* measures with Arimo 400/500/600/700, so every weight must be loaded before
* the first draw) and the 1080×1920 draw itself. The export helpers are the
* card studio's (`cardCanvas`) — one PNG pipeline, not a second one.
*/
const STORY_FONT_WEIGHTS = [400, 500, 600, 700] as const
export const ensureStoryFont = async (fontFamily: string): Promise<boolean> => {
if (typeof document === 'undefined' || !document.fonts) return false
const primaryFamily = fontFamily.split(',')[0]?.trim() || fontFamily
try {
const faces = await Promise.all(
STORY_FONT_WEIGHTS.map((weight) => document.fonts.load(`${weight} 100px ${primaryFamily}`)),
)
return faces.every((face) => face.length > 0)
} catch {
return false
}
}
export const drawStoryOnCanvas = (canvas: HTMLCanvasElement, input: SectionStoryInput): boolean => {
canvas.width = STORY_WIDTH
canvas.height = STORY_HEIGHT
const context = canvas.getContext('2d')
if (!context) return false
drawSectionStory(context, input)
return true
}
export { canvasToPngBlob, downloadBlob }

View file

@ -0,0 +1,82 @@
import type { Page } from '@playwright/test'
import { expect, test } from './fixtures/e2eTest'
/**
* S46 — the public `/potencial` flow over real HTTP: the form queries the
* committed TSE artifact through `/api/potencial/*`, the panel shows the real
* Serrinha/BA ZE 150 seção 50 readings (conferidas contra o CSV do TSE) and
* the story canvas exports the 1080×1920 PNG with the same numbers.
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const consultSerrinha = async (page: Page, section = '50') => {
await page.getByLabel('Município').fill('serr')
await page.getByRole('option', { name: 'Serrinha' }).click()
await page.getByLabel('Zona').fill('150')
await page.getByLabel('Seção').fill(section)
await page.getByRole('button', { name: 'Ver o potencial da minha seção' }).click()
}
test.describe('potencial da seção (S46)', () => {
test('consulta a seção real de Serrinha e baixa o story 1080×1920', async ({ page }) => {
await page.goto(`${BASE_URL}/potencial`)
await expect(
page.getByRole('heading', { name: 'Quanto o Lula pode crescer na sua seção?' }),
).toBeVisible()
await consultSerrinha(page)
await expect(page.getByText('Potencial nesta seção')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('66,9%').first()).toBeVisible()
await expect(page.getByText('73,7%').first()).toBeVisible()
await expect(page.getByText('79,8%').first()).toBeVisible()
await expect(page.getByText('+12,9 p.p.').first()).toBeVisible()
await expect(page.getByText('+6,8 p.p.').first()).toBeVisible()
const canvas = page.locator('canvas')
await expect(canvas).toHaveAttribute('width', '1080')
await expect(canvas).toHaveAttribute('height', '1920')
const download = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Baixar imagem' }).click(),
]).then(([event]) => event)
expect(download.suggestedFilename()).toBe('potencial-lula-ba-serrinha-z150-s50.png')
await expect(page.getByText('Imagem pronta — 1080 × 1920 px')).toBeVisible()
})
// A probe da seção inexistente provoca o 404 da API de propósito; o browser
// loga o recurso falho mesmo com o app tratando — declarado só neste bloco,
// para uma falha inesperada da rota no caminho feliz continuar sendo falha.
test.describe('seção inexistente', () => {
test.use({ expectedRequestFailurePaths: [/\/api\/potencial\/secao$/] })
test('não inventa número para seção inexistente', async ({ page }) => {
await page.goto(`${BASE_URL}/potencial`)
await consultSerrinha(page, '9999')
await expect(page.getByRole('heading', { name: 'Não encontramos essa seção' })).toBeVisible({
timeout: 20_000,
})
await expect(page.getByRole('alert').filter({ hasText: 'dados do TSE' })).toContainText(
'Essa combinação de zona e seção não aparece nos dados do TSE.',
)
await expect(page.getByLabel('Zona')).toHaveAttribute('aria-invalid', 'true')
await expect(page.getByLabel('Seção')).toHaveAttribute('aria-invalid', 'true')
})
})
test('mobile 390 empilha o fluxo sem estourar a largura', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${BASE_URL}/potencial`)
await consultSerrinha(page)
await expect(page.getByText('Potencial nesta seção')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('canvas')).toBeVisible()
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
)
expect(overflow).toBeLessThanOrEqual(0)
})
})

View file

@ -62,6 +62,10 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/convite/e2e-prewarm', '/campanha/convite/e2e-prewarm',
'/', '/',
'/cards', '/cards',
// S46 — the public story page (form + the two anonymous JSON reads).
'/potencial',
'/api/potencial/municipios?uf=BA',
'/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50',
]) { ]) {
await prewarmGet(path) await prewarmGet(path)
} }