From f0f3d4534dde924a83bf33a73b1a1a54640b95f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 22:49:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?B64=20=E2=80=94=20Wizard=20mudar=20tend?= =?UTF-8?q?=C3=AAncia=20(grid=20+=20justificativa=20+=20skip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/wizard-mudar-tendencia.md | 4 +- .../campanha/(app)/acoes/[slug]/page.tsx | 106 +++++++++++++- .../municipality/WizardTrendChoiceStep.tsx | 133 ++++++++++++++++++ .../municipality/WizardTrendNoteStep.tsx | 126 +++++++++++++++++ .../municipality/WizardTrendSkipTrailing.tsx | 14 ++ src/lib/campaignActionRoutes.ts | 58 ++++++++ src/lib/politicalTrendWizardMeta.ts | 62 ++++++++ src/lib/politicalTrendWizardUi.ts | 119 ++++++++++++++++ tests/unit/campaignActionRoutes.unit.spec.ts | 29 ++++ .../unit/politicalTrendWizardUi.unit.spec.ts | 101 +++++++++++++ 10 files changed, 747 insertions(+), 5 deletions(-) create mode 100644 src/components/campaign/municipality/WizardTrendChoiceStep.tsx create mode 100644 src/components/campaign/municipality/WizardTrendNoteStep.tsx create mode 100644 src/components/campaign/municipality/WizardTrendSkipTrailing.tsx create mode 100644 src/lib/politicalTrendWizardMeta.ts create mode 100644 src/lib/politicalTrendWizardUi.ts create mode 100644 tests/unit/politicalTrendWizardUi.unit.spec.ts 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..307dc7fd 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' @@ -15,12 +17,24 @@ import { resolveWizardSignalTypeParam, 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, + resolveWizardTrendStatusParam, } 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,65 @@ 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/components/campaign/municipality/WizardTrendChoiceStep.tsx b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx new file mode 100644 index 00000000..62c28911 --- /dev/null +++ b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx @@ -0,0 +1,133 @@ +'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 ( + } + > +
+ + + + +
+ +