mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
62b674cfba
commit
67d889661a
15 changed files with 1588 additions and 6 deletions
|
|
@ -198,6 +198,16 @@ export default defineConfig({
|
|||
dependencies: isProdMode ? [] : ['frontend'],
|
||||
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,
|
||||
// vote share, kill switch). Owns its content piece rows only, so it
|
||||
|
|
|
|||
|
|
@ -101,6 +101,23 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'],
|
||||
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,
|
||||
// the instant handoff and the announcement page (`.ics` included), and the
|
||||
|
|
@ -510,6 +527,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'campaignSpeechCut',
|
||||
'campaignReel',
|
||||
'frontendFotosSelfie',
|
||||
'frontendPotencial',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
89
src/app/(frontend)/(home)/potencial/page.tsx
Normal file
89
src/app/(frontend)/(home)/potencial/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { Arimo, Exo_2 } from 'next/font/google'
|
||||
import localFont from 'next/font/local'
|
||||
|
||||
/**
|
||||
|
|
@ -16,3 +17,12 @@ export const brexterBold = localFont({
|
|||
// loads it on demand via `document.fonts.load`).
|
||||
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' })
|
||||
|
|
|
|||
|
|
@ -7,15 +7,12 @@ import { resolveSiteMetadata } from '@/utilities/seo'
|
|||
import { Analytics } from '@vercel/analytics/next'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/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 { campaignDisplayFont, campaignTextFont } from './fonts'
|
||||
import './styles.css'
|
||||
|
||||
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> {
|
||||
const payload = await getCachedGlobal('metadata')()
|
||||
|
|
@ -69,7 +66,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
return (
|
||||
<html
|
||||
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' }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
|
|
|
|||
211
src/components/potencial/MunicipalityCombobox.tsx
Normal file
211
src/components/potencial/MunicipalityCombobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
214
src/components/potencial/SectionPotentialForm.tsx
Normal file
214
src/components/potencial/SectionPotentialForm.tsx
Normal 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>
|
||||
)
|
||||
191
src/components/potencial/SectionPotentialResult.tsx
Normal file
191
src/components/potencial/SectionPotentialResult.tsx
Normal 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>
|
||||
)
|
||||
297
src/components/potencial/SectionPotentialStoryPreview.tsx
Normal file
297
src/components/potencial/SectionPotentialStoryPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
307
src/components/potencial/SectionPotentialStudio.tsx
Normal file
307
src/components/potencial/SectionPotentialStudio.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/components/potencial/sectionPotentialClasses.ts
Normal file
49
src/components/potencial/sectionPotentialClasses.ts
Normal 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]'
|
||||
63
src/components/potencial/sectionPotentialCopy.ts
Normal file
63
src/components/potencial/sectionPotentialCopy.ts
Normal 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
|
||||
40
src/components/potencial/sectionStoryCanvas.ts
Normal file
40
src/components/potencial/sectionStoryCanvas.ts
Normal 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 }
|
||||
82
tests/e2e/frontendPotencial.e2e.spec.ts
Normal file
82
tests/e2e/frontendPotencial.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -62,6 +62,10 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
'/campanha/convite/e2e-prewarm',
|
||||
'/',
|
||||
'/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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue