Scope client state to where it is used
- MunicipalityMapSelection leaf owns hover/selection: a mouseover re-renders only the map + readout, not the whole panel (controls, legend, zone list); Leaflet paint stays ref-based (B6) - municípios list: MunicipalityEstimateScenarioProvider now wraps only the results — scenario switches no longer re-render the filter controls (dashboard already scoped it to the map) - LeaderContactsPanel is a server component again: header + contacts table render on the server; only LeaderContactForm (new client file) hydrates - ledgered with rationale: map-bundle slimming deferred (gzip crushes the repeating keys; instant year/scenario switching is a core analysis flow); props-to-state effect mirrors reviewed and accepted as the correct server-echo reconciliation pattern
This commit is contained in:
parent
05a47b881c
commit
590d311364
5 changed files with 361 additions and 300 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<CampaignListPendingBoundary>
|
||||
{filters}
|
||||
<CampaignListResults>{listBody}</CampaignListResults>
|
||||
<CampaignListResults>
|
||||
{isStaffView ? (
|
||||
<MunicipalityEstimateScenarioProvider>{listBody}</MunicipalityEstimateScenarioProvider>
|
||||
) : (
|
||||
listBody
|
||||
)}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
)
|
||||
|
||||
const main = isStaffView ? (
|
||||
<MunicipalityEstimateScenarioProvider>{filteredMain}</MunicipalityEstimateScenarioProvider>
|
||||
) : (
|
||||
filteredMain
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
|
|
|
|||
232
src/components/campaign/LeaderContactForm.tsx
Normal file
232
src/components/campaign/LeaderContactForm.tsx
Normal file
|
|
@ -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<string, string[]> | 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<LeaderSupporterFormState, FormData>(
|
||||
createLeaderSupporterFormAction,
|
||||
{},
|
||||
)
|
||||
const [city, setCity] = useState<string>((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 (
|
||||
<section
|
||||
aria-labelledby="leader-contact-form-title"
|
||||
className="flex flex-col gap-4 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlusIcon className="size-5 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id="leader-contact-form-title" className="text-base font-medium">
|
||||
Novo contato
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
key={state.status === 'success' ? `success-${state.revision ?? 0}` : 'draft'}
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível cadastrar</AlertTitle>
|
||||
<AlertDescription>
|
||||
{state.message ? <p>{state.message}</p> : null}
|
||||
{state.fieldErrors?.form?.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={phoneField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="leader-contact-phone"
|
||||
name="phone"
|
||||
required
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
autoFocus
|
||||
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
aria-invalid={phoneField.invalid}
|
||||
aria-describedby={phoneField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{phoneField.error ? (
|
||||
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={nameField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
|
||||
<Input
|
||||
id="leader-contact-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
aria-invalid={nameField.invalid}
|
||||
aria-describedby={nameField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{nameField.error ? (
|
||||
<FieldError id="leader-contact-name-error">{nameField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={cityField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-city">Município</FieldLabel>
|
||||
<input type="hidden" name="city" value={city} />
|
||||
<StrictCombobox
|
||||
id="leader-contact-city"
|
||||
options={municipalityComboboxOptions()}
|
||||
value={city}
|
||||
onValueChange={setCity}
|
||||
error={cityField.error}
|
||||
/>
|
||||
{cityField.error ? (
|
||||
<FieldError id="leader-contact-city-error">{cityField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
{showMunicipalitySelect ? (
|
||||
<Field data-invalid={municipalityField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-municipality">Município *</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leader-contact-municipality"
|
||||
name="municipality"
|
||||
required
|
||||
defaultValue={values?.municipality ?? ''}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
|
||||
>
|
||||
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
|
||||
{municipalityOptions.map((municipality) => (
|
||||
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
|
||||
{municipality.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{municipalityField.error ? (
|
||||
<FieldError id="leader-contact-municipality-error">
|
||||
{municipalityField.error}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
) : defaultMunicipalityId ? (
|
||||
<input type="hidden" name="municipality" value={String(defaultMunicipalityId)} />
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<section
|
||||
className="rounded-[6px] border bg-muted/30 p-4"
|
||||
aria-labelledby="leader-contact-consent-title"
|
||||
>
|
||||
<h3 id="leader-contact-consent-title" className="font-medium">
|
||||
Consentimento de cadastro
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador.
|
||||
</p>
|
||||
{!registrationConsentConfigured ? (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertTitle>Consentimento não configurado</AlertTitle>
|
||||
<AlertDescription>
|
||||
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(consentError)}>
|
||||
<Checkbox
|
||||
id="leader-contact-consent"
|
||||
name="consentAccepted"
|
||||
value="true"
|
||||
required
|
||||
aria-invalid={Boolean(consentError)}
|
||||
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="leader-contact-consent">
|
||||
A pessoa autorizou o cadastro *
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Você confirma que explicou o uso dos dados conforme o texto versionado da
|
||||
campanha.
|
||||
</FieldDescription>
|
||||
{consentError ? (
|
||||
<FieldError id="leader-contact-consent-error">{consentError}</FieldError>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full sm:w-auto sm:self-end"
|
||||
disabled={pending || !registrationConsentConfigured}
|
||||
>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Cadastrando…' : 'Cadastrar contato'}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string, string[]> | 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<LeaderSupporterFormState, FormData>(
|
||||
createLeaderSupporterFormAction,
|
||||
{},
|
||||
)
|
||||
const [city, setCity] = useState<string>((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 (
|
||||
<section
|
||||
aria-labelledby="leader-contact-form-title"
|
||||
className="flex flex-col gap-4 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlusIcon className="size-5 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id="leader-contact-form-title" className="text-base font-medium">
|
||||
Novo contato
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
key={state.status === 'success' ? `success-${state.revision ?? 0}` : 'draft'}
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível cadastrar</AlertTitle>
|
||||
<AlertDescription>
|
||||
{state.message ? <p>{state.message}</p> : null}
|
||||
{state.fieldErrors?.form?.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={phoneField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="leader-contact-phone"
|
||||
name="phone"
|
||||
required
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
autoFocus
|
||||
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
aria-invalid={phoneField.invalid}
|
||||
aria-describedby={phoneField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{phoneField.error ? (
|
||||
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={nameField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
|
||||
<Input
|
||||
id="leader-contact-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
aria-invalid={nameField.invalid}
|
||||
aria-describedby={nameField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{nameField.error ? (
|
||||
<FieldError id="leader-contact-name-error">{nameField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={cityField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-city">Município</FieldLabel>
|
||||
<input type="hidden" name="city" value={city} />
|
||||
<StrictCombobox
|
||||
id="leader-contact-city"
|
||||
options={municipalityComboboxOptions()}
|
||||
value={city}
|
||||
onValueChange={setCity}
|
||||
error={cityField.error}
|
||||
/>
|
||||
{cityField.error ? (
|
||||
<FieldError id="leader-contact-city-error">{cityField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
{showMunicipalitySelect ? (
|
||||
<Field data-invalid={municipalityField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-municipality">Município *</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leader-contact-municipality"
|
||||
name="municipality"
|
||||
required
|
||||
defaultValue={values?.municipality ?? ''}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
|
||||
>
|
||||
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
|
||||
{municipalityOptions.map((municipality) => (
|
||||
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
|
||||
{municipality.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{municipalityField.error ? (
|
||||
<FieldError id="leader-contact-municipality-error">
|
||||
{municipalityField.error}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
) : defaultMunicipalityId ? (
|
||||
<input type="hidden" name="municipality" value={String(defaultMunicipalityId)} />
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<section
|
||||
className="rounded-[6px] border bg-muted/30 p-4"
|
||||
aria-labelledby="leader-contact-consent-title"
|
||||
>
|
||||
<h3 id="leader-contact-consent-title" className="font-medium">
|
||||
Consentimento de cadastro
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador.
|
||||
</p>
|
||||
{!registrationConsentConfigured ? (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertTitle>Consentimento não configurado</AlertTitle>
|
||||
<AlertDescription>
|
||||
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(consentError)}>
|
||||
<Checkbox
|
||||
id="leader-contact-consent"
|
||||
name="consentAccepted"
|
||||
value="true"
|
||||
required
|
||||
aria-invalid={Boolean(consentError)}
|
||||
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="leader-contact-consent">
|
||||
A pessoa autorizou o cadastro *
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Você confirma que explicou o uso dos dados conforme o texto versionado da
|
||||
campanha.
|
||||
</FieldDescription>
|
||||
{consentError ? (
|
||||
<FieldError id="leader-contact-consent-error">{consentError}</FieldError>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full sm:w-auto sm:self-end"
|
||||
disabled={pending || !registrationConsentConfigured}
|
||||
>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Cadastrando…' : 'Cadastrar contato'}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] }) => (
|
||||
<section
|
||||
aria-labelledby="leader-contacts-list-title"
|
||||
|
|
@ -312,6 +86,11 @@ const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] })
|
|||
</section>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ export const MunicipalityMapPanel = ({
|
|||
)
|
||||
const estimateScenario = scenarioContext?.scenario ?? localEstimateScenario
|
||||
const setEstimateScenario = scenarioContext?.setScenario ?? setLocalEstimateScenario
|
||||
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
|
||||
const selectedKeyRef = useRef<string | null>(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 (
|
||||
<section
|
||||
aria-labelledby="municipality-map-title"
|
||||
|
|
@ -301,16 +258,15 @@ export const MunicipalityMapPanel = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<BahiaMap
|
||||
mode="municipality"
|
||||
values={displayValues}
|
||||
scaleMax={displayMax > 0 ? displayMax : undefined}
|
||||
fillMode={comparisonActive ? 'diverging' : 'sequential'}
|
||||
fitToKeys={scopedKeys}
|
||||
interactiveKeys={scopedKeys}
|
||||
selectedKey={selectedFeature?.key ?? null}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
onFeatureActivate={handleFeatureActivate}
|
||||
<MunicipalityMapSelection
|
||||
displayValues={displayValues}
|
||||
rawValues={rawValues}
|
||||
displayMax={displayMax}
|
||||
scopedKeys={scopedKeys}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={comparisonActive ? 'absolute' : scaleMode}
|
||||
comparisonActive={comparisonActive}
|
||||
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
|
||||
ariaLabel={
|
||||
comparisonActive && comparison
|
||||
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
|
||||
|
|
@ -318,16 +274,7 @@ export const MunicipalityMapPanel = ({
|
|||
}
|
||||
/>
|
||||
|
||||
<MapFeatureReadout
|
||||
feature={selectedFeature}
|
||||
metricValue={selectedMetricValue}
|
||||
rawMetricValue={selectedRawMetricValue}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={comparisonActive ? 'absolute' : scaleMode}
|
||||
comparisonActive={comparisonActive}
|
||||
navigation={selectedNavigation}
|
||||
/>
|
||||
|
||||
{/* zone breakdown below */}
|
||||
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
|
||||
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
|
||||
|
|
@ -363,3 +310,102 @@ export const MunicipalityMapPanel = ({
|
|||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, number>
|
||||
rawValues: Record<string, number>
|
||||
displayMax: number
|
||||
scopedKeys: string[]
|
||||
metricLabel: string
|
||||
scaleMode: MunicipalityMapScaleMode
|
||||
comparisonActive: boolean
|
||||
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
|
||||
ariaLabel: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
|
||||
const selectedKeyRef = useRef<string | null>(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 (
|
||||
<>
|
||||
<BahiaMap
|
||||
mode="municipality"
|
||||
values={displayValues}
|
||||
scaleMax={displayMax > 0 ? displayMax : undefined}
|
||||
fillMode={comparisonActive ? 'diverging' : 'sequential'}
|
||||
fitToKeys={scopedKeys}
|
||||
interactiveKeys={scopedKeys}
|
||||
selectedKey={selectedFeature?.key ?? null}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
onFeatureActivate={handleFeatureActivate}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
|
||||
<MapFeatureReadout
|
||||
feature={selectedFeature}
|
||||
metricValue={selectedMetricValue}
|
||||
rawMetricValue={selectedRawMetricValue}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={scaleMode}
|
||||
comparisonActive={comparisonActive}
|
||||
navigation={selectedNavigation}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue