diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc index d9ffda7..cf8d0a2 100644 --- a/.cursor/rules/create-flow.mdc +++ b/.cursor/rules/create-flow.mdc @@ -16,6 +16,9 @@ alwaysApply: false `app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind inside a screen — pick the matching shell (`CreateFlowStepShell` / `CreateFlowTwoColumnSelectShell`). +- Keep create-flow step routing centralized in + `app/(app)/create/utils/createFlowPaths.ts` (`createFlowStepPath`, + `CREATE_ROUTES`) — do not introduce new hardcoded `/create/...` literals. - Shared create-flow pieces go in `app/(app)/create/components/` (layout shells, field composites). Generic primitives go in `app/components/`. @@ -49,8 +52,9 @@ file are a smell once they're used more than once. namespace (see `localization.mdc`). - Modal `sections` defaults are DB-shaped seed placeholders, not UI constants — expect replacement with live data. -- Modal `sections` defaults are DB-shaped seed placeholders, not UI - constants — expect replacement with live data. +- Custom-rule facet mappings (step ids, template-category aliases, selection + keys, strip keys) must be sourced from `lib/create/customRuleFacets.ts` + (`CUSTOM_RULE_FACETS`) instead of adding new ad-hoc switches/tables. ## Interaction tracking diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index e59ff21..d5618d7 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -20,12 +20,20 @@ import { getNextStep, getStepIndex, parseReviewReturnSearchParam, - CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, + createFlowStepUsesSelectSplitScroll, TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_VALUE, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, } from "./utils/flowSteps"; +import { + CREATE_FLOW_SYNC_DRAFT_QUERY, + CREATE_FLOW_SYNC_DRAFT_VALUE, + CREATE_ROUTES, + createFlowStepPath, + createFlowStepPathAfterStrippingReviewReturn, + createFlowStepPathWithSyncDraft, +} from "./utils/createFlowPaths"; import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; import { createFlowStepUsesCenteredTextLayout, @@ -42,12 +50,12 @@ import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; -import type { CreateFlowMethodCardFacetSection } from "./types"; import { createFlowStateFromPublishedRule, isPublishedRuleSelectionMissing, methodSectionsPinsFromPublishedHydratePatch, } from "../../../lib/create/publishedDocumentToCreateFlowState"; +import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { deleteServerDraft } from "../../../lib/create/api"; import messages from "../../../messages/en/index"; @@ -193,8 +201,8 @@ function CreateFlowLayoutContent({ const loginReturnPath = currentStep === "edit-rule" - ? "/create/edit-rule?syncDraft=1" - : "/create/final-review?syncDraft=1"; + ? createFlowStepPathWithSyncDraft("edit-rule") + : createFlowStepPathWithSyncDraft("final-review"); const { publishBannerMessage, @@ -248,7 +256,7 @@ function CreateFlowLayoutContent({ if (sessionUser) { void deleteServerDraft(); } - router.push("/"); + router.push(CREATE_ROUTES.root); return; } @@ -262,7 +270,7 @@ function CreateFlowLayoutContent({ variant: "saveProgress", nextPath: returnToTemplateReview ?? - `${pathname ?? "/create"}?syncDraft=1`, + `${pathname != null && pathname.length > 0 ? pathname : CREATE_ROUTES.createRoot}?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`, backdropVariant: "blurredYellow", }); return; @@ -278,7 +286,7 @@ function CreateFlowLayoutContent({ sessionUser && currentStep === "community-save" ) { - router.replace("/create/review"); + router.replace(CREATE_ROUTES.review); } }, [sessionResolved, sessionUser, currentStep, router]); @@ -294,12 +302,12 @@ function CreateFlowLayoutContent({ if (currentStep !== "edit-rule") return; const last = readLastPublishedRule(); if (!last) { - router.replace("/create/completed"); + router.replace(CREATE_ROUTES.completed); return; } const editingId = state.editingPublishedRuleId?.trim() ?? ""; if (editingId.length > 0 && editingId !== last.id) { - router.replace("/create/completed"); + router.replace(CREATE_ROUTES.completed); return; } const titleOk = @@ -307,9 +315,7 @@ function CreateFlowLayoutContent({ const sectionsClear = (state.sections?.length ?? 0) === 0; const patch = createFlowStateFromPublishedRule(last); const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch); - const METHOD_CARD_PIN_FACETS: readonly CreateFlowMethodCardFacetSection[] = - ["communication", "membership", "decisionApproaches", "conflictManagement"]; - const needsPinMerge = METHOD_CARD_PIN_FACETS.some( + const needsPinMerge = METHOD_FACET_API_SECTION_IDS.some( (key) => pinPatch[key] === true && state.methodSectionsPinCommitted?.[key] !== true, @@ -406,11 +412,9 @@ function CreateFlowLayoutContent({ currentStep === "final-review" || currentStep === "edit-rule"; const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep); /** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */ - const isSelectSplitScrollStep = - currentStep === "community-size" || - currentStep === "community-structure" || - currentStep === "core-values" || - currentStep === "decision-approaches"; + const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( + currentStep, + ); const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; /** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ @@ -586,7 +590,7 @@ function CreateFlowLayoutContent({ editingPublishedRuleId: last.id, sections: [], }); - router.push("/create/edit-rule"); + router.push(createFlowStepPath("edit-rule")); } : undefined } @@ -738,15 +742,11 @@ function CreateFlowLayoutContent({ setMethodSectionsPinCommitted(facet, true); } if (reviewReturnTarget) { - const params = new URLSearchParams( - searchParams?.toString() ?? "", - ); - params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); - const qs = params.toString(); router.push( - qs.length > 0 - ? `/create/${reviewReturnTarget}?${qs}` - : `/create/${reviewReturnTarget}`, + createFlowStepPathAfterStrippingReviewReturn( + reviewReturnTarget, + searchParams, + ), ); return; } @@ -784,20 +784,16 @@ function CreateFlowLayoutContent({ ? () => router.push( templateReviewFooterBackToCreateReview - ? "/create/review" - : "/", + ? CREATE_ROUTES.review + : CREATE_ROUTES.root, ) : reviewReturnTarget ? () => { - const params = new URLSearchParams( - searchParams?.toString() ?? "", - ); - params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); - const qs = params.toString(); router.push( - qs.length > 0 - ? `/create/${reviewReturnTarget}?${qs}` - : `/create/${reviewReturnTarget}`, + createFlowStepPathAfterStrippingReviewReturn( + reviewReturnTarget, + searchParams, + ), ); } : previousStep diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx index 5b9beb1..37169fe 100644 --- a/app/(app)/create/context/CreateFlowContext.tsx +++ b/app/(app)/create/context/CreateFlowContext.tsx @@ -185,8 +185,8 @@ export function CreateFlowProvider({ clearCoreValueDetailsLocalStorage(); }, []); - // Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`. - // Kept in sync with `CreateFlowState` comments marked "Create Custom —". + // Keys cleared here match `STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS` from + // `lib/create/customRuleFacets.ts` (CUSTOM_RULE_FACETS / CR-92). const resetCustomRuleSelections = useCallback(() => { setState((prev) => stripCustomRuleSelectionFields(prev)); // Effect on `state.coreValueDetailsByChipId` clears its dedicated diff --git a/app/(app)/create/hooks/useCreateFlowFinalize.ts b/app/(app)/create/hooks/useCreateFlowFinalize.ts index 9415997..f187f7a 100644 --- a/app/(app)/create/hooks/useCreateFlowFinalize.ts +++ b/app/(app)/create/hooks/useCreateFlowFinalize.ts @@ -10,6 +10,7 @@ import { CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, } from "../utils/flowSteps"; +import { createFlowStepPath } from "../utils/createFlowPaths"; type AppRouterLike = { push: (_href: string) => void }; @@ -80,7 +81,7 @@ export function useCreateFlowFinalize({ document: ruleDocument, }); updateState({ editingPublishedRuleId: undefined }); - router.push("/create/completed"); + router.push(createFlowStepPath("completed")); return; } if (updateResult.status === 401) { @@ -113,7 +114,10 @@ export function useCreateFlowFinalize({ document: ruleDocument, }); router.push( - `/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`, + createFlowStepPath("completed", { + [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, + }), ); return; } diff --git a/app/(app)/create/hooks/useFacetRecommendations.ts b/app/(app)/create/hooks/useFacetRecommendations.ts index 950f6c7..42b4efd 100644 --- a/app/(app)/create/hooks/useFacetRecommendations.ts +++ b/app/(app)/create/hooks/useFacetRecommendations.ts @@ -2,16 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; +import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets"; import { useCreateFlow } from "../context/CreateFlowContext"; /** * Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2). + * Same tuple as {@link METHOD_FACET_API_SECTION_IDS} (`CUSTOM_RULE_FACETS`, CR-92). */ -export type RecommendationSection = - | "communication" - | "membership" - | "decisionApproaches" - | "conflictManagement"; +export type RecommendationSection = MethodFacetApiSectionId; export type FacetRecommendationsResult = { /** `true` once the network call completes (or short-circuits with no facets). */ diff --git a/app/(app)/create/screens/CreateFlowScreenView.tsx b/app/(app)/create/screens/CreateFlowScreenView.tsx index 4ffd1f9..635cd15 100644 --- a/app/(app)/create/screens/CreateFlowScreenView.tsx +++ b/app/(app)/create/screens/CreateFlowScreenView.tsx @@ -2,20 +2,7 @@ import type { ReactNode } from "react"; import type { CreateFlowStep } from "../types"; -import { InformationalScreen } from "./informational/InformationalScreen"; -import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen"; -import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen"; -import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen"; -import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen"; -import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen"; -import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; -import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; -import { FinalReviewScreen } from "./review/FinalReviewScreen"; -import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen"; -import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen"; -import { ConflictManagementScreen } from "./card/ConflictManagementScreen"; -import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen"; -import { CompletedScreen } from "./completed/CompletedScreen"; +import { renderCreateFlowScreen } from "./createFlowScreenComponents"; /** * Maps each wizard `screenId` to its screen component. @@ -23,73 +10,14 @@ import { CompletedScreen } from "./completed/CompletedScreen"; * **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind` * — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc. * The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`. + * + * Implementation lives in {@link renderCreateFlowScreen} (`createFlowScreenComponents.tsx`) + * so the registry metadata and this router stay easier to keep in sync (CR-92 §3). */ export function CreateFlowScreenView({ screenId, }: { screenId: CreateFlowStep; }): ReactNode { - switch (screenId) { - case "informational": - return ; - case "community-name": - return ( - - ); - case "community-structure": - return ; - case "community-context": - return ( - - ); - case "community-size": - return ; - case "community-upload": - return ; - case "community-save": - return ( - - ); - case "review": - return ; - case "core-values": - return ; - case "communication-methods": - return ; - case "membership-methods": - return ; - case "decision-approaches": - return ; - case "conflict-management": - return ; - case "confirm-stakeholders": - return ; - case "final-review": - return ; - case "edit-rule": - return ; - case "completed": - return ; - default: { - const _exhaustive: never = screenId; - return _exhaustive; - } - } + return renderCreateFlowScreen(screenId); } diff --git a/app/(app)/create/screens/createFlowScreenComponents.tsx b/app/(app)/create/screens/createFlowScreenComponents.tsx new file mode 100644 index 0000000..581ca0f --- /dev/null +++ b/app/(app)/create/screens/createFlowScreenComponents.tsx @@ -0,0 +1,88 @@ +/** + * Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView} + * thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so + * new steps do not drift. + */ + +import type { ReactNode } from "react"; +import type { CreateFlowStep } from "../types"; +import { InformationalScreen } from "./informational/InformationalScreen"; +import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen"; +import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen"; +import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen"; +import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen"; +import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen"; +import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; +import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; +import { FinalReviewScreen } from "./review/FinalReviewScreen"; +import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen"; +import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen"; +import { ConflictManagementScreen } from "./card/ConflictManagementScreen"; +import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen"; +import { CompletedScreen } from "./completed/CompletedScreen"; + +export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode { + switch (screenId) { + case "informational": + return ; + case "community-name": + return ( + + ); + case "community-structure": + return ; + case "community-context": + return ( + + ); + case "community-size": + return ; + case "community-upload": + return ; + case "community-save": + return ( + + ); + case "review": + return ; + case "core-values": + return ; + case "communication-methods": + return ; + case "membership-methods": + return ; + case "decision-approaches": + return ; + case "conflict-management": + return ; + case "confirm-stakeholders": + return ; + case "final-review": + return ; + case "edit-rule": + return ; + case "completed": + return ; + default: { + const _exhaustive: never = screenId; + return _exhaustive; + } + } +} diff --git a/app/(app)/create/screens/review/CommunityReviewScreen.tsx b/app/(app)/create/screens/review/CommunityReviewScreen.tsx index 0536910..c5f164b 100644 --- a/app/(app)/create/screens/review/CommunityReviewScreen.tsx +++ b/app/(app)/create/screens/review/CommunityReviewScreen.tsx @@ -17,19 +17,7 @@ import { vectorMarkPath, } from "../../../../../lib/assetUtils"; import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState"; - -/** - * Targets for a `pendingTemplateAction` redirect. Customize resumes the - * custom-rule stage with chips already prefilled; useWithoutChanges jumps to - * the review-and-complete stage since the template body is already in state. - */ -const PENDING_TEMPLATE_REDIRECT_TARGET: Record< - "customize" | "useWithoutChanges", - string -> = { - customize: "/create/core-values", - useWithoutChanges: "/create/confirm-stakeholders", -}; +import { createFlowStepPath } from "../../utils/createFlowPaths"; /** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */ export function CommunityReviewScreen() { @@ -56,8 +44,10 @@ export function CommunityReviewScreen() { if (firedRedirectRef.current) return; const pending = state.pendingTemplateAction; if (!pending) return; - const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode]; - if (!target) return; + const target = + pending.mode === "customize" + ? createFlowStepPath("core-values") + : createFlowStepPath("confirm-stakeholders"); firedRedirectRef.current = true; const pinMerge = pending.mode === "customize" diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 57ca0b8..526f1cc 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -5,6 +5,8 @@ * including step types, state management, and context interfaces. */ +import type { MethodFacetApiSectionId } from "../../../lib/create/customRuleFacets"; + /** * Valid step IDs for the create rule flow (URL segment after `/create/`). * Create Community order matches Figma; `review` closes that stage per design. @@ -36,12 +38,11 @@ export type CreateFlowTextStateField = | "communityContext" | "communitySaveEmail"; -/** Facet-backed method card stacks (`GET /api/create-flow/methods?section=`). */ -export type CreateFlowMethodCardFacetSection = - | "communication" - | "membership" - | "decisionApproaches" - | "conflictManagement"; +/** + * Facet-backed method card stacks (`GET /api/create-flow/methods?section=`). + * Canonical ids: {@link METHOD_FACET_API_SECTION_IDS} in `lib/create/customRuleFacets.ts`. + */ +export type CreateFlowMethodCardFacetSection = MethodFacetApiSectionId; /** * Serialized chip row for `community-structure` (preset + custom labels). diff --git a/app/(app)/create/utils/createFlowPaths.ts b/app/(app)/create/utils/createFlowPaths.ts new file mode 100644 index 0000000..8d5ba0f --- /dev/null +++ b/app/(app)/create/utils/createFlowPaths.ts @@ -0,0 +1,78 @@ +/** + * Central `/create/...` path builders (Linear CR-92 §2). + * Prefer these over string literals so layout, redirects, hooks, and tests stay aligned. + */ + +import type { CreateFlowStep } from "../types"; +import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps"; + +export const CREATE_ROUTES = { + root: "/", + createRoot: "/create", + /** First step resolves via redirect from `/create`. */ + createFirstStep: "/create", + review: "/create/review", + finalReview: "/create/final-review", + completed: "/create/completed", + editRule: "/create/edit-rule", +} as const; + +/** + * Post-login return and session-gate paths on wizard steps. + * (Also used when `pathname` is unknown but `syncDraft` must be appended.) + */ +export const CREATE_FLOW_SYNC_DRAFT_QUERY = "syncDraft" as const; +export const CREATE_FLOW_SYNC_DRAFT_VALUE = "1" as const; + +export function createFlowStepPathWithSyncDraft(step: CreateFlowStep): string { + return createFlowStepPath(step, { + [CREATE_FLOW_SYNC_DRAFT_QUERY]: CREATE_FLOW_SYNC_DRAFT_VALUE, + }); +} + +export type CreateFlowPathQuery = Record< + string, + string | number | boolean | undefined +>; + +/** + * Path for a wizard step: `/create/{screenId}` with optional query string. + */ +export function createFlowStepPath( + step: CreateFlowStep, + query?: CreateFlowPathQuery, +): string { + const base = `/create/${step}`; + if (query == null || Object.keys(query).length === 0) return base; + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue; + sp.set(k, String(v)); + } + const q = sp.toString(); + return q.length > 0 ? `${base}?${q}` : base; +} + +export function createCompletedPath(query?: CreateFlowPathQuery): string { + return createFlowStepPath("completed", query); +} + +/** + * Navigate back from a facet step to final-review / edit-rule, dropping + * `reviewReturn` from the current query while preserving other params. + */ +export function createFlowStepPathAfterStrippingReviewReturn( + step: CreateFlowStep, + searchParams: URLSearchParams | null | undefined, +): string { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); + const query: CreateFlowPathQuery = {}; + params.forEach((value, key) => { + query[key] = value; + }); + return createFlowStepPath( + step, + Object.keys(query).length > 0 ? query : undefined, + ); +} diff --git a/app/(app)/create/utils/customRuleConfirmFooterSteps.ts b/app/(app)/create/utils/customRuleConfirmFooterSteps.ts index 4b22413..26e6beb 100644 --- a/app/(app)/create/utils/customRuleConfirmFooterSteps.ts +++ b/app/(app)/create/utils/customRuleConfirmFooterSteps.ts @@ -1,3 +1,4 @@ +import { CUSTOM_RULE_FACETS } from "../../../../lib/create/customRuleFacets"; import type { CreateFlowMethodCardFacetSection, CreateFlowState, @@ -11,9 +12,9 @@ type FooterMessageKey = keyof typeof footerMessages; * Binding for each Custom Rule stage step whose footer primary button * gates the user on "has at least one chip selected?". All five screens * render the same `