Create flow: session UI + sign out

This commit is contained in:
adilallo
2026-04-06 19:22:50 -06:00
parent 4b14510dde
commit 759f5f1555
47 changed files with 1383 additions and 370 deletions
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { isValidStep } from "./utils/flowSteps";
import { saveDraftToServer } from "../../lib/create/api";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
* Uploads anonymous localStorage draft to `RuleDraft` once, then hydrates context.
*/
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) {
if (attemptedRef.current) return;
attemptedRef.current = true;
setTransferError(
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
);
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
return;
}
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 segment = pathname?.split("/").pop() ?? "";
const step = isValidStep(segment) ? segment : undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
};
const ok = await saveDraftToServer(payload);
if (cancelled) return;
if (!ok) {
setTransferError(
"Could not save your draft to your account. Your progress is still stored on this device.",
);
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>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { CreateFlowState } from "./types";
/** Anonymous in-progress create flow (local only until magic-link transfer). */
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
/**
* 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;
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 CreateFlowState;
return typeof parsed === "object" && parsed !== null ? 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;
}
}
export function clearTransferPendingFlag(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY);
} catch {
// ignore
}
}
/** 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
}
}
+24 -9
View File
@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import HeaderLockup from "../../components/type/HeaderLockup";
import { useCreateFlow } from "../context/CreateFlowContext";
import CardStack from "../../components/utility/CardStack";
import Create from "../../components/modals/Create";
import TextArea from "../../components/controls/TextArea";
@@ -130,6 +131,7 @@ function AddPlatformModalContent({
}: {
platformCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId];
const [sectionValues, setSectionValues] = useState<
Record<SectionKey, string>
@@ -141,9 +143,13 @@ function AddPlatformModalContent({
},
);
const updateSection = useCallback((key: SectionKey, value: string) => {
setSectionValues((prev) => ({ ...prev, [key]: value }));
}, []);
const updateSection = useCallback(
(key: SectionKey, value: string) => {
markCreateFlowInteraction();
setSectionValues((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
if (!defaults) return null;
@@ -230,6 +236,7 @@ function getCreateModalConfig(pendingCardId: string | null) {
/** Create flow card stack step: compact grid with optional expand to full list. */
export default function CardsPage() {
const { markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -239,10 +246,14 @@ export default function CardsPage() {
const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION;
const modalConfig = getCreateModalConfig(pendingCardId);
const handleCardClick = useCallback((id: string) => {
setPendingCardId(id);
setCreateModalOpen(true);
}, []);
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
@@ -250,6 +261,7 @@ export default function CardsPage() {
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds((prev) =>
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
@@ -257,7 +269,7 @@ export default function CardsPage() {
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [pendingCardId]);
}, [markCreateFlowInteraction, pendingCardId]);
return (
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
@@ -276,7 +288,10 @@ export default function CardsPage() {
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => setExpanded((prev) => !prev)}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
/>
</div>
+6
View File
@@ -6,6 +6,7 @@ import HeaderLockup from "../../components/type/HeaderLockup";
import MultiSelect from "../../components/controls/MultiSelect";
import Alert from "../../components/modals/Alert";
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
import { useCreateFlow } from "../context/CreateFlowContext";
const TITLE =
"Do other stakeholders need to be involved in creating your community?";
@@ -20,6 +21,7 @@ const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!";
* Figma: 21104-46594.
*/
export default function ConfirmStakeholdersPage() {
const { markCreateFlowInteraction } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const [toastDismissed, setToastDismissed] = useState(false);
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
@@ -35,6 +37,7 @@ export default function ConfirmStakeholdersPage() {
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const handleAddStakeholder = () => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
@@ -42,6 +45,7 @@ export default function ConfirmStakeholdersPage() {
};
const handleCustomChipConfirm = (chipId: string, value: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) =>
prev.map((opt) =>
opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt,
@@ -50,10 +54,12 @@ export default function ConfirmStakeholdersPage() {
};
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));
};
@@ -1,71 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
fetchAuthSession,
fetchDraftFromServer,
saveDraftToServer,
} from "../../../lib/create/api";
import { useCreateFlow } from "./CreateFlowContext";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
const DEBOUNCE_MS = 1000;
/**
* When NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true, loads the signed-in user's draft
* from the server and debounces saves. Anonymous users keep localStorage-only behavior.
*/
export function CreateFlowBackendSync() {
const { state, replaceState } = useCreateFlow();
const [hydrated, setHydrated] = useState(!SYNC_ENABLED);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!SYNC_ENABLED) return;
let cancelled = false;
(async () => {
try {
const { user } = await fetchAuthSession();
if (cancelled || !user) {
setHydrated(true);
return;
}
const serverDraft = await fetchDraftFromServer();
if (cancelled) return;
if (serverDraft && Object.keys(serverDraft).length > 0) {
replaceState(serverDraft);
}
} finally {
if (!cancelled) setHydrated(true);
}
})();
return () => {
cancelled = true;
};
}, [replaceState]);
useEffect(() => {
if (!SYNC_ENABLED || !hydrated) return;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveTimer.current = null;
void (async () => {
const { user } = await fetchAuthSession();
if (!user) return;
await saveDraftToServer(state);
})();
}, DEBOUNCE_MS);
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [state, hydrated]);
return null;
}
+48 -58
View File
@@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
@@ -13,64 +14,68 @@ import type {
CreateFlowContextValue,
CreateFlowStep,
} from "../types";
import {
clearAnonymousCreateFlowStorage,
clearLegacyCreateFlowKeysOnce,
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "../anonymousDraftStorage";
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
const STORAGE_KEY = "create-flow-state";
const DRAFT_STORAGE_KEY = "create-flow-draft";
function readStateFromStorage(key: string): CreateFlowState {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(key);
if (!raw) return {};
const parsed = JSON.parse(raw) as CreateFlowState;
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeStateToStorage(key: string, value: CreateFlowState): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore storage errors (e.g. quota, private mode)
}
}
function removeFromStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// Ignore
}
}
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;
}
/**
* Provider component for Create Flow state management
*
* Manages flow state with optional localStorage persistence and draft support.
* 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>(() =>
readStateFromStorage(STORAGE_KEY),
enableAnonymousPersistence ? readAnonymousCreateFlowState() : {},
);
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableAnonymousPersistence);
useEffect(() => {
writeStateToStorage(STORAGE_KEY, state);
}, [state]);
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;
setState((prev) =>
Object.keys(prev).length > 0 ? prev : { ...from },
);
}, [enableAnonymousPersistence]);
useEffect(() => {
if (!enableAnonymousPersistence) return;
writeAnonymousCreateFlowState(state);
}, [state, enableAnonymousPersistence]);
const markCreateFlowInteraction = useCallback(() => {
setInteractionTouched(true);
}, []);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => ({
@@ -81,13 +86,12 @@ export function CreateFlowProvider({
const replaceState = useCallback((next: CreateFlowState) => {
setState(next);
writeStateToStorage(STORAGE_KEY, next);
}, []);
const clearState = useCallback(() => {
setState({});
removeFromStorage(STORAGE_KEY);
removeFromStorage(DRAFT_STORAGE_KEY);
setInteractionTouched(false);
clearAnonymousCreateFlowStorage();
}, []);
const contextValue: CreateFlowContextValue = {
@@ -96,6 +100,8 @@ export function CreateFlowProvider({
updateState,
replaceState,
clearState,
interactionTouched,
markCreateFlowInteraction,
};
return (
@@ -105,22 +111,6 @@ export function CreateFlowProvider({
);
}
/** Save current state as draft (e.g. on "Save & Exit"). Stub for CR-57. */
export function saveCreateFlowDraft(state: CreateFlowState): void {
writeStateToStorage(DRAFT_STORAGE_KEY, state);
}
/** Load draft state if present. Caller can merge into initial state when entering flow. */
export function loadCreateFlowDraft(): CreateFlowState {
return readStateFromStorage(DRAFT_STORAGE_KEY);
}
/**
* Hook to access Create Flow context
*
* @throws Error if used outside CreateFlowProvider
* @returns CreateFlowContextValue
*/
export function useCreateFlow(): CreateFlowContextValue {
const context = useContext(CreateFlowContext);
if (!context) {
+27
View File
@@ -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;
}
+62
View File
@@ -0,0 +1,62 @@
"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,
}: {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
clearState: CreateFlowExitClearState;
router: AppRouterLike;
user: { id: string; email: string } | null;
}): (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 ok = await saveDraftToServer(payload);
if (!ok && typeof window !== "undefined") {
const leave = window.confirm(
messages.create.topNav.leaveConfirmSaveFailed,
);
if (!leave) return;
}
}
clearState();
router.push("/");
},
[state, currentStep, clearState, router, user],
);
}
+92 -35
View File
@@ -1,27 +1,71 @@
"use client";
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { CreateFlowBackendSync } from "./context/CreateFlowBackendSync";
import {
CreateFlowProvider,
useCreateFlow,
saveCreateFlowDraft,
} from "./context/CreateFlowContext";
Suspense,
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 { getStepIndex } from "./utils/flowSteps";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { fetchAuthSession } from "../../lib/create/api";
import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
/**
* Layout for the Create Rule Flow
*
* Provides a full-screen layout without the root layout's TopNav/Footer.
* This layout wraps all create flow pages and provides the CreateFlowContext.
* Includes the create flow-specific TopNav and Footer components.
*/
function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
/** First step where Save & Exit is offered (after informational + name / `text`). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
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}
>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</CreateFlowProvider>
);
}
function CreateFlowLayoutContent({
children,
sessionUser,
sessionResolved,
}: {
children: ReactNode;
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
const { openLogin } = useAuthModal();
const {
currentStep,
nextStep,
@@ -31,40 +75,58 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
} = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow();
const handleExit = (options?: { saveDraft?: boolean }) => {
const saveDraft = options?.saveDraft ?? false;
if (!saveDraft && typeof window !== "undefined") {
const confirmed = window.confirm(
"Leave create flow? Your progress will be lost.",
);
if (!confirmed) return;
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
clearState,
router,
user: sessionUser ?? null,
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
if (sessionUser === null) {
if (saveDraft) return;
openLogin({
variant: "saveProgress",
nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
backdropVariant: "blurredYellow",
});
return;
}
if (saveDraft) {
saveCreateFlowDraft(state);
}
clearState();
router.push("/");
if (!sessionUser) return;
await runAuthenticatedExit(opts);
};
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
const stepIdx =
currentStep != null ? getStepIndex(currentStep) : -1;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
return (
<div
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
loggedIn={isCompletedStep}
saveDraftOnExit={saveDraftOnExit}
onEdit={
isCompletedStep
? () => router.push("/create/final-review")
: undefined
}
onExit={handleExit}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
@@ -106,10 +168,5 @@ export default function CreateFlowLayout({
}: {
children: ReactNode;
}) {
return (
<CreateFlowProvider>
<CreateFlowBackendSync />
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
</CreateFlowProvider>
);
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
}
+15 -7
View File
@@ -6,6 +6,7 @@ import DecisionMakingSidebar from "../../components/utility/DecisionMakingSideba
import CardStack from "../../components/utility/CardStack";
import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types";
import { useCreateFlow } from "../context/CreateFlowContext";
const SIDEBAR_TITLE = "How should conflicts be resolved?";
@@ -78,6 +79,7 @@ const SAMPLE_CARDS: CardStackItem[] = [
* Two-column layout (sidebar + card stack) at 640+, single column at 320-639.
*/
export default function RightRailPage() {
const { markCreateFlowInteraction } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
@@ -96,22 +98,28 @@ export default function RightRailPage() {
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[],
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback((id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}, []);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
},
[markCreateFlowInteraction],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, []);
}, [markCreateFlowInteraction]);
if (showDesktopLayout) {
return (
+40 -12
View File
@@ -11,27 +11,36 @@ import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import MultiSelect from "../../components/controls/MultiSelect";
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
import { useCreateFlow } from "../context/CreateFlowContext";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
confirmState: "Unselected" | "Selected",
onInteraction?: () => void,
) {
const touch = () => onInteraction?.();
return {
onAddClick: () =>
onAddClick: () => {
touch();
setList((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
]),
onCustomChipConfirm: (chipId: string, value: string) =>
]);
},
onCustomChipConfirm: (chipId: string, value: string) => {
touch();
setList((prev) =>
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: confirmState }
: opt,
),
),
onCustomChipClose: (chipId: string) =>
setList((prev) => prev.filter((o) => o.id !== chipId)),
);
},
onCustomChipClose: (chipId: string) => {
touch();
setList((prev) => prev.filter((o) => o.id !== chipId));
},
};
}
@@ -43,6 +52,7 @@ function createListCustomHandlers(
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
*/
export default function SelectPage() {
const { markCreateFlowInteraction } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
@@ -85,19 +95,35 @@ export default function SelectPage() {
]);
const communityCustomHandlers = useMemo(
() => createListCustomHandlers(setCommunitySizeOptions, "Unselected"),
[],
() =>
createListCustomHandlers(
setCommunitySizeOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const organizationCustomHandlers = useMemo(
() => createListCustomHandlers(setOrganizationTypeOptions, "Unselected"),
[],
() =>
createListCustomHandlers(
setOrganizationTypeOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const governanceCustomHandlers = useMemo(
() => createListCustomHandlers(setGovernanceStyleOptions, "Unselected"),
[],
() =>
createListCustomHandlers(
setGovernanceStyleOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const handleCommunitySizeClick = (chipId: string) => {
markCreateFlowInteraction();
setCommunitySizeOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
@@ -111,6 +137,7 @@ export default function SelectPage() {
};
const handleOrganizationTypeClick = (chipId: string) => {
markCreateFlowInteraction();
setOrganizationTypeOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
@@ -124,6 +151,7 @@ export default function SelectPage() {
};
const handleGovernanceStyleClick = (chipId: string) => {
markCreateFlowInteraction();
setGovernanceStyleOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
+17 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import TextInput from "../../components/controls/TextInput";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Text page for the create flow
@@ -12,9 +13,18 @@ import TextInput from "../../components/controls/TextInput";
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
*/
export default function TextPage() {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const [value, setValue] = useState("");
const [value, setValue] = useState(() =>
typeof state.title === "string" ? state.title : "",
);
useEffect(() => {
const incoming = state.title;
if (typeof incoming !== "string" || incoming.length === 0) return;
setValue((prev) => (prev === "" ? incoming : prev));
}, [state.title]);
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
@@ -43,7 +53,12 @@ export default function TextPage() {
<TextInput
placeholder="Enter your community name"
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ title: v });
}}
inputSize={effectiveMdOrLarger ? "medium" : "small"}
formHeader={false}
textHint={`${characterCount}/${maxLength}`}
+7 -1
View File
@@ -47,8 +47,14 @@ export interface CreateFlowContextValue {
updateState: (_updates: Partial<CreateFlowState>) => void;
/** Replace entire flow state (e.g. hydrate from server draft). */
replaceState: (_next: CreateFlowState) => void;
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
/** 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(state)`.
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
}
/**
+3
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import Upload from "../../components/controls/Upload";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Upload page for the create flow
@@ -13,6 +14,7 @@ import Upload from "../../components/controls/Upload";
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
*/
export default function UploadPage() {
const { markCreateFlowInteraction } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
@@ -25,6 +27,7 @@ export default function UploadPage() {
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const handleUploadClick = () => {
markCreateFlowInteraction();
// TODO: Handle upload button click (e.g. open file picker)
};