diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 3b6b2465..d37dc41e 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -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 ( ) @@ -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 + return ( + + ) } diff --git a/src/components/campaign/dashboard/CampaignHomeActions.tsx b/src/components/campaign/dashboard/CampaignHomeActions.tsx index db70930d..bbd4bbf3 100644 --- a/src/components/campaign/dashboard/CampaignHomeActions.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActions.tsx @@ -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 diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index ca4ed62d..4b609c43 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -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(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} diff --git a/src/components/campaign/municipality/WizardSignalBodyStep.tsx b/src/components/campaign/municipality/WizardSignalBodyStep.tsx index a3313969..b23e9b2d 100644 --- a/src/components/campaign/municipality/WizardSignalBodyStep.tsx +++ b/src/components/campaign/municipality/WizardSignalBodyStep.tsx @@ -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 ? : undefined} diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index 9af18f38..61c0bf30 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -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(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 (
  • + returnPath?: string } export const WizardTrendChoiceStep = ({ @@ -47,9 +49,10 @@ export const WizardTrendChoiceStep = ({ currentStatus, entryAction, prefillExtraParams, + returnPath, }: WizardTrendChoiceStepProps) => { const [infoEntry, setInfoEntry] = useState(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 ? : 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', diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index f416d24d..40ed2a71 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -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 ? : undefined} diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index 5d2438a8..f3d44ddc 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -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('central') const [violation, setViolation] = useState(null) const [saveError, setSaveError] = useState(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} diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx index b4555abe..64bf62f9 100644 --- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx +++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx @@ -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 }) => (
  • (

    {wizardNextStepPlaceholder(actionSlug)}

    diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index daeb943c..9565a1c5 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -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, + ] } diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts index ea2e9ffa..01854e1b 100644 --- a/src/lib/campaignActionRoutes.ts +++ b/src/lib/campaignActionRoutes.ts @@ -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, + 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 = ( diff --git a/src/lib/campaignHomeActions.ts b/src/lib/campaignHomeActions.ts index a4ed9def..a5ff80fb 100644 --- a/src/lib/campaignHomeActions.ts +++ b/src/lib/campaignHomeActions.ts @@ -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 { 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, diff --git a/src/lib/campaignMunicipalityQuickActions.ts b/src/lib/campaignMunicipalityQuickActions.ts index 194b9366..0fd76537 100644 --- a/src/lib/campaignMunicipalityQuickActions.ts +++ b/src/lib/campaignMunicipalityQuickActions.ts @@ -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 [] diff --git a/src/lib/campaignQuickActionDemands.ts b/src/lib/campaignQuickActionDemands.ts index bd53d1e4..a8005f31 100644 --- a/src/lib/campaignQuickActionDemands.ts +++ b/src/lib/campaignQuickActionDemands.ts @@ -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, + }), }, ] }) diff --git a/src/lib/campaignQuickActionDobradinhas.ts b/src/lib/campaignQuickActionDobradinhas.ts index 6b84396c..dc6ed3be 100644 --- a/src/lib/campaignQuickActionDobradinhas.ts +++ b/src/lib/campaignQuickActionDobradinhas.ts @@ -42,7 +42,7 @@ export const resolveDobradinhasQuickActions = ( return [] } - const staffActions = resolveStaffHomeQuickActions(role) + const staffActions = resolveStaffHomeQuickActions(role, pathname) if (isStateDeputyListPath(pathname)) { return [newStateDeputyAction(), ...staffActions] diff --git a/src/lib/campaignQuickActionLeadership.ts b/src/lib/campaignQuickActionLeadership.ts index 143a9466..851f445e 100644 --- a/src/lib/campaignQuickActionLeadership.ts +++ b/src/lib/campaignQuickActionLeadership.ts @@ -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 diff --git a/src/lib/campaignQuickActionRegistry.ts b/src/lib/campaignQuickActionRegistry.ts index b35ab409..695b475f 100644 --- a/src/lib/campaignQuickActionRegistry.ts +++ b/src/lib/campaignQuickActionRegistry.ts @@ -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) diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts index f340ff64..d47e99fa 100644 --- a/src/lib/campaignWizardCopy.ts +++ b/src/lib/campaignWizardCopy.ts @@ -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 diff --git a/src/lib/politicalTrendWizardUi.ts b/src/lib/politicalTrendWizardUi.ts index 75a81f66..45242b40 100644 --- a/src/lib/politicalTrendWizardUi.ts +++ b/src/lib/politicalTrendWizardUi.ts @@ -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 diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts index bb68d083..ff4a63c7 100644 --- a/src/lib/wizardActionChain.ts +++ b/src/lib/wizardActionChain.ts @@ -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. */ diff --git a/src/lib/wizardLeadershipContract.ts b/src/lib/wizardLeadershipContract.ts index 158b51ed..283734c8 100644 --- a/src/lib/wizardLeadershipContract.ts +++ b/src/lib/wizardLeadershipContract.ts @@ -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 diff --git a/src/lib/wizardSignalUi.ts b/src/lib/wizardSignalUi.ts index c1c5d9e3..16de7458 100644 --- a/src/lib/wizardSignalUi.ts +++ b/src/lib/wizardSignalUi.ts @@ -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 diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index c6c09fa6..b27deeb3 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -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([]) }) }) diff --git a/tests/unit/campaignQuickActionLeadership.unit.spec.ts b/tests/unit/campaignQuickActionLeadership.unit.spec.ts index 4a8f6ea8..ccb63959 100644 --- a/tests/unit/campaignQuickActionLeadership.unit.spec.ts +++ b/tests/unit/campaignQuickActionLeadership.unit.spec.ts @@ -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`, ) }) diff --git a/tests/unit/wizardActionChain.unit.spec.ts b/tests/unit/wizardActionChain.unit.spec.ts index 8ca05a10..2ad6d416 100644 --- a/tests/unit/wizardActionChain.unit.spec.ts +++ b/tests/unit/wizardActionChain.unit.spec.ts @@ -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') diff --git a/tests/unit/wizardLeadershipStep.unit.spec.ts b/tests/unit/wizardLeadershipStep.unit.spec.ts index b60b9623..5f106279 100644 --- a/tests/unit/wizardLeadershipStep.unit.spec.ts +++ b/tests/unit/wizardLeadershipStep.unit.spec.ts @@ -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, diff --git a/tests/unit/wizardReturnPath.unit.spec.ts b/tests/unit/wizardReturnPath.unit.spec.ts new file mode 100644 index 00000000..852b5c5f --- /dev/null +++ b/tests/unit/wizardReturnPath.unit.spec.ts @@ -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) + }) +})