Harden server draft sync (Save & Exit + post-login transfer)
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { isValidStep } from "./utils/flowSteps";
|
||||
import { saveDraftToServer } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
@@ -32,14 +33,14 @@ export function PostLoginDraftTransfer({
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionUser == null || sessionUser === undefined) return;
|
||||
const wantsTransfer =
|
||||
syncDraft === "1" || hasTransferPendingFlag();
|
||||
const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag();
|
||||
if (!wantsTransfer) return;
|
||||
if (attemptedRef.current) return;
|
||||
|
||||
if (!SYNC_ENABLED) {
|
||||
if (attemptedRef.current) return;
|
||||
attemptedRef.current = true;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query
|
||||
setTransferError(
|
||||
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
|
||||
);
|
||||
@@ -78,12 +79,15 @@ export function PostLoginDraftTransfer({
|
||||
...(step ? { currentStep: step } : {}),
|
||||
};
|
||||
|
||||
const ok = await saveDraftToServer(payload);
|
||||
const saveResult = await saveDraftToServer(payload);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!ok) {
|
||||
if (saveResult.ok === false) {
|
||||
setTransferError(
|
||||
"Could not save your draft to your account. Your progress is still stored on this device.",
|
||||
messages.create.topNav.postLoginSaveFailedWithReason.replace(
|
||||
"{reason}",
|
||||
saveResult.message,
|
||||
),
|
||||
);
|
||||
attemptedRef.current = false;
|
||||
return;
|
||||
@@ -103,14 +107,7 @@ export function PostLoginDraftTransfer({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
sessionUser,
|
||||
pathname,
|
||||
syncDraft,
|
||||
replaceState,
|
||||
router,
|
||||
searchParams,
|
||||
]);
|
||||
}, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]);
|
||||
|
||||
if (!transferError) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { CreateFlowState } from "./types";
|
||||
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
|
||||
import {
|
||||
clearAnonymousCreateFlowStorage,
|
||||
hasTransferPendingFlag,
|
||||
readAnonymousCreateFlowState,
|
||||
} from "./anonymousDraftStorage";
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { fetchDraftFromServer } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
/**
|
||||
* When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context.
|
||||
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
|
||||
*
|
||||
* **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
|
||||
* chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
|
||||
*/
|
||||
export function SignedInDraftHydration({
|
||||
sessionUser,
|
||||
sessionResolved,
|
||||
}: {
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const syncDraftParam = searchParams.get("syncDraft");
|
||||
const { replaceState, interactionTouched } = useCreateFlow();
|
||||
const touchedRef = useRef(interactionTouched);
|
||||
touchedRef.current = interactionTouched;
|
||||
|
||||
const [loadingHydration, setLoadingHydration] = useState(false);
|
||||
const finishedUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED) return;
|
||||
if (!sessionResolved) return;
|
||||
if (sessionUser == null || sessionUser === undefined) {
|
||||
finishedUserIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = sessionUser.id;
|
||||
if (finishedUserIdRef.current === userId) return;
|
||||
|
||||
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingHydration(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const serverDraft = await fetchDraftFromServer();
|
||||
if (cancelled) return;
|
||||
|
||||
const localDraft = readAnonymousCreateFlowState();
|
||||
const hasServer =
|
||||
serverDraft != null && createFlowStateHasKeys(serverDraft);
|
||||
const hasLocal = createFlowStateHasKeys(localDraft);
|
||||
|
||||
if (touchedRef.current) {
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasServer && hasLocal) {
|
||||
const useAccount =
|
||||
typeof window !== "undefined" &&
|
||||
window.confirm(messages.create.draftHydration.conflictPrompt);
|
||||
if (cancelled) return;
|
||||
if (useAccount) {
|
||||
replaceState(serverDraft as CreateFlowState);
|
||||
} else {
|
||||
replaceState(localDraft);
|
||||
}
|
||||
clearAnonymousCreateFlowStorage();
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
replaceState(serverDraft as CreateFlowState);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLocal) {
|
||||
replaceState(localDraft);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
}
|
||||
|
||||
finishedUserIdRef.current = userId;
|
||||
} finally {
|
||||
if (!cancelled) setLoadingHydration(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionResolved, sessionUser, syncDraftParam, replaceState]);
|
||||
|
||||
if (!loadingHydration) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] py-[var(--spacing-measures-spacing-200,8px)] md:px-[var(--measures-spacing-1800,64px)] text-center font-inter text-sm text-[var(--color-text-default-secondary,#a3a3a3)]"
|
||||
>
|
||||
{messages.create.draftHydration.loadingSavedProgress}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,11 @@ export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||
export const CREATE_FLOW_TRANSFER_PENDING_KEY =
|
||||
"create-flow-transfer-pending" as const;
|
||||
|
||||
/**
|
||||
* When signed-in + sync, {@link SignedInDraftHydration} resolves server vs this key via `window.confirm`
|
||||
* if both are non-empty; see `messages/en/create/draftHydration.json`.
|
||||
*/
|
||||
|
||||
const LEGACY_LIVE_KEY = "create-flow-state";
|
||||
const LEGACY_DRAFT_KEY = "create-flow-draft";
|
||||
|
||||
|
||||
@@ -63,9 +63,8 @@ export function CreateFlowProvider({
|
||||
if (!wasOff) return;
|
||||
const from = readAnonymousCreateFlowState();
|
||||
if (Object.keys(from).length === 0) return;
|
||||
setState((prev) =>
|
||||
Object.keys(prev).length > 0 ? prev : { ...from },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on
|
||||
setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from }));
|
||||
}, [enableAnonymousPersistence]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type CreateFlowDraftSaveBannerContextValue = {
|
||||
draftSaveBannerMessage: string | null;
|
||||
setDraftSaveBannerMessage: (_message: string | null) => void;
|
||||
};
|
||||
|
||||
const CreateFlowDraftSaveBannerContext =
|
||||
createContext<CreateFlowDraftSaveBannerContextValue | null>(null);
|
||||
|
||||
export function CreateFlowDraftSaveBannerProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
draftSaveBannerMessage,
|
||||
setDraftSaveBannerMessage,
|
||||
}),
|
||||
[draftSaveBannerMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowDraftSaveBannerContext.Provider value={value}>
|
||||
{children}
|
||||
</CreateFlowDraftSaveBannerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue {
|
||||
const ctx = useContext(CreateFlowDraftSaveBannerContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -20,13 +20,16 @@ export function useCreateFlowExit({
|
||||
clearState,
|
||||
router,
|
||||
user,
|
||||
setDraftSaveBannerMessage,
|
||||
}: {
|
||||
state: CreateFlowState;
|
||||
currentStep: CreateFlowStep | null;
|
||||
clearState: CreateFlowExitClearState;
|
||||
router: AppRouterLike;
|
||||
user: { id: string; email: string } | null;
|
||||
}): (options?: { saveDraft?: boolean }) => Promise<void> {
|
||||
/** When save fails, surface the server message in the create shell banner (no leave confirm). */
|
||||
setDraftSaveBannerMessage?: (_message: string | null) => void;
|
||||
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
|
||||
return useCallback(
|
||||
async (options?: { saveDraft?: boolean }) => {
|
||||
if (!user) return;
|
||||
@@ -45,18 +48,18 @@ export function useCreateFlowExit({
|
||||
...state,
|
||||
...(currentStep ? { currentStep } : {}),
|
||||
};
|
||||
const ok = await saveDraftToServer(payload);
|
||||
if (!ok && typeof window !== "undefined") {
|
||||
const leave = window.confirm(
|
||||
messages.create.topNav.leaveConfirmSaveFailed,
|
||||
);
|
||||
if (!leave) return;
|
||||
const result = await saveDraftToServer(payload);
|
||||
if (result.ok === true) {
|
||||
setDraftSaveBannerMessage?.(null);
|
||||
} else {
|
||||
setDraftSaveBannerMessage?.(result.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
clearState();
|
||||
router.push("/");
|
||||
},
|
||||
[state, currentStep, clearState, router, user],
|
||||
[state, currentStep, clearState, router, user, setDraftSaveBannerMessage],
|
||||
);
|
||||
}
|
||||
|
||||
+40
-19
@@ -1,11 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||
@@ -15,8 +10,15 @@ import { getStepIndex } from "./utils/flowSteps";
|
||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||
import Button from "../components/buttons/Button";
|
||||
import { fetchAuthSession } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import Alert from "../components/modals/Alert";
|
||||
import {
|
||||
CreateFlowDraftSaveBannerProvider,
|
||||
useCreateFlowDraftSaveBanner,
|
||||
} from "./context/CreateFlowDraftSaveBannerContext";
|
||||
|
||||
/** First step where Save & Exit is offered (after informational + name / `text`). */
|
||||
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
|
||||
@@ -37,19 +39,18 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const sessionResolved = sessionUser !== undefined;
|
||||
const enableAnonymousPersistence =
|
||||
sessionResolved && sessionUser === null;
|
||||
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
|
||||
|
||||
return (
|
||||
<CreateFlowProvider
|
||||
enableAnonymousPersistence={enableAnonymousPersistence}
|
||||
>
|
||||
<CreateFlowLayoutContent
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
>
|
||||
{children}
|
||||
</CreateFlowLayoutContent>
|
||||
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
|
||||
<CreateFlowDraftSaveBannerProvider>
|
||||
<CreateFlowLayoutContent
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
>
|
||||
{children}
|
||||
</CreateFlowLayoutContent>
|
||||
</CreateFlowDraftSaveBannerProvider>
|
||||
</CreateFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +75,8 @@ function CreateFlowLayoutContent({
|
||||
goToPreviousStep,
|
||||
} = useCreateFlowNavigation();
|
||||
const { state, clearState } = useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
|
||||
const runAuthenticatedExit = useCreateFlowExit({
|
||||
state,
|
||||
@@ -81,6 +84,7 @@ function CreateFlowLayoutContent({
|
||||
clearState,
|
||||
router,
|
||||
user: sessionUser ?? null,
|
||||
setDraftSaveBannerMessage,
|
||||
});
|
||||
|
||||
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
||||
@@ -104,8 +108,7 @@ function CreateFlowLayoutContent({
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const useFullHeightMain = isCompletedStep || isRightRailStep;
|
||||
const stepIdx =
|
||||
currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
const saveDraftOnExit =
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
|
||||
@@ -113,6 +116,24 @@ function CreateFlowLayoutContent({
|
||||
<div
|
||||
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
|
||||
>
|
||||
{draftSaveBannerMessage ? (
|
||||
<div className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)] z-[100]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={messages.create.topNav.draftSaveBannerTitle}
|
||||
description={draftSaveBannerMessage}
|
||||
onClose={() => setDraftSaveBannerMessage(null)}
|
||||
className="w-full max-w-[960px] mx-auto"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
<SignedInDraftHydration
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<PostLoginDraftTransfer sessionUser={sessionUser} />
|
||||
</Suspense>
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function TextPage() {
|
||||
useEffect(() => {
|
||||
const incoming = state.title;
|
||||
if (typeof incoming !== "string" || incoming.length === 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync controlled field when context hydrates from server/local
|
||||
setValue((prev) => (prev === "" ? incoming : prev));
|
||||
}, [state.title]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user