teqo-backup/src/components/campaign/CampaignInviteForm.tsx
Francisco Ignácio Fontoura Solla bcac665cb5 Ship /campanha nuclei MVP with RBAC, invites, and vote estimates.
Adds electoral nuclei, leaderships, updates, and WhatsApp invite flows behind campaign auth, plus the consolidated schema migration that Vercel applies on deploy.
2026-07-18 15:01:56 -03:00

370 lines
13 KiB
TypeScript

'use client'
import { type ReactNode, useActionState } from 'react'
import { CheckCircle2Icon, ShieldCheckIcon } from 'lucide-react'
import type {
BoundCampaignInviteFormAction,
CampaignInviteFormState,
} from '@/app/(campaign)/campanha/convite/[token]/formActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 { Textarea } from '@/components/ui/textarea'
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi'
type CampaignInviteProfile = {
name: string
phone: string
email: string | null
gender: keyof typeof leadershipGenderLabels | null
sector: keyof typeof leadershipSectorLabels | null | undefined
sectorNotes: string | null
}
const genderOptions = Object.entries(leadershipGenderLabels) as Array<
[NonNullable<CampaignInviteProfile['gender']>, string]
>
const sectorOptions = Object.entries(leadershipSectorLabels) as Array<
[NonNullable<CampaignInviteProfile['sector']>, string]
>
const firstError = (state: CampaignInviteFormState, field: string) =>
state.fieldErrors?.[field]?.[0]
const CampaignInviteFeedback = ({ state }: { state: CampaignInviteFormState }) => {
if (!state.message || state.status === 'success') return null
return (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível confirmar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)
}
const ConsentField = ({
children,
error,
}: {
children: ReactNode
error?: string
}) => (
<section id="texto-consentimento" className="flex flex-col gap-3" aria-labelledby="consent-title">
<div>
<h2 id="consent-title" className="font-medium">
Consentimento para uso dos dados
</h2>
<p className="text-sm text-muted-foreground">
Leia o texto e confirme somente se estiver de acordo.{' '}
<a className="underline underline-offset-4" href="#campaign-invite-consent-full">
Ler o texto completo
</a>
</p>
</div>
<div
id="campaign-invite-consent-full"
className="max-h-56 overflow-y-auto rounded-lg border p-3 text-sm"
tabIndex={-1}
>
{children}
</div>
<Field orientation="horizontal" data-invalid={Boolean(error)}>
<Checkbox
id="campaign-invite-consent"
name="consentAccepted"
required
aria-invalid={Boolean(error)}
aria-describedby={error ? 'campaign-invite-consent-error' : 'campaign-invite-consent-help'}
/>
<FieldContent>
<FieldLabel htmlFor="campaign-invite-consent">
Li e concordo com o texto de consentimento *
</FieldLabel>
<FieldDescription id="campaign-invite-consent-help">
Você confirma os próprios dados enviados neste formulário.
</FieldDescription>
{error ? <FieldError id="campaign-invite-consent-error">{error}</FieldError> : null}
</FieldContent>
</Field>
</section>
)
const ProfileFields = ({
preview,
state,
}: {
preview: { profile: CampaignInviteProfile }
state: CampaignInviteFormState
}) => {
const { profile } = preview
return (
<FieldGroup>
<Field data-invalid={Boolean(firstError(state, 'name'))}>
<FieldLabel htmlFor="campaign-invite-name">Nome *</FieldLabel>
<Input
id="campaign-invite-name"
name="name"
defaultValue={profile.name}
autoComplete="name"
required
maxLength={120}
aria-invalid={Boolean(firstError(state, 'name'))}
aria-describedby={firstError(state, 'name') ? 'campaign-invite-name-error' : undefined}
/>
{firstError(state, 'name') ? (
<FieldError id="campaign-invite-name-error">{firstError(state, 'name')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(firstError(state, 'phone'))}>
<FieldLabel htmlFor="campaign-invite-phone">Celular (WhatsApp) *</FieldLabel>
<Input
id="campaign-invite-phone"
name="phone"
type="tel"
inputMode="tel"
defaultValue={profile.phone}
autoComplete="tel"
required
aria-invalid={Boolean(firstError(state, 'phone'))}
aria-describedby={firstError(state, 'phone') ? 'campaign-invite-phone-error' : undefined}
/>
{firstError(state, 'phone') ? (
<FieldError id="campaign-invite-phone-error">{firstError(state, 'phone')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(firstError(state, 'email'))}>
<FieldLabel htmlFor="campaign-invite-email">E-mail (opcional)</FieldLabel>
<Input
id="campaign-invite-email"
name="email"
type="email"
defaultValue={profile.email ?? ''}
autoComplete="email"
aria-invalid={Boolean(firstError(state, 'email'))}
aria-describedby={firstError(state, 'email') ? 'campaign-invite-email-error' : undefined}
/>
{firstError(state, 'email') ? (
<FieldError id="campaign-invite-email-error">{firstError(state, 'email')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(firstError(state, 'gender'))}>
<FieldLabel htmlFor="campaign-invite-gender">Gênero</FieldLabel>
<NativeSelect
id="campaign-invite-gender"
name="gender"
className="w-full"
defaultValue={profile.gender ?? ''}
aria-invalid={Boolean(firstError(state, 'gender'))}
aria-describedby={
firstError(state, 'gender') ? 'campaign-invite-gender-error' : undefined
}
>
<NativeSelectOption value="">Não informar</NativeSelectOption>
{genderOptions.map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{firstError(state, 'gender') ? (
<FieldError id="campaign-invite-gender-error">{firstError(state, 'gender')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(firstError(state, 'sector'))}>
<FieldLabel htmlFor="campaign-invite-sector">Setor</FieldLabel>
<NativeSelect
id="campaign-invite-sector"
name="sector"
className="w-full"
defaultValue={profile.sector ?? ''}
aria-invalid={Boolean(firstError(state, 'sector'))}
aria-describedby={
firstError(state, 'sector') ? 'campaign-invite-sector-error' : undefined
}
>
<NativeSelectOption value="">Não informar</NativeSelectOption>
{sectorOptions.map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{firstError(state, 'sector') ? (
<FieldError id="campaign-invite-sector-error">{firstError(state, 'sector')}</FieldError>
) : null}
</Field>
</div>
<Field data-invalid={Boolean(firstError(state, 'sectorNotes'))}>
<FieldLabel htmlFor="campaign-invite-sector-notes">
Como você atua nesse setor? (opcional)
</FieldLabel>
<Textarea
id="campaign-invite-sector-notes"
name="sectorNotes"
defaultValue={profile.sectorNotes ?? ''}
maxLength={1000}
aria-invalid={Boolean(firstError(state, 'sectorNotes'))}
aria-describedby={
firstError(state, 'sectorNotes') ? 'campaign-invite-sector-notes-error' : undefined
}
/>
{firstError(state, 'sectorNotes') ? (
<FieldError id="campaign-invite-sector-notes-error">
{firstError(state, 'sectorNotes')}
</FieldError>
) : null}
</Field>
</FieldGroup>
)
}
export const CampaignInviteForm = ({
action,
children,
kind,
profile,
requiresConsent,
}: {
action: BoundCampaignInviteFormAction
children?: ReactNode
kind: 'autopreenchimento' | 'login'
profile: CampaignInviteProfile
requiresConsent: boolean
}) => {
const [state, formAction, pending] = useActionState(action, {})
const consentError = firstError(state, 'consentAccepted')
const passwordError = firstError(state, 'password')
const confirmationError = firstError(state, 'passwordConfirmation')
if (state.status === 'success') {
return (
<Card className="w-full max-w-lg">
<CardHeader>
<CheckCircle2Icon aria-hidden="true" />
<CardTitle>Dados confirmados</CardTitle>
<CardDescription>{state.message}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Você já pode fechar esta página.</p>
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-lg">
<CardHeader>
<div className="flex items-center gap-2 text-primary">
<ShieldCheckIcon aria-hidden="true" />
<span className="text-sm font-medium">Campanha do Solla</span>
</div>
<CardTitle>Oi, {profile.name}!</CardTitle>
<CardDescription>
{kind === 'login'
? 'Revise seus dados e defina uma senha para acessar a plataforma da campanha.'
: 'Revise e confirme seus dados para a campanha do Jorge Solla.'}
</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction} className="flex flex-col gap-5" aria-busy={pending}>
<CampaignInviteFeedback state={state} />
<ProfileFields preview={{ profile }} state={state} />
{requiresConsent ? (
<ConsentField error={consentError}>{children}</ConsentField>
) : (
<Alert>
<CheckCircle2Icon aria-hidden="true" />
<AlertTitle>Consentimento já confirmado</AlertTitle>
<AlertDescription>
Você pode revisar seus dados e continuar sem aceitar novamente.
</AlertDescription>
</Alert>
)}
{kind === 'login' ? (
<FieldGroup>
<Field data-invalid={Boolean(passwordError)}>
<FieldLabel htmlFor="campaign-invite-password">Nova senha *</FieldLabel>
<Input
id="campaign-invite-password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
aria-invalid={Boolean(passwordError)}
aria-describedby={
passwordError
? 'campaign-invite-password-error'
: 'campaign-invite-password-help'
}
/>
<FieldDescription id="campaign-invite-password-help">
Use pelo menos 8 caracteres.
</FieldDescription>
{passwordError ? (
<FieldError id="campaign-invite-password-error">{passwordError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(confirmationError)}>
<FieldLabel htmlFor="campaign-invite-password-confirmation">
Confirme a nova senha *
</FieldLabel>
<Input
id="campaign-invite-password-confirmation"
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
aria-invalid={Boolean(confirmationError)}
aria-describedby={
confirmationError ? 'campaign-invite-password-confirmation-error' : undefined
}
/>
{confirmationError ? (
<FieldError id="campaign-invite-password-confirmation-error">
{confirmationError}
</FieldError>
) : null}
</Field>
</FieldGroup>
) : null}
<Button type="submit" className="min-h-11 w-full" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending
? 'Confirmando…'
: kind === 'login'
? 'Criar ou recuperar meu acesso'
: 'Confirmar meus dados'}
</Button>
<p className="text-center text-xs text-muted-foreground">
Esta página não usa rastreadores externos.
</p>
</form>
</CardContent>
</Card>
)
}