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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -31,7 +31,10 @@ export function LoginView({
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`}
|
||||
onClick={onClose}
|
||||
onPointerDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -44,17 +44,51 @@ function MailIconInline() {
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export type LoginFormVariant = "default" | "saveProgress";
|
||||
export type LoginFormVariant = "default" | "saveProgress" | "keepRule";
|
||||
|
||||
function loginFormHeading(
|
||||
variant: LoginFormVariant,
|
||||
sent: boolean,
|
||||
t: (_key: string) => string,
|
||||
): { title: string; description: string } {
|
||||
if (sent) {
|
||||
return { title: t("successTitle"), description: t("successBody") };
|
||||
}
|
||||
if (variant === "saveProgress") {
|
||||
return {
|
||||
title: t("saveProgressTitle"),
|
||||
description: t("saveProgressSubtitle"),
|
||||
};
|
||||
}
|
||||
if (variant === "keepRule") {
|
||||
return {
|
||||
title: t("keepRuleTitle"),
|
||||
description: t("keepRuleSubtitle"),
|
||||
};
|
||||
}
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
function shouldAttachCreateFlowDraft(
|
||||
variant: LoginFormVariant,
|
||||
nextPath: string,
|
||||
): boolean {
|
||||
if (variant === "keepRule") return false;
|
||||
return variant === "saveProgress" || nextPath.includes("syncDraft=1");
|
||||
}
|
||||
|
||||
export type LoginFormProps = {
|
||||
variant?: LoginFormVariant;
|
||||
/** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */
|
||||
magicLinkNextPath?: string;
|
||||
/** `keepRule`: Continue without saving. */
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export default function LoginForm({
|
||||
variant = "default",
|
||||
magicLinkNextPath,
|
||||
onDismiss,
|
||||
}: LoginFormProps) {
|
||||
const t = useTranslation("pages.login");
|
||||
const tFooter = useTranslation("footer");
|
||||
@@ -72,7 +106,7 @@ export default function LoginForm({
|
||||
const nextParam = searchParams.get("next");
|
||||
const errorParam = searchParams.get("error");
|
||||
|
||||
const isSaveProgress = variant === "saveProgress";
|
||||
const heading = loginFormHeading(variant, sent, t);
|
||||
|
||||
/** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */
|
||||
const stripErrorQuery = useCallback(() => {
|
||||
@@ -96,8 +130,7 @@ export default function LoginForm({
|
||||
try {
|
||||
const rawNext = magicLinkNextPath ?? nextParam;
|
||||
const nextPath = safeInternalPath(rawNext);
|
||||
const shouldAttachDraft =
|
||||
isSaveProgress || nextPath.includes("syncDraft=1");
|
||||
const shouldAttachDraft = shouldAttachCreateFlowDraft(variant, nextPath);
|
||||
const localDraft = readAnonymousCreateFlowState();
|
||||
const draft =
|
||||
shouldAttachDraft && Object.keys(localDraft).length > 0
|
||||
@@ -115,7 +148,7 @@ export default function LoginForm({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isSaveProgress || nextPath.includes("syncDraft=1")) {
|
||||
if (shouldAttachDraft) {
|
||||
setTransferPendingFlag();
|
||||
}
|
||||
setEmail(trimmed);
|
||||
@@ -127,11 +160,11 @@ export default function LoginForm({
|
||||
}
|
||||
}, [
|
||||
email,
|
||||
isSaveProgress,
|
||||
magicLinkNextPath,
|
||||
nextParam,
|
||||
stripErrorQuery,
|
||||
t,
|
||||
variant,
|
||||
]);
|
||||
|
||||
const urlErrorMessage =
|
||||
@@ -155,20 +188,8 @@ export default function LoginForm({
|
||||
</div>
|
||||
<ContentLockup
|
||||
titleId={titleId}
|
||||
title={
|
||||
sent
|
||||
? t("successTitle")
|
||||
: isSaveProgress
|
||||
? t("saveProgressTitle")
|
||||
: t("title")
|
||||
}
|
||||
description={
|
||||
sent
|
||||
? t("successBody")
|
||||
: isSaveProgress
|
||||
? t("saveProgressSubtitle")
|
||||
: t("subtitle")
|
||||
}
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
variant="login"
|
||||
alignment="left"
|
||||
/>
|
||||
@@ -255,6 +276,19 @@ export default function LoginForm({
|
||||
>
|
||||
{t("sendMagicLink")}
|
||||
</Button>
|
||||
{onDismiss ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="large"
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
disabled={submitting}
|
||||
onClick={onDismiss}
|
||||
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
|
||||
>
|
||||
{t("continueWithoutSaving")}
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{t("legalPrefix")}
|
||||
<Link
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import Login from "../components/modals/Login";
|
||||
import LoginForm from "../components/modals/Login/LoginForm";
|
||||
|
||||
export type AuthModalLoginVariant = "default" | "saveProgress";
|
||||
export type AuthModalLoginVariant = "default" | "saveProgress" | "keepRule";
|
||||
|
||||
export type AuthModalBackdropVariant = "solid" | "blurredYellow";
|
||||
|
||||
@@ -20,6 +20,11 @@ export type OpenLoginOptions = {
|
||||
/** Passed to `requestMagicLink` as `next` (internal path). */
|
||||
nextPath?: string;
|
||||
backdropVariant?: AuthModalBackdropVariant;
|
||||
/**
|
||||
* `keepRule` only: **Continue without saving**. Default is close the overlay.
|
||||
* Guest completed Save & Exit passes leave-the-flow.
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
type AuthModalContextValue = {
|
||||
@@ -49,6 +54,14 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
const backdropVariant = opts.backdropVariant ?? "blurredYellow";
|
||||
const keepRuleDismiss =
|
||||
opts.variant === "keepRule"
|
||||
? () => {
|
||||
const extra = opts.onDismiss;
|
||||
closeLogin();
|
||||
extra?.();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AuthModalContext.Provider value={value}>
|
||||
@@ -63,6 +76,7 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||
<LoginForm
|
||||
variant={opts.variant ?? "default"}
|
||||
magicLinkNextPath={opts.nextPath}
|
||||
onDismiss={keepRuleDismiss}
|
||||
/>
|
||||
</Login>
|
||||
</AuthModalContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user