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:
commit
5ee9e840a2
11 changed files with 742 additions and 6 deletions
|
|
@ -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 ✓
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
135
src/components/campaign/municipality/WizardTrendChoiceStep.tsx
Normal file
135
src/components/campaign/municipality/WizardTrendChoiceStep.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
126
src/components/campaign/municipality/WizardTrendNoteStep.tsx
Normal file
126
src/components/campaign/municipality/WizardTrendNoteStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
54
src/lib/politicalTrendWizardMeta.ts
Normal file
54
src/lib/politicalTrendWizardMeta.ts
Normal 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>
|
||||
116
src/lib/politicalTrendWizardUi.ts
Normal file
116
src/lib/politicalTrendWizardUi.ts
Normal 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' }
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
101
tests/unit/politicalTrendWizardUi.unit.spec.ts
Normal file
101
tests/unit/politicalTrendWizardUi.unit.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue