diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 0499c88..74f3e58 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -25,6 +25,7 @@ import { getNextStep, parseReviewReturnSearchParam, shouldOfferCreateFlowSaveAndExit, + isDirectTemplateReviewEntry, createFlowStepUsesSelectSplitScroll, TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_VALUE, @@ -155,9 +156,9 @@ function CreateFlowLayoutContent({ const { currentStep, nextStep, - previousStep, goToNextStep, goToPreviousStep, + canGoBack, templateReviewFooterBackToCreateReview, } = useCreateFlowNavigation( 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 { isTemplateReviewRoute, templateReviewSlug, @@ -295,6 +299,8 @@ function CreateFlowLayoutContent({ updateState, replaceState, router, + fromCreateWizard, + markCreateFlowInteraction, }); const runAuthenticatedExit = useCreateFlowExit({ @@ -308,6 +314,14 @@ function CreateFlowLayoutContent({ }); 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; // Completed is post-publish. Guests still need Save & Exit → keep-this-rule @@ -979,7 +993,7 @@ function CreateFlowLayoutContent({ ), ); } - : previousStep + : canGoBack() ? goToPreviousStep : undefined } diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index e5b1588..ca487f3 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -102,6 +102,18 @@ export function SignedInDraftHydration({ if (urlStep === "completed") { 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; setLoadingHydration(true); diff --git a/app/(app)/create/hooks/useCreateFlowNavigation.ts b/app/(app)/create/hooks/useCreateFlowNavigation.ts index c7e2e22..b48168d 100644 --- a/app/(app)/create/hooks/useCreateFlowNavigation.ts +++ b/app/(app)/create/hooks/useCreateFlowNavigation.ts @@ -34,9 +34,12 @@ const blurActiveElement = (): void => { * * Resolves the active step from `/create/{screenId}` via * {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses - * {@link resolveCreateFlowBackTarget} so template **Use without changes** - * (which skips the custom-rule segment) returns to `/create/review-template/{slug}` - * from `confirm-stakeholders` instead of `conflict-management`. + * {@link resolveCreateFlowBackTarget}: template **Use without changes** that + * still needs identity (direct from a template) walks name / description / + * 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 * `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so @@ -83,17 +86,27 @@ export function useCreateFlowNavigation( updateState, ]); - const nextStep = getNextStep(validStep, options); - const previousStep = getPreviousStep(validStep, options); + const identityOnly = + 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( () => resolveCreateFlowBackTarget( validStep, - options, + navigationOptions, state.templateReviewBackSlug, ), - [validStep, options?.skipCommunitySave, state.templateReviewBackSlug], + [validStep, navigationOptions, state.templateReviewBackSlug], ); const goToNextStep = useCallback(() => { diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index fb6ce8d..8353250 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -10,6 +10,7 @@ import type { CreateFlowContextValue, CreateFlowState, } from "../types"; +import { createFlowStepPath } from "../utils/createFlowPaths"; type AppRouterLike = { push: (_href: string) => void }; type UpdateState = CreateFlowContextValue["updateState"]; @@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = { * Use without changes: scrub any prior customize picks, seed core values + * method-card selections from the template body (same id mapping as * Customize) so drilling from final-review via + shows selected cards, drop - * the Values row from `state.sections`, and route to - * `/create/confirm-stakeholders` (or `/create/informational` with a pin to - * skip past `/create/review` to `/create/confirm-stakeholders` later). + * the Values row from `state.sections`. Direct template entry routes to + * `/create/community-name` with a pin so Next/Back collect only name → + * description → photo. In-flow (`?fromFlow=1`, community steps already done) + * routes to `/create/confirm-stakeholders`. */ handleUseWithoutChanges: () => Promise; }; @@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = { * setTemplateReviewApplyError, * handleCustomize, * handleUseWithoutChanges, - * } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router }); + * } = useTemplateReviewActions({ + * pathname, state, updateState, replaceState, router, + * fromCreateWizard, markCreateFlowInteraction, + * }); */ export function useTemplateReviewActions({ pathname, @@ -67,12 +72,21 @@ export function useTemplateReviewActions({ updateState, replaceState, router, + fromCreateWizard = false, + markCreateFlowInteraction, }: { pathname: string | null | undefined; state: CreateFlowState; updateState: UpdateState; replaceState: ReplaceStateFn; 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 { const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const [templateReviewApplyError, setTemplateReviewApplyError] = useState< @@ -90,6 +104,7 @@ export function useTemplateReviewActions({ const handleCustomize = useCallback(async () => { if (!templateReviewSlug) return; + markCreateFlowInteraction(); setTemplateReviewApplyError(null); setIsApplyingTemplate(true); const loaded = await loadTemplateReviewBySlug(templateReviewSlug); @@ -119,7 +134,9 @@ export function useTemplateReviewActions({ }), }); router.push( - hasCommunityName ? "/create/core-values" : "/create/informational", + hasCommunityName + ? createFlowStepPath("core-values") + : createFlowStepPath("informational"), ); }, [ router, @@ -127,10 +144,12 @@ export function useTemplateReviewActions({ state.title, templateReviewSlug, updateState, + markCreateFlowInteraction, ]); const handleUseWithoutChanges = useCallback(async () => { if (!templateReviewSlug) return; + markCreateFlowInteraction(); setTemplateReviewApplyError(null); setIsApplyingTemplate(true); const loaded = await loadTemplateReviewBySlug(templateReviewSlug); @@ -158,9 +177,6 @@ export function useTemplateReviewActions({ return; } - const hasCommunityName = - typeof state.title === "string" && state.title.trim().length > 0; - // 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 // (reset + update separately could leave selections undefined in Strict Mode). @@ -179,13 +195,10 @@ export function useTemplateReviewActions({ }) : sections; - const hasCommunityName = - typeof prev.title === "string" && prev.title.trim().length > 0; - const pinPatch = methodSectionsPinsForHydratedSelections(customizePrefill); - return { + const seeded: CreateFlowState = { ...base, ...(hasValuesSeed ? { @@ -221,22 +234,40 @@ export function useTemplateReviewActions({ sections: sectionsWithoutValues, methodSectionsPinCommitted: pinPatch, templateReviewBackSlug: templateReviewSlug, - ...(hasCommunityName - ? { pendingTemplateAction: undefined } - : { - pendingTemplateAction: { - slug: templateReviewSlug, - mode: "useWithoutChanges", - }, - }), + }; + + if (fromCreateWizard) { + return { + ...seeded, + pendingTemplateAction: undefined, + }; + } + + return { + ...seeded, + title: undefined, + communityContext: undefined, + communityAvatarUrl: undefined, + currentStep: undefined, + templateReviewEntryFromCreateFlow: undefined, + pendingTemplateAction: { + slug: templateReviewSlug, + mode: "useWithoutChanges", + }, }; }); router.push( - hasCommunityName - ? "/create/confirm-stakeholders" - : "/create/informational", + fromCreateWizard + ? createFlowStepPath("confirm-stakeholders") + : createFlowStepPath("community-name"), ); - }, [replaceState, router, state.title, templateReviewSlug]); + }, [ + fromCreateWizard, + markCreateFlowInteraction, + replaceState, + router, + templateReviewSlug, + ]); return { isTemplateReviewRoute, diff --git a/app/(app)/create/screens/review/CommunityReviewScreen.tsx b/app/(app)/create/screens/review/CommunityReviewScreen.tsx index af19ec4..9252158 100644 --- a/app/(app)/create/screens/review/CommunityReviewScreen.tsx +++ b/app/(app)/create/screens/review/CommunityReviewScreen.tsx @@ -27,14 +27,11 @@ export function CommunityReviewScreen() { const { state, updateState } = useCreateFlow(); /** - * If the user picked "Customize" or "Use without changes" from a template - * before entering community stage, we pinned `pendingTemplateAction` so - * this screen can skip itself — they already expressed their intent, no - * reason to make them re-pick from the review footer. We `replace` (not - * `push`) so Back from the destination goes to `community-save` instead of - * 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. + * If the user picked **Customize** from a template before finishing community + * stage, we pinned `pendingTemplateAction` so this screen can skip itself + * and `replace` to `core-values`. **Use without changes** does not skip + * identity: if they land here with that pin, send them to `community-name` + * and leave the pin so name → description → photo stay in the skip list. * * Ref guard covers React 18 StrictMode's double-mount in dev so we don't * fire `router.replace` twice on the same transition. @@ -44,20 +41,19 @@ export function CommunityReviewScreen() { if (firedRedirectRef.current) return; const pending = state.pendingTemplateAction; if (!pending) return; - const target = - pending.mode === "customize" - ? createFlowStepPath("core-values") - : createFlowStepPath("confirm-stakeholders"); + if (pending.mode !== "customize") { + firedRedirectRef.current = true; + router.replace(createFlowStepPath("community-name")); + return; + } + const target = createFlowStepPath("core-values"); firedRedirectRef.current = true; - const pinMerge = - pending.mode === "customize" - ? { - methodSectionsPinCommitted: { - ...state.methodSectionsPinCommitted, - ...methodSectionsPinsForHydratedSelections(state), - }, - } - : {}; + const pinMerge = { + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...methodSectionsPinsForHydratedSelections(state), + }, + }; updateState({ pendingTemplateAction: undefined, ...pinMerge }); router.replace(target); }, [ diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 8ac929e..c70a719 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -194,11 +194,13 @@ export interface CreateFlowState { customMethodCardFieldBlocksById?: Record; /** * Set when a user picks a template (Customize or Use without changes) before - * completing the community stage. The community-review screen consumes this - * to `router.replace` past `/create/review` to the correct downstream step - * (`core-values` for customize; `confirm-stakeholders` for use-without-changes) - * once community data is captured. Cleared the moment the redirect fires, so - * later visits to `/create/review` render normally. + * completing the community stage. Customize: the community-review screen + * `router.replace`s past `/create/review` to `core-values`. Use without + * changes: navigation skips to name → description → optional photo, then + * `confirm-stakeholders` (the pin keeps that skip list active). Set only for + * **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?: { slug: string; @@ -206,12 +208,9 @@ export interface CreateFlowState { }; /** * Set when the user chooses **Use without changes** on a template-review - * page. The custom-rule segment (`core-values` … `conflict-management`) is - * skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly - * point at `conflict-management`. Navigation uses this slug so Back from - * `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. + * page. Used so Back from `community-name` (identity-only path) returns to + * `/create/review-template/{slug}` instead of the intro. Cleared when the user + * picks **Customize** from template review or when the flow state is cleared. */ templateReviewBackSlug?: string; /** diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index ac476d0..55933f8 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -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). */ export type CreateFlowNavigationOptions = { 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([ + "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 */ @@ -57,13 +93,14 @@ export function getNextStep( options?: CreateFlowNavigationOptions, ): CreateFlowStep | null { if (!currentStep) return null; - const index = FLOW_STEP_ORDER.indexOf(currentStep); - if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null; - const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep; - if (options?.skipCommunitySave && next === "community-save") { - return getNextStep("community-save", options); + let index = FLOW_STEP_ORDER.indexOf(currentStep); + if (index === -1) return null; + while (index < FLOW_STEP_ORDER.length - 1) { + index += 1; + 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, ): CreateFlowStep | null { if (!currentStep) return null; - const index = FLOW_STEP_ORDER.indexOf(currentStep); + let index = FLOW_STEP_ORDER.indexOf(currentStep); if (index <= 0) return null; - const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep; - if (options?.skipCommunitySave && prev === "community-save") { - return getPreviousStep("community-save", options); + while (index > 0) { + index -= 1; + 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 - * step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders` - * via template **Use without changes**, Back returns to template review instead - * of `conflict-management` (that segment was skipped). + * step in {@link FLOW_STEP_ORDER}. + * + * 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 = | { kind: "step"; step: CreateFlowStep } @@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget( typeof templateReviewBackSlug === "string" ? 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 }; } 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_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`. * Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx index c907f6e..dd1b241 100644 --- a/app/(marketing)/templates/TemplatesPageClient.tsx +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -102,7 +102,9 @@ function TemplatesGrid({ entries={entries} onTemplateClick={(slug) => { 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( buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 0fbf074..e079f95 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -97,9 +97,10 @@ const RuleStackContainer = memo( } } logger.debug(`${slug} template clicked`); - // Marketing home “Popular templates”: same fresh start as Top “Create rule” - // (local + server draft when sync) so stale state cannot break template apply. - prepareFreshCreateFlowEntrySync(); + // Marketing home “Popular templates”: same fresh start as Top “Create rule”. + // `signedIn: true` because this surface has no session in hand; DELETE is + // best-effort and the sentinel blocks stale-draft hydration. + prepareFreshCreateFlowEntrySync({ signedIn: true }); router.push(`/create/review-template/${encodeURIComponent(slug)}`); }; diff --git a/docs/create-flow.md b/docs/create-flow.md index 31e2880..56a27b3 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -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. -**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 user’s 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]` | | `/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. diff --git a/docs/guides/template-recommendation-matrix.md b/docs/guides/template-recommendation-matrix.md index aee7597..c896ba5 100644 --- a/docs/guides/template-recommendation-matrix.md +++ b/docs/guides/template-recommendation-matrix.md @@ -255,8 +255,13 @@ now prefills the custom-rule flow via [`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts) (applied in `CreateFlowLayoutClient.tsx`) and routes to `core-values` when Community already has input, else to `informational`. Template **Use -without changes** writes `template.body.sections` into `state.sections` -and routes to `confirm-stakeholders`, so the user exits via the normal +without changes** writes `template.body.sections` into `state.sections`. +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 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 `sections`/`summary`) and pin a `pendingTemplateAction: { slug, mode: "customize" | "useWithoutChanges" }` -on `CreateFlowState`, then route to `informational`. Once the user -reaches `/create/review`, `CommunityReviewScreen` consumes the pin and -`router.replace`s past itself — to `core-values` for `customize`, to -`confirm-stakeholders` for `useWithoutChanges`. The community-review -screen is therefore only shown when the user came from "Create Custom" -(no template), matching the intent already expressed at the +on `CreateFlowState`. Customize then routes to `informational`. Direct +Use without changes routes to `community-name` (identity-only skip list +while the pin is set). Once the user reaches `/create/review`, `CommunityReviewScreen` +consumes the pin and `router.replace`s past itself — to `core-values` for +`customize`, to `community-name` for `useWithoutChanges`. 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. --- diff --git a/tests/components/ReviewPage.test.tsx b/tests/components/ReviewPage.test.tsx index e31c25a..d0f71f2 100644 --- a/tests/components/ReviewPage.test.tsx +++ b/tests/components/ReviewPage.test.tsx @@ -104,12 +104,10 @@ describe("CommunityReviewScreen — pendingTemplateAction redirect", () => { 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(); await waitFor(() => { - expect(testRouter.replace).toHaveBeenCalledWith( - "/create/confirm-stakeholders", - ); + expect(testRouter.replace).toHaveBeenCalledWith("/create/community-name"); }); expect(testRouter.push).not.toHaveBeenCalled(); }); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index ee55315..37180d9 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -9,6 +9,7 @@ import { parseReviewReturnSearchParam, resolveCreateFlowBackTarget, shouldOfferCreateFlowSaveAndExit, + isDirectTemplateReviewEntry, TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_VALUE, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, @@ -82,6 +83,31 @@ describe("flowSteps", () => { 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", () => { expect( resolveCreateFlowBackTarget( @@ -92,6 +118,26 @@ describe("flowSteps", () => { ).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", () => { expect( resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined), @@ -150,4 +196,29 @@ describe("flowSteps", () => { expect(shouldOfferCreateFlowSaveAndExit("completed", null)).toBe(true); 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); + }); }); diff --git a/tests/unit/hooks/useTemplateReviewActions.test.tsx b/tests/unit/hooks/useTemplateReviewActions.test.tsx new file mode 100644 index 0000000..b183fbb --- /dev/null +++ b/tests/unit/hooks/useTemplateReviewActions.test.tsx @@ -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"); + }); +});