diff --git a/docs/plans/wizard-mudar-tendencia.md b/docs/plans/wizard-mudar-tendencia.md index 2501f6d6..45ab8d97 100644 --- a/docs/plans/wizard-mudar-tendencia.md +++ b/docs/plans/wizard-mudar-tendencia.md @@ -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 ✓ diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 5394cb8f..9277e916 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -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, +): Record | 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 = {} + + 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> @@ -55,11 +92,15 @@ export default async function CampaignActionWizardPage({ return } + 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 ( + + ) + } + + return ( + + ) + } + if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']) { const entryAction = parseWizardEntryActionParam( resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY], diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index 383f1bfa..e5a90b91 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -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: diff --git a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx new file mode 100644 index 00000000..17853d78 --- /dev/null +++ b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx @@ -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 +} + +export const WizardTrendChoiceStep = ({ + actionSlug, + municipalityName, + municipalitySlug, + currentStatus, + entryAction, + prefillExtraParams, +}: WizardTrendChoiceStepProps) => { + const [infoEntry, setInfoEntry] = useState(null) + const skip: WizardTrendSkipAction = resolveWizardTrendSkip() + const options = selectablePoliticalTrendStatuses(currentStatus) + + return ( + <> + } + > +
    + {options.map((status) => { + const entry = politicalTrendWizardMetaByStatus[status] + const Icon = entry.icon + return ( +
  • + + +
    + {entry.label} + + {entry.changeDescription} + +
    + + +
  • + ) + })} +
+
+ + !open && setInfoEntry(null)}> + + {infoEntry ? ( + <> + + {infoEntry.label} + {infoEntry.changeDescription} + +
+

{infoEntry.infoContent}

+
+ + Fechar + + + ) : null} +
+
+ + ) +} diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx new file mode 100644 index 00000000..22d8b481 --- /dev/null +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -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 ( + } + > +
+ + + + +
+ +