Merge pull request #67 from fsolla/cursor/wizard-mudar-tendencia-a971

B64 — Wizard mudar tendência (grid colorido + justificativa + Pular)
This commit is contained in:
Francisco Solla 2026-07-30 20:10:37 -03:00 committed by GitHub
commit 5ee9e840a2
11 changed files with 742 additions and 6 deletions

View file

@ -1,7 +1,7 @@
# Wizard mudar tendência — escolher destino + justificativa
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-30)
Atualizado em: 2026-07-30
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B64 — UX-1 wizards)
Impeccable: C — UI nova (grid de tendências coloridas + Drawer de info + passo de justificativa)
Appetite: ~1–1,25 dia eng; 2 etapas no B59; write via `POST /campanha/municipios/political-trend` / action B24 ✓

View file

@ -3,6 +3,8 @@ import { notFound, redirect } from 'next/navigation'
import { WizardLeadershipStep } from '@/components/campaign/leadership/WizardLeadershipStep'
import { WizardSignalBodyStep } from '@/components/campaign/municipality/WizardSignalBodyStep'
import { WizardSignalTypeStep } from '@/components/campaign/municipality/WizardSignalTypeStep'
import { WizardTrendChoiceStep } from '@/components/campaign/municipality/WizardTrendChoiceStep'
import { WizardTrendNoteStep } from '@/components/campaign/municipality/WizardTrendNoteStep'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
@ -13,14 +15,26 @@ import {
parseWizardEntryActionParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
WIZARD_ENTRY_ACTION_QUERY_KEY,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
WIZARD_SIGNAL_BODY_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
WIZARD_TREND_STATUS_QUERY_KEY,
WIZARD_VOTE_FROM_QUERY_KEY,
WIZARD_VOTE_TO_QUERY_KEY,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
selectablePoliticalTrendStatuses,
} from '@/lib/politicalTrendWizardUi'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -28,6 +42,29 @@ import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
import { getPayload } from 'payload'
const politicalTrendWizardPrefillParams = (
searchParams: Record<string, string | string[] | undefined>,
): Record<string, string> | undefined => {
const keys = [
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_SIGNAL_BODY_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
WIZARD_VOTE_FROM_QUERY_KEY,
WIZARD_VOTE_TO_QUERY_KEY,
] as const
const result: Record<string, string> = {}
for (const key of keys) {
const raw = searchParams[key]
const value = Array.isArray(raw) ? raw[0] : raw
if (value?.trim()) {
result[key] = value.trim()
}
}
return Object.keys(result).length > 0 ? result : undefined
}
type CampaignActionWizardPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -55,11 +92,15 @@ export default async function CampaignActionWizardPage({
return <WizardMunicipalitySearchStep actionSlug={slug} previousHref={CAMPAIGN_HOME} />
}
const needsPoliticalTrend = slug === CAMPAIGN_WIZARD_ACTION_SLUGS['change-trend']
const user = await requireCampaignPageActor()
const payload = await getPayload({ config: await config })
const { municipalities } = await loadMunicipalityScope(payload, user, {
slug: { equals: municipalitySlug },
})
const { municipalities } = await loadMunicipalityScope(
payload,
user,
{ slug: { equals: municipalitySlug } },
needsPoliticalTrend ? { extraSelect: { politicalTrend: true } } : undefined,
)
const municipality = municipalities[0]
if (!municipality || municipality.slug !== municipalitySlug) {
@ -99,6 +140,67 @@ export default async function CampaignActionWizardPage({
)
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['change-trend']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const { signalType } = resolveWizardSignalTypeParam(
resolvedSearchParams[WIZARD_SIGNAL_TYPE_QUERY_KEY],
)
const prefillExtraParams = politicalTrendWizardPrefillParams(resolvedSearchParams)
const prefillSource = resolvePoliticalTrendNotePrefillSource({
entryAction,
notePrefill: resolvedSearchParams[WIZARD_NOTE_PREFILL_QUERY_KEY],
signalType,
signalBody: resolvedSearchParams[WIZARD_SIGNAL_BODY_QUERY_KEY],
voteFrom: resolvedSearchParams[WIZARD_VOTE_FROM_QUERY_KEY],
voteTo: resolvedSearchParams[WIZARD_VOTE_TO_QUERY_KEY],
})
const initialNote = buildPoliticalTrendNotePrefill(prefillSource)
const currentStatus = municipality.politicalTrend?.status ?? null
const { trendStatus, invalid } = resolveWizardTrendStatusParam(
resolvedSearchParams[WIZARD_TREND_STATUS_QUERY_KEY],
)
if (invalid) {
redirect(wizardTrendHref(slug, municipalitySlug, undefined, entryAction, prefillExtraParams))
}
if (trendStatus) {
if (
trendStatus === currentStatus ||
!selectablePoliticalTrendStatuses(currentStatus).includes(trendStatus)
) {
redirect(
wizardTrendHref(slug, municipalitySlug, undefined, entryAction, prefillExtraParams),
)
}
return (
<WizardTrendNoteStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
trendStatus={trendStatus}
initialNote={initialNote}
entryAction={entryAction}
/>
)
}
return (
<WizardTrendChoiceStep
actionSlug={slug}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
currentStatus={currentStatus}
entryAction={entryAction}
prefillExtraParams={prefillExtraParams}
/>
)
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],

View file

@ -14,6 +14,7 @@ import {
requiredRelationshipFormValue,
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import { WIZARD_TREND_SAVED_MESSAGE } from '@/lib/politicalTrendWizardUi'
import {
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
parsePoliticalTrendStatusFormValue,
@ -61,7 +62,7 @@ export const setMunicipalityPoliticalTrendFormAction = async (
note: nullableFormText(formData, 'trendNote'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Tendência política registrada.' }
return { message: WIZARD_TREND_SAVED_MESSAGE }
},
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:

View file

@ -0,0 +1,135 @@
'use client'
import { Info } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { WizardTrendSkipTrailing } from '@/components/campaign/municipality/WizardTrendSkipTrailing'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref, wizardTrendHref } from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
politicalTrendWizardMetaByStatus,
type PoliticalTrendWizardMetaEntry,
} from '@/lib/politicalTrendWizardMeta'
import {
resolveWizardTrendSkip,
selectablePoliticalTrendStatuses,
wizardTrendChoiceStepTitle,
type WizardTrendSkipAction,
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { cn } from '@/lib/utils'
type WizardTrendChoiceStepProps = {
actionSlug: string
municipalityName: string
municipalitySlug: string
currentStatus: PoliticalTrendStatusValue | null
entryAction?: CampaignWizardActionId
prefillExtraParams?: Record<string, string>
}
export const WizardTrendChoiceStep = ({
actionSlug,
municipalityName,
municipalitySlug,
currentStatus,
entryAction,
prefillExtraParams,
}: WizardTrendChoiceStepProps) => {
const [infoEntry, setInfoEntry] = useState<PoliticalTrendWizardMetaEntry | null>(null)
const skip: WizardTrendSkipAction = resolveWizardTrendSkip()
const options = selectablePoliticalTrendStatuses(currentStatus)
return (
<>
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={wizardTrendChoiceStepTitle(currentStatus)}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction })}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={<WizardTrendSkipTrailing skip={skip} />}
>
<ul className="grid list-none grid-cols-2 gap-3 md:grid-cols-3">
{options.map((status) => {
const entry = politicalTrendWizardMetaByStatus[status]
const Icon = entry.icon
return (
<li key={status} className="relative">
<Link
href={wizardTrendHref(
actionSlug,
municipalitySlug,
status,
entryAction,
prefillExtraParams,
)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border bg-background p-3 pr-10 text-left',
entry.tileClassName,
'transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<Icon className="size-5 shrink-0" aria-hidden />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{entry.label}</span>
<span className="line-clamp-2 text-xs opacity-80">
{entry.changeDescription}
</span>
</div>
</Link>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1 size-8"
aria-label={`Informações sobre ${entry.label}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setInfoEntry(entry)
}}
>
<Info className="size-4" aria-hidden />
</Button>
</li>
)
})}
</ul>
</CampaignWizardShell>
<Drawer open={infoEntry != null} onOpenChange={(open) => !open && setInfoEntry(null)}>
<DrawerContent>
{infoEntry ? (
<>
<DrawerHeader>
<DrawerTitle>{infoEntry.label}</DrawerTitle>
<DrawerDescription>{infoEntry.changeDescription}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-2">
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
</div>
<DrawerFooter>
<DrawerCloseButton>Fechar</DrawerCloseButton>
</DrawerFooter>
</>
) : null}
</DrawerContent>
</Drawer>
</>
)
}

View file

@ -0,0 +1,126 @@
'use client'
import { useRouter } from 'next/navigation'
import { useActionState, useState } from 'react'
import { setMunicipalityPoliticalTrendFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
import { WizardTrendSkipTrailing } from '@/components/campaign/municipality/WizardTrendSkipTrailing'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardTrendHref } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
resolveWizardTrendSkip,
WIZARD_TREND_CLEAR_LABEL,
WIZARD_TREND_SAVE_LABEL,
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
type WizardTrendNoteStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
trendStatus: PoliticalTrendStatusValue
initialNote: string
entryAction?: CampaignWizardActionId
}
export const WizardTrendNoteStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
trendStatus,
initialNote,
entryAction,
}: WizardTrendNoteStepProps) => {
const router = useRouter()
const [note, setNote] = useState(initialNote)
const [state, submitAction, isPending] = useActionState(
setMunicipalityPoliticalTrendFormAction,
{},
)
const skip = resolveWizardTrendSkip()
const stepTitle = `Mudar tendência para ${politicalTrendLabels[trendStatus]}`
useCampaignFormSuccessToast(state, () => {
router.push(CAMPAIGN_HOME)
})
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={<WizardTrendSkipTrailing skip={skip} />}
>
<form
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<input type="hidden" name="trendStatus" value={trendStatus} />
<div className="flex flex-col gap-2">
<label htmlFor="wizard-trend-note" className="text-sm font-medium">
Por que mudar a tendência?
</label>
<Textarea
id="wizard-trend-note"
name="trendNote"
value={note}
onChange={(event) => setNote(event.target.value)}
rows={5}
maxLength={2000}
required
disabled={isPending}
className="min-h-28"
placeholder="Descreva o que mudou na leitura política do município."
/>
</div>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex flex-wrap items-center justify-end gap-3">
<Button
type="button"
variant="outline"
disabled={isPending || note.length === 0}
className="min-h-11"
onClick={() => setNote('')}
>
{WIZARD_TREND_CLEAR_LABEL}
</Button>
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
WIZARD_TREND_SAVE_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando tendência.' : null}
</div>
</form>
</CampaignWizardShell>
)
}

View file

@ -0,0 +1,14 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import type { WizardTrendSkipAction } from '@/lib/politicalTrendWizardUi'
type WizardTrendSkipTrailingProps = {
skip: WizardTrendSkipAction
}
export const WizardTrendSkipTrailing = ({ skip }: WizardTrendSkipTrailingProps) => (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<Link href={skip.href}>{skip.label}</Link>
</Button>
)

View file

@ -1,5 +1,9 @@
/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */
import {
parsePoliticalTrendStatusFormValue,
type PoliticalTrendStatusValue,
} from '@/lib/schemas/municipality'
import {
parseMunicipalitySignalType,
type MunicipalitySignalType,
@ -51,6 +55,16 @@ export const WIZARD_SIGNAL_TYPE_QUERY_KEY = 'signalType' as const
export const WIZARD_ENTRY_ACTION_QUERY_KEY = 'entry' as const
export const WIZARD_TREND_STATUS_QUERY_KEY = 'trendStatus' as const
export const WIZARD_NOTE_PREFILL_QUERY_KEY = 'notePrefill' as const
export const WIZARD_SIGNAL_BODY_QUERY_KEY = 'signalBody' as const
export const WIZARD_VOTE_FROM_QUERY_KEY = 'voteFrom' as const
export const WIZARD_VOTE_TO_QUERY_KEY = 'voteTo' as const
export type WizardActionHrefOptions = {
entryAction?: CampaignWizardActionId
}
@ -130,3 +144,47 @@ export const resolveWizardSignalTypeParam = (
return { signalType: undefined, invalid: true }
}
export const wizardTrendHref = (
actionSlug: string,
municipalitySlug?: string,
trendStatus?: PoliticalTrendStatusValue,
entryAction?: CampaignWizardActionId,
extraParams?: Record<string, string>,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
if (trendStatus) {
params.set(WIZARD_TREND_STATUS_QUERY_KEY, trendStatus)
}
if (entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
}
if (extraParams) {
for (const [key, value] of Object.entries(extraParams)) {
if (value) params.set(key, value)
}
}
return `${base}?${params.toString()}`
}
export const resolveWizardTrendStatusParam = (
value: string | string[] | undefined,
): { trendStatus?: PoliticalTrendStatusValue; invalid: boolean } => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) {
return { trendStatus: undefined, invalid: false }
}
const trendStatus = parsePoliticalTrendStatusFormValue(trimmed)
if (trendStatus) {
return { trendStatus, invalid: false }
}
return { trendStatus: undefined, invalid: true }
}

View file

@ -0,0 +1,54 @@
import { Minus, ThumbsDown, ThumbsUp, type LucideIcon } from 'lucide-react'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
const politicalTrendDisplayLabels: Record<PoliticalTrendStatusValue, string> = {
favoravel: 'Favorável',
neutra: 'Neutra',
desfavoravel: 'Desfavorável',
}
export type PoliticalTrendWizardMetaEntry = {
status: PoliticalTrendStatusValue
label: string
changeDescription: string
infoContent: string
icon: LucideIcon
tileClassName: string
}
const trendIcons: Record<PoliticalTrendStatusValue, LucideIcon> = {
favoravel: ThumbsUp,
neutra: Minus,
desfavoravel: ThumbsDown,
}
const trendTileClassNames: Record<PoliticalTrendStatusValue, string> = {
favoravel: 'border-estimate-confirmed-foreground text-estimate-confirmed-foreground',
neutra: 'border-border text-foreground',
desfavoravel: 'border-destructive text-destructive',
}
const trendInfoContent: Record<PoliticalTrendStatusValue, string> = {
favoravel:
'A leitura local favorece a campanha: aliados ativos, rede respondendo, espaço político aberto ou apoio consolidado. Use quando a equipe tem confiança de que o município está a nosso favor — não só por um evento isolado.',
neutra:
'Sem leitura clara de favor ou desfavor: disputa aberta, rede morna ou informação insuficiente para classificar. Use quando não há sinal forte para favorável ou desfavorável.',
desfavoravel:
'A leitura local pressiona a campanha: adversário ganhando espaço, rede esfriando ou apoio em risco. Use quando a equipe precisa registrar que o município está desfavorável — com o porquê na justificativa.',
}
export const politicalTrendWizardMeta: PoliticalTrendWizardMetaEntry[] = politicalTrendStatuses.map(
(status) => ({
status,
label: politicalTrendDisplayLabels[status],
changeDescription: `Mudar tendência para ${politicalTrendDisplayLabels[status]}`,
infoContent: trendInfoContent[status],
icon: trendIcons[status],
tileClassName: trendTileClassNames[status],
}),
)
export const politicalTrendWizardMetaByStatus = Object.fromEntries(
politicalTrendWizardMeta.map((entry) => [entry.status, entry]),
) as Record<PoliticalTrendStatusValue, PoliticalTrendWizardMetaEntry>

View file

@ -0,0 +1,116 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import {
municipalitySignalTypeLabels,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
const politicalTrendDisplayLabels: Record<PoliticalTrendStatusValue, string> = {
favoravel: 'Favorável',
neutra: 'Neutra',
desfavoravel: 'Desfavorável',
}
export const WIZARD_TREND_SKIP_LABEL = 'Pular mudança de tendência →' as const
export const WIZARD_TREND_SAVE_LABEL = 'Salvar' as const
export const WIZARD_TREND_CLEAR_LABEL = 'Limpar' as const
export const WIZARD_TREND_SAVED_MESSAGE = 'Tendência política registrada.' as const
export const WIZARD_TREND_UNREGISTERED_TITLE = 'Tendência não registrada' as const
export type WizardTrendSkipAction = {
label: string
href: string
}
export const wizardTrendChoiceStepTitle = (
currentStatus: PoliticalTrendStatusValue | null,
): string =>
currentStatus
? `Tendência ${politicalTrendDisplayLabels[currentStatus]}`
: WIZARD_TREND_UNREGISTERED_TITLE
export const selectablePoliticalTrendStatuses = (
currentStatus: PoliticalTrendStatusValue | null,
): PoliticalTrendStatusValue[] =>
currentStatus
? politicalTrendStatuses.filter((status) => status !== currentStatus)
: [...politicalTrendStatuses]
export const resolveWizardTrendSkip = (): WizardTrendSkipAction => ({
label: WIZARD_TREND_SKIP_LABEL,
href: CAMPAIGN_HOME,
})
export type PoliticalTrendNotePrefillSource =
| { kind: 'none' }
| { kind: 'signal'; signalType: MunicipalitySignalType; description: string }
| { kind: 'voteAdjustment'; previousValue: number; newValue: number }
| { kind: 'custom'; text: string }
export const buildPoliticalTrendNotePrefill = (source: PoliticalTrendNotePrefillSource): string => {
switch (source.kind) {
case 'none':
return ''
case 'signal':
return `Sinal de ${municipalitySignalTypeLabels[source.signalType].toLowerCase()}: ${source.description}`
case 'voteAdjustment':
return `Ajuste de votos: ${source.previousValue} → ${source.newValue}`
case 'custom':
return source.text
default: {
const exhaustive: never = source
return exhaustive
}
}
}
const parseOptionalIntParam = (value: string | string[] | undefined): number | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) return undefined
const parsed = Number.parseInt(trimmed, 10)
return Number.isFinite(parsed) ? parsed : undefined
}
export const resolvePoliticalTrendNotePrefillSource = ({
entryAction,
notePrefill,
signalType,
signalBody,
voteFrom,
voteTo,
}: {
entryAction?: CampaignWizardActionId
notePrefill?: string | string[] | undefined
signalType?: MunicipalitySignalType
signalBody?: string | string[] | undefined
voteFrom?: string | string[] | undefined
voteTo?: string | string[] | undefined
}): PoliticalTrendNotePrefillSource => {
const customText = Array.isArray(notePrefill) ? notePrefill[0] : notePrefill
if (customText?.trim()) {
return { kind: 'custom', text: customText.trim() }
}
if (entryAction === 'register-signal' && signalType) {
const body = Array.isArray(signalBody) ? signalBody[0] : signalBody
if (body?.trim()) {
return { kind: 'signal', signalType, description: body.trim() }
}
}
if (entryAction === 'update-votes') {
const previousValue = parseOptionalIntParam(voteFrom)
const newValue = parseOptionalIntParam(voteTo)
if (previousValue !== undefined && newValue !== undefined) {
return { kind: 'voteAdjustment', previousValue, newValue }
}
}
return { kind: 'none' }
}

View file

@ -11,8 +11,10 @@ import {
parseWizardEntryActionParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
describe('campaignActionRoutes', () => {
@ -95,4 +97,31 @@ describe('campaignActionRoutes', () => {
invalid: true,
})
})
it('builds trend wizard hrefs with optional trendStatus and entry', () => {
expect(wizardTrendHref('mudar-tendencia', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu`,
)
expect(wizardTrendHref('mudar-tendencia', 'cairu', 'favoravel')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&trendStatus=favoravel`,
)
expect(wizardTrendHref('mudar-tendencia', 'cairu', 'neutra', 'update-votes')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&trendStatus=neutra&entry=update-votes`,
)
})
it('resolves trendStatus search param with invalid flag', () => {
expect(resolveWizardTrendStatusParam(undefined)).toEqual({
trendStatus: undefined,
invalid: false,
})
expect(resolveWizardTrendStatusParam('desfavoravel')).toEqual({
trendStatus: 'desfavoravel',
invalid: false,
})
expect(resolveWizardTrendStatusParam('invalid')).toEqual({
trendStatus: undefined,
invalid: true,
})
})
})

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
politicalTrendWizardMeta,
politicalTrendWizardMetaByStatus,
} from '@/lib/politicalTrendWizardMeta'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
resolveWizardTrendSkip,
selectablePoliticalTrendStatuses,
WIZARD_TREND_SKIP_LABEL,
WIZARD_TREND_UNREGISTERED_TITLE,
wizardTrendChoiceStepTitle,
} from '@/lib/politicalTrendWizardUi'
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
describe('politicalTrendWizardUi', () => {
it('titles the choice step from the current status', () => {
expect(wizardTrendChoiceStepTitle(null)).toBe(WIZARD_TREND_UNREGISTERED_TITLE)
expect(wizardTrendChoiceStepTitle('favoravel')).toBe('Tendência Favorável')
})
it('filters out the current trend from selectable options', () => {
expect(selectablePoliticalTrendStatuses(null)).toEqual(politicalTrendStatuses)
expect(selectablePoliticalTrendStatuses('neutra')).toEqual(['favoravel', 'desfavoravel'])
expect(selectablePoliticalTrendStatuses('desfavoravel')).toEqual(['favoravel', 'neutra'])
})
it('always exposes skip to abandon the trend change', () => {
expect(resolveWizardTrendSkip()).toEqual({
label: WIZARD_TREND_SKIP_LABEL,
href: CAMPAIGN_HOME,
})
})
it('builds note prefills from embedded wizard sources', () => {
expect(buildPoliticalTrendNotePrefill({ kind: 'none' })).toBe('')
expect(
buildPoliticalTrendNotePrefill({
kind: 'signal',
signalType: 'invasao',
description: 'Reunião do adversário no território',
}),
).toBe('Sinal de invasão: Reunião do adversário no território')
expect(
buildPoliticalTrendNotePrefill({
kind: 'voteAdjustment',
previousValue: 120,
newValue: 180,
}),
).toBe('Ajuste de votos: 120 → 180')
expect(buildPoliticalTrendNotePrefill({ kind: 'custom', text: 'Contexto do fluxo' })).toBe(
'Contexto do fluxo',
)
})
it('resolves prefill sources from entry action and query params', () => {
expect(
resolvePoliticalTrendNotePrefillSource({
entryAction: 'update-votes',
voteFrom: '100',
voteTo: '150',
}),
).toEqual({ kind: 'voteAdjustment', previousValue: 100, newValue: 150 })
expect(
resolvePoliticalTrendNotePrefillSource({
entryAction: 'register-signal',
signalType: 'esfriamento',
signalBody: 'Lideranças sumiram',
}),
).toEqual({
kind: 'signal',
signalType: 'esfriamento',
description: 'Lideranças sumiram',
})
expect(
resolvePoliticalTrendNotePrefillSource({
notePrefill: 'Texto direto',
}),
).toEqual({ kind: 'custom', text: 'Texto direto' })
})
})
describe('politicalTrendWizardMeta', () => {
it('covers every trend with icon, copy and info content', () => {
expect(politicalTrendWizardMeta).toHaveLength(politicalTrendStatuses.length)
for (const status of politicalTrendStatuses) {
const entry = politicalTrendWizardMetaByStatus[status]
expect(entry.label.length).toBeGreaterThan(0)
expect(entry.changeDescription).toContain(entry.label)
expect(entry.infoContent.length).toBeGreaterThan(20)
expect(entry.icon).toBeTruthy()
expect(entry.tileClassName.length).toBeGreaterThan(0)
}
})
})