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
+693
View File
@@ -0,0 +1,693 @@
"use client";
import {
Suspense,
useCallback,
useEffect,
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav";
import { getNextStep, getStepIndex } from "./utils/flowSteps";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import {
createFlowStepUsesCenteredTextLayout,
createFlowStepUsesCardLayout,
} from "./utils/createFlowScreenRegistry";
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
import Button from "../../components/buttons/Button";
import { buildPublishPayload } from "../../../lib/create/buildPublishPayload";
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
import {
fetchAuthSession,
publishRule,
requestMagicLink,
} from "../../../lib/create/api";
import { safeInternalPath } from "../../../lib/safeInternalPath";
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
fetchTemplateBySlug,
type RuleTemplateDto,
} from "../../../lib/create/fetchTemplates";
import messages from "../../../messages/en/index";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import Alert from "../../components/modals/Alert";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
>(undefined);
useEffect(() => {
let cancelled = false;
void fetchAuthSession().then(({ user }) => {
if (!cancelled) setSessionUser(user);
});
return () => {
cancelled = true;
};
}, []);
const sessionResolved = sessionUser !== undefined;
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
return (
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider>
);
}
function CreateFlowLayoutContent({
children,
sessionUser,
sessionResolved,
}: {
children: ReactNode;
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const { create } = useMessages();
const footer = create.footer;
const communitySaveMessages = create.communitySave;
const tLogin = useTranslation("pages.login");
const router = useRouter();
const pathname = usePathname();
const { openLogin } = useAuthModal();
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
);
const { state, clearState, updateState } = useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const [publishBannerMessage, setPublishBannerMessage] = useState<
string | null
>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
string | null
>(null);
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
useState(false);
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
string | null
>(null);
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
useState(false);
const templateReviewMatch = pathname?.match(
/\/create\/review-template\/([^/?#]+)/,
);
const templateReviewSlug = templateReviewMatch?.[1]
? decodeURIComponent(templateReviewMatch[1])
: null;
/** Match anywhere in path so locale/basePath variants still get template footer + layout. */
const isTemplateReviewRoute = Boolean(
pathname?.includes("/create/review-template/"),
);
const handleFinalize = useCallback(async () => {
setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state);
if (payloadResult.ok === false) {
setPublishBannerMessage(
payloadResult.error === "missingCommunityName"
? messages.create.publish.missingCommunityName
: payloadResult.error,
);
return;
}
const { title, summary, document: ruleDocument } = payloadResult;
setIsPublishing(true);
const publishResult = await publishRule({
title,
summary,
document: ruleDocument,
});
setIsPublishing(false);
if (publishResult.ok === true) {
writeLastPublishedRule({
id: publishResult.id,
title,
summary: summary ?? null,
document: ruleDocument,
});
router.push("/create/completed");
return;
}
if (publishResult.status === 401) {
openLogin({
variant: "default",
nextPath: "/create/final-review?syncDraft=1",
backdropVariant: "blurredYellow",
});
return;
}
setPublishBannerMessage(
publishResult.error.trim() !== ""
? publishResult.error
: messages.create.publish.genericPublishFailed,
);
}, [state, router, openLogin]);
const handleUseTemplateWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return;
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const result = await fetchTemplateBySlug(templateReviewSlug);
setIsApplyingTemplate(false);
if (result === null) {
setTemplateReviewApplyError(messages.create.templateReview.errors.notFound);
return;
}
if ("error" in result) {
setTemplateReviewApplyError(result.error);
return;
}
const template: RuleTemplateDto = result;
const doc = template.body;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
return;
}
const summaryRaw =
typeof template.description === "string"
? template.description.trim()
: "";
writeLastPublishedRule({
id: `template:${template.slug}`,
title: template.title,
summary: summaryRaw.length > 0 ? summaryRaw : null,
document: doc as Record<string, unknown>,
});
router.push("/create/completed");
}, [router, templateReviewSlug]);
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
clearState,
router,
user: sessionUser ?? null,
setDraftSaveBannerMessage,
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
: null;
openLogin({
variant: "saveProgress",
nextPath:
returnToTemplateReview ??
`${pathname ?? "/create"}?syncDraft=1`,
backdropVariant: "blurredYellow",
});
return;
}
if (!sessionUser) return;
await runAuthenticatedExit(opts);
};
useEffect(() => {
if (
sessionResolved &&
sessionUser &&
currentStep === "community-save"
) {
router.replace("/create/review");
}
}, [sessionResolved, sessionUser, currentStep, router]);
useEffect(() => {
if (currentStep !== "community-save") {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
setCommunitySaveMagicLinkSubmitting(false);
}
}, [currentStep]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail;
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!isValidCreateFlowSaveEmail(trimmed)) return;
setCommunitySaveMagicLinkSubmitting(true);
try {
const stepAfterSave = getNextStep("community-save");
const segment = stepAfterSave ?? "review";
const rawNext = `/create/${segment}?syncDraft=1`;
const nextPath = safeInternalPath(rawNext);
const result = await requestMagicLink(trimmed, nextPath);
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
setCommunitySaveMagicLinkError(
tLogin("errors.rateLimited").replace("{seconds}", String(seconds)),
);
} else {
setCommunitySaveMagicLinkError(
result.error || tLogin("errors.generic"),
);
}
return;
}
setTransferPendingFlag();
updateState({ communitySaveEmail: trimmed });
setCommunitySaveMagicLinkSuccess(true);
} catch {
setCommunitySaveMagicLinkError(tLogin("errors.network"));
} finally {
setCommunitySaveMagicLinkSubmitting(false);
}
}, [state.communitySaveEmail, tLogin, updateState]);
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "decision-approaches";
const isFinalReviewStep = currentStep === "final-review";
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 stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
isTextStep && !isCompletedStep && !isRightRailStep
? "max-md:justify-center"
: "max-md:justify-start";
const mainMaxMdCross = isCompletedStep
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
);
const footerPrimaryButtonClass =
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
const hasTopOverlays =
Boolean(draftSaveBannerMessage) ||
Boolean(publishBannerMessage) ||
Boolean(templateReviewApplyError) ||
Boolean(communitySaveMagicLinkError) ||
Boolean(communitySaveMagicLinkSuccess);
return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
{hasTopOverlays ? (
<div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
aria-live="polite"
>
{draftSaveBannerMessage ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={messages.create.topNav.draftSaveBannerTitle}
description={draftSaveBannerMessage}
onClose={() => setDraftSaveBannerMessage(null)}
className="w-full"
/>
</div>
) : null}
{publishBannerMessage ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={messages.create.publish.finalizeBannerTitle}
description={publishBannerMessage}
onClose={() => setPublishBannerMessage(null)}
className="w-full"
/>
</div>
) : null}
{templateReviewApplyError ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={messages.create.templateReview.errors.applyFailed}
description={templateReviewApplyError}
onClose={() => setTemplateReviewApplyError(null)}
className="w-full"
/>
</div>
) : null}
{communitySaveMagicLinkError ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={communitySaveMessages.magicLinkErrorTitle}
description={communitySaveMagicLinkError}
onClose={() => setCommunitySaveMagicLinkError(null)}
className="w-full"
/>
</div>
) : null}
{communitySaveMagicLinkSuccess ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="positive"
title={communitySaveMessages.magicLinkSuccessTitle}
description={communitySaveMessages.magicLinkSuccessDescription}
onClose={() => setCommunitySaveMagicLinkSuccess(false)}
className="w-full"
/>
</div>
) : null}
</div>
) : null}
<Suspense fallback={null}>
<SignedInDraftHydration
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
saveDraftOnExit={saveDraftOnExit}
onEdit={
isCompletedStep
? () => router.push("/create/final-review")
: undefined
}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={`shrink-0 ${
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
<main
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`}
>
{children}
</main>
{!isCompletedStep && (
<CreateFlowFooter
className="shrink-0"
progressBar={!isTemplateReviewRoute && !isFinalReviewStep}
proportionBarProgress={proportionBarProgress}
proportionBarVariant="segmented"
secondButton={
isTemplateReviewRoute ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="ghost"
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white"
onClick={() => void handleUseTemplateWithoutChanges()}
>
{messages.create.templateReview.footer.useWithoutChanges}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
title={
messages.create.templateReview.footer.customizeAriaHint
}
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
onClick={() => {
if (!templateReviewSlug) return;
// Preserve template slug for a future customize / prefill ticket (informational does not read it yet).
router.push(
`/create/informational?template=${encodeURIComponent(templateReviewSlug)}`,
);
}}
>
{messages.create.templateReview.footer.customize}
</Button>
</div>
) : currentStep === "community-name" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmName}
</Button>
) : currentStep === "community-save" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.saveLater}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
communitySaveMagicLinkSubmitting ||
communitySaveMagicLinkSuccess ||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
className={footerPrimaryButtonClass}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
>
{communitySaveMagicLinkSubmitting
? footer.submitEmailSending
: footer.submitEmail}
</Button>
</div>
) : currentStep === "review" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.createCustom}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
router.push("/templates");
}}
>
{footer.createFromTemplate}
</Button>
</div>
) : currentStep === "core-values" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedCoreValueIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmCoreValues}
</Button>
) : currentStep === "communication-methods" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedCommunicationMethodIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmCommunication}
</Button>
) : currentStep === "membership-methods" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedMembershipMethodIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmMembership}
</Button>
) : currentStep === "decision-approaches" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedDecisionApproachIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmRightRail}
</Button>
) : currentStep === "conflict-management" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedConflictManagementIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmConflictManagement}
</Button>
) : nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
if (currentStep === "final-review") {
void handleFinalize();
} else {
goToNextStep();
}
}}
>
{currentStep === "final-review"
? isPublishing
? messages.create.publish.finalizeButtonPublishing
: footer.finalizeCommunityRule
: currentStep === "confirm-stakeholders"
? footer.confirmStakeholders
: currentStep === "community-context"
? footer.confirmDescription
: currentStep === "community-structure"
? footer.confirmDetails
: currentStep === "community-size"
? footer.confirmMembers
: footer.next}
</Button>
) : null
}
onBackClick={
isTemplateReviewRoute
? () => router.push("/")
: previousStep
? goToPreviousStep
: undefined
}
/>
)}
</div>
);
}
export default function CreateFlowLayoutClient({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
const CreateFlowLayoutClient = dynamic(
() => import("./CreateFlowLayoutClient"),
{
ssr: false,
loading: () => (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label="Loading create flow"
/>
),
},
);
export default function CreateFlowLayoutGate({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowLayoutClient>{children}</CreateFlowLayoutClient>;
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
import { saveDraftToServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
* With backend sync: PUT draft once then hydrates context. Without sync: hydrates from
* `create-flow-anonymous` localStorage only (no server write).
*/
export function PostLoginDraftTransfer({
sessionUser,
}: {
sessionUser: { id: string; email: string } | null | undefined;
}) {
const { replaceState } = useCreateFlow();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const syncDraft = searchParams.get("syncDraft");
const [transferError, setTransferError] = useState<string | null>(null);
const attemptedRef = useRef(false);
useEffect(() => {
if (sessionUser == null || sessionUser === undefined) return;
const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag();
if (!wantsTransfer) return;
if (attemptedRef.current) return;
if (!SYNC_ENABLED) {
attemptedRef.current = true;
let cancelled = false;
void (async () => {
const local = readAnonymousCreateFlowState();
const pending = hasTransferPendingFlag();
if (Object.keys(local).length === 0 && !pending) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
};
if (cancelled) return;
clearAnonymousCreateFlowStorage();
replaceState(payload);
if (cancelled) return;
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
})();
return () => {
cancelled = true;
};
}
attemptedRef.current = true;
let cancelled = false;
void (async () => {
const local = readAnonymousCreateFlowState();
const pending = hasTransferPendingFlag();
if (Object.keys(local).length === 0 && !pending) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
};
const saveResult = await saveDraftToServer(payload);
if (cancelled) return;
if (saveResult.ok === false) {
setTransferError(
messages.create.topNav.postLoginSaveFailedWithReason.replace(
"{reason}",
saveResult.message,
),
);
attemptedRef.current = false;
return;
}
clearAnonymousCreateFlowStorage();
replaceState(payload);
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
})();
return () => {
cancelled = true;
};
}, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]);
if (!transferError) return null;
return (
<div
role="alert"
className="mx-auto max-w-[640px] px-5 py-3 text-center font-inter text-sm text-[var(--color-border-default-utility-negative)]"
>
{transferError}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context.
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
*
* **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
* chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
*/
export function SignedInDraftHydration({
sessionUser,
sessionResolved,
}: {
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const searchParams = useSearchParams();
const syncDraftParam = searchParams.get("syncDraft");
const { replaceState, interactionTouched } = useCreateFlow();
const touchedRef = useRef(interactionTouched);
touchedRef.current = interactionTouched;
const [loadingHydration, setLoadingHydration] = useState(false);
const finishedUserIdRef = useRef<string | null>(null);
useEffect(() => {
if (!SYNC_ENABLED) return;
if (!sessionResolved) return;
if (sessionUser == null || sessionUser === undefined) {
finishedUserIdRef.current = null;
return;
}
const userId = sessionUser.id;
if (finishedUserIdRef.current === userId) return;
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false;
setLoadingHydration(true);
void (async () => {
try {
const serverDraft = await fetchDraftFromServer();
if (cancelled) return;
const localDraft = readAnonymousCreateFlowState();
const hasServer =
serverDraft != null && createFlowStateHasKeys(serverDraft);
const hasLocal = createFlowStateHasKeys(localDraft);
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
if (hasServer && hasLocal) {
const useAccount =
typeof window !== "undefined" &&
window.confirm(messages.create.draftHydration.conflictPrompt);
if (cancelled) return;
if (useAccount) {
replaceState(serverDraft as CreateFlowState);
} else {
replaceState(localDraft);
}
clearAnonymousCreateFlowStorage();
finishedUserIdRef.current = userId;
return;
}
if (hasServer) {
replaceState(serverDraft as CreateFlowState);
clearAnonymousCreateFlowStorage();
finishedUserIdRef.current = userId;
return;
}
if (hasLocal) {
replaceState(localDraft);
clearAnonymousCreateFlowStorage();
}
finishedUserIdRef.current = userId;
} finally {
if (!cancelled) setLoadingHydration(false);
}
})();
return () => {
cancelled = true;
};
}, [sessionResolved, sessionUser, syncDraftParam, replaceState]);
if (!loadingHydration) return null;
return (
<div
role="status"
aria-live="polite"
className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] py-[var(--spacing-measures-spacing-200,8px)] md:px-[var(--measures-spacing-1800,64px)] text-center font-inter text-sm text-[var(--color-text-default-secondary,#a3a3a3)]"
>
{messages.create.draftHydration.loadingSavedProgress}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types";
/**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
*
* Only **canonical** `screenId` values from `CreateFlowStep` are valid. Old placeholder
* segments from pre-product shells are not redirected — unknown slugs `notFound()`.
*/
interface PageProps {
params: Promise<{ screenId: string }>;
}
export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params;
if (!isValidStep(raw)) {
notFound();
}
return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
}
@@ -0,0 +1,142 @@
"use client";
/**
* Shared "Applicable Scope" field used by the `decision-approaches` and
* `conflict-management` create flow modals. Pairs an `InputLabel` with a
* horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
* that reveals a pill text input for creating new scope values.
*/
import { memo, useState } from "react";
import Chip from "../../../components/controls/Chip";
import InputLabel from "../../../components/utility/InputLabel";
export interface ApplicableScopeFieldProps {
/** Label rendered above the capsule row. */
label: string;
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
addLabel: string;
/**
* The full list of chip values shown to the user. Each value is a unique
* string (chip label).
*/
scopes: string[];
/** Values currently toggled on (rendered in the Chip "Selected" state). */
selectedScopes: string[];
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
onToggleScope: (_scope: string) => void;
/**
* Fired when the user submits a new scope via the inline input. Duplicate
* values (already in `scopes`) are filtered out before the callback fires.
*/
onAddScope: (_scope: string) => void;
/**
* Optional placeholder for the inline input. Defaults to `addLabel`.
*/
inputPlaceholder?: string;
className?: string;
}
function ApplicableScopeFieldComponent({
label,
addLabel,
scopes,
selectedScopes,
onToggleScope,
onAddScope,
inputPlaceholder,
className = "",
}: ApplicableScopeFieldProps) {
const [draft, setDraft] = useState("");
const [isAdding, setIsAdding] = useState(false);
const submitDraft = () => {
const trimmed = draft.trim();
if (!trimmed) {
setIsAdding(false);
setDraft("");
return;
}
if (!scopes.includes(trimmed)) {
onAddScope(trimmed);
}
setDraft("");
setIsAdding(false);
};
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
return (
<Chip
key={scope}
label={scope}
state={isSelected ? "selected" : "disabled"}
palette="default"
size="s"
disabled={false}
onClick={() => onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
/>
);
})}
{isAdding ? (
<input
type="text"
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={submitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitDraft();
} else if (e.key === "Escape") {
setDraft("");
setIsAdding(false);
}
}}
placeholder={inputPlaceholder ?? addLabel}
aria-label={inputPlaceholder ?? addLabel}
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
/>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<AddGlyph />
{addLabel}
</button>
)}
</div>
</div>
);
}
function AddGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="block size-[14px]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
export default memo(ApplicableScopeFieldComponent);
@@ -0,0 +1,22 @@
"use client";
import HeaderLockup from "../../../components/type/HeaderLockup";
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
/** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */
size?: HeaderLockupProps["size"];
};
/**
* Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly.
*/
export function CreateFlowHeaderLockup({
size: sizeProp,
...rest
}: CreateFlowHeaderLockupProps) {
const mdUp = useCreateFlowMdUp();
const size = sizeProp ?? (mdUp ? "L" : "M");
return <HeaderLockup {...rest} size={size} />;
}
@@ -0,0 +1,49 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens";
/** Shared `RuleCard` / template card chrome: width + radius; padding comes from `RuleCard` (L+expanded = 24px). */
export const CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS =
"w-full min-w-0 rounded-[12px] md:rounded-[24px] md:max-w-[640px]";
type CreateFlowLockupCardStepShellProps = {
lockupTitle: string;
lockupDescription?: string;
children: ReactNode;
};
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */
export function CreateFlowLockupCardStepShell({
lockupTitle,
lockupDescription,
children,
}: CreateFlowLockupCardStepShellProps) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex min-w-0 flex-col justify-start md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={lockupTitle}
description={lockupDescription}
justification="left"
/>
</div>
<div
className={`flex min-w-0 flex-col items-stretch ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
export type CreateFlowStepShellVariant =
| "centeredNarrow"
| "centeredNarrowBottomPad"
| "wideGrid"
| "wideGridLoosePadding"
| "bare";
/** Semantic top padding below create-flow top nav (applied at all breakpoints; name is legacy). */
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
centeredNarrow:
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
centeredNarrowBottomPad:
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
wideGridLoosePadding:
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
bare: "w-full min-w-0",
};
const contentTopBelowMdClass: Record<CreateFlowContentTopBelowMd, string> = {
none: "",
"space-1400": "pt-[var(--space-1400)]",
"space-800": "pt-[var(--space-800)]",
};
interface CreateFlowStepShellProps {
children: ReactNode;
variant?: CreateFlowStepShellVariant;
/** Top spacing below top chrome (`CreateFlowTextFieldScreen` defaults to `space-1400`). */
contentTopBelowMd?: CreateFlowContentTopBelowMd;
className?: string;
}
/**
* Shared horizontal padding and width constraints for create-flow step pages.
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
*/
export function CreateFlowStepShell({
children,
variant = "centeredNarrow",
contentTopBelowMd = "none",
className = "",
}: CreateFlowStepShellProps) {
const topClass = contentTopBelowMdClass[contentTopBelowMd];
return (
<div
className={`${outerByVariant[variant]} ${topClass} ${className}`.trim()}
>
{children}
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
interface CreateFlowTwoColumnSelectShellProps {
header: ReactNode;
children: ReactNode;
/**
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
* (Figma Flow — Right Rail).
*/
contentTopBelowMd?: CreateFlowContentTopBelowMd;
/**
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
*/
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
}
/**
* Two-column layout for create-flow select steps (community size/structure, core values) and
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
*/
export function CreateFlowTwoColumnSelectShell({
header,
children,
contentTopBelowMd = "space-1400",
lgVerticalAlign = "center",
}: CreateFlowTwoColumnSelectShellProps) {
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
const rowLgCrossAlignClass =
lgVerticalAlign === "start" ? "lg:items-stretch" : "lg:items-center";
const leftLgMainJustifyClass =
lgVerticalAlign === "start" ? "lg:justify-start" : "lg:justify-center";
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd={contentTopBelowMd}
className={
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
"w-full min-w-0 max-lg:flex-none lg:min-h-0 lg:h-full lg:max-h-full lg:flex-1 lg:overflow-hidden lg:items-stretch lg:self-stretch"
}
>
<div
className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
}
>
<div
className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
}
>
{header}
</div>
<div
className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,70 @@
"use client";
/**
* Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
* appearance — matching the Figma "Control / Text Area" pattern.
*/
import { memo, useId } from "react";
import TextArea from "../../../components/controls/TextArea";
import InputLabel from "../../../components/utility/InputLabel";
export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */
label: string;
/** Show the help "?" icon next to the label (default `true`). */
helpIcon?: boolean;
/** Current text value. */
value: string;
/** Fired on every change with the new value (no event). */
onChange: (_value: string) => void;
/** Optional rows for the underlying `<textarea>` (default 4). */
rows?: number;
/** Optional placeholder. */
placeholder?: string;
/** Disable the field. */
disabled?: boolean;
className?: string;
}
function ModalTextAreaFieldComponent({
label,
helpIcon = true,
value,
onChange,
rows = 4,
placeholder,
disabled = false,
className = "",
}: ModalTextAreaFieldProps) {
const labelId = useId();
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<div id={labelId}>
<InputLabel
label={label}
helpIcon={helpIcon}
size="s"
palette="default"
/>
</div>
<TextArea
formHeader={false}
value={value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={rows}
appearance="embedded"
placeholder={placeholder}
disabled={disabled}
aria-labelledby={labelId}
/>
</div>
);
}
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
export default memo(ModalTextAreaFieldComponent);
@@ -0,0 +1,18 @@
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
"w-full min-w-0 md:max-w-[640px]";
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
/**
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
* Cardcard gap uses `gap-2` in `CardStack` (same on mobile and md+).
*/
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
"w-full min-w-0 md:max-w-[min(100%,860px)]";
@@ -0,0 +1,153 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import type {
CreateFlowState,
CreateFlowContextValue,
CreateFlowStep,
} from "../types";
import {
clearAnonymousCreateFlowStorage,
clearLegacyCreateFlowKeysOnce,
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "../utils/anonymousDraftStorage";
import {
clearCoreValueDetailsLocalStorage,
readCoreValueDetailsFromLocalStorage,
writeCoreValueDetailsToLocalStorage,
} from "../utils/coreValueDetailsLocalStorage";
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
interface CreateFlowProviderProps {
children: ReactNode;
initialStep?: CreateFlowStep | null;
/**
* When true (signed-out, session resolved), load/sync `create-flow-anonymous` in localStorage.
* When false, in-memory only (authenticated fresh create).
*/
enableAnonymousPersistence?: boolean;
}
/**
* Create flow state. Anonymous users mirror state to localStorage; authenticated users stay in memory.
*/
export function CreateFlowProvider({
children,
initialStep = null,
enableAnonymousPersistence = false,
}: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() => {
const base = enableAnonymousPersistence
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
if (Object.keys(storedDetails).length === 0) return base;
return {
...base,
coreValueDetailsByChipId: {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
},
};
});
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableAnonymousPersistence);
useEffect(() => {
clearLegacyCreateFlowKeysOnce();
}, []);
// Session resolved as guest after initial paint: hydrate from localStorage if still empty.
useEffect(() => {
if (!enableAnonymousPersistence) {
prevPersistRef.current = false;
return;
}
const wasOff = !prevPersistRef.current;
prevPersistRef.current = true;
if (!wasOff) return;
const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on
setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from }));
}, [enableAnonymousPersistence]);
useEffect(() => {
if (!enableAnonymousPersistence) return;
writeAnonymousCreateFlowState(state);
}, [state, enableAnonymousPersistence]);
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
useEffect(() => {
writeCoreValueDetailsToLocalStorage(state.coreValueDetailsByChipId);
}, [state.coreValueDetailsByChipId]);
const markCreateFlowInteraction = useCallback(() => {
setInteractionTouched(true);
}, []);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => {
const merged: CreateFlowState = { ...prevState, ...updates };
if (updates.communityStructureChipSnapshots !== undefined) {
merged.communityStructureChipSnapshots = {
...(prevState.communityStructureChipSnapshots ?? {}),
...updates.communityStructureChipSnapshots,
};
}
if (updates.coreValueDetailsByChipId !== undefined) {
merged.coreValueDetailsByChipId = {
...(prevState.coreValueDetailsByChipId ?? {}),
...updates.coreValueDetailsByChipId,
};
}
return merged;
});
}, []);
const replaceState = useCallback((next: CreateFlowState) => {
setState(next);
}, []);
const clearState = useCallback(() => {
setState({});
setInteractionTouched(false);
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
}, []);
const contextValue: CreateFlowContextValue = {
state,
currentStep,
updateState,
replaceState,
clearState,
interactionTouched,
markCreateFlowInteraction,
};
return (
<CreateFlowContext.Provider value={contextValue}>
{children}
</CreateFlowContext.Provider>
);
}
export function useCreateFlow(): CreateFlowContextValue {
const context = useContext(CreateFlowContext);
if (!context) {
throw new Error("useCreateFlow must be used within CreateFlowProvider");
}
return context;
}
@@ -0,0 +1,51 @@
"use client";
import {
createContext,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
type CreateFlowDraftSaveBannerContextValue = {
draftSaveBannerMessage: string | null;
setDraftSaveBannerMessage: (_message: string | null) => void;
};
const CreateFlowDraftSaveBannerContext =
createContext<CreateFlowDraftSaveBannerContextValue | null>(null);
export function CreateFlowDraftSaveBannerProvider({
children,
}: {
children: ReactNode;
}) {
const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState<
string | null
>(null);
const value = useMemo(
() => ({
draftSaveBannerMessage,
setDraftSaveBannerMessage,
}),
[draftSaveBannerMessage],
);
return (
<CreateFlowDraftSaveBannerContext.Provider value={value}>
{children}
</CreateFlowDraftSaveBannerContext.Provider>
);
}
export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue {
const ctx = useContext(CreateFlowDraftSaveBannerContext);
if (!ctx) {
throw new Error(
"useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider",
);
}
return ctx;
}
@@ -0,0 +1,65 @@
"use client";
import { useCallback } from "react";
import type { CreateFlowState, CreateFlowStep } from "../types";
import { saveDraftToServer } from "../../../../lib/create/api";
import messages from "../../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
export type CreateFlowExitClearState = () => void;
type AppRouterLike = { push: (_href: string) => void };
/**
* Leave the create flow for a **signed-in** user. Caller must not invoke for anonymous users.
*/
export function useCreateFlowExit({
state,
currentStep,
clearState,
router,
user,
setDraftSaveBannerMessage,
}: {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
clearState: CreateFlowExitClearState;
router: AppRouterLike;
user: { id: string; email: string } | null;
/** When save fails, surface the server message in the create shell banner (no leave confirm). */
setDraftSaveBannerMessage?: (_message: string | null) => void;
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
return useCallback(
async (options?: { saveDraft?: boolean }) => {
if (!user) return;
const saveDraft = options?.saveDraft ?? false;
if (!saveDraft && typeof window !== "undefined") {
const confirmed = window.confirm(
messages.create.topNav.leaveConfirmLoss,
);
if (!confirmed) return;
}
if (saveDraft && SYNC_ENABLED) {
const payload: CreateFlowState = {
...state,
...(currentStep ? { currentStep } : {}),
};
const result = await saveDraftToServer(payload);
if (result.ok === true) {
setDraftSaveBannerMessage?.(null);
} else {
setDraftSaveBannerMessage?.(result.message);
return;
}
}
clearState();
router.push("/");
},
[state, currentStep, clearState, router, user, setDraftSaveBannerMessage],
);
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
/** True at viewport ≥1024px (e.g. review grid column split with Tailwind `lg:`). */
export function useCreateFlowLgUp(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isLgOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_LG);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isLgOrLarger;
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
/** True at viewport ≥640px (pairs with Tailwind `md:` on create-flow screens). */
export function useCreateFlowMdUp(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isMdOrLarger;
}
@@ -0,0 +1,85 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import type { CreateFlowStep } from "../types";
import {
type CreateFlowNavigationOptions,
getNextStep,
getPreviousStep,
parseCreateFlowScreenFromPathname,
} from "../utils/flowSteps";
/**
* Options passed to navigation handlers (e.g. for blur before navigate)
*/
const blurActiveElement = (): void => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
};
/**
* Hook for Create Rule Flow navigation.
*
* Resolves the active step from `/create/{screenId}` via {@link parseCreateFlowScreenFromPathname} (flowSteps).
*/
export function useCreateFlowNavigation(
options?: CreateFlowNavigationOptions,
): {
currentStep: CreateFlowStep | null;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
nextStep: CreateFlowStep | null;
previousStep: CreateFlowStep | null;
} {
const pathname = usePathname();
const router = useRouter();
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
const nextStep = getNextStep(validStep, options);
const previousStep = getPreviousStep(validStep, options);
const goToNextStep = useCallback(() => {
blurActiveElement();
if (nextStep) {
router.push(`/create/${nextStep}`);
}
}, [router, nextStep]);
const goToPreviousStep = useCallback(() => {
blurActiveElement();
if (previousStep) {
router.push(`/create/${previousStep}`);
}
}, [router, previousStep]);
const goToStep = useCallback(
(step: CreateFlowStep) => {
blurActiveElement();
router.push(`/create/${step}`);
},
[router],
);
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
const canGoBack = useCallback(() => previousStep !== null, [previousStep]);
return {
currentStep: validStep,
goToNextStep,
goToPreviousStep,
goToStep,
canGoNext,
canGoBack,
nextStep,
previousStep,
};
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
}
+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,117 @@
"use client";
import { use, useEffect, useState } from "react";
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
import { useTranslation } from "../../../../contexts/MessagesContext";
import {
fetchTemplateBySlug,
isTemplatesFetchAborted,
type RuleTemplateDto,
} from "../../../../../lib/create/fetchTemplates";
import messages from "../../../../../messages/en/index";
import Alert from "../../../../components/modals/Alert";
import {
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
interface PageProps {
params: Promise<{ slug: string }>;
}
/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */
export default function ReviewTemplatePage({ params }: PageProps) {
const { slug: rawSlug } = use(params);
const slug = decodeURIComponent(rawSlug);
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.templateReview");
const [template, setTemplate] = useState<RuleTemplateDto | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const ac = new AbortController();
let cancelled = false;
void (async () => {
if (!cancelled) {
setLoading(true);
setError(null);
}
try {
const result = await fetchTemplateBySlug(slug, {
signal: ac.signal,
});
if (cancelled) return;
if (result === null) {
setError(messages.create.templateReview.errors.notFound);
setTemplate(null);
} else if ("error" in result) {
setError(result.error);
setTemplate(null);
} else {
setTemplate(result);
setError(null);
}
} catch (e) {
if (cancelled || isTemplatesFetchAborted(e)) return;
setError(messages.create.templateReview.errors.loadFailed);
setTemplate(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
ac.abort();
};
}, [slug]);
if (loading) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
{t("loading")}
</p>
</div>
</CreateFlowStepShell>
);
}
if (error || !template) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div
className={`flex shrink-0 flex-col gap-4 pb-8 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<Alert
type="banner"
status="danger"
title={t("errors.loadFailed")}
description={error ?? t("errors.notFound")}
className="w-full"
/>
</div>
</CreateFlowStepShell>
);
}
return (
<CreateFlowLockupCardStepShell
lockupTitle={t("intro.title")}
lockupDescription={t("intro.description")}
>
<TemplateReviewCard
template={template}
ruleCardClassName={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
size={mdUp ? "L" : "M"}
/>
</CreateFlowLockupCardStepShell>
);
}
@@ -0,0 +1,93 @@
"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 { 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";
/**
* Maps each wizard `screenId` to its screen component.
*
* **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`.
*/
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-structure":
return <CommunityStructureSelectScreen />;
case "community-context":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityContext"
stateField="communityContext"
maxLength={48}
mainAlign="center"
/>
);
case "community-size":
return <CommunitySizeSelectScreen />;
case "community-upload":
return <CommunityUploadScreen />;
case "community-save":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communitySave"
stateField="communitySaveEmail"
maxLength={254}
mainAlign="center"
inputType="email"
showCharacterCount={false}
headerJustification="center"
/>
);
case "review":
return <CommunityReviewScreen />;
case "core-values":
return <CoreValuesSelectScreen />;
case "communication-methods":
return <CommunicationMethodsScreen />;
case "membership-methods":
return <MembershipMethodsScreen />;
case "decision-approaches":
return <DecisionApproachesScreen />;
case "conflict-management":
return <ConflictManagementScreen />;
case "confirm-stakeholders":
return <ConfirmStakeholdersScreen />;
case "final-review":
return <FinalReviewScreen />;
case "completed":
return <CompletedScreen />;
default: {
const _exhaustive: never = screenId;
return _exhaustive;
}
}
}
@@ -0,0 +1,266 @@
"use client";
/**
* `communication-methods` step — Figma “Flow — Compact Card Stack” (node `20246-15828`).
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
*
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*/
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 InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
const SECTION_FIELDS = [
"corePrinciple",
"logisticsAdmin",
"codeOfConduct",
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const COMMUNICATION_CARD_ORDER = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
"4",
"5",
"6",
"7",
] as const;
function AddPlatformModalContent({
platformCardId,
}: {
platformCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const comm = m.create.communication;
const modal =
platformCardId in comm.modals
? comm.modals[platformCardId as keyof typeof comm.modals]
: null;
const defaults = modal?.sections ?? {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
};
const [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
>(() => ({
corePrinciple: defaults.corePrinciple,
logisticsAdmin: defaults.logisticsAdmin,
codeOfConduct: defaults.codeOfConduct,
}));
const updateSection = useCallback(
(key: SectionField, value: string) => {
markCreateFlowInteraction();
setSectionValues((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={comm.sectionHeadings[field]}
rows={6}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
))}
</div>
);
}
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedCommunicationMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
COMMUNICATION_CARD_ORDER.map((id) => {
const row = comm.cards[id as keyof typeof comm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[comm],
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded ? (
comm.page.expandedDescription
) : (
<>
{comm.page.compactDescriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{comm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{comm.page.compactDescriptionAfter}
</>
);
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
if (pendingCardId in comm.modals) {
const modal = comm.modals[pendingCardId as keyof typeof comm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in comm.cards
? comm.cards[pendingCardId as keyof typeof comm.cards]
: null;
return {
title: cardRow?.label ?? comm.confirmModal.title,
description: cardRow?.supportText ?? comm.confirmModal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={3}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddPlatformModalContent
key={pendingCardId}
platformCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,296 @@
"use client";
/**
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
*
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four
* controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration
* & Fallbacks. Section defaults are sourced from
* `messages/en/create/conflictManagement.json` and will be replaced with DB-driven
* content; labels are hard-coded per the Figma design.
*/
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 InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONFLICT_CARD_ORDER = [
"peer-mediation",
"conflict-resolution-council",
"facilitated-negotiation",
"ad-hoc-arbitration",
"conflict-workshops",
"6",
"7",
"8",
] as const;
type ConflictModalSections = {
corePrinciple: string;
applicableScope: string[];
selectedApplicableScope: string[];
processProtocol: string;
restorationFallbacks: string;
};
function AddConflictApproachModalContent({
approachCardId,
}: {
approachCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const cm = m.create.conflictManagement;
const modal =
approachCardId in cm.modals
? cm.modals[approachCardId as keyof typeof cm.modals]
: null;
const modalSections = modal?.sections;
const defaults: ConflictModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
selectedApplicableScope: [],
processProtocol: modalSections?.processProtocol ?? "",
restorationFallbacks: modalSections?.restorationFallbacks ?? "",
};
const [sections, setSections] = useState<ConflictModalSections>(() => ({
corePrinciple: defaults.corePrinciple,
applicableScope: [...defaults.applicableScope],
selectedApplicableScope: [...defaults.selectedApplicableScope],
processProtocol: defaults.processProtocol,
restorationFallbacks: defaults.restorationFallbacks,
}));
const patch = useCallback(
<K extends keyof ConflictModalSections>(
key: K,
value: ConflictModalSections[K],
) => {
markCreateFlowInteraction();
setSections((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={cm.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={cm.sectionHeadings.applicableScope}
addLabel={cm.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
sections.selectedApplicableScope.includes(scope)
? sections.selectedApplicableScope.filter((s) => s !== scope)
: [...sections.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...sections.applicableScope, scope])
}
/>
<ModalTextAreaField
label={cm.sectionHeadings.processProtocol}
value={sections.processProtocol}
onChange={(v) => patch("processProtocol", v)}
/>
<ModalTextAreaField
label={cm.sectionHeadings.restorationFallbacks}
value={sections.restorationFallbacks}
onChange={(v) => patch("restorationFallbacks", v)}
/>
</div>
);
}
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.conflictManagement;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedConflictManagementIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedConflictManagementIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
CONFLICT_CARD_ORDER.map((id) => {
const row = cm.cards[id as keyof typeof cm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[cm],
);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
const description = expanded ? (
cm.page.expandedDescription
) : (
<>
{cm.page.compactDescriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{cm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{cm.page.compactDescriptionAfter}
</>
);
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
if (pendingCardId in cm.modals) {
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in cm.cards
? cm.cards[pendingCardId as keyof typeof cm.cards]
: null;
return {
title: cardRow?.label ?? cm.confirmModal.title,
description: cardRow?.supportText ?? cm.confirmModal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddConflictApproachModalContent
key={pendingCardId}
approachCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,265 @@
"use client";
/**
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
*
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections — Eligibility & Philosophy, Joining Process, and
* Expectations & Removal. Section defaults come from
* `messages/en/create/membership.json` and will be replaced with DB-driven
* content.
*/
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 InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const SECTION_FIELDS = [
"eligibility",
"joiningProcess",
"expectations",
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const MEMBERSHIP_CARD_ORDER = [
"open-access",
"orientation-required",
"invitation-only",
"contribution-based",
"mentorship",
"6",
"7",
"8",
] as const;
function AddMembershipModalContent({
membershipCardId,
}: {
membershipCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const mem = m.create.membership;
const modal =
membershipCardId in mem.modals
? mem.modals[membershipCardId as keyof typeof mem.modals]
: null;
const defaults = modal?.sections ?? {
eligibility: "",
joiningProcess: "",
expectations: "",
};
const [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
>(() => ({
eligibility: defaults.eligibility,
joiningProcess: defaults.joiningProcess,
expectations: defaults.expectations,
}));
const updateSection = useCallback(
(key: SectionField, value: string) => {
markCreateFlowInteraction();
setSectionValues((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={mem.sectionHeadings[field]}
rows={6}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
))}
</div>
);
}
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.membership;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedMembershipMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedMembershipMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
MEMBERSHIP_CARD_ORDER.map((id) => {
const row = mem.cards[id as keyof typeof mem.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[mem],
);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
const description = expanded ? (
mem.page.expandedDescription
) : (
<>
{mem.page.compactDescriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{mem.page.compactDescriptionLinkLabel}
</InlineTextButton>
{mem.page.compactDescriptionAfter}
</>
);
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
if (pendingCardId in mem.modals) {
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in mem.cards
? mem.cards[pendingCardId as keyof typeof mem.cards]
: null;
return {
title: cardRow?.label ?? mem.confirmModal.title,
description: cardRow?.supportText ?? mem.confirmModal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddMembershipModalContent
key={pendingCardId}
membershipCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,108 @@
"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 {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
export function CompletedScreen() {
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.completed;
const fallbackSections = useMemo(
() =>
[...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[],
[completed.fallbackDocumentSections],
);
const [toastDismissed, setToastDismissed] = useState(false);
const [headerTitle, setHeaderTitle] = useState(
() => completed.fallbackTitle,
);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
>(() => completed.fallbackDescription);
const [documentSections, setDocumentSections] =
useState<CommunityRuleDocumentSection[]>(fallbackSections);
useEffect(() => {
const stored = readLastPublishedRule();
if (!stored) return;
const parsed = parseDocumentSectionsForDisplay(stored.document);
if (parsed.length === 0) return;
queueMicrotask(() => {
setDocumentSections(parsed);
setHeaderTitle(stored.title);
const sum =
typeof stored.summary === "string" ? stored.summary.trim() : "";
setHeaderDescription(sum.length > 0 ? sum : undefined);
});
}, []);
const toast = !toastDismissed ? (
<div
className="fixed bottom-0 left-0 right-0 z-10 w-full"
role="status"
aria-live="polite"
>
<Alert
type="toast"
status="default"
title={completed.toastTitle}
description={completed.toastDescription}
hasLeadingIcon
hasBodyText
onClose={() => setToastDismissed(true)}
className="w-full"
/>
</div>
) : null;
return (
<>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col justify-start overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={headerTitle}
description={headerDescription}
justification="left"
size="L"
palette="inverse"
/>
</div>
<div
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
aria-hidden
/>
<div className="w-full min-w-0 py-0 md:pb-8">
<CommunityRuleDocument
sections={documentSections}
useCardStyle={!mdUp}
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
/>
</div>
</div>
</div>
</div>
{toast}
</>
);
}
@@ -0,0 +1,67 @@
"use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
/**
* Create Community — frame 1 (Figma [20094-16005](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20094-16005)).
* URL: /create/informational
*/
export function InformationalScreen() {
const mdUp = useCreateFlowMdUp();
const copy = useMessages().create.informational;
const items = [
{
title: copy.steps["0"].title,
description: copy.steps["0"].description,
},
{
title: copy.steps["1"].title,
description: copy.steps["1"].description,
},
{
title: copy.steps["2"].title,
description: copy.steps["2"].description,
},
];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-inter font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-center gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<CreateFlowHeaderLockup
title={copy.title}
description={description}
justification="left"
/>
<NumberedList items={items} size={mdUp ? "M" : "S"} />
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,61 @@
"use client";
import RuleCard from "../../../../components/cards/RuleCard";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.review");
const { state } = useCreateFlow();
const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim()
: t("ruleCard.title");
const cardDescription =
typeof state.communityContext === "string" &&
state.communityContext.trim().length > 0
? state.communityContext.trim()
: t("ruleCard.description");
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-1400"
>
<div
className={`flex w-full min-w-0 flex-col items-center gap-6 lg:mx-auto lg:w-full lg:grid lg:grid-cols-2 lg:items-center lg:justify-items-center lg:gap-x-[var(--measures-spacing-1200,48px)] lg:gap-y-6 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col justify-center lg:min-h-[212px] ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
/>
</div>
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}>
<RuleCard
title={cardTitle}
description={cardDescription}
size={lgUp ? "L" : "M"}
expanded={false}
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={cardTitle}
className="rounded-[24px]"
/>
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,68 @@
"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 {
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
function buildFinalReviewCategories(
rows: { name: string; chips: string[] }[],
): Category[] {
return rows.map((cat) => ({
name: cat.name,
chipOptions: cat.chips.map((label, idx) => ({
id: `${cat.name}-${idx}`,
label,
state: "unselected" as const,
})),
}));
}
export function FinalReviewScreen() {
const { state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.finalReview");
const m = useMessages();
const finalReviewCategories = useMemo(
() => buildFinalReviewCategories(m.create.finalReview.categories),
[m.create.finalReview.categories],
);
const ruleCardTitle = useMemo(() => {
const raw = typeof state.title === "string" ? state.title.trim() : "";
return raw.length > 0 ? raw : t("ruleCardTitleFallback");
}, [state.title, t]);
const ruleCardDescription = useMemo(() => {
const raw =
typeof state.summary === "string" ? state.summary.trim() : "";
return raw.length > 0 ? raw : t("ruleCardDescriptionFallback");
}, [state.summary, t]);
return (
<CreateFlowLockupCardStepShell
lockupTitle={t("title")}
lockupDescription={t("description")}
>
<RuleCard
title={ruleCardTitle}
description={ruleCardDescription}
size={mdUp ? "L" : "M"}
expanded={true}
backgroundColor="bg-[#c9fef9]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={ruleCardTitle}
categories={finalReviewCategories}
className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
onClick={() => {}}
/>
</CreateFlowLockupCardStepShell>
);
}
@@ -0,0 +1,320 @@
"use client";
/**
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
*
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls:
* Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections &
* Deadlocks. Section defaults are sourced from `messages/en/create/rightRail.json` and will be
* replaced with DB-driven content; labels are hard-coded per the Figma design.
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
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 { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
const CONSENSUS_LEVEL_DEFAULT = 75;
type RightRailModalSections = {
corePrinciple: string;
applicableScope: string[];
selectedApplicableScope: string[];
stepByStepInstructions: string;
consensusLevel: number;
objectionsDeadlocks: string;
};
function AddDecisionApproachModalContent({
approachCardId,
}: {
approachCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const rr = m.create.rightRail;
const modal =
approachCardId in rr.modals
? rr.modals[approachCardId as keyof typeof rr.modals]
: null;
const modalSections = modal?.sections;
const defaults: RightRailModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
selectedApplicableScope: [],
stepByStepInstructions: modalSections?.stepByStepInstructions ?? "",
consensusLevel: modalSections?.consensusLevel ?? CONSENSUS_LEVEL_DEFAULT,
objectionsDeadlocks: modalSections?.objectionsDeadlocks ?? "",
};
const [sections, setSections] = useState<RightRailModalSections>(() => ({
corePrinciple: defaults.corePrinciple,
applicableScope: [...defaults.applicableScope],
selectedApplicableScope: [...defaults.selectedApplicableScope],
stepByStepInstructions: defaults.stepByStepInstructions,
consensusLevel: defaults.consensusLevel,
objectionsDeadlocks: defaults.objectionsDeadlocks,
}));
const patch = useCallback(
<K extends keyof RightRailModalSections>(
key: K,
value: RightRailModalSections[K],
) => {
markCreateFlowInteraction();
setSections((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={rr.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={rr.sectionHeadings.applicableScope}
addLabel={rr.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
sections.selectedApplicableScope.includes(scope)
? sections.selectedApplicableScope.filter((s) => s !== scope)
: [...sections.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...sections.applicableScope, scope])
}
/>
<ModalTextAreaField
label={rr.sectionHeadings.stepByStepInstructions}
value={sections.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
/>
<IncrementerBlock
label={rr.sectionHeadings.consensusLevel}
value={sections.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
/>
<ModalTextAreaField
label={rr.sectionHeadings.objectionsDeadlocks}
value={sections.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
/>
</div>
);
}
export function DecisionApproachesScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
})),
[rr.cards],
);
const cardById = useMemo(
() => new Map(rr.cards.map((c) => [c.id, c])),
[rr.cards],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
</InlineTextButton>
{rr.sidebar.descriptionAfter}
</>
);
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, [markCreateFlowInteraction]);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: rr.confirmModal.title,
description: rr.confirmModal.description,
nextButtonText: rr.confirmModal.nextButtonText,
};
}
if (pendingCardId in rr.modals) {
const modal = rr.modals[pendingCardId as keyof typeof rr.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: rr.addApproach.nextButtonText,
};
}
const card = cardById.get(pendingCardId);
return {
title: card?.label ?? rr.confirmModal.title,
description: card?.supportText ?? rr.confirmModal.description,
nextButtonText: rr.addApproach.nextButtonText,
};
})();
return (
<CreateFlowTwoColumnSelectShell
contentTopBelowMd="space-800"
lgVerticalAlign="start"
header={
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
}
>
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddDecisionApproachModalContent
key={pendingCardId}
approachCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowTwoColumnSelectShell>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
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 — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
export function CommunitySizeSelectScreen() {
const m = useMessages();
const cs = m.create.communitySize;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[]
>(() => {
const base = chipRowsFromLabels(cs.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 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 multiSelectBlock = (
<MultiSelect
formHeader={false}
size="m"
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
addButton={false}
/>
);
return (
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
}
>
{multiSelectBlock}
</CreateFlowTwoColumnSelectShell>
);
}
@@ -0,0 +1,350 @@
"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 } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
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 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),
},
);
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "selected")
.map((o) => o.id);
}
function chipOptionsToSnapshotRows(
options: ChipOption[],
): CommunityStructureChipSnapshotRow[] {
return options.map((o) => ({
id: o.id,
label: o.label,
...(o.state !== undefined ? { state: o.state } : {}),
}));
}
/** Returns chips when a draft snapshot exists; otherwise null (use preset rows + selected ids). */
function snapshotRowsToChipOptions(
rows: CommunityStructureChipSnapshotRow[] | undefined,
): ChipOption[] | null {
if (!Array.isArray(rows) || rows.length === 0) return null;
return rows.map((r) => ({
id: r.id,
label: r.label,
...(r.state !== undefined
? { state: r.state as ChipOption["state"] }
: {}),
}));
}
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
export function CommunityStructureSelectScreen() {
const m = useMessages();
const cs = m.create.communityStructure;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
ChipOption[]
>(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.organizationTypes,
);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(cs.organizationTypes),
state.selectedOrganizationTypeIds,
);
});
const [scaleOptions, setScaleOptions] = useState<ChipOption[]>(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.scale,
);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(cs.scaleOptions),
state.selectedScaleIds,
);
});
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.maturity,
);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(cs.maturityOptions),
state.selectedMaturityIds,
);
});
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.organizationTypes,
);
if (fromSnap) {
setOrganizationTypeOptions(fromSnap);
return;
}
setOrganizationTypeOptions((prev) =>
applySavedSelection(prev, state.selectedOrganizationTypeIds),
);
}, [
state.communityStructureChipSnapshots?.organizationTypes,
state.selectedOrganizationTypeIds,
]);
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.scale,
);
if (fromSnap) {
setScaleOptions(fromSnap);
return;
}
setScaleOptions((prev) => applySavedSelection(prev, state.selectedScaleIds));
}, [
state.communityStructureChipSnapshots?.scale,
state.selectedScaleIds,
]);
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.maturity,
);
if (fromSnap) {
setMaturityOptions(fromSnap);
return;
}
setMaturityOptions((prev) =>
applySavedSelection(prev, state.selectedMaturityIds),
);
}, [
state.communityStructureChipSnapshots?.maturity,
state.selectedMaturityIds,
]);
const organizationCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setOrganizationTypeOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const scaleCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setScaleOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const maturityCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setMaturityOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const persistOrg = (next: ChipOption[]) => {
markCreateFlowInteraction();
setOrganizationTypeOptions(next);
updateState({
selectedOrganizationTypeIds: selectedIdsFromOptions(next),
communityStructureChipSnapshots: {
organizationTypes: chipOptionsToSnapshotRows(next),
},
});
};
const persistScale = (next: ChipOption[]) => {
markCreateFlowInteraction();
setScaleOptions(next);
updateState({
selectedScaleIds: selectedIdsFromOptions(next),
communityStructureChipSnapshots: {
scale: chipOptionsToSnapshotRows(next),
},
});
};
const persistMaturity = (next: ChipOption[]) => {
markCreateFlowInteraction();
setMaturityOptions(next);
updateState({
selectedMaturityIds: selectedIdsFromOptions(next),
communityStructureChipSnapshots: {
maturity: chipOptionsToSnapshotRows(next),
},
});
};
const handleOrganizationTypeClick = (chipId: string) => {
persistOrg(
organizationTypeOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
);
};
const handleScaleClick = (chipId: string) => {
persistScale(
scaleOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
);
};
const handleMaturityClick = (chipId: string) => {
persistMaturity(
maturityOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
);
};
const multiSelectBlock = (
<>
<MultiSelect
label={cs.organizationMultiSelect.label}
showHelpIcon
size="s"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
{...organizationCustomHandlers}
addButton
addButtonText={cs.organizationMultiSelect.addButtonText}
/>
<MultiSelect
label={cs.scaleMultiSelect.label}
showHelpIcon
size="s"
options={scaleOptions}
onChipClick={handleScaleClick}
{...scaleCustomHandlers}
addButton
addButtonText={cs.scaleMultiSelect.addButtonText}
/>
<MultiSelect
label={cs.maturityMultiSelect.label}
showHelpIcon
size="s"
options={maturityOptions}
onChipClick={handleMaturityClick}
{...maturityCustomHandlers}
addButton
addButtonText={cs.maturityMultiSelect.addButtonText}
/>
</>
);
return (
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
}
>
{multiSelectBlock}
</CreateFlowTwoColumnSelectShell>
);
}
@@ -0,0 +1,98 @@
"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 { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
export function ConfirmStakeholdersScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
const t = useTranslation("create.confirmStakeholders");
const [toastDismissed, setToastDismissed] = useState(false);
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
[],
);
const handleAddStakeholder = () => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "custom" },
]);
};
const handleCustomChipConfirm = (chipId: string, value: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) =>
prev.map((opt) =>
opt.id === chipId ? { ...opt, label: value, state: "selected" } : opt,
),
);
};
const handleCustomChipClose = (chipId: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
};
const handleChipClick = (chipId: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
};
return (
<>
<CreateFlowStepShell
variant="centeredNarrowBottomPad"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
</div>
<MultiSelect
formHeader={false}
showHelpIcon={false}
size="s"
options={stakeholderOptions}
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
onCustomChipConfirm={handleCustomChipConfirm}
onCustomChipClose={handleCustomChipClose}
addButton
addButtonText={t("addStakeholder")}
/>
</div>
</CreateFlowStepShell>
{!toastDismissed && (
<div
className="fixed bottom-[5.25rem] left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 md:bottom-[5.5rem]"
role="status"
aria-live="polite"
>
<Alert
type="banner"
status="positive"
title={t("draftToastTitle")}
hasLeadingIcon={false}
hasBodyText={false}
onClose={() => setToastDismissed(true)}
className="w-full !px-[var(--space-600,24px)] !py-[var(--space-400,16px)] md:!py-4"
/>
</div>
)}
</>
);
}
@@ -0,0 +1,401 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import TextArea from "../../../../components/controls/TextArea";
import Create from "../../../../components/modals/Create";
import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
const MAX_CORE_VALUES = 5;
type ModalSession = "pending" | "editing";
/** Row in `coreValues.json` `values` — string (legacy) or `{ label, meaning, signals }`. */
type CoreValuePresetJson =
| string
| { label: string; meaning?: string; signals?: string };
type CoreValuePreset = {
label: string;
meaning: string;
signals: string;
};
function normalizeCoreValuePresets(
values: readonly CoreValuePresetJson[],
): CoreValuePreset[] {
return values.map((v) => {
if (typeof v === "string") {
return { label: v, meaning: "", signals: "" };
}
return {
label: v.label,
meaning: typeof v.meaning === "string" ? v.meaning : "",
signals: typeof v.signals === "string" ? v.signals : "",
};
});
}
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
return presets.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "unselected" as const,
}));
}
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),
},
);
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "selected")
.map((o) => o.id);
}
function chipOptionsToSnapshotRows(
options: ChipOption[],
): CommunityStructureChipSnapshotRow[] {
return options.map((o) => ({
id: o.id,
label: o.label,
...(o.state !== undefined ? { state: o.state } : {}),
}));
}
function snapshotRowsToChipOptions(
rows: CommunityStructureChipSnapshotRow[] | undefined,
): ChipOption[] | null {
if (!Array.isArray(rows) || rows.length === 0) return null;
return rows.map((r) => ({
id: r.id,
label: r.label,
...(r.state !== undefined
? { state: r.state as ChipOption["state"] }
: {}),
}));
}
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
export function CoreValuesSelectScreen() {
const m = useMessages();
const cv = m.create.coreValues;
const presets = useMemo(
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
[cv.values],
);
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
() => {
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromPresets(presets),
state.selectedCoreValueIds,
);
},
);
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
null,
);
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
const [draftMeaning, setDraftMeaning] = useState("");
const [draftSignals, setDraftSignals] = useState("");
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
if (fromSnap) {
setCoreValueOptions(fromSnap);
return;
}
setCoreValueOptions((prev) =>
applySavedSelection(prev, state.selectedCoreValueIds),
);
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
const syncCoreValuesToDraft = useCallback(
(next: ChipOption[]) => {
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
},
[updateState],
);
const persistCoreValues = useCallback(
(next: ChipOption[]) => {
markCreateFlowInteraction();
setCoreValueOptions(next);
syncCoreValuesToDraft(next);
},
[markCreateFlowInteraction, syncCoreValuesToDraft],
);
/** Default meaning/signals from `coreValues.json` `values` for each preset label. */
const getPresetTexts = useCallback(
(valueLabel: string): { meaning: string; signals: string } => {
const row = presets.find((p) => p.label === valueLabel);
if (!row) return { meaning: "", signals: "" };
return { meaning: row.meaning, signals: row.signals };
},
[presets],
);
const getInitialTexts = useCallback(
(chipId: string, valueLabel: string) => {
const saved = state.coreValueDetailsByChipId?.[chipId];
const preset = getPresetTexts(valueLabel);
return {
meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals,
};
},
[state.coreValueDetailsByChipId, getPresetTexts],
);
const openModal = useCallback(
(chipId: string, session: ModalSession, valueLabel: string) => {
const initial = getInitialTexts(chipId, valueLabel);
setDraftMeaning(initial.meaning);
setDraftSignals(initial.signals);
setActiveModalChipId(chipId);
setModalSession(session);
markCreateFlowInteraction();
},
[getInitialTexts, markCreateFlowInteraction],
);
const handleModalDismiss = useCallback(() => {
if (activeModalChipId && modalSession === "pending") {
const next = coreValueOptions.map((opt) =>
opt.id === activeModalChipId
? { ...opt, state: "unselected" as const }
: opt,
);
persistCoreValues(next);
}
setActiveModalChipId(null);
setModalSession(null);
}, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]);
const handleModalConfirm = useCallback(() => {
if (!activeModalChipId) return;
markCreateFlowInteraction();
updateState({
coreValueDetailsByChipId: {
[activeModalChipId]: {
meaning: draftMeaning,
signals: draftSignals,
},
},
});
setActiveModalChipId(null);
setModalSession(null);
}, [
activeModalChipId,
draftMeaning,
draftSignals,
markCreateFlowInteraction,
updateState,
]);
const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId);
if (!target || target.state === "custom") return;
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
if (target.state === "selected") {
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? { ...opt, state: "unselected" as const }
: opt,
);
persistCoreValues(next);
return;
}
if (selectedCount >= MAX_CORE_VALUES) return;
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? { ...opt, state: "selected" as const }
: opt,
);
persistCoreValues(next);
openModal(chipId, "pending", target.label);
};
const addHandlers = {
onAddClick: () => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next: ChipOption[] = [
...prev,
{ id: crypto.randomUUID(), label: "", state: "custom" },
];
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
},
onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const withLabel = prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "unselected" as const }
: opt,
);
const selectedCount = withLabel.filter(
(o) => o.state === "selected",
).length;
const canSelect = selectedCount < MAX_CORE_VALUES;
const next = canSelect
? withLabel.map((opt) =>
opt.id === chipId
? { ...opt, state: "selected" as const }
: opt,
)
: withLabel;
queueMicrotask(() => {
syncCoreValuesToDraft(next);
if (canSelect) {
openModal(chipId, "pending", value);
} else {
openModal(chipId, "editing", value);
}
});
return next;
});
},
onCustomChipClose: (chipId: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next = prev.filter((o) => o.id !== chipId);
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
},
};
const modalChipLabel =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
const description = (
<>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{cv.header.descriptionLead}{" "}
</span>
<button
type="button"
onClick={addHandlers.onAddClick}
className="cursor-pointer font-inter font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
>
{cv.header.addLink}
</button>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{" "}
{cv.header.descriptionTrail}
</span>
</>
);
const detailModal = cv.detailModal;
return (
<CreateFlowTwoColumnSelectShell
lgVerticalAlign="start"
header={
<CreateFlowHeaderLockup
title={cv.header.title}
description={description}
justification="left"
/>
}
>
<MultiSelect
formHeader={false}
size="m"
options={coreValueOptions}
onChipClick={handleChipClick}
onAddClick={addHandlers.onAddClick}
onCustomChipConfirm={addHandlers.onCustomChipConfirm}
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
/>
{detailModal && (
<Create
isOpen={activeModalChipId !== null}
onClose={handleModalDismiss}
backdropVariant="loginYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={modalChipLabel}
description={detailModal.subtitle}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
onNext={handleModalConfirm}
nextButtonText={detailModal.addValueButton}
ariaLabel={modalChipLabel || "Core value details"}
>
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
<TextArea
label={detailModal.meaningLabel}
showHelpIcon
appearance="embedded"
size="medium"
value={draftMeaning}
onChange={(e) => setDraftMeaning(e.target.value)}
rows={4}
/>
<TextArea
label={detailModal.signalsLabel}
showHelpIcon
appearance="embedded"
size="medium"
value={draftSignals}
onChange={(e) => setDraftSignals(e.target.value)}
rows={4}
/>
</div>
</Create>
)}
</CreateFlowTwoColumnSelectShell>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
messageNamespace: string;
stateField: CreateFlowTextStateField;
maxLength: number;
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute;
showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
contentTopBelowMd?: CreateFlowContentTopBelowMd;
};
/**
* Shared narrow-column + TextInput pattern for Create Community text frames.
*/
export function CreateFlowTextFieldScreen({
messageNamespace,
stateField,
maxLength,
mainAlign = "start",
inputType = "text",
showCharacterCount = true,
headerJustification = "left",
contentTopBelowMd = "space-1400",
}: Props) {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation(messageNamespace);
const readFromState = (): string => {
const raw = state[stateField];
return typeof raw === "string" ? raw : "";
};
const [value, setValue] = useState(() => readFromState());
useEffect(() => {
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, stateField]);
const characterCount = value.length;
const hint =
showCharacterCount === false
? false
: t("characterCountTemplate")
.replace("{current}", String(characterCount))
.replace("{max}", String(maxLength));
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd={contentTopBelowMd}
>
<div
className={`flex flex-col gap-[18px] ${mainItems} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="w-full">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification={headerJustification}
/>
</div>
<div className="w-full">
<TextInput
className="!transition-none"
type={inputType}
placeholder={t("placeholder")}
value={value}
onChange={(e) => {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ [stateField]: v } as Record<string, string>);
}}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
textHint={hint}
maxLength={maxLength}
/>
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,46 @@
"use client";
import Upload from "../../../../components/controls/Upload";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
/** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() {
const m = useMessages();
const u = m.create.communityUpload;
const { markCreateFlowInteraction } = useCreateFlow();
const handleUploadClick = () => {
markCreateFlowInteraction();
};
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-center gap-[18px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="w-full">
<CreateFlowHeaderLockup
title={u.title}
description={u.description}
justification="center"
/>
</div>
<div className="w-full">
<Upload
active={true}
showHelpIcon={false}
hintText={u.hintText}
onClick={handleUploadClick}
/>
</div>
</div>
</CreateFlowStepShell>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Type definitions for the Create Rule Flow
*
* These types define the structure for the full-screen create rule flow,
* including step types, state management, and context interfaces.
*/
/**
* 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"
| "community-name"
| "community-size"
| "community-context"
| "community-structure"
| "community-upload"
| "community-save"
| "review"
| "core-values"
| "communication-methods"
| "membership-methods"
| "decision-approaches"
| "conflict-management"
| "confirm-stakeholders"
| "final-review"
| "completed";
/** String keys used by generic text-field steps for `CreateFlowState`. */
export type CreateFlowTextStateField =
| "title"
| "summary"
| "communityContext"
| "communitySaveEmail";
/**
* Serialized chip row for `community-structure` (preset + custom labels).
* Stored in drafts so custom chips survive refresh and server sync.
*/
export type CommunityStructureChipSnapshotRow = {
id: string;
label: string;
state?: string;
};
/** Meaning + violation signals copy for a core value chip (draft + publish). */
export type CoreValueDetailEntry = {
meaning: string;
signals: string;
};
/**
* Flow state for inputs across create-flow steps.
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
* Additional string keys are allowed at runtime for forward-compatible step data.
*/
export interface CreateFlowState {
title?: string;
summary?: string;
/** Additional copy fields for multi-step Create Community text frames (Figma). */
communityContext?: string;
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
communitySaveEmail?: 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` (scale). */
selectedScaleIds?: string[];
/** Selected chip ids from `community-structure` (maturity). */
selectedMaturityIds?: string[];
/**
* Full chip lists for `community-structure` (needed so custom chips round-trip in drafts).
* IDs alone are insufficient because custom rows are not reconstructible from copy JSON.
*/
communityStructureChipSnapshots?: {
organizationTypes?: CommunityStructureChipSnapshotRow[];
scale?: CommunityStructureChipSnapshotRow[];
maturity?: CommunityStructureChipSnapshotRow[];
};
/** Create Custom — core values step (max five `selectedCoreValueIds`). */
selectedCoreValueIds?: string[];
/** Full chip rows for core values (custom labels). */
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
/** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.communication` presets. */
selectedCommunicationMethodIds?: string[];
/** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.membership` presets. */
selectedMembershipMethodIds?: string[];
/** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.rightRail` presets. */
selectedDecisionApproachIds?: string[];
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
stakeholders?: Record<string, unknown>[];
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
[key: string]: unknown;
}
/**
* Context value interface for CreateFlowContext
* Provides state management and navigation capabilities
*/
export interface CreateFlowContextValue {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
updateState: (_updates: Partial<CreateFlowState>) => void;
/** Replace entire flow state (e.g. hydrate from server draft). */
replaceState: (_next: CreateFlowState) => void;
/** Reset flow state and clear anonymous localStorage draft keys when present. */
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 (utils/hasCreateFlowUserInput.ts).
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
}
/**
* Base props interface for page templates
* Will be expanded in template implementation tickets (CR-51-55)
*/
export interface PageTemplateProps {
// Base props for all page templates
// Will be expanded in template tickets
}
/**
* Navigation handlers interface
* Will be implemented in CR-56
*/
export interface NavigationHandlers {
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
}
@@ -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;
}