teqo/src/components/campaign/SupporterForm.tsx
Francisco Ignácio Fontoura Solla a18e2f15f5 Simplify C2 supporter paths and register C6 scale follow-ups.
Tighten import/create view models after review, and capture the deferred DRY/import-scale work as roadmap item C6.
2026-07-18 20:33:50 -03:00

308 lines
11 KiB
TypeScript

'use client'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
export type SupporterFormAction = (
state: SupporterFormState,
formData: FormData,
) => Promise<SupporterFormState>
const fieldError = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
const error = fieldError(fieldErrors, field)
return {
error,
invalid: Boolean(error),
describedBy: error ? `supporter-${field}-error` : undefined,
}
}
const ConsentBlock = ({
id,
name,
title,
description,
configured,
error,
required = true,
}: {
id: string
name: string
title: string
description: string
configured: boolean
error?: string
required?: boolean
}) => (
<section className="rounded-[6px] border bg-muted/30 p-4" aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`} className="font-medium">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
{!configured ? (
<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(error)}>
<Checkbox
id={id}
name={name}
value="true"
required={required}
aria-invalid={Boolean(error)}
aria-describedby={error ? `${id}-error` : `${id}-help`}
/>
<FieldContent>
<FieldLabel htmlFor={id}>Li e confirmo o consentimento do titular *</FieldLabel>
<FieldDescription id={`${id}-help`}>
O operador atesta que o apoiador concordou com o uso dos dados conforme o texto
versionado no admin.
</FieldDescription>
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
</FieldContent>
</Field>
)}
</section>
)
export const SupporterForm = ({
action,
nucleusOptions,
registrationConsentConfigured,
voteIntentionConsentConfigured,
requireNucleus,
}: {
action: SupporterFormAction
nucleusOptions: SupporterNucleusOption[]
registrationConsentConfigured: boolean
voteIntentionConsentConfigured: boolean
requireNucleus: boolean
}) => {
const router = useRouter()
const [state, formAction, pending] = useActionState(action, {})
const [voteIntention, setVoteIntention] = useState<SupporterVoteIntention | ''>(
(state.values?.voteIntention as SupporterVoteIntention | undefined) ?? '',
)
const values = state.values
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
if (state.supporterId) {
router.push(`/campanha/apoiadores/${state.supporterId}`, { scroll: false })
}
}, [router, state.message, state.status, state.supporterId])
const nameField = errorProps(state.fieldErrors, 'name')
const phoneField = errorProps(state.fieldErrors, 'phone')
const emailField = errorProps(state.fieldErrors, 'email')
const cityField = errorProps(state.fieldErrors, 'city')
const nucleusField = errorProps(state.fieldErrors, 'nucleus')
const voteIntentionField = errorProps(state.fieldErrors, 'voteIntention')
return (
<form action={formAction} className="flex flex-col gap-6">
{(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={nameField.invalid}>
<FieldLabel htmlFor="supporter-name">Nome *</FieldLabel>
<Input
id="supporter-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="supporter-name-error">{nameField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="supporter-phone">Celular *</FieldLabel>
<FormattedInput
id="supporter-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
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="supporter-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={emailField.invalid}>
<FieldLabel htmlFor="supporter-email">E-mail</FieldLabel>
<Input
id="supporter-email"
name="email"
type="email"
autoComplete="email"
defaultValue={values?.email}
aria-invalid={emailField.invalid}
aria-describedby={emailField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{emailField.error ? (
<FieldError id="supporter-email-error">{emailField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="supporter-city">Município</FieldLabel>
<Input
id="supporter-city"
name="city"
defaultValue={values?.city}
aria-invalid={cityField.invalid}
aria-describedby={cityField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{cityField.error ? (
<FieldError id="supporter-city-error">{cityField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={nucleusField.invalid}>
<FieldLabel htmlFor="supporter-nucleus">Núcleo{requireNucleus ? ' *' : ''}</FieldLabel>
<NativeSelect
id="supporter-nucleus"
name="nucleus"
required={requireNucleus}
defaultValue={values?.nucleus ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
{!requireNucleus ? <NativeSelectOption value="">Sem núcleo</NativeSelectOption> : null}
{nucleusOptions.map((nucleus) => (
<NativeSelectOption key={nucleus.id} value={String(nucleus.id)}>
{nucleus.name}
</NativeSelectOption>
))}
</NativeSelect>
{nucleusField.error ? (
<FieldError id="supporter-nucleus-error">{nucleusField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={voteIntentionField.invalid}>
<FieldLabel>Intenção de voto (opcional)</FieldLabel>
<input type="hidden" name="voteIntention" value={voteIntention} />
<ToggleGroup
type="single"
value={voteIntention}
onValueChange={(value) => setVoteIntention((value as SupporterVoteIntention) ?? '')}
variant="outline"
className="flex w-full flex-wrap"
>
{Object.entries(supporterVoteIntentionLabels).map(([value, label]) => (
<ToggleGroupItem key={value} value={value} className="min-h-11 flex-1">
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
{voteIntentionField.error ? (
<FieldError id="supporter-voteIntention-error">{voteIntentionField.error}</FieldError>
) : null}
</Field>
</FieldGroup>
<ConsentBlock
id="supporter-registration-consent"
name="consentAccepted"
title="Consentimento de cadastro"
description="Confirme que o titular autorizou o cadastro como apoiador."
configured={registrationConsentConfigured}
error={fieldError(state.fieldErrors, 'consentAccepted')}
/>
{voteIntention ? (
<ConsentBlock
id="supporter-vote-intention-consent"
name="voteIntentionConsentAccepted"
title="Consentimento de intenção de voto"
description="Dado sensível — exige consentimento destacado separado."
configured={voteIntentionConsentConfigured}
error={fieldError(state.fieldErrors, 'voteIntentionConsentAccepted')}
/>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => router.push('/campanha/apoiadores', { scroll: false })}
disabled={pending}
>
Cancelar
</Button>
<Button
type="submit"
className="min-h-11"
disabled={
pending ||
!registrationConsentConfigured ||
(Boolean(voteIntention) && !voteIntentionConsentConfigured)
}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cadastrando…' : 'Cadastrar apoiador'}
</Button>
</div>
</form>
)
}