diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index b53364f..e54605b 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -27,8 +27,10 @@ const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; * server draft on top would clobber unsaved keystrokes with a stale snapshot. * * Server draft becomes authoritative only when localStorage is empty — i.e. - * fresh device, after explicit Save & Exit (which clears localStorage), or - * after Exit-from-completed clears local state. + * fresh device, after explicit Save & Exit (which clears localStorage), + * after Exit-from-completed clears local state, or after + * {@link prepareFreshCreateFlowEntry} (Create rule / new template entry) clears + * local + deletes the server draft when sync is on. * * Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} * owns that path. diff --git a/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts b/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts index 868bf46..4dbe23f 100644 --- a/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts +++ b/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts @@ -4,17 +4,13 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag /** * Wipe the anonymous in-progress create-flow draft from `localStorage` (both * the main `create-flow-anonymous` blob and the separate core-value details - * key). Intended for call sites that navigate **into** the create flow from - * outside and want a fresh slate — today that's the marketing "Popular - * templates" click handler on the home page and the `/templates` index page - * (when not in-flow). `CreateFlowProvider` reads `localStorage` during its - * `useState` initializer, so clearing *before* pushing the next route means - * the provider mounts empty and the Create Community stage starts clean. + * key). Clearing *before* `router.push` means `CreateFlowProvider` can read + * empty storage on mount. * - * Note: this only touches localStorage. It does **not** delete the - * authenticated user's server draft (`/api/drafts/me`). Server drafts are - * loaded deliberately from the profile page, not re-hydrated into the flow - * on every entry, so there's nothing to wipe here for signed-in users. + * For marketing/profile “new rule” entry that should also remove the signed-in + * server draft when backend sync is on, use {@link prepareFreshCreateFlowEntry}. + * + * This helper only touches `localStorage`; it does **not** `DELETE /api/drafts/me`. */ export function clearCreateFlowPersistedDrafts(): void { clearAnonymousCreateFlowStorage(); diff --git a/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts b/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts new file mode 100644 index 0000000..9eaa332 --- /dev/null +++ b/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts @@ -0,0 +1,23 @@ +import { deleteServerDraft } from "../../../../lib/create/api"; +import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage"; +import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage"; + +const SYNC_ENABLED = + process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; + +/** + * Call **before** navigating into `/create` from marketing or profile “new rule” + * entry points so signed-in + sync matches an anonymous fresh start: wipe + * `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`. + * Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone + * before mount so {@link SignedInDraftHydration} does not rehydrate stale work. + * + * Do **not** use for “Continue draft” — that path should load the server draft. + */ +export async function prepareFreshCreateFlowEntry(): Promise { + clearAnonymousCreateFlowStorage(); + clearCoreValueDetailsLocalStorage(); + if (SYNC_ENABLED) { + await deleteServerDraft(); + } +} diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx index aeb6446..1631a69 100644 --- a/app/(app)/profile/ProfilePageClient.tsx +++ b/app/(app)/profile/ProfilePageClient.tsx @@ -22,6 +22,8 @@ import { } from "../create/utils/flowSteps"; import type { CreateFlowStep } from "../create/types"; import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage"; +import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage"; +import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { ProfilePageSignedOutView, @@ -245,9 +247,18 @@ export default function ProfilePageClient() { const handleContinueDraft = useCallback(() => { if (draft == null || !draft.hasDraft) return; const step = resolveContinueStepState(draft.state); + clearAnonymousCreateFlowStorage(); + clearCoreValueDetailsLocalStorage(); router.push(`/create/${step}`); }, [draft, router]); + const handleStartNewCustomRule = useCallback(() => { + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push("/create"); + })(); + }, [router]); + const handleRequestDeleteDraft = useCallback(() => { setActionError(null); setDraftDeleteOpen(true); @@ -360,6 +371,7 @@ export default function ProfilePageClient() { }} onCloseDeleteAccount={() => setAccountDeleteOpen(false)} onConfirmDeleteAccount={handleConfirmDeleteAccount} + onStartNewCustomRule={handleStartNewCustomRule} /> ); } diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index fd9ba6d..ad03d80 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -72,6 +72,8 @@ export type ProfilePageViewProps = { onDismissProfileSuccess: () => void; onDismissActionError: () => void; onDismissRulesError: () => void; + /** Clears local + server draft (when sync) then routes to `/create` — same fresh start as marketing “Create rule”. */ + onStartNewCustomRule: () => void; }; /** @@ -199,6 +201,7 @@ export function ProfilePageView({ onDismissProfileSuccess, onDismissActionError, onDismissRulesError, + onStartNewCustomRule, }: ProfilePageViewProps) { const t = useTranslation("pages.profile"); const tLogin = useTranslation("pages.login"); @@ -213,7 +216,7 @@ export function ProfilePageView({ id: "create-custom", title: t("optionCreateCustom"), description: "", - href: "/create", + onClick: onStartNewCustomRule, leadingIcon: "edit", showDescription: false, }, @@ -251,7 +254,7 @@ export function ProfilePageView({ showDescription: false, }, ]; - }, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]); + }, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange, onStartNewCustomRule]); const ruleCardShellClass = "w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]"; diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx index f3cbe19..0d292d0 100644 --- a/app/(marketing)/templates/TemplatesPageClient.tsx +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import HeaderLockup from "../../components/type/HeaderLockup"; import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; -import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts"; +import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps"; import { useTranslation } from "../../contexts/MessagesContext"; @@ -83,11 +83,13 @@ function TemplatesGrid({ entries={entries} onTemplateClick={(slug) => { if (!fromFlow) { - // Direct entry to `/templates`: treat template click as a fresh - // create-flow start and wipe any stale anonymous draft before - // navigating. In-flow entry (`?fromFlow=1`) skips the clear so - // the user's community stage survives the detour through here. - clearCreateFlowPersistedDrafts(); + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push( + buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), + ); + })(); + return; } router.push( buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), diff --git a/app/components/navigation/Top/Top.container.tsx b/app/components/navigation/Top/Top.container.tsx index b32a525..13344b9 100644 --- a/app/components/navigation/Top/Top.container.tsx +++ b/app/components/navigation/Top/Top.container.tsx @@ -9,8 +9,7 @@ import Button from "../../buttons/Button"; import AvatarContainer from "../../asset/AvatarContainer"; import Avatar from "../../asset/Avatar"; import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; -import { clearAnonymousCreateFlowStorage } from "../../../(app)/create/utils/anonymousDraftStorage"; -import { clearCoreValueDetailsLocalStorage } from "../../../(app)/create/utils/coreValueDetailsLocalStorage"; +import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { TopView } from "./Top.view"; import type { TopProps, NavSize } from "./Top.types"; @@ -45,16 +44,16 @@ const TopContainer = memo( /** * `Top` is hidden on `/create` routes by ConditionalNavigationClient, so - * this button is always clicked from outside the wizard — there is no - * mounted CreateFlowProvider to reset. Wiping the anonymous draft keys - * here guarantees a fresh start; the provider that mounts on `/create` - * will read empty storage. Server drafts (signed-in Save & Exit) are - * left alone — they're intentional persistence the user opted into. + * this button is always clicked from outside the wizard. Clears anonymous + * `localStorage` and, when backend sync is on, deletes the server draft + * so signed-in users get the same fresh start as guests (see + * {@link prepareFreshCreateFlowEntry}). */ const handleCreateRuleClick = useCallback(() => { - clearAnonymousCreateFlowStorage(); - clearCoreValueDetailsLocalStorage(); - router.push("/create"); + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push("/create"); + })(); }, [router]); // Schema markup for site navigation diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 5bf2faf..3b5685b 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -3,7 +3,7 @@ import { memo, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { logger } from "../../../../lib/logger"; -import { clearCreateFlowPersistedDrafts } from "../../../(app)/create/utils/clearCreateFlowPersistedDrafts"; +import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { fetchTemplates, isTemplatesFetchAborted, @@ -90,12 +90,12 @@ const RuleStackContainer = memo( } } logger.debug(`${slug} template clicked`); - // Marketing entry is always a *fresh* create-flow start: wipe any - // in-progress anonymous draft so a stale community name/structure from - // an earlier abandoned session can't short-circuit the `state.title` - // check in `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges`. - clearCreateFlowPersistedDrafts(); - router.push(`/create/review-template/${encodeURIComponent(slug)}`); + // Marketing home “Popular templates”: same fresh start as Top “Create rule” + // (local + server draft when sync) so stale state cannot break template apply. + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push(`/create/review-template/${encodeURIComponent(slug)}`); + })(); }; return ( diff --git a/docs/create-flow.md b/docs/create-flow.md index a03b913..232c7b9 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -43,10 +43,20 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSt | 15 | Review and complete | `final-review` | `/create/final-review` | | 16 | Review and complete | `completed` | `/create/completed` | -**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx)). +**Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)). Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/(app)/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/(app)/create/hooks/useCreateFlowNavigation.ts). +### Fresh start vs continue draft (signed-in + sync) + +**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Signed-in users additionally get **Save & Exit** and **publish**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`. + +- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped. +- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path. +- **Local-only wipe** without touching the server: [`clearCreateFlowPersistedDrafts`](../app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts) (same two `localStorage` keys). Prefer **`prepareFreshCreateFlowEntry`** for any user-facing “start new rule” navigation so signed-in + sync stays aligned with anonymous. + +Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) (Create rule), profile **Create new custom Rule** ([`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)), home **Popular templates** ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)), and **direct** `/templates` template picks ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) when **`fromFlow` is absent**. + --- ## Auxiliary route (not a wizard step or Figma stage) @@ -61,17 +71,17 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren **Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` + `summary` 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. -**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, using [`clearCreateFlowPersistedDrafts`](../app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts) — a tiny helper that wipes the anonymous draft from `localStorage` (both `create-flow-anonymous` and the core-value-details key) **before** the navigation fires. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, the provider mounts empty and `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges` naturally take the no-community branch — no per-handler marker plumbing needed. +**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. | Origin | Click-site behavior | URL the user lands on | | --- | --- | --- | -| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always calls `clearCreateFlowPersistedDrafts()` | `/create/review-template/[slug]` | -| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → calls `clearCreateFlowPersistedDrafts()` | `/create/review-template/[slug]` | -| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → skips the clear | `/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]` | +| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]` | 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. -Server drafts (`/api/drafts/me`) are **not** touched here. Per product plan they are not auto-hydrated into the create flow; users select and load a specific saved draft from the profile page. So wiping `localStorage` is sufficient for the "fresh slate" invariant. +**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. **Final-review Rule category chips** are derived from `CreateFlowState` via [`buildFinalReviewCategoriesFromState`](../lib/create/buildFinalReviewCategories.ts): for the Customize / plain custom-rule path it resolves `selected{Communication,Membership,DecisionApproach,ConflictManagement}MethodIds` against the curated method presets in `messages/en/create/customRule/*.json`, and `buildCoreValuesForDocument` supplies the `Values` row from `coreValuesChipsSnapshot` + `selectedCoreValueIds`. For the Use-without-changes path the template body lives in `state.sections`; the helper renders `categoryName` + entry titles directly. The demo chips shipped in `finalReview.json` remain the fallback only when nothing in state resolves to any chip (e.g. direct navigation for development). @@ -92,7 +102,7 @@ Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticke ## Known implementation gaps -- **Profile + drafts (CR-86):** The profile page lists the server draft, **Continue** deep-links to `/create/{currentStep}`, and **Start new rule** clears local + server draft before opening the wizard. `SignedInDraftHydration` calls `router.replace` to the saved step when it applies a server draft so the URL matches hydrated state. Remaining edge cases (e.g. template review routes) are handled when they surface in QA. +- **Profile + drafts (CR-86):** The profile page lists the server draft. **Continue** clears anonymous `localStorage` (and core-value details) then deep-links to `/create/{currentStep}` so hydration loads the server draft. **Create new custom Rule** and marketing **Create rule** use **`prepareFreshCreateFlowEntry`** (local + `DELETE /api/drafts/me` when sync is on) before opening the wizard so signed-in behavior matches a fresh anonymous start. `SignedInDraftHydration` may `router.replace` to the saved step when it applies a server draft so the URL matches hydrated state. Remaining edge cases (e.g. template review routes) are handled when they surface in QA. - **Inner “text/select shells”:** deferred until Create Community is stable; screens use **`CreateFlowStepShell`** only for Stage 1. --- diff --git a/tests/components/Top.test.tsx b/tests/components/Top.test.tsx index 39b656d..dd3f75b 100644 --- a/tests/components/Top.test.tsx +++ b/tests/components/Top.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; import Top from "../../app/components/navigation/Top"; @@ -92,10 +92,12 @@ describe('Top "Create rule" button', () => { }); await userEvent.click(btn); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith("/create"); + }); expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); expect( window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY), ).toBeNull(); - expect(pushMock).toHaveBeenCalledWith("/create"); }); });