351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
/**
|
|
* Step definitions and helpers for the Create Rule Flow
|
|
*
|
|
* Single source of truth for step order and navigation helpers.
|
|
* Order matches Figma Create Community then later stages.
|
|
* `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap).
|
|
* `setup-questions` sits after size and before the logo upload.
|
|
*/
|
|
|
|
import type { CreateFlowStep } from "../types";
|
|
|
|
/**
|
|
* Ordered list of steps in the create rule flow
|
|
*/
|
|
export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
|
"informational",
|
|
"community-name",
|
|
"community-structure",
|
|
"community-context",
|
|
"community-size",
|
|
"setup-questions",
|
|
"community-upload",
|
|
"community-save",
|
|
"review",
|
|
"core-values",
|
|
"communication-methods",
|
|
"membership-methods",
|
|
"decision-approaches",
|
|
"conflict-management",
|
|
"final-review",
|
|
"confirm-stakeholders",
|
|
"completed",
|
|
] as const;
|
|
|
|
/**
|
|
* Valid URL segments for `/create/[screenId]` (includes branch-only `edit-rule`).
|
|
* Linear order for navigation remains {@link FLOW_STEP_ORDER}.
|
|
*/
|
|
export const VALID_STEPS: readonly CreateFlowStep[] = [
|
|
...FLOW_STEP_ORDER,
|
|
"edit-rule",
|
|
] as const;
|
|
|
|
/**
|
|
* First step in the flow (entry point)
|
|
*/
|
|
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, setup questions, 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 `final-review`.
|
|
*/
|
|
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
|
|
"informational",
|
|
"community-structure",
|
|
"community-size",
|
|
"setup-questions",
|
|
"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
|
|
*/
|
|
export function getNextStep(
|
|
currentStep: CreateFlowStep | null | undefined,
|
|
options?: CreateFlowNavigationOptions,
|
|
): CreateFlowStep | null {
|
|
if (!currentStep) return null;
|
|
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 null;
|
|
}
|
|
|
|
/**
|
|
* Returns the previous step in the flow, or null if current is first/invalid
|
|
*/
|
|
export function getPreviousStep(
|
|
currentStep: CreateFlowStep | null | undefined,
|
|
options?: CreateFlowNavigationOptions,
|
|
): CreateFlowStep | null {
|
|
if (!currentStep) return null;
|
|
let index = FLOW_STEP_ORDER.indexOf(currentStep);
|
|
if (index <= 0) return null;
|
|
while (index > 0) {
|
|
index -= 1;
|
|
const prev = FLOW_STEP_ORDER[index] as CreateFlowStep;
|
|
if (!shouldSkipStep(prev, options)) return prev;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Where the create-flow footer Back action should go. Usually the previous
|
|
* 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
|
|
* `final-review` is `community-upload`.
|
|
* - In-flow (`?fromFlow=1`, community identity already collected): Back
|
|
* from `final-review` returns to template review instead of
|
|
* `conflict-management`.
|
|
*/
|
|
export type CreateFlowBackTarget =
|
|
| { kind: "step"; step: CreateFlowStep }
|
|
| { kind: "templateReview"; slug: string };
|
|
|
|
export function resolveCreateFlowBackTarget(
|
|
currentStep: CreateFlowStep | null | undefined,
|
|
options: CreateFlowNavigationOptions | undefined,
|
|
templateReviewBackSlug: string | undefined | null,
|
|
): CreateFlowBackTarget | null {
|
|
const slug =
|
|
typeof templateReviewBackSlug === "string"
|
|
? templateReviewBackSlug.trim()
|
|
: "";
|
|
if (
|
|
currentStep === "community-name" &&
|
|
options?.useWithoutChangesIdentityOnly &&
|
|
slug.length > 0
|
|
) {
|
|
return { kind: "templateReview", slug };
|
|
}
|
|
if (
|
|
currentStep === "final-review" &&
|
|
slug.length > 0 &&
|
|
!options?.useWithoutChangesIdentityOnly
|
|
) {
|
|
return { kind: "templateReview", slug };
|
|
}
|
|
const prev = getPreviousStep(currentStep, options);
|
|
return prev != null ? { kind: "step", step: prev } : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the index of the step (0-based), or -1 if invalid
|
|
*/
|
|
export function getStepIndex(step: CreateFlowStep | null | undefined): number {
|
|
if (!step) return -1;
|
|
return FLOW_STEP_ORDER.indexOf(step);
|
|
}
|
|
|
|
/** First wizard step that offers Save and exit (after the community is ready to keep). */
|
|
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-save");
|
|
|
|
/**
|
|
* Top-nav Save and exit (vs Exit). Guests and signed-in users share this from
|
|
* `community-save` onward and on `edit-rule`. On completed, guests still
|
|
* get Save and exit (claim/login); signed-in users get Exit (already owned).
|
|
*
|
|
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
|
|
* not a guest — completed stays Exit until the session resolves.
|
|
*/
|
|
export function shouldOfferCreateFlowSaveAndExit(
|
|
currentStep: CreateFlowStep | null | undefined,
|
|
sessionUser?: { id: string } | null,
|
|
): boolean {
|
|
if (currentStep == null) return false;
|
|
if (currentStep === "completed") return sessionUser === null;
|
|
if (currentStep === "edit-rule") return true;
|
|
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
|
|
}
|
|
|
|
/**
|
|
* Steps where below `lg` the main column scrolls with split layout
|
|
* (`CreateFlowLayoutClient`).
|
|
*/
|
|
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
|
"community-size",
|
|
"setup-questions",
|
|
"community-structure",
|
|
"core-values",
|
|
"communication-methods",
|
|
"membership-methods",
|
|
"decision-approaches",
|
|
"conflict-management",
|
|
"confirm-stakeholders",
|
|
] as const;
|
|
|
|
export function createFlowStepUsesSelectSplitScroll(
|
|
step: CreateFlowStep | null | undefined,
|
|
): boolean {
|
|
if (!step) return false;
|
|
return (CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS as readonly string[]).includes(
|
|
step,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the given string is a valid create flow step
|
|
*/
|
|
export function isValidStep(
|
|
step: string | null | undefined,
|
|
): step is CreateFlowStep {
|
|
return (
|
|
typeof step === "string" &&
|
|
(VALID_STEPS as readonly string[]).includes(step)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parses `/create/{screenId}` (and optional trailing segments) from pathname.
|
|
* Returns null for non-wizard paths (e.g. `/create/review-template/...`).
|
|
*/
|
|
export function parseCreateFlowScreenFromPathname(
|
|
pathname: string | null,
|
|
): CreateFlowStep | null {
|
|
if (!pathname || pathname.length === 0) return null;
|
|
if (pathname.includes("/create/review-template/")) return null;
|
|
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
|
|
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
const createIdx = parts.indexOf("create");
|
|
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
|
|
|
const segment = parts[createIdx + 1];
|
|
if (segment === "review-template" || segment === "templates") return null;
|
|
|
|
return isValidStep(segment) ? segment : null;
|
|
}
|
|
|
|
/**
|
|
* `/create/templates` — in-flow catalog after review “Create from template”.
|
|
* Not a wizard step; layout keeps Back / Save & Exit and does not use
|
|
* marketing `/templates` chrome.
|
|
*/
|
|
export function isCreateFlowTemplatesPickerPath(
|
|
pathname: string | null | undefined,
|
|
): boolean {
|
|
if (!pathname) return false;
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
const createIdx = parts.indexOf("create");
|
|
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
|
|
return parts[createIdx + 1] === "templates";
|
|
}
|
|
|
|
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
|
|
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
|
|
* on profile and marketing so stale localStorage facets never show badges.
|
|
*/
|
|
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
|
|
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
|
|
|
|
/** `/create/completed?celebrate=1` — post-publish toast; set only after **initial** POST publish, not PATCH updates. */
|
|
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
|
|
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
|
|
|
|
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
|
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
|
|
|
/**
|
|
* `/create/confirm-stakeholders?manageStakeholders=1` — published-rule invites.
|
|
* Layout hydrates `editingPublishedRuleId` from the last published rule when needed.
|
|
* Typically paired with `reviewReturn=edit-rule` or `reviewReturn=completed`.
|
|
*/
|
|
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY = "manageStakeholders" as const;
|
|
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const;
|
|
|
|
export type CreateFlowReviewReturnTarget =
|
|
| "final-review"
|
|
| "edit-rule"
|
|
| "review"
|
|
| "completed";
|
|
|
|
export function parseReviewReturnSearchParam(
|
|
searchParams: { get: (name: string) => string | null } | null | undefined,
|
|
): CreateFlowReviewReturnTarget | null {
|
|
if (!searchParams) return null;
|
|
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
|
if (
|
|
raw === "final-review" ||
|
|
raw === "edit-rule" ||
|
|
raw === "review" ||
|
|
raw === "completed"
|
|
) {
|
|
return raw;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `/create/review-template/{slug}` with optional marker so chrome can send
|
|
* footer Back to `/create/review` instead of marketing home.
|
|
*/
|
|
export function buildTemplateReviewHref(
|
|
slug: string,
|
|
options?: { fromCreateWizard?: boolean },
|
|
): string {
|
|
const path = `/create/review-template/${encodeURIComponent(slug)}`;
|
|
if (options?.fromCreateWizard) {
|
|
return `${path}?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}`;
|
|
}
|
|
return path;
|
|
}
|