Let Use without changes from a template collect identity instead of the full questionnaire.

Direct catalog picks walk name, description, and photo, then stakeholders; leftover drafts no longer skip that path. In-flow template picks keep community identity. Exit on a direct template preview leaves immediately because nothing has been collected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-02 17:00:17 -06:00
co-authored by Cursor
parent f91ac7a893
commit b6afdb6e32
14 changed files with 457 additions and 100 deletions
+16 -2
View File
@@ -25,6 +25,7 @@ import {
getNextStep, getNextStep,
parseReviewReturnSearchParam, parseReviewReturnSearchParam,
shouldOfferCreateFlowSaveAndExit, shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll, createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE, TEMPLATES_FACET_RECOMMEND_VALUE,
@@ -155,9 +156,9 @@ function CreateFlowLayoutContent({
const { const {
currentStep, currentStep,
nextStep, nextStep,
previousStep,
goToNextStep, goToNextStep,
goToPreviousStep, goToPreviousStep,
canGoBack,
templateReviewFooterBackToCreateReview, templateReviewFooterBackToCreateReview,
} = useCreateFlowNavigation( } = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined, skipCommunitySave ? { skipCommunitySave: true } : undefined,
@@ -281,6 +282,9 @@ function CreateFlowLayoutContent({
}, },
}); });
const fromCreateWizard =
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE;
const { const {
isTemplateReviewRoute, isTemplateReviewRoute,
templateReviewSlug, templateReviewSlug,
@@ -295,6 +299,8 @@ function CreateFlowLayoutContent({
updateState, updateState,
replaceState, replaceState,
router, router,
fromCreateWizard,
markCreateFlowInteraction,
}); });
const runAuthenticatedExit = useCreateFlowExit({ const runAuthenticatedExit = useCreateFlowExit({
@@ -308,6 +314,14 @@ function CreateFlowLayoutContent({
}); });
const handleExit = async (opts?: { saveDraft?: boolean }) => { const handleExit = async (opts?: { saveDraft?: boolean }) => {
// Direct template preview: browsing a catalog card, nothing collected yet.
// In-flow (`?fromFlow=1`) still has community progress — keep save / confirm.
if (isDirectTemplateReviewEntry(pathname, searchParams)) {
clearState();
router.push(CREATE_ROUTES.root);
return;
}
if (!sessionResolved) return; if (!sessionResolved) return;
// Completed is post-publish. Guests still need Save & Exit → keep-this-rule // Completed is post-publish. Guests still need Save & Exit → keep-this-rule
@@ -979,7 +993,7 @@ function CreateFlowLayoutContent({
), ),
); );
} }
: previousStep : canGoBack()
? goToPreviousStep ? goToPreviousStep
: undefined : undefined
} }
@@ -102,6 +102,18 @@ export function SignedInDraftHydration({
if (urlStep === "completed") { if (urlStep === "completed") {
return; return;
} }
/**
* Direct template preview is a new-rule entry, not “continue draft”.
* Applying a saved `currentStep` here is what yanked Use without changes
* past name / description / photo to stakeholders.
*/
if (pathname?.includes("/create/review-template/")) {
return;
}
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false; let cancelled = false;
setLoadingHydration(true); setLoadingHydration(true);
@@ -34,9 +34,12 @@ const blurActiveElement = (): void => {
* *
* Resolves the active step from `/create/{screenId}` via * Resolves the active step from `/create/{screenId}` via
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses * {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
* {@link resolveCreateFlowBackTarget} so template **Use without changes** * {@link resolveCreateFlowBackTarget}: template **Use without changes** that
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}` * still needs identity (direct from a template) walks name / description /
* from `confirm-stakeholders` instead of `conflict-management`. * photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* to the template instead of `conflict-management`.
* *
* Template review footer Back uses {@link buildTemplateReviewHref}s * Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so * `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
@@ -83,17 +86,27 @@ export function useCreateFlowNavigation(
updateState, updateState,
]); ]);
const nextStep = getNextStep(validStep, options); const identityOnly =
const previousStep = getPreviousStep(validStep, options); state.pendingTemplateAction?.mode === "useWithoutChanges";
const navigationOptions = useMemo(
(): CreateFlowNavigationOptions => ({
skipCommunitySave: options?.skipCommunitySave,
useWithoutChangesIdentityOnly: identityOnly,
}),
[options?.skipCommunitySave, identityOnly],
);
const nextStep = getNextStep(validStep, navigationOptions);
const previousStep = getPreviousStep(validStep, navigationOptions);
const backTarget = useMemo( const backTarget = useMemo(
() => () =>
resolveCreateFlowBackTarget( resolveCreateFlowBackTarget(
validStep, validStep,
options, navigationOptions,
state.templateReviewBackSlug, state.templateReviewBackSlug,
), ),
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug], [validStep, navigationOptions, state.templateReviewBackSlug],
); );
const goToNextStep = useCallback(() => { const goToNextStep = useCallback(() => {
@@ -10,6 +10,7 @@ import type {
CreateFlowContextValue, CreateFlowContextValue,
CreateFlowState, CreateFlowState,
} from "../types"; } from "../types";
import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type UpdateState = CreateFlowContextValue["updateState"]; type UpdateState = CreateFlowContextValue["updateState"];
@@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = {
* Use without changes: scrub any prior customize picks, seed core values + * Use without changes: scrub any prior customize picks, seed core values +
* method-card selections from the template body (same id mapping as * method-card selections from the template body (same id mapping as
* Customize) so drilling from final-review via + shows selected cards, drop * Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`, and route to * the Values row from `state.sections`. Direct template entry routes to
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to * `/create/community-name` with a pin so Next/Back collect only name →
* skip past `/create/review` to `/create/confirm-stakeholders` later). * description → photo. In-flow (`?fromFlow=1`, community steps already done)
* routes to `/create/confirm-stakeholders`.
*/ */
handleUseWithoutChanges: () => Promise<void>; handleUseWithoutChanges: () => Promise<void>;
}; };
@@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = {
* setTemplateReviewApplyError, * setTemplateReviewApplyError,
* handleCustomize, * handleCustomize,
* handleUseWithoutChanges, * handleUseWithoutChanges,
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router }); * } = useTemplateReviewActions({
* pathname, state, updateState, replaceState, router,
* fromCreateWizard, markCreateFlowInteraction,
* });
*/ */
export function useTemplateReviewActions({ export function useTemplateReviewActions({
pathname, pathname,
@@ -67,12 +72,21 @@ export function useTemplateReviewActions({
updateState, updateState,
replaceState, replaceState,
router, router,
fromCreateWizard = false,
markCreateFlowInteraction,
}: { }: {
pathname: string | null | undefined; pathname: string | null | undefined;
state: CreateFlowState; state: CreateFlowState;
updateState: UpdateState; updateState: UpdateState;
replaceState: ReplaceStateFn; replaceState: ReplaceStateFn;
router: AppRouterLike; router: AppRouterLike;
/**
* True when this template-review URL was opened from the create wizard
* (`?fromFlow=1`). Direct catalog / marketing entry is false — a leftover
* draft title must not count as “community steps already done”.
*/
fromCreateWizard?: boolean;
markCreateFlowInteraction: () => void;
}): UseTemplateReviewActionsResult { }): UseTemplateReviewActionsResult {
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState< const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
@@ -90,6 +104,7 @@ export function useTemplateReviewActions({
const handleCustomize = useCallback(async () => { const handleCustomize = useCallback(async () => {
if (!templateReviewSlug) return; if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null); setTemplateReviewApplyError(null);
setIsApplyingTemplate(true); setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug); const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -119,7 +134,9 @@ export function useTemplateReviewActions({
}), }),
}); });
router.push( router.push(
hasCommunityName ? "/create/core-values" : "/create/informational", hasCommunityName
? createFlowStepPath("core-values")
: createFlowStepPath("informational"),
); );
}, [ }, [
router, router,
@@ -127,10 +144,12 @@ export function useTemplateReviewActions({
state.title, state.title,
templateReviewSlug, templateReviewSlug,
updateState, updateState,
markCreateFlowInteraction,
]); ]);
const handleUseWithoutChanges = useCallback(async () => { const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return; if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null); setTemplateReviewApplyError(null);
setIsApplyingTemplate(true); setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug); const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -158,9 +177,6 @@ export function useTemplateReviewActions({
return; return;
} }
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
// Atomic read-modify-write: strip prior custom-rule picks and merge template // Atomic read-modify-write: strip prior custom-rule picks and merge template
// body in one replaceState so method ids are never lost across React batching // body in one replaceState so method ids are never lost across React batching
// (reset + update separately could leave selections undefined in Strict Mode). // (reset + update separately could leave selections undefined in Strict Mode).
@@ -179,13 +195,10 @@ export function useTemplateReviewActions({
}) })
: sections; : sections;
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch = const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill); methodSectionsPinsForHydratedSelections(customizePrefill);
return { const seeded: CreateFlowState = {
...base, ...base,
...(hasValuesSeed ...(hasValuesSeed
? { ? {
@@ -221,22 +234,40 @@ export function useTemplateReviewActions({
sections: sectionsWithoutValues, sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch, methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug, templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName };
? { pendingTemplateAction: undefined }
: { if (fromCreateWizard) {
pendingTemplateAction: { return {
slug: templateReviewSlug, ...seeded,
mode: "useWithoutChanges", pendingTemplateAction: undefined,
}, };
}), }
return {
...seeded,
title: undefined,
communityContext: undefined,
communityAvatarUrl: undefined,
currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined,
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "useWithoutChanges",
},
}; };
}); });
router.push( router.push(
hasCommunityName fromCreateWizard
? "/create/confirm-stakeholders" ? createFlowStepPath("confirm-stakeholders")
: "/create/informational", : createFlowStepPath("community-name"),
); );
}, [replaceState, router, state.title, templateReviewSlug]); }, [
fromCreateWizard,
markCreateFlowInteraction,
replaceState,
router,
templateReviewSlug,
]);
return { return {
isTemplateReviewRoute, isTemplateReviewRoute,
@@ -27,14 +27,11 @@ export function CommunityReviewScreen() {
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** /**
* If the user picked "Customize" or "Use without changes" from a template * If the user picked **Customize** from a template before finishing community
* before entering community stage, we pinned `pendingTemplateAction` so * stage, we pinned `pendingTemplateAction` so this screen can skip itself
* this screen can skip itself — they already expressed their intent, no * and `replace` to `core-values`. **Use without changes** does not skip
* reason to make them re-pick from the review footer. We `replace` (not * identity: if they land here with that pin, send them to `community-name`
* `push`) so Back from the destination goes to `community-save` instead of * and leave the pin so name → description → photo stay in the skip list.
* bouncing through here again. The action is cleared synchronously via
* `updateState` to guarantee the redirect only fires once: later visits to
* `/create/review` (e.g. navigating here directly) render normally.
* *
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't * Ref guard covers React 18 StrictMode's double-mount in dev so we don't
* fire `router.replace` twice on the same transition. * fire `router.replace` twice on the same transition.
@@ -44,20 +41,19 @@ export function CommunityReviewScreen() {
if (firedRedirectRef.current) return; if (firedRedirectRef.current) return;
const pending = state.pendingTemplateAction; const pending = state.pendingTemplateAction;
if (!pending) return; if (!pending) return;
const target = if (pending.mode !== "customize") {
pending.mode === "customize" firedRedirectRef.current = true;
? createFlowStepPath("core-values") router.replace(createFlowStepPath("community-name"));
: createFlowStepPath("confirm-stakeholders"); return;
}
const target = createFlowStepPath("core-values");
firedRedirectRef.current = true; firedRedirectRef.current = true;
const pinMerge = const pinMerge = {
pending.mode === "customize" methodSectionsPinCommitted: {
? { ...state.methodSectionsPinCommitted,
methodSectionsPinCommitted: { ...methodSectionsPinsForHydratedSelections(state),
...state.methodSectionsPinCommitted, },
...methodSectionsPinsForHydratedSelections(state), };
},
}
: {};
updateState({ pendingTemplateAction: undefined, ...pinMerge }); updateState({ pendingTemplateAction: undefined, ...pinMerge });
router.replace(target); router.replace(target);
}, [ }, [
+10 -11
View File
@@ -194,11 +194,13 @@ export interface CreateFlowState {
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>; customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
/** /**
* Set when a user picks a template (Customize or Use without changes) before * Set when a user picks a template (Customize or Use without changes) before
* completing the community stage. The community-review screen consumes this * completing the community stage. Customize: the community-review screen
* to `router.replace` past `/create/review` to the correct downstream step * `router.replace`s past `/create/review` to `core-values`. Use without
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes) * changes: navigation skips to name → description → optional photo, then
* once community data is captured. Cleared the moment the redirect fires, so * `confirm-stakeholders` (the pin keeps that skip list active). Set only for
* later visits to `/create/review` render normally. * **direct** template entry, not when community identity was already collected
* in-flow. Cleared when community-review consumes a customize pin, or when
* the flow is reset.
*/ */
pendingTemplateAction?: { pendingTemplateAction?: {
slug: string; slug: string;
@@ -206,12 +208,9 @@ export interface CreateFlowState {
}; };
/** /**
* Set when the user chooses **Use without changes** on a template-review * Set when the user chooses **Use without changes** on a template-review
* page. The custom-rule segment (`core-values` … `conflict-management`) is * page. Used so Back from `community-name` (identity-only path) returns to
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly * `/create/review-template/{slug}` instead of the intro. Cleared when the user
* point at `conflict-management`. Navigation uses this slug so Back from * picks **Customize** from template review or when the flow state is cleared.
* `confirm-stakeholders` returns to `/create/review-template/{slug}`.
* Cleared when the user picks **Customize** from template review (normal
* linear back applies) or when the flow state is cleared.
*/ */
templateReviewBackSlug?: string; templateReviewBackSlug?: string;
/** /**
+86 -15
View File
@@ -47,8 +47,44 @@ export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
/** Options for navigation when the email / magic-link save step is not shown (signed-in users). */ /** Options for navigation when the email / magic-link save step is not shown (signed-in users). */
export type CreateFlowNavigationOptions = { export type CreateFlowNavigationOptions = {
skipCommunitySave?: boolean; skipCommunitySave?: boolean;
/**
* Template **Use without changes**: collect community name, description, and
* optional photo only. Skips intro, structure, size, save, community review,
* and the custom-rule authoring segment.
*/
useWithoutChangesIdentityOnly?: boolean;
}; };
/**
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` →
* `community-upload`, then `confirm-stakeholders`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
"community-structure",
"community-size",
"community-save",
"review",
"core-values",
"communication-methods",
"membership-methods",
"decision-approaches",
"conflict-management",
]);
function shouldSkipStep(
step: CreateFlowStep,
options?: CreateFlowNavigationOptions,
): boolean {
if (options?.skipCommunitySave && step === "community-save") return true;
return Boolean(
options?.useWithoutChangesIdentityOnly &&
USE_WITHOUT_CHANGES_IDENTITY_SKIP.has(step),
);
}
/** /**
* Returns the next step in the flow, or null if current is last/invalid * Returns the next step in the flow, or null if current is last/invalid
*/ */
@@ -57,13 +93,14 @@ export function getNextStep(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!currentStep) return null; if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep); let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null; if (index === -1) return null;
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep; while (index < FLOW_STEP_ORDER.length - 1) {
if (options?.skipCommunitySave && next === "community-save") { index += 1;
return getNextStep("community-save", options); const next = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(next, options)) return next;
} }
return next; return null;
} }
/** /**
@@ -74,20 +111,27 @@ export function getPreviousStep(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!currentStep) return null; if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep); let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null; if (index <= 0) return null;
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep; while (index > 0) {
if (options?.skipCommunitySave && prev === "community-save") { index -= 1;
return getPreviousStep("community-save", options); const prev = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(prev, options)) return prev;
} }
return prev; return null;
} }
/** /**
* Where the create-flow footer Back action should go. Usually the previous * Where the create-flow footer Back action should go. Usually the previous
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders` * step in {@link FLOW_STEP_ORDER}.
* via template **Use without changes**, Back returns to template review instead *
* of `conflict-management` (that segment was skipped). * Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* `conflict-management`.
*/ */
export type CreateFlowBackTarget = export type CreateFlowBackTarget =
| { kind: "step"; step: CreateFlowStep } | { kind: "step"; step: CreateFlowStep }
@@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget(
typeof templateReviewBackSlug === "string" typeof templateReviewBackSlug === "string"
? templateReviewBackSlug.trim() ? templateReviewBackSlug.trim()
: ""; : "";
if (currentStep === "confirm-stakeholders" && slug.length > 0) { if (
currentStep === "community-name" &&
options?.useWithoutChangesIdentityOnly &&
slug.length > 0
) {
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
return { kind: "templateReview", slug }; return { kind: "templateReview", slug };
} }
const prev = getPreviousStep(currentStep, options); const prev = getPreviousStep(currentStep, options);
@@ -194,6 +249,22 @@ export function parseCreateFlowScreenFromPathname(
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
/**
* Catalog / marketing template preview (`/create/review-template/[slug]`
* without `?fromFlow=1`). There is nothing to save yet; top-nav Exit should
* leave immediately. In-flow picks keep the usual save / leave-confirm path.
*/
export function isDirectTemplateReviewEntry(
pathname: string | null | undefined,
searchParams?: { get: (name: string) => string | null } | null,
): boolean {
if (!pathname?.includes("/create/review-template/")) return false;
return (
searchParams?.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
);
}
/** /**
* Only set from `/create/review` “Create from template” with `fromFlow=1`. * Only set from `/create/review` “Create from template” with `fromFlow=1`.
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit * Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
@@ -102,7 +102,9 @@ function TemplatesGrid({
entries={entries} entries={entries}
onTemplateClick={(slug) => { onTemplateClick={(slug) => {
if (!fromFlow) { if (!fromFlow) {
prepareFreshCreateFlowEntrySync(); // Marketing /templates has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
} }
router.push( router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
@@ -97,9 +97,10 @@ const RuleStackContainer = memo<RuleStackProps>(
} }
} }
logger.debug(`${slug} template clicked`); logger.debug(`${slug} template clicked`);
// Marketing home “Popular templates”: same fresh start as Top “Create rule” // Marketing home “Popular templates”: same fresh start as Top “Create rule”.
// (local + server draft when sync) so stale state cannot break template apply. // `signedIn: true` because this surface has no session in hand; DELETE is
prepareFreshCreateFlowEntrySync(); // best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push(`/create/review-template/${encodeURIComponent(slug)}`); router.push(`/create/review-template/${encodeURIComponent(slug)}`);
}; };
+12 -4
View File
@@ -79,9 +79,17 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors. From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists. **Exit on template preview.** Direct catalog / marketing entry (`/create/review-template/[slug]` without `?fromFlow=1`) has nothing to save — top-nav **Exit** clears client state and goes home, with no save-progress modal and no leave confirm. In-flow (`?fromFlow=1`) still has community progress, so Exit keeps the usual save / leave-confirm path.
**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally. **Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and skips custom-rule authoring. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**Entering a template before community stage is done.** Both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` when the community questionnaire still needs to run.
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
- **Use without changes (in-flow)** — `/create/review``/templates?fromFlow=1` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
**Direct entry vs in-flow template pick.** The same `/create/review-template/[slug]` URL is reached from two different origins. We disambiguate at the *click site*, not on the review-template page. **Direct** picks call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** navigation (local + server draft when sync is on — see **Fresh start vs continue draft** above). **In-flow** picks skip that call so the users community-stage state survives the detour. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, clearing **before** `push` means a direct entry mounts without stale anonymous keys; signed-in users also avoid a stale server draft overwriting the empty mirror. **Direct entry vs in-flow template pick.** The same `/create/review-template/[slug]` URL is reached from two different origins. We disambiguate at the *click site*, not on the review-template page. **Direct** picks call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** navigation (local + server draft when sync is on — see **Fresh start vs continue draft** above). **In-flow** picks skip that call so the users community-stage state survives the detour. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, clearing **before** `push` means a direct entry mounts without stale anonymous keys; signed-in users also avoid a stale server draft overwriting the empty mirror.
@@ -89,9 +97,9 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
| --- | --- | --- | | --- | --- | --- |
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` | | Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` | | `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]` | | In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]?fromFlow=1` |
Only one `?fromFlow=1` marker exists, on one hop (`/create/review``/templates`). It is not forwarded onto the review-template URL. The review-template handlers branch solely on `state.title` — they don't need to know the origin. Only one `?fromFlow=1` marker exists on `/templates`. Template clicks forward it onto `/create/review-template/[slug]?fromFlow=1` so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path. **Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path.
+14 -8
View File
@@ -255,8 +255,13 @@ now prefills the custom-rule flow via
[`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts) [`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts)
(applied in `CreateFlowLayoutClient.tsx`) and routes to `core-values` (applied in `CreateFlowLayoutClient.tsx`) and routes to `core-values`
when Community already has input, else to `informational`. Template **Use when Community already has input, else to `informational`. Template **Use
without changes** writes `template.body.sections` into `state.sections` without changes** writes `template.body.sections` into `state.sections`.
and routes to `confirm-stakeholders`, so the user exits via the normal Direct catalog / marketing entry routes to `community-name` and Next/Back
collect only name, description, and optional photo, then
`confirm-stakeholders`. A leftover draft title does not skip those
identity steps. In-flow (`?fromFlow=1` after the community questionnaire)
skips identity and opens `confirm-stakeholders`. The user
exits via the normal
`final-review → handleFinalize → publishRule` path and picks up the `final-review → handleFinalize → publishRule` path and picks up the
server-enforced 401 sign-in gate for free. server-enforced 401 sign-in gate for free.
@@ -264,12 +269,13 @@ When the user picks a template **before** completing the community
stage, both handlers still apply their side effects eagerly (prefill or stage, both handlers still apply their side effects eagerly (prefill or
`sections`/`summary`) and pin a `sections`/`summary`) and pin a
`pendingTemplateAction: { slug, mode: "customize" | "useWithoutChanges" }` `pendingTemplateAction: { slug, mode: "customize" | "useWithoutChanges" }`
on `CreateFlowState`, then route to `informational`. Once the user on `CreateFlowState`. Customize then routes to `informational`. Direct
reaches `/create/review`, `CommunityReviewScreen` consumes the pin and Use without changes routes to `community-name` (identity-only skip list
`router.replace`s past itself — to `core-values` for `customize`, to while the pin is set). Once the user reaches `/create/review`, `CommunityReviewScreen`
`confirm-stakeholders` for `useWithoutChanges`. The community-review consumes the pin and `router.replace`s past itself — to `core-values` for
screen is therefore only shown when the user came from "Create Custom" `customize`, to `community-name` for `useWithoutChanges`. The
(no template), matching the intent already expressed at the community-review screen is therefore only shown when the user came from
"Create Custom" (no template), matching the intent already expressed at the
template-review step. template-review step.
--- ---
+2 -4
View File
@@ -104,12 +104,10 @@ describe("CommunityReviewScreen — pendingTemplateAction redirect", () => {
expect(testRouter.push).not.toHaveBeenCalled(); expect(testRouter.push).not.toHaveBeenCalled();
}); });
it("redirects to /create/confirm-stakeholders when mode === 'useWithoutChanges'", async () => { it("redirects Use without changes to community-name so identity is not skipped", async () => {
render(<ReviewWithPendingAction mode="useWithoutChanges" />); render(<ReviewWithPendingAction mode="useWithoutChanges" />);
await waitFor(() => { await waitFor(() => {
expect(testRouter.replace).toHaveBeenCalledWith( expect(testRouter.replace).toHaveBeenCalledWith("/create/community-name");
"/create/confirm-stakeholders",
);
}); });
expect(testRouter.push).not.toHaveBeenCalled(); expect(testRouter.push).not.toHaveBeenCalled();
}); });
+71
View File
@@ -9,6 +9,7 @@ import {
parseReviewReturnSearchParam, parseReviewReturnSearchParam,
resolveCreateFlowBackTarget, resolveCreateFlowBackTarget,
shouldOfferCreateFlowSaveAndExit, shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE, TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
@@ -82,6 +83,31 @@ describe("flowSteps", () => {
expect(getPreviousStep("communication-methods", opts)).toBe("core-values"); expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
}); });
it("useWithoutChangesIdentityOnly walks name → description → photo → stakeholders", () => {
const opts = { useWithoutChangesIdentityOnly: true } as const;
expect(getNextStep("community-name", opts)).toBe("community-context");
expect(getNextStep("community-context", opts)).toBe("community-upload");
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review");
expect(getPreviousStep("community-context", opts)).toBe("community-name");
expect(getPreviousStep("community-upload", opts)).toBe("community-context");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
expect(getPreviousStep("community-name", opts)).toBeNull();
});
it("useWithoutChangesIdentityOnly composes with skipCommunitySave", () => {
const opts = {
skipCommunitySave: true,
useWithoutChangesIdentityOnly: true,
} as const;
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
});
it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => { it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => {
expect( expect(
resolveCreateFlowBackTarget( resolveCreateFlowBackTarget(
@@ -92,6 +118,26 @@ describe("flowSteps", () => {
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" }); ).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" });
}); });
it("resolveCreateFlowBackTarget sends identity-only community-name back to template review", () => {
expect(
resolveCreateFlowBackTarget(
"community-name",
{ useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays",
),
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" });
});
it("resolveCreateFlowBackTarget uses photo step behind stakeholders on the identity-only path", () => {
expect(
resolveCreateFlowBackTarget(
"confirm-stakeholders",
{ useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays",
),
).toEqual({ kind: "step", step: "community-upload" });
});
it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => { it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => {
expect( expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined), resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined),
@@ -150,4 +196,29 @@ describe("flowSteps", () => {
expect(shouldOfferCreateFlowSaveAndExit("completed", null)).toBe(true); expect(shouldOfferCreateFlowSaveAndExit("completed", null)).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit(null)).toBe(false); expect(shouldOfferCreateFlowSaveAndExit(null)).toBe(false);
}); });
it("isDirectTemplateReviewEntry is true only on template preview without fromFlow", () => {
expect(
isDirectTemplateReviewEntry("/create/review-template/consensus"),
).toBe(true);
expect(
isDirectTemplateReviewEntry(
"/create/review-template/consensus",
new URLSearchParams(),
),
).toBe(true);
expect(
isDirectTemplateReviewEntry(
"/create/review-template/consensus",
new URLSearchParams("fromFlow=1"),
),
).toBe(false);
expect(
isDirectTemplateReviewEntry(
"/create/community-name",
new URLSearchParams(),
),
).toBe(false);
expect(isDirectTemplateReviewEntry(null)).toBe(false);
});
}); });
@@ -0,0 +1,135 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CreateFlowState } from "../../../app/(app)/create/types";
import { useTemplateReviewActions } from "../../../app/(app)/create/hooks/useTemplateReviewActions";
import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug";
vi.mock("../../../lib/create/loadTemplateReviewBySlug", () => ({
loadTemplateReviewBySlug: vi.fn(),
}));
const templateBody = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Signal" }],
},
],
};
const loadedTemplate = {
ok: true as const,
template: {
id: "t1",
slug: "mutual-aid-mondays",
title: "Mutual Aid Mondays",
category: null,
description: null,
body: templateBody,
sortOrder: 0,
featured: false,
},
};
describe("useTemplateReviewActions", () => {
const router = { push: vi.fn() };
const updateState = vi.fn();
const markCreateFlowInteraction = vi.fn();
let applied: CreateFlowState | undefined;
const replaceState = vi.fn(
(updater: (_prev: CreateFlowState) => CreateFlowState) => {
applied = updater({});
},
);
beforeEach(() => {
vi.mocked(loadTemplateReviewBySlug).mockReset();
vi.mocked(loadTemplateReviewBySlug).mockResolvedValue(loadedTemplate);
router.push.mockReset();
updateState.mockReset();
markCreateFlowInteraction.mockReset();
replaceState.mockClear();
applied = undefined;
});
function renderActions(
state: CreateFlowState,
fromCreateWizard = false,
) {
return renderHook(() =>
useTemplateReviewActions({
pathname: "/create/review-template/mutual-aid-mondays",
state,
updateState,
replaceState,
router,
fromCreateWizard,
markCreateFlowInteraction,
}),
);
}
it("direct Use without changes walks identity from community-name", async () => {
const { result } = renderActions({});
await act(async () => {
await result.current.handleUseWithoutChanges();
});
expect(markCreateFlowInteraction).toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith("/create/community-name");
expect(applied?.pendingTemplateAction).toEqual({
slug: "mutual-aid-mondays",
mode: "useWithoutChanges",
});
});
it("direct Use without changes ignores a leftover draft title", async () => {
replaceState.mockImplementation(
(updater: (_prev: CreateFlowState) => CreateFlowState) => {
applied = updater({
title: "Neighborhood",
communityContext: "Stale description",
currentStep: "confirm-stakeholders",
});
},
);
const { result } = renderActions({ title: "Neighborhood" });
await act(async () => {
await result.current.handleUseWithoutChanges();
});
expect(router.push).toHaveBeenCalledWith("/create/community-name");
expect(applied?.title).toBeUndefined();
expect(applied?.communityContext).toBeUndefined();
expect(applied?.currentStep).toBeUndefined();
expect(applied?.pendingTemplateAction?.mode).toBe("useWithoutChanges");
});
it("in-flow Use without changes skips identity and keeps the community name", async () => {
replaceState.mockImplementation(
(updater: (_prev: CreateFlowState) => CreateFlowState) => {
applied = updater({
title: "Neighborhood",
communityContext: "We meet weekly",
templateReviewEntryFromCreateFlow: true,
});
},
);
const { result } = renderActions(
{ title: "Neighborhood", templateReviewEntryFromCreateFlow: true },
true,
);
await act(async () => {
await result.current.handleUseWithoutChanges();
});
expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders");
expect(applied?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly");
expect(applied?.pendingTemplateAction).toBeUndefined();
expect(applied?.templateReviewBackSlug).toBe("mutual-aid-mondays");
});
});