diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index d0151d27..f41c87c0 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -18,6 +18,8 @@ Ledger of known engineering debt. Items large enough to be scheduled work become | Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | | Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | planned (Phase 2) | | | Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | +| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | | +| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | ## Smell Inventory diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index e5072b8f..d40185f3 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -133,19 +133,21 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie ) // Shared transition: the filters navigate, the results dim ("Feel the action"). - const filteredMain = ( + // The estimate-scenario provider wraps ONLY the results — a scenario switch + // re-renders the overview/list cells, never the filter controls. + const main = ( {filters} - {listBody} + + {isStaffView ? ( + {listBody} + ) : ( + listBody + )} + ) - const main = isStaffView ? ( - {filteredMain} - ) : ( - filteredMain - ) - return (
diff --git a/src/components/campaign/LeaderContactForm.tsx b/src/components/campaign/LeaderContactForm.tsx new file mode 100644 index 00000000..1cba8d76 --- /dev/null +++ b/src/components/campaign/LeaderContactForm.tsx @@ -0,0 +1,232 @@ +'use client' + +import { UserPlusIcon } from 'lucide-react' +import { useActionState, useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { + createLeaderSupporterFormAction, + type LeaderSupporterFormState, +} from '@/app/(campaign)/campanha/actions/leaderSupporter' +import type { RelationOption } from '@/components/campaign/RelationMultiSelect' +import { StrictCombobox } from '@/components/campaign/StrictCombobox' +import { FormattedInput } from '@/components/FormattedInput' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/Checkbox' +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Spinner } from '@/components/ui/Spinner' +import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions' + +const errorProps = (fieldErrors: Record | undefined, field: string) => + buildErrorProps(fieldErrors, field, 'leader-contact') + +export type LeaderContactFormProps = { + municipalityOptions: RelationOption[] + defaultMunicipalityId: number | null + showMunicipalitySelect: boolean + registrationConsentConfigured: boolean +} + +export const LeaderContactForm = ({ + municipalityOptions, + defaultMunicipalityId, + showMunicipalitySelect, + registrationConsentConfigured, +}: LeaderContactFormProps) => { + const [state, formAction, pending] = useActionState( + createLeaderSupporterFormAction, + {}, + ) + const [city, setCity] = useState((state.values?.city as string | undefined) ?? '') + const values = state.values + + useEffect(() => { + if (state.status !== 'success') return + toast.success(state.message) + }, [state.message, state.status]) + + const phoneField = errorProps(state.fieldErrors, 'phone') + const nameField = errorProps(state.fieldErrors, 'name') + const cityField = errorProps(state.fieldErrors, 'city') + const municipalityField = errorProps(state.fieldErrors, 'municipality') + const consentError = fieldError(state.fieldErrors, 'consentAccepted') + + return ( +
+
+
+ +
+ {(state.message || state.fieldErrors?.form) && state.status !== 'success' ? ( + + Não foi possível cadastrar + + {state.message ?

{state.message}

: null} + {state.fieldErrors?.form?.map((error) => ( +

{error}

+ ))} +
+
+ ) : null} + + + + Celular * + + {phoneField.error ? ( + {phoneField.error} + ) : null} + + + + Nome * + + {nameField.error ? ( + {nameField.error} + ) : null} + + + + Município + + + {cityField.error ? ( + {cityField.error} + ) : null} + + + {showMunicipalitySelect ? ( + + Município * + + Selecione a Praça + {municipalityOptions.map((municipality) => ( + + {municipality.name} + + ))} + + {municipalityField.error ? ( + + {municipalityField.error} + + ) : null} + + ) : defaultMunicipalityId ? ( + + ) : null} + + +
+ +

+ Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador. +

+ {!registrationConsentConfigured ? ( + + Consentimento não configurado + + O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro. + + + ) : ( + + + + + A pessoa autorizou o cadastro * + + + Você confirma que explicou o uso dos dados conforme o texto versionado da + campanha. + + {consentError ? ( + {consentError} + ) : null} + + + )} +
+ + +
+
+ ) +} diff --git a/src/components/campaign/LeaderContactsPanel.tsx b/src/components/campaign/LeaderContactsPanel.tsx index dc21cfa4..05d8ac6e 100644 --- a/src/components/campaign/LeaderContactsPanel.tsx +++ b/src/components/campaign/LeaderContactsPanel.tsx @@ -1,31 +1,8 @@ -'use client' +import { PhoneIcon } from 'lucide-react' -import { PhoneIcon, UserPlusIcon } from 'lucide-react' -import { useActionState, useEffect, useState } from 'react' -import { toast } from 'sonner' - -import { - createLeaderSupporterFormAction, - type LeaderSupporterFormState, -} from '@/app/(campaign)/campanha/actions/leaderSupporter' import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { LeaderContactForm } from '@/components/campaign/LeaderContactForm' import type { RelationOption } from '@/components/campaign/RelationMultiSelect' -import { StrictCombobox } from '@/components/campaign/StrictCombobox' -import { FormattedInput } from '@/components/FormattedInput' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/Checkbox' -import { - Field, - FieldContent, - FieldDescription, - FieldError, - FieldGroup, - FieldLabel, -} from '@/components/ui/field' -import { Input } from '@/components/ui/input' -import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { Spinner } from '@/components/ui/Spinner' import { Table, TableBody, @@ -34,19 +11,14 @@ import { TableHeader, TableRow, } from '@/components/ui/Table' -import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields' import type { LeaderContactListItem } from '@/utilities/leaderContactsPageData' -import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' -import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions' +import { formatBrazilianPhoneInput } from '@/utilities/phone' const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short', }) -const errorProps = (fieldErrors: Record | undefined, field: string) => - buildErrorProps(fieldErrors, field, 'leader-contact') - type LeaderContactsPanelProps = { userName: string municipalityOptions: RelationOption[] @@ -56,204 +28,6 @@ type LeaderContactsPanelProps = { contacts: LeaderContactListItem[] } -const LeaderContactForm = ({ - municipalityOptions, - defaultMunicipalityId, - showMunicipalitySelect, - registrationConsentConfigured, -}: Pick< - LeaderContactsPanelProps, - | 'municipalityOptions' - | 'defaultMunicipalityId' - | 'showMunicipalitySelect' - | 'registrationConsentConfigured' ->) => { - const [state, formAction, pending] = useActionState( - createLeaderSupporterFormAction, - {}, - ) - const [city, setCity] = useState((state.values?.city as string | undefined) ?? '') - const values = state.values - - useEffect(() => { - if (state.status !== 'success') return - toast.success(state.message) - }, [state.message, state.status]) - - const phoneField = errorProps(state.fieldErrors, 'phone') - const nameField = errorProps(state.fieldErrors, 'name') - const cityField = errorProps(state.fieldErrors, 'city') - const municipalityField = errorProps(state.fieldErrors, 'municipality') - const consentError = fieldError(state.fieldErrors, 'consentAccepted') - - return ( -
-
-
- -
- {(state.message || state.fieldErrors?.form) && state.status !== 'success' ? ( - - Não foi possível cadastrar - - {state.message ?

{state.message}

: null} - {state.fieldErrors?.form?.map((error) => ( -

{error}

- ))} -
-
- ) : null} - - - - Celular * - - {phoneField.error ? ( - {phoneField.error} - ) : null} - - - - Nome * - - {nameField.error ? ( - {nameField.error} - ) : null} - - - - Município - - - {cityField.error ? ( - {cityField.error} - ) : null} - - - {showMunicipalitySelect ? ( - - Município * - - Selecione a Praça - {municipalityOptions.map((municipality) => ( - - {municipality.name} - - ))} - - {municipalityField.error ? ( - - {municipalityField.error} - - ) : null} - - ) : defaultMunicipalityId ? ( - - ) : null} - - -
- -

- Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador. -

- {!registrationConsentConfigured ? ( - - Consentimento não configurado - - O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro. - - - ) : ( - - - - - A pessoa autorizou o cadastro * - - - Você confirma que explicou o uso dos dados conforme o texto versionado da - campanha. - - {consentError ? ( - {consentError} - ) : null} - - - )} -
- - -
-
- ) -} - const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] }) => (
) +/** + * Leader home (lockdown role). Server component: the header and the contacts + * table render on the server; only the registration form hydrates + * (`LeaderContactForm` is the client island). + */ export const LeaderContactsPanel = ({ userName, municipalityOptions, diff --git a/src/components/campaign/MunicipalityMapPanel.tsx b/src/components/campaign/MunicipalityMapPanel.tsx index 3cc7f1c7..911c4dcb 100644 --- a/src/components/campaign/MunicipalityMapPanel.tsx +++ b/src/components/campaign/MunicipalityMapPanel.tsx @@ -56,8 +56,6 @@ export const MunicipalityMapPanel = ({ ) const estimateScenario = scenarioContext?.scenario ?? localEstimateScenario const setEstimateScenario = scenarioContext?.setScenario ?? setLocalEstimateScenario - const [selectedFeature, setSelectedFeature] = useState(null) - const selectedKeyRef = useRef(null) const comparison = bundle.comparison const comparisonActive = comparison !== null && year !== 2026 @@ -132,47 +130,6 @@ export const MunicipalityMapPanel = ({ }) } - const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => { - selectedKeyRef.current = info?.key ?? null - setSelectedFeature(info) - }, []) - - const handleFeatureActivate = useCallback( - (key: string) => { - if (selectedKeyRef.current !== key) return - - const navigation = resolveMunicipalityMapNavigation(key, bundle.municipalitiesByIbgeCode) - if (navigation.kind === 'navigate') { - router.push(`/campanha/municipios/${navigation.slug}`) - return - } - - if (navigation.kind === 'zones') { - document.getElementById('municipality-zone-breakdown')?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }) - } - }, - [bundle.municipalitiesByIbgeCode, router], - ) - - const selectedMetricValue = - selectedFeature && selectedFeature.key in displayValues - ? displayValues[selectedFeature.key] - : undefined - - const selectedRawMetricValue = - selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined - - const selectedNavigation = useMemo( - () => - selectedFeature - ? resolveMunicipalityMapNavigation(selectedFeature.key, bundle.municipalitiesByIbgeCode) - : null, - [bundle.municipalitiesByIbgeCode, selectedFeature], - ) - return (
) : null} - 0 ? displayMax : undefined} - fillMode={comparisonActive ? 'diverging' : 'sequential'} - fitToKeys={scopedKeys} - interactiveKeys={scopedKeys} - selectedKey={selectedFeature?.key ?? null} - onFeatureSelect={handleFeatureSelect} - onFeatureActivate={handleFeatureActivate} + - - + {/* zone breakdown below */} {bundle.zoneBreakdown.length > 0 && !comparisonActive ? (

Praças por zona eleitoral

@@ -363,3 +310,102 @@ export const MunicipalityMapPanel = ({
) } + +/** + * Selection/hover live here so a mouseover re-renders only the map and the + * readout — not the whole panel (controls, legend, zone list). The Leaflet + * paint path itself stays ref-based inside BahiaMap (O(2) restyle). + */ +const MunicipalityMapSelection = ({ + displayValues, + rawValues, + displayMax, + scopedKeys, + metricLabel, + scaleMode, + comparisonActive, + municipalitiesByIbgeCode, + ariaLabel, +}: { + displayValues: Record + rawValues: Record + displayMax: number + scopedKeys: string[] + metricLabel: string + scaleMode: MunicipalityMapScaleMode + comparisonActive: boolean + municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode'] + ariaLabel: string +}) => { + const router = useRouter() + const [selectedFeature, setSelectedFeature] = useState(null) + const selectedKeyRef = useRef(null) + + const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => { + selectedKeyRef.current = info?.key ?? null + setSelectedFeature(info) + }, []) + + const handleFeatureActivate = useCallback( + (key: string) => { + if (selectedKeyRef.current !== key) return + + const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByIbgeCode) + if (navigation.kind === 'navigate') { + router.push(`/campanha/municipios/${navigation.slug}`) + return + } + + if (navigation.kind === 'zones') { + document.getElementById('municipality-zone-breakdown')?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }) + } + }, + [municipalitiesByIbgeCode, router], + ) + + const selectedMetricValue = + selectedFeature && selectedFeature.key in displayValues + ? displayValues[selectedFeature.key] + : undefined + + const selectedRawMetricValue = + selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined + + const selectedNavigation = useMemo( + () => + selectedFeature + ? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByIbgeCode) + : null, + [municipalitiesByIbgeCode, selectedFeature], + ) + + return ( + <> + 0 ? displayMax : undefined} + fillMode={comparisonActive ? 'diverging' : 'sequential'} + fitToKeys={scopedKeys} + interactiveKeys={scopedKeys} + selectedKey={selectedFeature?.key ?? null} + onFeatureSelect={handleFeatureSelect} + onFeatureActivate={handleFeatureActivate} + ariaLabel={ariaLabel} + /> + + + + ) +}