App reorganization
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import type { CreateFlowState } from "../types";
|
||||
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||
|
||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||
|
||||
/**
|
||||
* Set when the user submits magic link from “Save your progress?” so after verify we PUT to server.
|
||||
* Value is arbitrary truthy string; cleared after successful transfer or abandon.
|
||||
*/
|
||||
export const CREATE_FLOW_TRANSFER_PENDING_KEY =
|
||||
"create-flow-transfer-pending" as const;
|
||||
|
||||
/**
|
||||
* When signed-in + sync, {@link SignedInDraftHydration} resolves server vs this key via `window.confirm`
|
||||
* if both are non-empty; see `messages/en/create/draftHydration.json`.
|
||||
*/
|
||||
|
||||
const LEGACY_LIVE_KEY = "create-flow-state";
|
||||
const LEGACY_DRAFT_KEY = "create-flow-draft";
|
||||
|
||||
export function readAnonymousCreateFlowState(): CreateFlowState {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? migrateLegacyCreateFlowState(parsed)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAnonymousCreateFlowState(value: CreateFlowState): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
CREATE_FLOW_ANONYMOUS_KEY,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAnonymousCreateFlowStorage(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY);
|
||||
window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function setTransferPendingFlag(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTransferPendingFlag(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return Boolean(
|
||||
window.localStorage.getItem(CREATE_FLOW_TRANSFER_PENDING_KEY),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** One-time cleanup of pre–anonymous-draft keys. */
|
||||
export function clearLegacyCreateFlowKeysOnce(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const done = window.sessionStorage.getItem("create-flow-legacy-cleared");
|
||||
if (done) return;
|
||||
window.localStorage.removeItem(LEGACY_LIVE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_DRAFT_KEY);
|
||||
window.sessionStorage.setItem("create-flow-legacy-cleared", "1");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CoreValueDetailEntry } from "../types";
|
||||
|
||||
/** Persists meaning/signals per chip id across refresh (esp. signed-in create flow, in-memory only). */
|
||||
export const CORE_VALUE_DETAILS_STORAGE_KEY =
|
||||
"create-flow-core-value-details" as const;
|
||||
|
||||
export function readCoreValueDetailsFromLocalStorage(): Record<
|
||||
string,
|
||||
CoreValueDetailEntry
|
||||
> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
const out: Record<string, CoreValueDetailEntry> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (!v || typeof v !== "object") continue;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
|
||||
continue;
|
||||
}
|
||||
out[k] = { meaning: o.meaning, signals: o.signals };
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCoreValueDetailsToLocalStorage(
|
||||
value: Record<string, CoreValueDetailEntry> | undefined,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (!value || Object.keys(value).length === 0) {
|
||||
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
CORE_VALUE_DETAILS_STORAGE_KEY,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCoreValueDetailsLocalStorage(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ProportionBarState } from "../../../components/progress/ProportionBar/ProportionBar.types";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
||||
|
||||
/**
|
||||
* One `ProportionBarState` per index in `FLOW_STEP_ORDER` (same length).
|
||||
* Third Create Community step (`community-structure`) uses `1-2` per Figma.
|
||||
*/
|
||||
const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
|
||||
"1-0", // informational
|
||||
"1-1", // community-name
|
||||
"1-2", // community-structure
|
||||
"1-3", // community-context
|
||||
"1-4", // community-size
|
||||
"1-5", // community-upload
|
||||
"2-0", // community-save
|
||||
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
|
||||
"2-0", // core-values (same segment as review / end of Create Community)
|
||||
"2-1", // communication-methods (Figma — Compact Card Stack)
|
||||
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
|
||||
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
|
||||
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
|
||||
"3-1", // confirm-stakeholders
|
||||
"3-2", // final-review
|
||||
"3-2", // completed
|
||||
] as const;
|
||||
|
||||
if (PROPORTION_BY_STEP_INDEX.length !== FLOW_STEP_ORDER.length) {
|
||||
throw new Error(
|
||||
"createFlowProportionProgress: PROPORTION_BY_STEP_INDEX length must match FLOW_STEP_ORDER",
|
||||
);
|
||||
}
|
||||
|
||||
export function getProportionBarProgressForCreateFlowStep(
|
||||
step: CreateFlowStep | null | undefined,
|
||||
): ProportionBarState {
|
||||
const idx = getStepIndex(step);
|
||||
if (idx < 0) return "1-0";
|
||||
return PROPORTION_BY_STEP_INDEX[idx] ?? "1-0";
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
/**
|
||||
* Figma layout families for the create flow (not encoded in the URL).
|
||||
* `app/(app)/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
|
||||
* `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
|
||||
*/
|
||||
type CreateFlowLayoutKind =
|
||||
| "informational"
|
||||
| "text"
|
||||
| "select"
|
||||
| "upload"
|
||||
| "review"
|
||||
| "card"
|
||||
| "right-rail"
|
||||
| "completed";
|
||||
|
||||
interface CreateFlowScreenDefinition {
|
||||
layoutKind: CreateFlowLayoutKind;
|
||||
/** Figma node id (file Community-Rule-System), dev mode. */
|
||||
figmaNodeId: string;
|
||||
/**
|
||||
* Namespace for `useTranslation`, e.g. `create.communityName`.
|
||||
* Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere).
|
||||
*/
|
||||
messageNamespace: string;
|
||||
/** Match legacy `text` step: main area vertically centered below `md`. */
|
||||
centeredBodyBelowMd: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry: **distinct URL (`CreateFlowStep`) → Figma + layout**.
|
||||
* Source of truth for product order remains `FLOW_STEP_ORDER` in `flowSteps.ts`.
|
||||
*/
|
||||
export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
CreateFlowStep,
|
||||
CreateFlowScreenDefinition
|
||||
> = {
|
||||
/** Figma: Flow — Informational (node 20094-16005). */
|
||||
informational: {
|
||||
layoutKind: "informational",
|
||||
figmaNodeId: "20094-16005",
|
||||
messageNamespace: "create.informational",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-name": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20094-18187",
|
||||
messageNamespace: "create.communityName",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
"community-size": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-41317",
|
||||
messageNamespace: "create.communitySize",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-context": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20094-41243",
|
||||
messageNamespace: "create.communityContext",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
"community-structure": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-18244",
|
||||
messageNamespace: "create.communityStructure",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-upload": {
|
||||
layoutKind: "upload",
|
||||
figmaNodeId: "20094-41524",
|
||||
messageNamespace: "create.communityUpload",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-save": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20097-14948",
|
||||
messageNamespace: "create.communitySave",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
review: {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "19706-12135",
|
||||
messageNamespace: "create.review",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"core-values": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20264-68378",
|
||||
messageNamespace: "create.coreValues",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"communication-methods": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20246-15828",
|
||||
messageNamespace: "create.communication",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"membership-methods": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20858-13947",
|
||||
messageNamespace: "create.membership",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"decision-approaches": {
|
||||
layoutKind: "right-rail",
|
||||
figmaNodeId: "20523-23509",
|
||||
messageNamespace: "create.rightRail",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"conflict-management": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20879-15979",
|
||||
messageNamespace: "create.conflictManagement",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"confirm-stakeholders": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "21104-46594",
|
||||
messageNamespace: "create.confirmStakeholders",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"final-review": {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "20907-212767",
|
||||
messageNamespace: "create.finalReview",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
completed: {
|
||||
layoutKind: "completed",
|
||||
figmaNodeId: "20907-213286",
|
||||
messageNamespace: "create.completed",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function createFlowStepUsesCenteredTextLayout(
|
||||
step: CreateFlowStep | null,
|
||||
): boolean {
|
||||
if (!step) return false;
|
||||
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
|
||||
}
|
||||
|
||||
/** Steps whose main area uses the CardStack-style layout (`layoutKind: "card"`). */
|
||||
export function createFlowStepUsesCardLayout(
|
||||
step: CreateFlowStep | null,
|
||||
): boolean {
|
||||
if (!step) return false;
|
||||
return CREATE_FLOW_SCREEN_REGISTRY[step].layoutKind === "card";
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Step definitions and helpers for the Create Rule Flow
|
||||
*
|
||||
* Single source of truth for step order and navigation helpers.
|
||||
* Order matches Figma Create Community (frames 1–8) then later stages.
|
||||
* `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap).
|
||||
*/
|
||||
|
||||
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",
|
||||
"community-upload",
|
||||
"community-save",
|
||||
"review",
|
||||
"core-values",
|
||||
"communication-methods",
|
||||
"membership-methods",
|
||||
"decision-approaches",
|
||||
"conflict-management",
|
||||
"confirm-stakeholders",
|
||||
"final-review",
|
||||
"completed",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create flow (for validation)
|
||||
*/
|
||||
export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const 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);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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") return null;
|
||||
|
||||
return isValidStep(segment) ? segment : null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CreateFlowState } from "../types";
|
||||
|
||||
const IGNORED_KEYS = new Set<string>(["currentStep"]);
|
||||
|
||||
function valueIndicatesUserInput(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "object") {
|
||||
return Object.keys(value as object).length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the user has entered meaningful create-flow data (not only navigation metadata).
|
||||
* Used to show "Save & Exit" vs a plain "Exit" that confirms data loss.
|
||||
*/
|
||||
export function hasCreateFlowUserInput(state: CreateFlowState): boolean {
|
||||
for (const key of Object.keys(state)) {
|
||||
if (IGNORED_KEYS.has(key)) continue;
|
||||
if (valueIndicatesUserInput(state[key])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user