B110 — wizard return path via allowlisted from query

Propagate return path from quick actions through the B98 wizard chain so
dismiss, skip, and chain end navigate back to the originating page instead
of always redirecting to Início.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 20:39:24 +00:00
parent 5c21552621
commit d5697bcfcb
No known key found for this signature in database
28 changed files with 315 additions and 85 deletions

View file

@ -15,12 +15,14 @@ import {
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
parseWizardReturnPath,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
WIZARD_ENTRY_ACTION_QUERY_KEY,
WIZARD_LEADERSHIP_ID_QUERY_KEY,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_RETURN_PATH_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
WIZARD_SIGNAL_BODY_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
@ -31,14 +33,13 @@ import {
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
resolveWizardTrendNoteDestination,
} from '@/lib/politicalTrendWizardUi'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { isWizardChainActionId, wizardChainContinueHref } from '@/lib/wizardActionChain'
import { isWizardChainActionId, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData'
@ -83,6 +84,7 @@ export default async function CampaignActionWizardPage({
}: CampaignActionWizardPageProps) {
const { slug } = await params
const resolvedSearchParams = await searchParams
const returnPath = parseWizardReturnPath(resolvedSearchParams[WIZARD_RETURN_PATH_QUERY_KEY])
if (!isCampaignWizardActionSlug(slug)) {
notFound()
@ -104,7 +106,8 @@ export default async function CampaignActionWizardPage({
return (
<WizardMunicipalitySearchStep
actionSlug={slug}
previousHref={CAMPAIGN_HOME}
previousHref={wizardChainEndHref(returnPath)}
returnPath={returnPath}
accessibleMunicipalities={accessibleMunicipalities}
/>
)
@ -142,6 +145,7 @@ export default async function CampaignActionWizardPage({
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
@ -164,6 +168,7 @@ export default async function CampaignActionWizardPage({
entryAction={entryAction}
initialTiles={tiles}
initialLeadershipId={initialLeadershipId}
returnPath={returnPath}
/>
)
}
@ -200,9 +205,11 @@ export default async function CampaignActionWizardPage({
if (noteDestination === 'home') {
// Chained session: advance the queue (B98). Standalone stale deep-link: Início (B97).
if (isWizardChainActionId(entryAction)) {
redirect(wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug))
redirect(
wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug, returnPath),
)
}
redirect(CAMPAIGN_HOME)
redirect(wizardChainEndHref(returnPath))
}
if (noteDestination === 'choice') {
@ -220,6 +227,7 @@ export default async function CampaignActionWizardPage({
trendStatus={trendStatus}
initialNote={initialNote}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
@ -232,6 +240,7 @@ export default async function CampaignActionWizardPage({
currentStatus={currentStatus}
entryAction={entryAction}
prefillExtraParams={prefillExtraParams}
returnPath={returnPath}
/>
)
}
@ -257,6 +266,7 @@ export default async function CampaignActionWizardPage({
municipalitySlug={municipality.slug}
signalType={signalType}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
@ -267,9 +277,16 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
return (
<WizardMunicipalitySelectedStub
actionSlug={slug}
municipalityName={municipality.name}
returnPath={returnPath}
/>
)
}

View file

@ -2,6 +2,7 @@
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
export const CampaignHomeActions = ({
@ -11,7 +12,10 @@ export const CampaignHomeActions = ({
role: CampaignRole
uncoveredMunicipalitiesHref?: string
}) => {
const actions = toHomeActionButtonProps(homeActionsForRole(role), uncoveredMunicipalitiesHref)
const actions = toHomeActionButtonProps(homeActionsForRole(role), {
uncoveredMunicipalitiesHref,
returnPath: CAMPAIGN_HOME,
})
if (actions.length === 0) return null

View file

@ -22,7 +22,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
WIZARD_LEADERSHIP_ADD_TILE_LABEL,
WIZARD_LEADERSHIP_CONTINUE_LABEL,
@ -36,7 +35,7 @@ import {
} from '@/lib/campaignWizardCopy'
import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate'
import { cn } from '@/lib/utils'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain'
import {
resolveWizardLeadershipSkip,
type WizardLeadershipTileViewModel,
@ -50,6 +49,7 @@ type WizardLeadershipStepProps = {
entryAction?: CampaignWizardActionId
initialTiles: WizardLeadershipTileViewModel[]
initialLeadershipId?: number
returnPath?: string
}
type WizardLeadershipMode =
@ -70,6 +70,7 @@ export const WizardLeadershipStep = ({
entryAction,
initialTiles,
initialLeadershipId,
returnPath,
}: WizardLeadershipStepProps) => {
const router = useRouter()
const [isContinuing, startContinueTransition] = useTransition()
@ -84,11 +85,12 @@ export const WizardLeadershipStep = ({
const [dirty, setDirty] = useState(false)
const [infoTile, setInfoTile] = useState<WizardLeadershipTileViewModel | null>(null)
const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug)
const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug, returnPath)
const chainContinueHref = wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
returnPath,
)
const stepTitle =
@ -123,8 +125,8 @@ export const WizardLeadershipStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={stepTitle}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skipConfig}
trailingAction={trailingAction}

View file

@ -17,7 +17,7 @@ import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain'
import {
resolveWizardSignalSkip,
WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX,
@ -31,6 +31,7 @@ type WizardSignalBodyStepProps = {
municipalitySlug: string
signalType: MunicipalitySignalType
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardSignalBodyStep = ({
@ -40,19 +41,22 @@ export const WizardSignalBodyStep = ({
municipalitySlug,
signalType,
entryAction,
returnPath,
}: WizardSignalBodyStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(
createMunicipalityListSignalFormAction,
{},
)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-signal')
router.replace(wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug))
router.replace(
wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug, returnPath),
)
})
return (
@ -60,7 +64,14 @@ export const WizardSignalBodyStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)}
previousHref={wizardSignalHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
returnPath,
)}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardSignalSkipTrailing skip={skip} /> : undefined}

View file

@ -26,12 +26,14 @@ import {
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import { resolveWizardSignalSkip, WIZARD_SIGNAL_TYPE_STEP_TITLE } from '@/lib/wizardSignalUi'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
type WizardSignalTypeStepProps = {
actionSlug: string
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardSignalTypeStep = ({
@ -39,10 +41,11 @@ export const WizardSignalTypeStep = ({
municipalityName,
municipalitySlug,
entryAction,
returnPath,
}: WizardSignalTypeStepProps) => {
const [infoType, setInfoType] = useState<MunicipalitySignalType | null>(null)
const infoEntry = infoType ? municipalitySignalTypeMetaByType[infoType] : null
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath)
return (
<>
@ -50,7 +53,8 @@ export const WizardSignalTypeStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE}
previousHref={wizardActionHref(actionSlug)}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
contentAlign="end"
skip={skip}
@ -62,7 +66,13 @@ export const WizardSignalTypeStep = ({
return (
<li key={entry.type} className="relative">
<CampaignWizardNavLink
href={wizardSignalHref(actionSlug, municipalitySlug, entry.type, entryAction)}
href={wizardSignalHref(
actionSlug,
municipalitySlug,
entry.type,
entryAction,
returnPath,
)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border border-border bg-transparent p-3 pr-10 text-left',
'transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',

View file

@ -30,6 +30,7 @@ import {
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { cn } from '@/lib/utils'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
type WizardTrendChoiceStepProps = {
actionSlug: string
@ -38,6 +39,7 @@ type WizardTrendChoiceStepProps = {
currentStatus: PoliticalTrendStatusValue | null
entryAction?: CampaignWizardActionId
prefillExtraParams?: Record<string, string>
returnPath?: string
}
export const WizardTrendChoiceStep = ({
@ -47,9 +49,10 @@ export const WizardTrendChoiceStep = ({
currentStatus,
entryAction,
prefillExtraParams,
returnPath,
}: WizardTrendChoiceStepProps) => {
const [infoEntry, setInfoEntry] = useState<PoliticalTrendWizardMetaEntry | null>(null)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath)
const options = selectablePoliticalTrendStatuses(currentStatus)
return (
@ -58,7 +61,8 @@ export const WizardTrendChoiceStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={wizardTrendChoiceStepTitle(currentStatus)}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction })}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}
@ -76,6 +80,7 @@ export const WizardTrendChoiceStep = ({
status,
entryAction,
prefillExtraParams,
returnPath,
)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border bg-background p-3 pr-10 text-left',

View file

@ -21,7 +21,7 @@ import {
WIZARD_TREND_SAVE_LABEL,
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
type WizardTrendNoteStepProps = {
@ -32,6 +32,7 @@ type WizardTrendNoteStepProps = {
trendStatus: PoliticalTrendStatusValue
initialNote: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardTrendNoteStep = ({
@ -42,6 +43,7 @@ export const WizardTrendNoteStep = ({
trendStatus,
initialNote,
entryAction,
returnPath,
}: WizardTrendNoteStepProps) => {
const router = useRouter()
const [note, setNote] = useState(initialNote)
@ -49,13 +51,15 @@ export const WizardTrendNoteStep = ({
setMunicipalityPoliticalTrendFormAction,
{},
)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = `Mudar tendência para ${politicalTrendLabels[trendStatus]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend')
router.replace(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug))
router.replace(
wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath),
)
})
return (
@ -63,7 +67,15 @@ export const WizardTrendNoteStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)}
previousHref={wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}

View file

@ -19,7 +19,6 @@ import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
resolveWizardVotesSkip,
WIZARD_VOTES_FINAL_CTA_LABEL,
@ -28,7 +27,7 @@ import {
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain'
import {
applyVoteShortcut,
getWizardVoteViolation,
@ -45,6 +44,7 @@ type WizardExpectedVotesStepProps = {
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardExpectedVotesStep = ({
@ -54,6 +54,7 @@ export const WizardExpectedVotesStep = ({
municipalitySlug,
initialExpectedVotes,
entryAction,
returnPath,
}: WizardExpectedVotesStepProps) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
@ -61,7 +62,7 @@ export const WizardExpectedVotesStep = ({
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const [violation, setViolation] = useState<WizardVoteViolation | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const skip = resolveWizardVotesSkip(entryAction, municipalitySlug)
const skip = resolveWizardVotesSkip(entryAction, municipalitySlug, returnPath)
const clearViolationState = () => {
if (violation) setViolation(null)
@ -80,7 +81,9 @@ export const WizardExpectedVotesStep = ({
const continueAfterVotes = () => {
const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes')
router.replace(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug))
router.replace(
wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath),
)
}
const handleConfirm = () => {
@ -131,8 +134,8 @@ export const WizardExpectedVotesStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={trailingAction}

View file

@ -53,6 +53,7 @@ type WizardSearchResultsState =
type WizardMunicipalitySearchStepProps = {
actionSlug: string
previousHref: string
returnPath?: string
accessibleMunicipalities: AccessibleMunicipality[]
}
@ -72,6 +73,7 @@ const buildSuggestSuccessState = (
export const WizardMunicipalitySearchStep = ({
actionSlug,
previousHref,
returnPath,
accessibleMunicipalities,
}: WizardMunicipalitySearchStepProps) => {
const { query, setRaw, isDebouncing } = useHomeSearchQuery()
@ -216,7 +218,7 @@ export const WizardMunicipalitySearchStep = ({
{displayRows.map(({ hit, continuityReason }) => (
<li key={hit.slug}>
<HomeSearchHitRow
href={wizardActionHref(actionSlug, hit.slug)}
href={wizardActionHref(actionSlug, hit.slug, { returnPath })}
primary={hit.name}
secondary={continuityReason ?? hit.region}
showPriority={hit.priority === 'alta'}

View file

@ -1,6 +1,6 @@
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
import {
wizardFlowTitleForSlug,
wizardNextStepPlaceholder,
@ -10,18 +10,20 @@ import {
type WizardMunicipalitySelectedStubProps = {
actionSlug: string
municipalityName: string
returnPath?: string
}
export const WizardMunicipalitySelectedStub = ({
actionSlug,
municipalityName,
returnPath,
}: WizardMunicipalitySelectedStubProps) => (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>

View file

@ -43,6 +43,7 @@ const listQuickActions = (): readonly CampaignQuickAction[] => [
const wizardActionsWithMunicipalityPrefill = (
role: CampaignRole,
municipalitySlug: string,
returnPath: string,
): readonly CampaignQuickAction[] => {
const homeActions = homeActionsForRole(role).filter((action) =>
WIZARD_ACTION_IDS.includes(action.id as CampaignWizardActionId),
@ -55,7 +56,9 @@ const wizardActionsWithMunicipalityPrefill = (
label: action.label,
icon: action.icon,
description: action.description,
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug),
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug, {
returnPath,
}),
}
})
}
@ -93,6 +96,7 @@ export const resolveActivityQuickActions = (
surface: ActivityQuickActionSurface,
role: CampaignRole,
context: CampaignQuickActionContext,
pathname?: string,
): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) return []
@ -102,6 +106,7 @@ export const resolveActivityQuickActions = (
const activitySlug = context.activitySlug ?? surface.activitySlug
const municipalitySlug = context.municipalitySlug
const returnPath = pathname ?? `/campanha/atividades/${activitySlug}`
const detailActions = detailVerticalQuickActions(activitySlug)
@ -109,5 +114,8 @@ export const resolveActivityQuickActions = (
return detailActions
}
return [...wizardActionsWithMunicipalityPrefill(role, municipalitySlug), ...detailActions]
return [
...wizardActionsWithMunicipalityPrefill(role, municipalitySlug, returnPath),
...detailActions,
]
}

View file

@ -67,9 +67,50 @@ export const WIZARD_VOTE_TO_QUERY_KEY = 'voteTo' as const
export const WIZARD_LEADERSHIP_ID_QUERY_KEY = 'leadershipId' as const
export const WIZARD_RETURN_PATH_QUERY_KEY = 'from' as const
const WIZARD_RETURN_PATH_AUTH_PREFIXES = [
'/campanha/login',
'/campanha/convite',
'/campanha/redefinir-senha',
'/campanha/webauthn',
] as const
/** Allowlisted internal return paths for wizard dismiss / chain end (B110). */
export const isWizardReturnPath = (pathname: string): boolean => {
if (!pathname.startsWith('/campanha')) return false
if (pathname.startsWith('/campanha/acoes')) return false
for (const prefix of WIZARD_RETURN_PATH_AUTH_PREFIXES) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return false
}
return true
}
export const parseWizardReturnPath = (
value: string | string[] | undefined,
): string | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) return undefined
if (trimmed.includes('://') || trimmed.startsWith('//')) return undefined
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
if (path.includes('?') || path.includes('#')) return undefined
return isWizardReturnPath(path) ? path : undefined
}
export const appendWizardReturnPath = (href: string, returnPath?: string): string => {
if (!returnPath || !isWizardReturnPath(returnPath)) return href
const queryIndex = href.indexOf('?')
const base = queryIndex === -1 ? href : href.slice(0, queryIndex)
const params = new URLSearchParams(queryIndex === -1 ? '' : href.slice(queryIndex + 1))
params.set(WIZARD_RETURN_PATH_QUERY_KEY, returnPath)
return `${base}?${params.toString()}`
}
export type WizardActionHrefOptions = {
entryAction?: CampaignWizardActionId
leadershipId?: number
returnPath?: string
}
export const wizardActionHref = (
@ -79,7 +120,7 @@ export const wizardActionHref = (
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug && !options?.leadershipId && !options?.entryAction) {
return base
return appendWizardReturnPath(base, options?.returnPath)
}
const params = new URLSearchParams()
if (municipalitySlug) {
@ -91,7 +132,8 @@ export const wizardActionHref = (
if (options?.leadershipId !== undefined) {
params.set(WIZARD_LEADERSHIP_ID_QUERY_KEY, String(options.leadershipId))
}
return `${base}?${params.toString()}`
const href = params.size > 0 ? `${base}?${params.toString()}` : base
return appendWizardReturnPath(href, options?.returnPath)
}
export const parseWizardMunicipioParam = (
@ -132,10 +174,11 @@ export const wizardSignalHref = (
municipalitySlug?: string,
signalType?: MunicipalitySignalType,
entryAction?: CampaignWizardActionId,
returnPath?: string,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
return appendWizardReturnPath(base, returnPath)
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
@ -145,7 +188,7 @@ export const wizardSignalHref = (
if (entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
}
return `${base}?${params.toString()}`
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
export const resolveWizardSignalTypeParam = (
@ -171,10 +214,11 @@ export const wizardTrendHref = (
trendStatus?: PoliticalTrendStatusValue,
entryAction?: CampaignWizardActionId,
extraParams?: Record<string, string>,
returnPath?: string,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
return appendWizardReturnPath(base, returnPath)
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
@ -189,7 +233,7 @@ export const wizardTrendHref = (
if (value) params.set(key, value)
}
}
return `${base}?${params.toString()}`
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
export const resolveWizardTrendStatusParam = (

View file

@ -13,6 +13,7 @@ import {
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignActionEntryHref,
appendWizardReturnPath,
isCampaignWizardActionId,
wizardActionHref,
type CampaignWizardActionId,
@ -132,6 +133,7 @@ export const UNCOVERED_MUNICIPALITIES_LIST_HREF =
export type HomeActionButtonPropsOptions = {
uncoveredMunicipalitiesHref?: string
municipalitySlug?: string
returnPath?: string
}
/** Staff Início catalog with hrefs — shared by quick-action registries (B80+). */
@ -139,14 +141,20 @@ const staffHomeQuickActionsByRole = new Map<CampaignRole, readonly ResolvedCampa
export const resolveStaffHomeQuickActions = (
role: CampaignRole,
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => {
if (!isStaffCampaignRole(role)) return []
const cached = staffHomeQuickActionsByRole.get(role)
if (cached) return cached
if (!returnPath) {
const cached = staffHomeQuickActionsByRole.get(role)
if (cached) return cached
}
const actions = toHomeActionButtonProps(homeActionsForRole(role), {
uncoveredMunicipalitiesHref: UNCOVERED_MUNICIPALITIES_LIST_HREF,
returnPath,
})
staffHomeQuickActionsByRole.set(role, actions)
if (!returnPath) {
staffHomeQuickActionsByRole.set(role, actions)
}
return actions
}
@ -164,9 +172,13 @@ export const toHomeActionButtonProps = (
} else if (action.id === 'uncovered-municipalities') {
href = resolved.uncoveredMunicipalitiesHref
} else if (isCampaignWizardActionId(action.id)) {
href = resolved.municipalitySlug
? wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[action.id], resolved.municipalitySlug)
: campaignActionEntryHref(action.id)
if (resolved.municipalitySlug) {
href = wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[action.id], resolved.municipalitySlug, {
returnPath: resolved.returnPath,
})
} else {
href = appendWizardReturnPath(campaignActionEntryHref(action.id), resolved.returnPath)
}
}
return {
...action,

View file

@ -22,18 +22,20 @@ export const parseMunicipalityDetailSlug = (pathname: string): string | undefine
export const resolveMunicipalityListQuickActions = (
role: CampaignRole,
): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role)
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role, returnPath)
export const resolveMunicipalityDetailQuickActions = (
role: CampaignRole,
municipalitySlug: string,
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => {
if (!isStaffCampaignRole(role)) return []
const actions = homeActionsForRole(role).filter(
(action) => action.id !== 'uncovered-municipalities',
)
return toHomeActionButtonProps(actions, { municipalitySlug })
return toHomeActionButtonProps(actions, { municipalitySlug, returnPath })
}
export const resolveMunicipalityQuickActionsForPath = (
@ -42,12 +44,12 @@ export const resolveMunicipalityQuickActionsForPath = (
context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] => {
if (isMunicipalitiesListPath(pathname)) {
return resolveMunicipalityListQuickActions(role)
return resolveMunicipalityListQuickActions(role, pathname)
}
const slug = context.municipalitySlug ?? parseMunicipalityDetailSlug(pathname)
if (slug) {
return resolveMunicipalityDetailQuickActions(role, slug)
return resolveMunicipalityDetailQuickActions(role, slug, pathname)
}
return []

View file

@ -66,6 +66,7 @@ export const resolveDemandsListQuickActions = (
export const resolveDemandDetailQuickActions = (
role: CampaignRole,
context: CampaignQuickActionContext,
returnPath: string,
): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) {
return []
@ -93,7 +94,9 @@ export const resolveDemandDetailQuickActions = (
return [
{
...action,
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[id], municipalitySlug),
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[id], municipalitySlug, {
returnPath,
}),
},
]
})

View file

@ -42,7 +42,7 @@ export const resolveDobradinhasQuickActions = (
return []
}
const staffActions = resolveStaffHomeQuickActions(role)
const staffActions = resolveStaffHomeQuickActions(role, pathname)
if (isStateDeputyListPath(pathname)) {
return [newStateDeputyAction(), ...staffActions]

View file

@ -1,5 +1,6 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
appendWizardReturnPath,
campaignActionEntryHref,
wizardActionHref,
type CampaignWizardActionId,
@ -34,40 +35,46 @@ const parseLeadershipDetailId = (pathname: string): number | undefined => {
const resolveLeadershipListActions = (
role: CampaignRole,
returnPath: string,
): readonly ResolvedCampaignHomeAction[] => {
const actions = homeActionsForRole(role).filter((action) => action.id === 'update-leadership')
return actions.map((action) => ({
...action,
href: campaignActionEntryHref('update-leadership'),
href: appendWizardReturnPath(campaignActionEntryHref('update-leadership'), returnPath),
}))
}
const hrefForLeadershipDetailAction = (
action: CampaignHomeAction,
context: CampaignQuickActionContext,
returnPath: string,
): string => {
const wizardSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action.id as CampaignWizardActionId]
const { municipalitySlug, leadershipId } = context
if (action.id === 'update-leadership') {
if (municipalitySlug && leadershipId !== undefined) {
return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId })
return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId, returnPath })
}
if (leadershipId !== undefined) {
return `${LEADERSHIP_LIST_PATH}/${leadershipId}`
}
return campaignActionEntryHref('update-leadership')
return appendWizardReturnPath(campaignActionEntryHref('update-leadership'), returnPath)
}
if (municipalitySlug) {
return wizardActionHref(wizardSlug, municipalitySlug)
return wizardActionHref(wizardSlug, municipalitySlug, { returnPath })
}
return campaignActionEntryHref(action.id as CampaignWizardActionId)
return appendWizardReturnPath(
campaignActionEntryHref(action.id as CampaignWizardActionId),
returnPath,
)
}
const resolveLeadershipDetailActions = (
role: CampaignRole,
context: CampaignQuickActionContext,
returnPath: string,
): readonly ResolvedCampaignHomeAction[] => {
const actions = homeActionsForRole(role).filter((action) =>
LEADERSHIP_DETAIL_WIZARD_IDS.includes(action.id as CampaignWizardActionId),
@ -75,7 +82,7 @@ const resolveLeadershipDetailActions = (
return actions.map((action) => ({
...action,
href: hrefForLeadershipDetailAction(action, context),
href: hrefForLeadershipDetailAction(action, context, returnPath),
}))
}
@ -87,7 +94,7 @@ export const resolveLeadershipQuickActions = (
if (!isStaffCampaignRole(role)) return null
if (isLeadershipListPath(pathname)) {
return resolveLeadershipListActions(role)
return resolveLeadershipListActions(role, pathname)
}
const leadershipId = parseLeadershipDetailId(pathname)
@ -95,7 +102,7 @@ export const resolveLeadershipQuickActions = (
return resolveLeadershipDetailActions(role, {
...context,
leadershipId: context.leadershipId ?? leadershipId,
})
}, pathname)
}
return null

View file

@ -48,7 +48,7 @@ export const resolveQuickActionsForPath = (
const activitySurface = parseActivityQuickActionSurface(pathname)
if (activitySurface) {
return resolveActivityQuickActions(activitySurface, role, context)
return resolveActivityQuickActions(activitySurface, role, context, pathname)
}
const organizationSurface = parseOrganizationQuickActionSurface(pathname)
@ -60,7 +60,7 @@ export const resolveQuickActionsForPath = (
return resolveDemandsListQuickActions(role)
}
if (isDemandDetailPath(pathname)) {
return resolveDemandDetailQuickActions(role, context)
return resolveDemandDetailQuickActions(role, context, pathname)
}
const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context)
@ -79,7 +79,7 @@ export const resolveQuickActionsForPath = (
if (advisorActions.length > 0) return advisorActions
if (isTerritoriesListPath(pathname)) {
return resolveStaffHomeQuickActions(role)
return resolveStaffHomeQuickActions(role, pathname)
}
const referenceActions = resolveReferenceQuickActionsForPath(pathname, role, context)

View file

@ -49,6 +49,7 @@ const shouldShowWizardVotesSkip = (entryAction: CampaignWizardActionId | undefin
export const resolveWizardVotesSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardVotesSkipAction | undefined =>
shouldShowWizardVotesSkip(entryAction)
? {
@ -57,6 +58,7 @@ export const resolveWizardVotesSkip = (
resolveWizardChainEntry(entryAction, 'update-votes'),
'update-votes',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -69,6 +69,7 @@ export const shouldShowWizardTrendSkip = (
export const resolveWizardTrendSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardTrendSkipAction | undefined =>
shouldShowWizardTrendSkip(entryAction)
? {
@ -77,6 +78,7 @@ export const resolveWizardTrendSkip = (
resolveWizardChainEntry(entryAction, 'change-trend'),
'change-trend',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -6,6 +6,7 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
type CampaignWizardActionId,
isWizardReturnPath,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
@ -54,16 +55,17 @@ const wizardHrefForChainStep = (
action: WizardChainActionId,
municipalitySlug: string,
entryAction: WizardChainActionId,
returnPath?: string,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
return wizardActionHref(actionSlug, municipalitySlug, { entryAction })
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
case 'register-signal':
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction, returnPath)
case 'change-trend':
return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)
return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction, undefined, returnPath)
default: {
const exhaustive: never = action
return exhaustive
@ -71,23 +73,28 @@ const wizardHrefForChainStep = (
}
}
/** Chain end or dismiss target: allowlisted `from`, else Início (B110). */
export const wizardChainEndHref = (returnPath?: string): string =>
returnPath && isWizardReturnPath(returnPath) ? returnPath : CAMPAIGN_HOME
/**
* Href for the next chained wizard after completing/skipping `completedAction`,
* or Início when the queue is empty / entry is not chainable.
* or the return path (default Início) when the queue is empty / entry is not chainable.
*/
export const wizardChainContinueHref = (
entryAction: CampaignWizardActionId,
completedAction: WizardChainActionId,
municipalitySlug: string,
returnPath?: string,
): string => {
if (!isWizardChainActionId(entryAction)) {
return CAMPAIGN_HOME
return wizardChainEndHref(returnPath)
}
const next = nextWizardChainStep(entryAction, completedAction)
if (!next) {
return CAMPAIGN_HOME
return wizardChainEndHref(returnPath)
}
return wizardHrefForChainStep(next, municipalitySlug, entryAction)
return wizardHrefForChainStep(next, municipalitySlug, entryAction, returnPath)
}
/** Session owner for navigation: explicit `entry` query, else the current principal. */

View file

@ -27,6 +27,7 @@ export const showLeadershipWizardSkip = (entryAction?: CampaignWizardActionId):
export const resolveWizardLeadershipSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardLeadershipSkipAction | undefined =>
showLeadershipWizardSkip(entryAction)
? {
@ -35,6 +36,7 @@ export const resolveWizardLeadershipSkip = (
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -22,6 +22,7 @@ export const shouldShowWizardSignalSkip = (
export const resolveWizardSignalSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardSignalSkipAction | undefined =>
shouldShowWizardSignalSkip(entryAction)
? {
@ -30,6 +31,7 @@ export const resolveWizardSignalSkip = (
resolveWizardChainEntry(entryAction, 'register-signal'),
'register-signal',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -218,7 +218,7 @@ describe('campaignQuickActionDobradinhas (B83)', () => {
expect(actions.map((action) => action.id)).toEqual(['new-state-deputy', ...staffActionIds])
expect(actions[0]?.href).toBe('/campanha/dobradinhas/nova')
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos',
'/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fdobradinhas',
)
expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe(
UNCOVERED_MUNICIPALITIES_LIST_HREF,
@ -279,7 +279,7 @@ describe('campaignQuickActionRegistry', () => {
})
expect(detail).toHaveLength(5)
expect(detail.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?municipio=foo',
'/campanha/acoes/atualizar-votos?municipio=foo&from=%2Fcampanha%2Fmunicipios%2Ffoo',
)
})
@ -303,7 +303,7 @@ describe('campaignQuickActionRegistry', () => {
)
const registerSignal = actions.find((action) => action.id === 'register-signal')
expect(registerSignal?.href).toBe(
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01`,
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01&from=%2Fcampanha%2Fatividades%2Fevento-zona-1`,
)
})
@ -311,7 +311,7 @@ describe('campaignQuickActionRegistry', () => {
const actions = resolveQuickActionsForPath('/campanha/territorios', 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(staffActionIds)
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos',
'/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fterritorios',
)
expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe(
UNCOVERED_MUNICIPALITIES_LIST_HREF,
@ -346,7 +346,7 @@ describe('campaignQuickActionRegistry', () => {
'register-demand',
])
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?municipio=cairu',
'/campanha/acoes/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fdemandas%2Fpedido-cairu',
)
expect(actions.find((action) => action.id === 'register-demand')?.href).toBe(
demandCreateHref(42),
@ -389,7 +389,11 @@ describe('campaignQuickActionDemands resolvers', () => {
it('returns empty catalog for leader lockdown', () => {
expect(resolveDemandsListQuickActions('leader')).toEqual([])
expect(
resolveDemandDetailQuickActions('leader', { municipalitySlug: 'cairu', municipalityId: 1 }),
resolveDemandDetailQuickActions(
'leader',
{ municipalitySlug: 'cairu', municipalityId: 1 },
'/campanha/demandas/foo',
),
).toEqual([])
})
})

View file

@ -8,7 +8,9 @@ describe('resolveLeadershipQuickActions', () => {
const actions = resolveLeadershipQuickActions('/campanha/liderancas', 'coordinator', {})
expect(actions).toHaveLength(1)
expect(actions?.[0]?.id).toBe('update-leadership')
expect(actions?.[0]?.href).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca`)
expect(actions?.[0]?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?from=%2Fcampanha%2Fliderancas`,
)
})
it('returns five wizard actions on detail with municipality prefill when N=1', () => {
@ -24,10 +26,10 @@ describe('resolveLeadershipQuickActions', () => {
'register-demand',
])
expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fliderancas%2F42`,
)
expect(actions?.find((action) => action.id === 'update-leadership')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42&from=%2Fcampanha%2Fliderancas%2F42`,
)
})
@ -39,7 +41,7 @@ describe('resolveLeadershipQuickActions', () => {
'/campanha/liderancas/7',
)
expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?from=%2Fcampanha%2Fliderancas%2F7`,
)
})

View file

@ -72,6 +72,16 @@ describe('wizardActionChain', () => {
expect(wizardChainContinueHref('register-demand', 'update-votes', 'cairu')).toBe(CAMPAIGN_HOME)
})
it('honors allowlisted return path when the chain ends', () => {
const origin = '/campanha/municipios/cairu'
expect(
wizardChainContinueHref('change-trend', 'update-leadership', 'cairu', origin),
).toBe(origin)
expect(
wizardChainContinueHref('update-votes', 'change-trend', 'cairu', origin),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes&from=${encodeURIComponent(origin)}`)
})
it('resolves session entry from query or current principal', () => {
expect(resolveWizardChainEntry(undefined, 'update-votes')).toBe('update-votes')
expect(resolveWizardChainEntry('register-signal', 'update-votes')).toBe('register-signal')

View file

@ -27,8 +27,12 @@ describe('resolveWizardLeadershipSkip', () => {
expect(resolveWizardLeadershipSkip('update-leadership', 'cairu')).toBeUndefined()
})
it('points skip to Início when leadership is the last chained step', () => {
// In the v1 matrix, leadership is always the final chained elo.
it('points skip to origin when from is set, else Início at chain end', () => {
const origin = '/campanha/liderancas/7'
expect(resolveWizardLeadershipSkip('update-votes', 'cairu', origin)).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: origin,
})
expect(resolveWizardLeadershipSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: CAMPAIGN_HOME,

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
appendWizardReturnPath,
isWizardReturnPath,
parseWizardReturnPath,
WIZARD_RETURN_PATH_QUERY_KEY,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
describe('wizard return path (B110)', () => {
it('allowlists internal /campanha paths outside acoes and auth', () => {
expect(isWizardReturnPath('/campanha')).toBe(true)
expect(isWizardReturnPath('/campanha/municipios/cairu')).toBe(true)
expect(isWizardReturnPath('/campanha/liderancas/42')).toBe(true)
expect(isWizardReturnPath('/campanha/acoes/mudar-tendencia')).toBe(false)
expect(isWizardReturnPath('/campanha/login')).toBe(false)
expect(isWizardReturnPath('/campanha/convite/token')).toBe(false)
expect(isWizardReturnPath('https://evil.test/phish')).toBe(false)
expect(isWizardReturnPath('/public')).toBe(false)
})
it('parseWizardReturnPath fails closed on invalid values', () => {
expect(parseWizardReturnPath(undefined)).toBeUndefined()
expect(parseWizardReturnPath('/campanha/municipios/foo')).toBe('/campanha/municipios/foo')
expect(parseWizardReturnPath('/campanha/acoes/foo')).toBeUndefined()
expect(parseWizardReturnPath('//evil.test')).toBeUndefined()
expect(parseWizardReturnPath('/campanha/foo?x=1')).toBeUndefined()
})
it('appendWizardReturnPath adds the from query key', () => {
const href = appendWizardReturnPath(
'/campanha/acoes/mudar-tendencia?municipio=cairu',
'/campanha/municipios/cairu',
)
expect(href).toContain(`${WIZARD_RETURN_PATH_QUERY_KEY}=`)
expect(href).toContain(
`${WIZARD_RETURN_PATH_QUERY_KEY}=${encodeURIComponent('/campanha/municipios/cairu')}`,
)
})
it('appendWizardReturnPath ignores invalid return paths', () => {
expect(appendWizardReturnPath('/campanha/acoes/foo', '/campanha/acoes/bar')).toBe(
'/campanha/acoes/foo',
)
})
it('CAMPAIGN_HOME is a valid return path', () => {
expect(parseWizardReturnPath(CAMPAIGN_HOME)).toBe(CAMPAIGN_HOME)
})
})