Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt.

Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-02 16:18:41 -06:00
co-authored by Cursor
parent 84ef943df4
commit db34a1f0df
17 changed files with 521 additions and 60 deletions
+65 -25
View File
@@ -23,8 +23,8 @@ import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep,
getStepIndex,
parseReviewReturnSearchParam,
shouldOfferCreateFlowSaveAndExit,
createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
@@ -64,6 +64,10 @@ import {
} from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
} from "../../../lib/create/pendingKeepRuleLogin";
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
import messages from "../../../messages/en/index";
import {
@@ -91,9 +95,6 @@ import {
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
@@ -111,9 +112,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const sessionResolved = sessionUser !== undefined;
// Mirror in-progress draft to localStorage for ALL visitors once we know who
// they are. Refresh-survival is the same UX for guest and signed-in users;
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
// the server (handled in `useCreateFlowExit`).
// they are. Refresh-survival is the same UX for guest and signed-in users.
// Save & Exit: guests open the save-progress login modal; signed-in users
// PUT the server draft (`useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved;
return (
@@ -197,6 +198,8 @@ function CreateFlowLayoutContent({
} | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null,
);
@@ -267,14 +270,14 @@ function CreateFlowLayoutContent({
title: completedCopy.guestInvitesSkippedTitle,
description: completedCopy.guestInvitesSkippedDescription,
});
return;
} else {
setCompletedFlowBanner({
key: "guestClaimHint",
status: "warning",
title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription,
});
}
setCompletedFlowBanner({
key: "guestClaimHint",
status: "warning",
title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription,
});
},
});
@@ -305,14 +308,26 @@ function CreateFlowLayoutContent({
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
// Completed is post-publish. Guests still need Save & Exit → keep-this-rule
// login (the document is live but unclaimed). Signed-in users just leave.
if (currentStep === "completed") {
if (sessionUser === null) {
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
onDismiss: () => {
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
},
});
return;
}
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
@@ -322,7 +337,6 @@ function CreateFlowLayoutContent({
}
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
@@ -428,9 +442,32 @@ function CreateFlowLayoutContent({
useEffect(() => {
if (currentStep !== "completed") {
setCompletedFlowBanner(null);
setCompletedHasPublishedRule(false);
return;
}
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
}, [currentStep]);
useEffect(() => {
if (currentStep !== "completed") return;
if (!sessionResolved) return;
if (sessionUser !== null) {
clearPendingKeepRuleLogin();
return;
}
if (!hasPendingKeepRuleLogin()) return;
const timeoutId = window.setTimeout(() => {
if (!hasPendingKeepRuleLogin()) return;
clearPendingKeepRuleLogin();
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
});
}, 0);
return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
@@ -486,7 +523,6 @@ function CreateFlowLayoutContent({
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
currentStep,
);
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
const mainContentClass = isCompletedStep
@@ -506,9 +542,10 @@ function CreateFlowLayoutContent({
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit =
Boolean(sessionUser) &&
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit(
currentStep,
sessionUser,
);
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
@@ -674,7 +711,10 @@ function CreateFlowLayoutContent({
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep && Boolean(sessionUser)}
hasEdit={
isCompletedStep &&
(Boolean(sessionUser) || completedHasPublishedRule)
}
hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit}
onShare={
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types";
import {
@@ -15,7 +16,7 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: {
variant: "default" | "saveProgress";
variant: "default" | "saveProgress" | "keepRule";
nextPath: string;
backdropVariant: "blurredYellow";
}) => void;
@@ -79,6 +80,21 @@ export function useCreateFlowFinalize({
? state.editingPublishedRuleId.trim()
: "";
if (editingId.length > 0 && sessionUser === null) {
setIsPublishing(false);
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document: ruleDocument,
});
updateState({ editingPublishedRuleId: undefined });
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: false });
router.push(createFlowStepPath("completed"));
return;
}
if (editingId.length > 0) {
const updateResult = await updatePublishedRule(editingId, {
title,
@@ -99,8 +115,11 @@ export function useCreateFlowFinalize({
}
if (updateResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
@@ -135,6 +154,7 @@ export function useCreateFlowFinalize({
document: ruleDocument,
});
if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
}
router.push(
@@ -147,8 +167,11 @@ export function useCreateFlowFinalize({
}
if (publishResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
+2 -3
View File
@@ -279,9 +279,8 @@ export interface CreateFlowContextValue {
*
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
* has already started editing, we skip replaying their server draft on top
* of in-progress local state. Save & Exit visibility is driven by step
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
* flag.
* of in-progress local state. Save & Exit visibility is
* `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
+21
View File
@@ -117,6 +117,27 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
return FLOW_STEP_ORDER.indexOf(step);
}
/** First wizard step that offers Save & Exit (first Create Community select). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
/**
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
* `community-structure` onward and on `edit-rule`. On completed, guests still
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
*
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
* not a guest — completed stays Exit until the session resolves.
*/
export function shouldOfferCreateFlowSaveAndExit(
currentStep: CreateFlowStep | null | undefined,
sessionUser?: { id: string } | null,
): boolean {
if (currentStep == null) return false;
if (currentStep === "completed") return sessionUser === null;
if (currentStep === "edit-rule") return true;
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
}
/**
* Steps where below `lg` the main column scrolls with split layout
* (`CreateFlowLayoutClient` — Linear CR-92 §4).