Update create flow pages

This commit is contained in:
adilallo
2026-04-13 18:24:13 -06:00
parent a39b4aa04b
commit a0de78c020
66 changed files with 1028 additions and 538 deletions
+5 -4
View File
@@ -13,6 +13,7 @@ import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import { getStepIndex } from "./utils/flowSteps";
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
@@ -33,8 +34,8 @@ import {
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (after informational + name / `text`). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-size");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
@@ -211,7 +212,7 @@ function CreateFlowLayoutContent({
variant: "saveProgress",
nextPath:
returnToTemplateReview ??
`${pathname ?? "/create/informational"}?syncDraft=1`,
`${pathname ?? "/create"}?syncDraft=1`,
backdropVariant: "blurredYellow",
});
return;
@@ -236,7 +237,7 @@ function CreateFlowLayoutContent({
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = currentStep === "text";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
isTextStep && !isCompletedStep && !isRightRailStep
? "max-md:justify-center"
+6 -6
View File
@@ -6,9 +6,9 @@ import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./anonymousDraftStorage";
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { isValidStep } from "./utils/flowSteps";
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
import { saveDraftToServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
@@ -56,8 +56,8 @@ export function PostLoginDraftTransfer({
return;
}
const segment = pathname?.split("/").pop() ?? "";
const step = isValidStep(segment) ? segment : undefined;
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
@@ -100,8 +100,8 @@ export function PostLoginDraftTransfer({
return;
}
const segment = pathname?.split("/").pop() ?? "";
const step = isValidStep(segment) ? segment : undefined;
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
+1 -1
View File
@@ -8,7 +8,7 @@ import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./anonymousDraftStorage";
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { notFound } from "next/navigation";
import { use } from "react";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types";
interface PageProps {
params: Promise<{ screenId: string }>;
}
export default function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = use(params);
if (!isValidStep(raw)) {
notFound();
}
const screenId = raw as CreateFlowStep;
return <CreateFlowScreenView screenId={screenId} />;
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { notFound } from "next/navigation";
import { use } from "react";
import { VALID_STEPS } from "../utils/flowSteps";
interface PageProps {
params: Promise<{ step: string }>;
}
/**
* Dynamic route handler for create flow steps
*
* Handles all flow steps via dynamic routing: /create/[step]
* Validates step exists and renders appropriate template (placeholder for now)
*/
export default function CreateFlowStepPage({ params }: PageProps) {
const { step } = use(params);
// Validate step exists
if (!(VALID_STEPS as readonly string[]).includes(step)) {
notFound();
}
// Placeholder content - templates will be implemented in CR-51-55
return (
<div className="flex flex-1 max-md:items-start max-md:justify-start md:items-center md:justify-center">
<div className="text-center">
<h1 className="text-white text-2xl font-bold mb-4">
Create Flow Step: {step}
</h1>
<p className="text-gray-400">
Template implementation coming in CR-51 through CR-55
</p>
</div>
</div>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ import {
clearLegacyCreateFlowKeysOnce,
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "../anonymousDraftStorage";
} from "../utils/anonymousDraftStorage";
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
+7 -6
View File
@@ -3,7 +3,11 @@
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import type { CreateFlowStep } from "../types";
import { getNextStep, getPreviousStep, isValidStep } from "../utils/flowSteps";
import {
getNextStep,
getPreviousStep,
parseCreateFlowScreenFromPathname,
} from "../utils/flowSteps";
/**
* Options passed to navigation handlers (e.g. for blur before navigate)
@@ -20,8 +24,7 @@ const blurActiveElement = (): void => {
/**
* Hook for Create Rule Flow navigation.
*
* Must be used within the create flow (pathname like /create/[step]).
* Uses the current step from the URL and provides type-safe navigation.
* Resolves the active step from `/create/{screenId}` via {@link parseCreateFlowScreenFromPathname} (flowSteps).
*/
export function useCreateFlowNavigation(): {
currentStep: CreateFlowStep | null;
@@ -36,9 +39,7 @@ export function useCreateFlowNavigation(): {
const pathname = usePathname();
const router = useRouter();
const currentStep = (pathname?.split("/").pop() ??
null) as CreateFlowStep | null;
const validStep = isValidStep(currentStep) ? currentStep : null;
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
const nextStep = getNextStep(validStep);
const previousStep = getPreviousStep(validStep);
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { FIRST_STEP } from "./utils/flowSteps";
/** `/create` redirects to the first wizard step (Figma frame 1). */
export default function CreateIndexPage() {
redirect(`/create/${FIRST_STEP}`);
}
@@ -0,0 +1,75 @@
"use client";
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 { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CardsScreen } from "./card/CardsScreen";
import { RightRailScreen } from "./right-rail/RightRailScreen";
import { CompletedScreen } from "./completed/CompletedScreen";
/**
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/).
*/
export function CreateFlowScreenView({
screenId,
}: {
screenId: CreateFlowStep;
}): ReactNode {
switch (screenId) {
case "informational":
return <InformationalScreen />;
case "community-name":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
stateField="title"
maxLength={48}
/>
);
case "community-size":
return <CommunitySizeSelectScreen />;
case "community-context":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityContext"
stateField="communityContext"
maxLength={2000}
/>
);
case "community-structure":
return <CommunityStructureSelectScreen />;
case "community-upload":
return <CommunityUploadScreen />;
case "community-reflection":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityReflection"
stateField="communityReflection"
maxLength={2000}
/>
);
case "review":
return <CommunityReviewScreen />;
case "cards":
return <CardsScreen />;
case "right-rail":
return <RightRailScreen />;
case "confirm-stakeholders":
return <ConfirmStakeholdersScreen />;
case "final-review":
return <FinalReviewScreen />;
case "completed":
return <CompletedScreen />;
default: {
const _exhaustive: never = screenId;
return _exhaustive;
}
}
}
@@ -1,14 +1,14 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import CardStack from "../../components/utility/CardStack";
import Create from "../../components/modals/Create";
import TextArea from "../../components/controls/TextArea";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
@@ -37,10 +37,6 @@ const COMMUNICATION_CARD_ORDER = [
"7",
] as const;
/**
* Section with heading + info icon and an editable TextArea.
* This variant uses TextArea only (no TextInput); design is "Add Signal" / "Add Video Meetings".
*/
function CreateModalSection({
title,
value: _value,
@@ -75,7 +71,6 @@ function CreateModalSection({
);
}
/** Body for any "Add platform" modal: three editable sections (TextArea only). */
function AddPlatformModalContent({
platformCardId,
}: {
@@ -133,8 +128,7 @@ function isAddPlatformCard(cardId: string | null): boolean {
);
}
/** Create flow card stack step: compact grid with optional expand to full list. */
export default function CardsPage() {
export function CardsScreen() {
const m = useMessages();
const comm = m.create.communication;
const mdUp = useCreateFlowMdUp();
@@ -1,20 +1,16 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../components/modals/Alert";
import { useMessages } from "../../contexts/MessagesContext";
import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../../components/modals/Alert";
import { useMessages } from "../../../contexts/MessagesContext";
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
/**
* Completed create flow page.
* Figma: 20907-213286 (main), 18002-28017 (toast).
*/
export default function CompletedPage() {
export function CompletedScreen() {
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.completed;
@@ -40,7 +36,6 @@ export default function CompletedPage() {
if (!stored) return;
const parsed = parseDocumentSectionsForDisplay(stored.document);
if (parsed.length === 0) return;
// One-shot hydration from client-only storage after mount.
queueMicrotask(() => {
setDocumentSections(parsed);
setHeaderTitle(stored.title);
@@ -1,18 +1,13 @@
"use client";
import NumberedList from "../../components/type/NumberedList";
import { useTranslation } from "../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import NumberedList from "../../../components/type/NumberedList";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/**
* Informational page for the create flow
*
* Displays information about the create flow process using HeaderLockup and NumberedList components.
* Lockup sizing via `CreateFlowHeaderLockup`. NumberedList: S / M by breakpoint.
*/
export default function InformationalPage() {
/** Create Community — frame 1 (Figma 20094-16005). */
export function InformationalScreen() {
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.informational");
@@ -1,13 +1,13 @@
"use client";
import RuleCard from "../../components/cards/RuleCard";
import { useTranslation } from "../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import RuleCard from "../../../components/cards/RuleCard";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/** Mid-flow review step (after upload, before cards). */
export default function ReviewPage() {
/** Create Community — frame 8 (Figma 19706-12135); URL segment `review`. */
export function CommunityReviewScreen() {
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.review");
@@ -1,15 +1,15 @@
"use client";
import { useMemo } from "react";
import RuleCard from "../../components/cards/RuleCard";
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import RuleCard from "../../../components/cards/RuleCard";
import type { Category } from "../../../components/cards/RuleCard/RuleCard.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../components/CreateFlowLockupCardStepShell";
} from "../../components/CreateFlowLockupCardStepShell";
function buildFinalReviewCategories(
rows: { name: string; chips: string[] }[],
@@ -24,11 +24,7 @@ function buildFinalReviewCategories(
}));
}
/**
* Final review step (right before completed).
* Figma: 20907-212767 (full-size), 20976-220705 (below `md`).
*/
export default function FinalReviewPage() {
export function FinalReviewScreen() {
const { state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.finalReview");
@@ -1,19 +1,15 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../components/utility/DecisionMakingSidebar";
import CardStack from "../../components/utility/CardStack";
import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
/**
* Right Rail step of the create flow.
* Two-column layout (sidebar + card stack) at 640+, single column at 320-639.
*/
export default function RightRailPage() {
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
@@ -81,9 +77,7 @@ export default function RightRailPage() {
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full">
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12">
<div className="mx-auto grid h-auto min-h-0 w-full max-w-[1280px] shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-12 md:pb-8">
<div
className="flex min-w-0 flex-col items-stretch justify-start overflow-hidden md:justify-center"
>
<div className="flex min-w-0 flex-col items-stretch justify-start overflow-hidden md:justify-center">
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
@@ -0,0 +1,171 @@
"use client";
import { useState, useMemo, useEffect, type Dispatch, type SetStateAction } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
confirmState: "Unselected" | "Selected",
onInteraction?: () => void,
) {
const touch = () => onInteraction?.();
return {
onAddClick: () => {
touch();
setList((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
]);
},
onCustomChipConfirm: (chipId: string, value: string) => {
touch();
setList((prev) =>
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: confirmState }
: opt,
),
);
},
onCustomChipClose: (chipId: string) => {
touch();
setList((prev) => prev.filter((o) => o.id !== chipId));
},
};
}
function chipRowsFromLabels(
rows: readonly { label: string }[],
): ChipOption[] {
return rows.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "Unselected" as const,
}));
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "Selected")
.map((o) => o.id);
}
/** Create Community — frame 3 (Figma 20094-18244). */
export function CommunitySizeSelectScreen() {
const m = useMessages();
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.communitySize");
const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[]
>(() => {
const base = chipRowsFromLabels(m.create.communitySize.communitySizes);
const selected = new Set(state.selectedCommunitySizeIds ?? []);
return base.map((opt) => ({
...opt,
state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const),
}));
});
useEffect(() => {
const selected = new Set(state.selectedCommunitySizeIds ?? []);
setCommunitySizeOptions((prev) =>
prev.map((opt) =>
opt.state === "Custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
},
),
);
}, [state.selectedCommunitySizeIds]);
const communityCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setCommunitySizeOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const persistSelection = (next: ChipOption[]) => {
markCreateFlowInteraction();
setCommunitySizeOptions(next);
updateState({
selectedCommunitySizeIds: selectedIdsFromOptions(next),
});
};
const handleCommunitySizeClick = (chipId: string) => {
const next: ChipOption[] = communitySizeOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistSelection(next);
};
const multiLabel = t("multiSelect.label");
const addText = t("multiSelect.addButtonText");
const multiSelectBlock = (
<MultiSelect
label={multiLabel}
size="S"
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
{...communityCustomHandlers}
addButton={true}
addButtonText={addText}
/>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
{mdUp ? (
<div className="flex w-full max-w-[1280px] items-center justify-center gap-[var(--measures-spacing-1200,48px)]">
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start justify-center gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start gap-[var(--measures-spacing-800,32px)]">
{multiSelectBlock}
</div>
</div>
) : (
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-400,16px)]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
{multiSelectBlock}
</div>
)}
</CreateFlowStepShell>
);
}
@@ -3,16 +3,17 @@
import {
useState,
useMemo,
useEffect,
type Dispatch,
type SetStateAction,
} from "react";
import MultiSelect from "../../components/controls/MultiSelect";
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
@@ -55,40 +56,60 @@ function chipRowsFromLabels(
}));
}
/**
* Select page for the create flow
*
* Displays selection options using HeaderLockup and MultiSelect components.
* Responsive layout: two-column at `md` and up, single column below (see `--breakpoint-md` in `app/tailwind.css`).
* Lockup sizing via `CreateFlowHeaderLockup`. MultiSelect stays `S`.
*/
export default function SelectPage() {
const m = useMessages();
const { markCreateFlowInteraction } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.select");
function applySavedSelection(
options: ChipOption[],
saved: string[] | undefined,
): ChipOption[] {
const selected = new Set(saved ?? []);
return options.map((opt) =>
opt.state === "Custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
},
);
}
const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[]
>(() => chipRowsFromLabels(m.create.select.communitySizes));
/** Create Community — frame 5 (Figma 20094-41317). */
export function CommunityStructureSelectScreen() {
const m = useMessages();
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.communityStructure");
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
ChipOption[]
>(() => chipRowsFromLabels(m.create.select.organizationTypes));
>(() =>
applySavedSelection(
chipRowsFromLabels(m.create.communityStructure.organizationTypes),
state.selectedOrganizationTypeIds,
),
);
const [governanceStyleOptions, setGovernanceStyleOptions] = useState<
ChipOption[]
>(() => chipRowsFromLabels(m.create.select.governanceStyles));
const communityCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setCommunitySizeOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
>(() =>
applySavedSelection(
chipRowsFromLabels(m.create.communityStructure.governanceStyles),
state.selectedGovernanceStyleIds,
),
);
useEffect(() => {
setOrganizationTypeOptions((prev) =>
applySavedSelection(prev, state.selectedOrganizationTypeIds),
);
}, [state.selectedOrganizationTypeIds]);
useEffect(() => {
setGovernanceStyleOptions((prev) =>
applySavedSelection(prev, state.selectedGovernanceStyleIds),
);
}, [state.selectedGovernanceStyleIds]);
const organizationCustomHandlers = useMemo(
() =>
createListCustomHandlers(
@@ -108,46 +129,54 @@ export default function SelectPage() {
[markCreateFlowInteraction],
);
const handleCommunitySizeClick = (chipId: string) => {
const persistOrg = (next: ChipOption[]) => {
markCreateFlowInteraction();
setCommunitySizeOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
}
: opt,
),
);
setOrganizationTypeOptions(next);
updateState({
selectedOrganizationTypeIds: next
.filter((o) => o.state === "Selected")
.map((o) => o.id),
});
};
const persistGov = (next: ChipOption[]) => {
markCreateFlowInteraction();
setGovernanceStyleOptions(next);
updateState({
selectedGovernanceStyleIds: next
.filter((o) => o.state === "Selected")
.map((o) => o.id),
});
};
const handleOrganizationTypeClick = (chipId: string) => {
markCreateFlowInteraction();
setOrganizationTypeOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
}
: opt,
),
const next: ChipOption[] = organizationTypeOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistOrg(next);
};
const handleGovernanceStyleClick = (chipId: string) => {
markCreateFlowInteraction();
setGovernanceStyleOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
}
: opt,
),
const next: ChipOption[] = governanceStyleOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistGov(next);
};
const multiLabel = t("multiSelect.label");
@@ -155,15 +184,6 @@ export default function SelectPage() {
const multiSelectBlock = (
<>
<MultiSelect
label={multiLabel}
size="S"
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
{...communityCustomHandlers}
addButton={true}
addButtonText={addText}
/>
<MultiSelect
label={multiLabel}
size="S"
@@ -1,19 +1,15 @@
"use client";
import { useState } from "react";
import MultiSelect from "../../components/controls/MultiSelect";
import Alert from "../../components/modals/Alert";
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
import { useTranslation } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import MultiSelect from "../../../components/controls/MultiSelect";
import Alert from "../../../components/modals/Alert";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/**
* Confirm stakeholders step stacked lockup + MultiSelect (not split columns).
* Figma: 21104-46594.
*/
export default function ConfirmStakeholdersPage() {
export function ConfirmStakeholdersScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
const t = useTranslation("create.confirmStakeholders");
const [toastDismissed, setToastDismissed] = useState(false);
@@ -1,36 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import TextInput from "../../components/controls/TextInput";
import { useTranslation } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import TextInput from "../../../components/controls/TextInput";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
messageNamespace: string;
stateField: CreateFlowTextStateField;
maxLength: number;
};
/**
* Text page for the create flow
*
* Displays a text input field for user input using HeaderLockup and TextInput components.
* Lockup sizing via `CreateFlowHeaderLockup`. TextInput: small / medium by breakpoint.
* Below `md`, this step stays vertically centered in the main area (see `CreateFlowLayoutClient`).
* Shared narrow-column + TextInput pattern for Create Community text frames.
*/
export default function TextPage() {
export function CreateFlowTextFieldScreen({
messageNamespace,
stateField,
maxLength,
}: Props) {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.text");
const [value, setValue] = useState(() =>
typeof state.title === "string" ? state.title : "",
);
const t = useTranslation(messageNamespace);
const readFromState = (): string => {
const raw = state[stateField];
return typeof raw === "string" ? raw : "";
};
const [value, setValue] = useState(() => readFromState());
useEffect(() => {
const incoming = state.title;
if (typeof incoming !== "string" || incoming.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync controlled field when context hydrates from server/local
const incoming = readFromState();
if (incoming.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
setValue((prev) => (prev === "" ? incoming : prev));
}, [state.title]);
}, [state, stateField]);
const maxLength = 48;
const characterCount = value.length;
const hint = t("characterCountTemplate")
.replace("{current}", String(characterCount))
@@ -52,7 +62,7 @@ export default function TextPage() {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ title: v });
updateState({ [stateField]: v } as Record<string, string>);
}}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
@@ -1,27 +1,20 @@
"use client";
import Upload from "../../components/controls/Upload";
import { useTranslation } from "../../contexts/MessagesContext";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import Upload from "../../../components/controls/Upload";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/**
* Upload page for the create flow
*
* Displays upload functionality using HeaderLockup and Upload components.
* Responsive layout: centered at `md` and up, left-aligned below.
* Lockup sizing via `CreateFlowHeaderLockup`.
*/
export default function UploadPage() {
/** Create Community — frame 6 (Figma 20094-41524). */
export function CommunityUploadScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.upload");
const t = useTranslation("create.communityUpload");
const handleUploadClick = () => {
markCreateFlowInteraction();
// TODO: Handle upload button click (e.g. open file picker)
};
return (
+25 -5
View File
@@ -6,13 +6,17 @@
*/
/**
* Valid step IDs for the create rule flow
* Valid step IDs for the create rule flow (URL segment after `/create/`).
* Create Community order matches Figma; `review` closes that stage per design.
*/
export type CreateFlowStep =
| "informational"
| "text"
| "select"
| "upload"
| "community-name"
| "community-size"
| "community-context"
| "community-structure"
| "community-upload"
| "community-reflection"
| "review"
| "cards"
| "right-rail"
@@ -20,6 +24,13 @@ export type CreateFlowStep =
| "final-review"
| "completed";
/** String keys used by generic text-field steps for `CreateFlowState`. */
export type CreateFlowTextStateField =
| "title"
| "summary"
| "communityContext"
| "communityReflection";
/**
* Flow state for inputs across create-flow steps.
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
@@ -28,6 +39,15 @@ export type CreateFlowStep =
export interface CreateFlowState {
title?: string;
summary?: string;
/** Additional copy fields for multi-step Create Community text frames (Figma). */
communityContext?: string;
communityReflection?: string;
/** Selected chip ids from `community-size` (MultiSelect). */
selectedCommunitySizeIds?: string[];
/** Selected chip ids from `community-structure` (organization types). */
selectedOrganizationTypeIds?: string[];
/** Selected chip ids from `community-structure` (governance styles). */
selectedGovernanceStyleIds?: string[];
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
@@ -51,7 +71,7 @@ export interface CreateFlowContextValue {
clearState: () => void;
/**
* True after the user edits any template control (pages use local state until wired to `state`).
* Drives Save & Exit visibility together with `hasCreateFlowUserInput(state)`.
* Drives Save & Exit visibility together with hasCreateFlowUserInput (utils/hasCreateFlowUserInput.ts).
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
@@ -1,4 +1,4 @@
import type { CreateFlowState } from "./types";
import type { CreateFlowState } from "../types";
/** Anonymous in-progress create flow (local only until magic-link transfer). */
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
@@ -0,0 +1,123 @@
import type { CreateFlowStep } from "../types";
/**
* Figma layout families for the create flow (not encoded in the URL).
* Registry and `app/create/screens/` are organized by these kinds.
*/
export type CreateFlowLayoutKind =
| "informational"
| "text"
| "select"
| "upload"
| "review"
| "card"
| "right-rail"
| "completed";
export 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
> = {
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-18244",
messageNamespace: "create.communitySize",
centeredBodyBelowMd: false,
},
"community-context": {
layoutKind: "text",
figmaNodeId: "20094-41243",
messageNamespace: "create.communityContext",
centeredBodyBelowMd: true,
},
"community-structure": {
layoutKind: "select",
figmaNodeId: "20094-41317",
messageNamespace: "create.communityStructure",
centeredBodyBelowMd: false,
},
"community-upload": {
layoutKind: "upload",
figmaNodeId: "20094-41524",
messageNamespace: "create.communityUpload",
centeredBodyBelowMd: false,
},
"community-reflection": {
layoutKind: "text",
figmaNodeId: "20097-14948",
messageNamespace: "create.communityReflection",
centeredBodyBelowMd: true,
},
review: {
layoutKind: "review",
figmaNodeId: "19706-12135",
messageNamespace: "create.review",
centeredBodyBelowMd: false,
},
cards: {
layoutKind: "card",
figmaNodeId: "TBD-cards",
messageNamespace: "create.communication",
centeredBodyBelowMd: false,
},
"right-rail": {
layoutKind: "right-rail",
figmaNodeId: "TBD-right-rail",
messageNamespace: "create.rightRail",
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;
}
+27 -3
View File
@@ -2,6 +2,7 @@
* 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.
*/
import type { CreateFlowStep } from "../types";
@@ -11,9 +12,12 @@ import type { CreateFlowStep } from "../types";
*/
export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"informational",
"text",
"select",
"upload",
"community-name",
"community-size",
"community-context",
"community-structure",
"community-upload",
"community-reflection",
"review",
"cards",
"right-rail",
@@ -75,3 +79,23 @@ export function isValidStep(
(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;
}
@@ -1,4 +1,4 @@
import type { CreateFlowState } from "./types";
import type { CreateFlowState } from "../types";
const IGNORED_KEYS = new Set<string>(["currentStep"]);