diff --git a/playwright.config.ts b/playwright.config.ts index 072f9da8..78a622d3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index c292f8da..71a35b63 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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', ], }, { diff --git a/src/app/(frontend)/(home)/potencial/page.tsx b/src/app/(frontend)/(home)/potencial/page.tsx new file mode 100644 index 00000000..096520f4 --- /dev/null +++ b/src/app/(frontend)/(home)/potencial/page.tsx @@ -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 { + 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 ( + <> + +
+
+ + Início + + / Potencial da sua seção +
+
+
+ +
+ + + ) +} diff --git a/src/app/(frontend)/fonts.ts b/src/app/(frontend)/fonts.ts index 776e5822..0c229ada 100644 --- a/src/app/(frontend)/fonts.ts +++ b/src/app/(frontend)/fonts.ts @@ -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' }) diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index f838aa01..b9a68ffc 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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 { const payload = await getCachedGlobal('metadata')() @@ -69,7 +66,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { return ( diff --git a/src/components/potencial/MunicipalityCombobox.tsx b/src/components/potencial/MunicipalityCombobox.tsx new file mode 100644 index 00000000..f89f4785 --- /dev/null +++ b/src/components/potencial/MunicipalityCombobox.tsx @@ -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()) + const [items, setItems] = useState([]) + 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) => { + 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 ( +
+ +
+ + { + 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)} + /> +
+ {showList ? ( +
    event.preventDefault()} + > + {matches.map((municipality, index) => ( +
  • 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} +
  • + ))} +
+ ) : null} + {helper ? ( +

+ {helper} +

+ ) : null} +
+ ) +} diff --git a/src/components/potencial/SectionPotentialForm.tsx b/src/components/potencial/SectionPotentialForm.tsx new file mode 100644 index 00000000..19c645f8 --- /dev/null +++ b/src/components/potencial/SectionPotentialForm.tsx @@ -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) => ( +
{ + event.preventDefault() + onSubmit() + }} + > +
+
+ +
+ + +
+
+ + + +
+ +
+ onZoneChange(event.target.value.replace(/\D/g, ''))} + className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)} + /> +
+
+ +
+ +
+ onSectionChange(event.target.value.replace(/\D/g, ''))} + className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)} + /> +
+
+
+ + {fieldsInvalid ? ( +

+ Zona e seção não conferem entre si. Revise os números no título. +

+ ) : null} + +
+

No título de eleitor, confira:

+
    + {POTENTIAL_CHECK_LIST.map((item) => ( +
  • · {item}
  • + ))} +
+
+ +
+ + + Consultar meu título no TSE + + +
+ +

+ 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. +

+
+) diff --git a/src/components/potencial/SectionPotentialResult.tsx b/src/components/potencial/SectionPotentialResult.tsx new file mode 100644 index 00000000..3a6fda72 --- /dev/null +++ b/src/components/potencial/SectionPotentialResult.tsx @@ -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) => ( +
+
+
+ + + +
+

+ + {numbers.uf} · {numbers.municipalityName} + + + {numbers.uf} · {numbers.municipalityName} · Zona {numbers.zone} · Seção{' '} + {numbers.section} + +

+

+ + Zona {numbers.zone} · Seção {numbers.section} + + Dados do título de eleitor +

+
+
+ +
+ +
+

Potencial nesta seção

+
+

+ {formatPotentialPoints(potential.gainTotalPp)} +

+

+ de ganho total entre turnos +
+ no cenário com todos os votos disponíveis +

+
+

+ Só com os votos imediatos (X₁):{' '} + {formatPotentialPoints(potential.gainImmediatePp)} +

+ +
+
+

1º turno

+

{formatPotentialPercent(potential.firstRoundPct)}

+

+ Lula na seção +

+
+
+

+ 2º · imediato + 2º turno · imediato +

+

{formatPotentialPercent(potential.immediatePct)}

+

só X₁

+
+
+

+ 2º · total + 2º turno · total +

+

{formatPotentialPercent(potential.totalPct)}

+

+ X₁ + faltantes +

+
+
+ +
+ + Como calculamos + + +
+

1º turno: % do Lula = votos do Lula ÷ votos válidos da seção × 100.

+

2º turno: (LULA + X) ÷ (LULA + X + FLÁVIO) × 100.

+

Ganho: 2º − 1º, em pontos percentuais.

+

+ X₁ = nulos + brancos + votos de terceiros + (disponíveis já no 1º turno). X₂ = X₁ + + faltantes. +

+
+
+ +

+ + Cenário hipotético — não é previsão nem promessa de resultado. + {' '} + Hipótese: na seção, só Lula e Flávio disputam o 2º turno; {SECTION_STORY_HYPOTHESIS} +

+

+ {POTENTIAL_SOURCE_PREFIX}{' '} + + Arquivos de Urna + + . {POTENTIAL_PRIVACY_NOTE} +

+
+
+) diff --git a/src/components/potencial/SectionPotentialStoryPreview.tsx b/src/components/potencial/SectionPotentialStoryPreview.tsx new file mode 100644 index 00000000..ae4b8cc0 --- /dev/null +++ b/src/components/potencial/SectionPotentialStoryPreview.tsx @@ -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 = () => ( + +) + +const ShareIcon = () => ( + +) + +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(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 => { + 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 ( +
+
+

Prévia do story

+ 1080 × 1920 +
+ + {generated ? ( +
+ + {POTENTIAL_STORY_READY} +
+ ) : null} + +
+ + {!previewReady ? ( +

+ Gerando prévia… +

+ ) : null} +
+ +

{POTENTIAL_STORY_PREVIEW_NOTE}

+
+ {generated ? ( + <> + + + + ) : ( + <> + + + + )} +
+ {exportFailed ? ( +

+ {POTENTIAL_STORY_EXPORT_FAILED} +

+ ) : null} +

{POTENTIAL_STORY_SHARE_NOTE}

+
+ ) +} diff --git a/src/components/potencial/SectionPotentialStudio.tsx b/src/components/potencial/SectionPotentialStudio.tsx new file mode 100644 index 00000000..976c86b8 --- /dev/null +++ b/src/components/potencial/SectionPotentialStudio.tsx @@ -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 = () => ( +