App reorganization

This commit is contained in:
adilallo
2026-04-18 14:12:49 -06:00
parent f866d11ff8
commit e9dab04b34
288 changed files with 2698 additions and 5029 deletions
@@ -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 preanonymous-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";
}
+120
View File
@@ -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 18) 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;
}