From a0de78c0205769bf415a19b5abdaf61a9a4963d2 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:24:13 -0600 Subject: [PATCH] Update create flow pages --- CONTRIBUTING.md | 4 + .../controls/TextArea/TextArea.types.ts | 4 +- app/components/modals/Login/LoginForm.tsx | 2 +- .../navigation/TopNav/TopNav.container.tsx | 2 +- app/create/CreateFlowLayoutClient.tsx | 9 +- app/create/PostLoginDraftTransfer.tsx | 12 +- app/create/SignedInDraftHydration.tsx | 2 +- app/create/[screenId]/page.tsx | 22 +++ app/create/[step]/page.tsx | 38 ---- app/create/context/CreateFlowContext.tsx | 2 +- app/create/hooks/useCreateFlowNavigation.ts | 13 +- app/create/page.tsx | 7 + app/create/screens/CreateFlowScreenView.tsx | 75 ++++++++ .../page.tsx => screens/card/CardsScreen.tsx} | 24 +-- .../completed/CompletedScreen.tsx} | 23 +-- .../informational/InformationalScreen.tsx} | 19 +- .../review/CommunityReviewScreen.tsx} | 14 +- .../review/FinalReviewScreen.tsx} | 18 +- .../right-rail/RightRailScreen.tsx} | 24 +-- .../select/CommunitySizeSelectScreen.tsx | 171 ++++++++++++++++++ .../CommunityStructureSelectScreen.tsx} | 166 +++++++++-------- .../select/ConfirmStakeholdersScreen.tsx} | 20 +- .../text/CreateFlowTextFieldScreen.tsx} | 54 +++--- .../upload/CommunityUploadScreen.tsx} | 25 +-- app/create/types.ts | 30 ++- .../{ => utils}/anonymousDraftStorage.ts | 2 +- app/create/utils/createFlowScreenRegistry.ts | 123 +++++++++++++ app/create/utils/flowSteps.ts | 30 ++- .../{ => utils}/hasCreateFlowUserInput.ts | 2 +- docs/backend-linear-tickets.md | 45 ++++- docs/backend-roadmap.md | 6 +- docs/create-flow.md | 81 +++++++++ lib/create/buildPublishPayload.ts | 19 +- lib/server/validation/createFlowSchemas.ts | 5 + messages/en/create/communityContext.json | 6 + messages/en/create/communityName.json | 6 + messages/en/create/communityReflection.json | 6 + messages/en/create/communitySize.json | 19 ++ messages/en/create/communityStructure.json | 22 +++ messages/en/create/communityUpload.json | 4 + messages/en/index.ts | 18 +- stories/pages/CardsPage.stories.js | 4 +- stories/pages/CompletedPage.stories.js | 4 +- .../pages/ConfirmStakeholdersPage.stories.js | 4 +- stories/pages/FinalReviewPage.stories.js | 4 +- stories/pages/InformationalPage.stories.js | 36 +--- stories/pages/ReviewPage.stories.js | 40 +--- stories/pages/RightRailPage.stories.js | 4 +- stories/pages/SelectPage.stories.js | 36 +--- stories/pages/TextPage.stories.js | 38 +--- stories/pages/UploadPage.stories.js | 36 +--- tests/components/AuthModalContext.test.tsx | 10 +- tests/components/CompletedPage.test.tsx | 18 +- .../ConfirmStakeholdersPage.test.tsx | 10 +- tests/components/FinalReviewPage.test.tsx | 22 +-- tests/components/InformationalPage.test.tsx | 10 +- tests/components/LoginForm.test.tsx | 10 +- tests/components/ReviewPage.test.tsx | 16 +- tests/components/SelectPage.test.tsx | 14 +- tests/components/TextPage.test.tsx | 20 +- tests/components/UploadPage.test.tsx | 8 +- tests/pages/cards.test.jsx | 10 +- tests/pages/right-rail.test.jsx | 20 +- tests/pages/templates.test.jsx | 10 +- tests/unit/flowSteps.test.ts | 2 +- tests/unit/hasCreateFlowUserInput.test.ts | 6 +- 66 files changed, 1028 insertions(+), 538 deletions(-) create mode 100644 app/create/[screenId]/page.tsx delete mode 100644 app/create/[step]/page.tsx create mode 100644 app/create/page.tsx create mode 100644 app/create/screens/CreateFlowScreenView.tsx rename app/create/{cards/page.tsx => screens/card/CardsScreen.tsx} (88%) rename app/create/{completed/page.tsx => screens/completed/CompletedScreen.tsx} (79%) rename app/create/{informational/page.tsx => screens/informational/InformationalScreen.tsx} (57%) rename app/create/{review/page.tsx => screens/review/CommunityReviewScreen.tsx} (68%) rename app/create/{final-review/page.tsx => screens/review/FinalReviewScreen.tsx} (75%) rename app/create/{right-rail/page.tsx => screens/right-rail/RightRailScreen.tsx} (81%) create mode 100644 app/create/screens/select/CommunitySizeSelectScreen.tsx rename app/create/{select/page.tsx => screens/select/CommunityStructureSelectScreen.tsx} (58%) rename app/create/{confirm-stakeholders/page.tsx => screens/select/ConfirmStakeholdersScreen.tsx} (80%) rename app/create/{text/page.tsx => screens/text/CreateFlowTextFieldScreen.tsx} (51%) rename app/create/{upload/page.tsx => screens/upload/CommunityUploadScreen.tsx} (50%) rename app/create/{ => utils}/anonymousDraftStorage.ts (98%) create mode 100644 app/create/utils/createFlowScreenRegistry.ts rename app/create/{ => utils}/hasCreateFlowUserInput.ts (94%) create mode 100644 docs/create-flow.md create mode 100644 messages/en/create/communityContext.json create mode 100644 messages/en/create/communityName.json create mode 100644 messages/en/create/communityReflection.json create mode 100644 messages/en/create/communitySize.json create mode 100644 messages/en/create/communityStructure.json create mode 100644 messages/en/create/communityUpload.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d45b7a7..c69c82c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,10 @@ Use `npx prisma studio` to inspect the database. Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish. +### Create flow URLs (custom wizard) + +The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`cards`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md). + ## Frontend / tests See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md). diff --git a/app/components/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index 3974612..facdf21 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -49,10 +49,10 @@ export interface TextAreaProps extends Omit< className?: string; rows?: number; /** - * Whether to show hint text below textarea (Figma prop). + * Hint below the textarea: `true` shows placeholder copy, or pass a string (e.g. character count). * @default false */ - textHint?: boolean; + textHint?: boolean | string; /** * Whether to show form header (label and help icon) above textarea (Figma prop). * @default true diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 250c437..b79e3b8 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -9,7 +9,7 @@ import TextInput from "../../controls/TextInput"; import ContentLockup from "../../type/ContentLockup"; import { requestMagicLink } from "../../../../lib/create/api"; import { safeInternalPath } from "../../../../lib/safeInternalPath"; -import { setTransferPendingFlag } from "../../../create/anonymousDraftStorage"; +import { setTransferPendingFlag } from "../../../create/utils/anonymousDraftStorage"; /** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */ function MailIconInline() { diff --git a/app/components/navigation/TopNav/TopNav.container.tsx b/app/components/navigation/TopNav/TopNav.container.tsx index b2f7379..ff8cc50 100644 --- a/app/components/navigation/TopNav/TopNav.container.tsx +++ b/app/components/navigation/TopNav/TopNav.container.tsx @@ -197,7 +197,7 @@ const TopNavContainer = memo( size={buttonSize} buttonType={buttonType} palette={palette} - onClick={() => router.push("/create/informational")} + onClick={() => router.push("/create")} ariaLabel={t("ariaLabels.createNewRule")} > {renderAvatarGroup(containerSize, avatarSize)} diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index 54cdeba..e14dae6 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -13,6 +13,7 @@ import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; import { getStepIndex } from "./utils/flowSteps"; +import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry"; import CreateFlowFooter from "../components/utility/CreateFlowFooter"; import Button from "../components/buttons/Button"; import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; @@ -33,8 +34,8 @@ import { useCreateFlowDraftSaveBanner, } from "./context/CreateFlowDraftSaveBannerContext"; -/** First step where Save & Exit is offered (after informational + name / `text`). */ -const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select"); +/** First step where Save & Exit is offered (first Create Community select per Figma). */ +const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-size"); function CreateFlowSessionShell({ children }: { children: ReactNode }) { const [sessionUser, setSessionUser] = useState< @@ -211,7 +212,7 @@ function CreateFlowLayoutContent({ variant: "saveProgress", nextPath: returnToTemplateReview ?? - `${pathname ?? "/create/informational"}?syncDraft=1`, + `${pathname ?? "/create"}?syncDraft=1`, backdropVariant: "blurredYellow", }); return; @@ -236,7 +237,7 @@ function CreateFlowLayoutContent({ ? "items-start justify-center overflow-y-auto" : "items-start justify-center overflow-y-auto md:items-center"; - const isTextStep = currentStep === "text"; + const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep); const mainMaxMdJustify = isTextStep && !isCompletedStep && !isRightRailStep ? "max-md:justify-center" diff --git a/app/create/PostLoginDraftTransfer.tsx b/app/create/PostLoginDraftTransfer.tsx index 154346a..92725e7 100644 --- a/app/create/PostLoginDraftTransfer.tsx +++ b/app/create/PostLoginDraftTransfer.tsx @@ -6,9 +6,9 @@ import { clearAnonymousCreateFlowStorage, hasTransferPendingFlag, readAnonymousCreateFlowState, -} from "./anonymousDraftStorage"; +} from "./utils/anonymousDraftStorage"; import { useCreateFlow } from "./context/CreateFlowContext"; -import { isValidStep } from "./utils/flowSteps"; +import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps"; import { saveDraftToServer } from "../../lib/create/api"; import messages from "../../messages/en/index"; @@ -56,8 +56,8 @@ export function PostLoginDraftTransfer({ return; } - const segment = pathname?.split("/").pop() ?? ""; - const step = isValidStep(segment) ? segment : undefined; + const step = + parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; const payload = { ...local, ...(step ? { currentStep: step } : {}), @@ -100,8 +100,8 @@ export function PostLoginDraftTransfer({ return; } - const segment = pathname?.split("/").pop() ?? ""; - const step = isValidStep(segment) ? segment : undefined; + const step = + parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; const payload = { ...local, ...(step ? { currentStep: step } : {}), diff --git a/app/create/SignedInDraftHydration.tsx b/app/create/SignedInDraftHydration.tsx index 229fb30..a68e7d4 100644 --- a/app/create/SignedInDraftHydration.tsx +++ b/app/create/SignedInDraftHydration.tsx @@ -8,7 +8,7 @@ import { clearAnonymousCreateFlowStorage, hasTransferPendingFlag, readAnonymousCreateFlowState, -} from "./anonymousDraftStorage"; +} from "./utils/anonymousDraftStorage"; import { useCreateFlow } from "./context/CreateFlowContext"; import { fetchDraftFromServer } from "../../lib/create/api"; import messages from "../../messages/en/index"; diff --git a/app/create/[screenId]/page.tsx b/app/create/[screenId]/page.tsx new file mode 100644 index 0000000..611a4a1 --- /dev/null +++ b/app/create/[screenId]/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { notFound } from "next/navigation"; +import { use } from "react"; +import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; +import { isValidStep } from "../utils/flowSteps"; +import type { CreateFlowStep } from "../types"; + +interface PageProps { + params: Promise<{ screenId: string }>; +} + +export default function CreateFlowScreenPage({ params }: PageProps) { + const { screenId: raw } = use(params); + + if (!isValidStep(raw)) { + notFound(); + } + + const screenId = raw as CreateFlowStep; + return ; +} diff --git a/app/create/[step]/page.tsx b/app/create/[step]/page.tsx deleted file mode 100644 index 0d05913..0000000 --- a/app/create/[step]/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { notFound } from "next/navigation"; -import { use } from "react"; -import { VALID_STEPS } from "../utils/flowSteps"; - -interface PageProps { - params: Promise<{ step: string }>; -} - -/** - * Dynamic route handler for create flow steps - * - * Handles all flow steps via dynamic routing: /create/[step] - * Validates step exists and renders appropriate template (placeholder for now) - */ -export default function CreateFlowStepPage({ params }: PageProps) { - const { step } = use(params); - - // Validate step exists - if (!(VALID_STEPS as readonly string[]).includes(step)) { - notFound(); - } - - // Placeholder content - templates will be implemented in CR-51-55 - return ( -
-
-

- Create Flow Step: {step} -

-

- Template implementation coming in CR-51 through CR-55 -

-
-
- ); -} diff --git a/app/create/context/CreateFlowContext.tsx b/app/create/context/CreateFlowContext.tsx index 0a0c5ce..a57d600 100644 --- a/app/create/context/CreateFlowContext.tsx +++ b/app/create/context/CreateFlowContext.tsx @@ -19,7 +19,7 @@ import { clearLegacyCreateFlowKeysOnce, readAnonymousCreateFlowState, writeAnonymousCreateFlowState, -} from "../anonymousDraftStorage"; +} from "../utils/anonymousDraftStorage"; const CreateFlowContext = createContext(null); diff --git a/app/create/hooks/useCreateFlowNavigation.ts b/app/create/hooks/useCreateFlowNavigation.ts index 1fbed44..74f5c4e 100644 --- a/app/create/hooks/useCreateFlowNavigation.ts +++ b/app/create/hooks/useCreateFlowNavigation.ts @@ -3,7 +3,11 @@ import { usePathname, useRouter } from "next/navigation"; import { useCallback } from "react"; import type { CreateFlowStep } from "../types"; -import { getNextStep, getPreviousStep, isValidStep } from "../utils/flowSteps"; +import { + getNextStep, + getPreviousStep, + parseCreateFlowScreenFromPathname, +} from "../utils/flowSteps"; /** * Options passed to navigation handlers (e.g. for blur before navigate) @@ -20,8 +24,7 @@ const blurActiveElement = (): void => { /** * Hook for Create Rule Flow navigation. * - * Must be used within the create flow (pathname like /create/[step]). - * Uses the current step from the URL and provides type-safe navigation. + * Resolves the active step from `/create/{screenId}` via {@link parseCreateFlowScreenFromPathname} (flowSteps). */ export function useCreateFlowNavigation(): { currentStep: CreateFlowStep | null; @@ -36,9 +39,7 @@ export function useCreateFlowNavigation(): { const pathname = usePathname(); const router = useRouter(); - const currentStep = (pathname?.split("/").pop() ?? - null) as CreateFlowStep | null; - const validStep = isValidStep(currentStep) ? currentStep : null; + const validStep = parseCreateFlowScreenFromPathname(pathname ?? null); const nextStep = getNextStep(validStep); const previousStep = getPreviousStep(validStep); diff --git a/app/create/page.tsx b/app/create/page.tsx new file mode 100644 index 0000000..c804cc2 --- /dev/null +++ b/app/create/page.tsx @@ -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}`); +} diff --git a/app/create/screens/CreateFlowScreenView.tsx b/app/create/screens/CreateFlowScreenView.tsx new file mode 100644 index 0000000..1edeb2c --- /dev/null +++ b/app/create/screens/CreateFlowScreenView.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { CreateFlowStep } from "../types"; +import { InformationalScreen } from "./informational/InformationalScreen"; +import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen"; +import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen"; +import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen"; +import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen"; +import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; +import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; +import { FinalReviewScreen } from "./review/FinalReviewScreen"; +import { CardsScreen } from "./card/CardsScreen"; +import { RightRailScreen } from "./right-rail/RightRailScreen"; +import { CompletedScreen } from "./completed/CompletedScreen"; + +/** + * Renders the create-flow screen for a validated `screenId` (URL segment under /create/). + */ +export function CreateFlowScreenView({ + screenId, +}: { + screenId: CreateFlowStep; +}): ReactNode { + switch (screenId) { + case "informational": + return ; + case "community-name": + return ( + + ); + case "community-size": + return ; + case "community-context": + return ( + + ); + case "community-structure": + return ; + case "community-upload": + return ; + case "community-reflection": + return ( + + ); + case "review": + return ; + case "cards": + return ; + case "right-rail": + return ; + case "confirm-stakeholders": + return ; + case "final-review": + return ; + case "completed": + return ; + default: { + const _exhaustive: never = screenId; + return _exhaustive; + } + } +} diff --git a/app/create/cards/page.tsx b/app/create/screens/card/CardsScreen.tsx similarity index 88% rename from app/create/cards/page.tsx rename to app/create/screens/card/CardsScreen.tsx index 80ad3db..e904cb1 100644 --- a/app/create/cards/page.tsx +++ b/app/create/screens/card/CardsScreen.tsx @@ -1,14 +1,14 @@ "use client"; import { useState, useCallback, useMemo } from "react"; -import { useMessages } from "../../contexts/MessagesContext"; -import { useCreateFlow } from "../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; -import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; -import CardStack from "../../components/utility/CardStack"; -import Create from "../../components/modals/Create"; -import TextArea from "../../components/controls/TextArea"; -import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; +import { useMessages } from "../../../contexts/MessagesContext"; +import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; +import CardStack from "../../../components/utility/CardStack"; +import Create from "../../../components/modals/Create"; +import TextArea from "../../../components/controls/TextArea"; +import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; const IN_PERSON_CARD_ID = "in-person-meetings"; const SIGNAL_CARD_ID = "signal"; @@ -37,10 +37,6 @@ const COMMUNICATION_CARD_ORDER = [ "7", ] as const; -/** - * Section with heading + info icon and an editable TextArea. - * This variant uses TextArea only (no TextInput); design is "Add Signal" / "Add Video Meetings". - */ function CreateModalSection({ title, value: _value, @@ -75,7 +71,6 @@ function CreateModalSection({ ); } -/** Body for any "Add platform" modal: three editable sections (TextArea only). */ function AddPlatformModalContent({ platformCardId, }: { @@ -133,8 +128,7 @@ function isAddPlatformCard(cardId: string | null): boolean { ); } -/** Create flow card stack step: compact grid with optional expand to full list. */ -export default function CardsPage() { +export function CardsScreen() { const m = useMessages(); const comm = m.create.communication; const mdUp = useCreateFlowMdUp(); diff --git a/app/create/completed/page.tsx b/app/create/screens/completed/CompletedScreen.tsx similarity index 79% rename from app/create/completed/page.tsx rename to app/create/screens/completed/CompletedScreen.tsx index 599fb3c..4ac5594 100644 --- a/app/create/completed/page.tsx +++ b/app/create/screens/completed/CompletedScreen.tsx @@ -1,20 +1,16 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument"; -import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types"; -import Alert from "../../components/modals/Alert"; -import { useMessages } from "../../contexts/MessagesContext"; -import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload"; -import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; -import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; +import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument"; +import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types"; +import Alert from "../../../components/modals/Alert"; +import { useMessages } from "../../../contexts/MessagesContext"; +import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload"; +import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; -/** - * Completed create flow page. - * Figma: 20907-213286 (main), 18002-28017 (toast). - */ -export default function CompletedPage() { +export function CompletedScreen() { const mdUp = useCreateFlowMdUp(); const m = useMessages(); const completed = m.create.completed; @@ -40,7 +36,6 @@ export default function CompletedPage() { if (!stored) return; const parsed = parseDocumentSectionsForDisplay(stored.document); if (parsed.length === 0) return; - // One-shot hydration from client-only storage after mount. queueMicrotask(() => { setDocumentSections(parsed); setHeaderTitle(stored.title); diff --git a/app/create/informational/page.tsx b/app/create/screens/informational/InformationalScreen.tsx similarity index 57% rename from app/create/informational/page.tsx rename to app/create/screens/informational/InformationalScreen.tsx index baca2ce..1eb5d37 100644 --- a/app/create/informational/page.tsx +++ b/app/create/screens/informational/InformationalScreen.tsx @@ -1,18 +1,13 @@ "use client"; -import NumberedList from "../../components/type/NumberedList"; -import { useTranslation } from "../../contexts/MessagesContext"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; -import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; -import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; +import NumberedList from "../../../components/type/NumberedList"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; -/** - * Informational page for the create flow - * - * Displays information about the create flow process using HeaderLockup and NumberedList components. - * Lockup sizing via `CreateFlowHeaderLockup`. NumberedList: S / M by breakpoint. - */ -export default function InformationalPage() { +/** Create Community — frame 1 (Figma 20094-16005). */ +export function InformationalScreen() { const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.informational"); diff --git a/app/create/review/page.tsx b/app/create/screens/review/CommunityReviewScreen.tsx similarity index 68% rename from app/create/review/page.tsx rename to app/create/screens/review/CommunityReviewScreen.tsx index 6d704da..ad08d0a 100644 --- a/app/create/review/page.tsx +++ b/app/create/screens/review/CommunityReviewScreen.tsx @@ -1,13 +1,13 @@ "use client"; -import RuleCard from "../../components/cards/RuleCard"; -import { useTranslation } from "../../contexts/MessagesContext"; -import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; -import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; +import RuleCard from "../../../components/cards/RuleCard"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; -/** Mid-flow review step (after upload, before cards). */ -export default function ReviewPage() { +/** Create Community — frame 8 (Figma 19706-12135); URL segment `review`. */ +export function CommunityReviewScreen() { const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.review"); diff --git a/app/create/final-review/page.tsx b/app/create/screens/review/FinalReviewScreen.tsx similarity index 75% rename from app/create/final-review/page.tsx rename to app/create/screens/review/FinalReviewScreen.tsx index 303d24d..4c970d0 100644 --- a/app/create/final-review/page.tsx +++ b/app/create/screens/review/FinalReviewScreen.tsx @@ -1,15 +1,15 @@ "use client"; import { useMemo } from "react"; -import RuleCard from "../../components/cards/RuleCard"; -import type { Category } from "../../components/cards/RuleCard/RuleCard.types"; -import { useMessages, useTranslation } from "../../contexts/MessagesContext"; -import { useCreateFlow } from "../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; +import RuleCard from "../../../components/cards/RuleCard"; +import type { Category } from "../../../components/cards/RuleCard/RuleCard.types"; +import { useMessages, useTranslation } from "../../../contexts/MessagesContext"; +import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS, CreateFlowLockupCardStepShell, -} from "../components/CreateFlowLockupCardStepShell"; +} from "../../components/CreateFlowLockupCardStepShell"; function buildFinalReviewCategories( rows: { name: string; chips: string[] }[], @@ -24,11 +24,7 @@ function buildFinalReviewCategories( })); } -/** - * Final review step (right before completed). - * Figma: 20907-212767 (full-size), 20976-220705 (below `md`). - */ -export default function FinalReviewPage() { +export function FinalReviewScreen() { const { state } = useCreateFlow(); const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.finalReview"); diff --git a/app/create/right-rail/page.tsx b/app/create/screens/right-rail/RightRailScreen.tsx similarity index 81% rename from app/create/right-rail/page.tsx rename to app/create/screens/right-rail/RightRailScreen.tsx index b5da495..b592951 100644 --- a/app/create/right-rail/page.tsx +++ b/app/create/screens/right-rail/RightRailScreen.tsx @@ -1,19 +1,15 @@ "use client"; import { useState, useCallback, useMemo } from "react"; -import DecisionMakingSidebar from "../../components/utility/DecisionMakingSidebar"; -import CardStack from "../../components/utility/CardStack"; -import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types"; -import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types"; -import { useMessages } from "../../contexts/MessagesContext"; -import { useCreateFlow } from "../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; +import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar"; +import CardStack from "../../../components/utility/CardStack"; +import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types"; +import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types"; +import { useMessages } from "../../../contexts/MessagesContext"; +import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; -/** - * Right Rail step of the create flow. - * Two-column layout (sidebar + card stack) at 640+, single column at 320-639. - */ -export default function RightRailPage() { +export function RightRailScreen() { const m = useMessages(); const rr = m.create.rightRail; const mdUp = useCreateFlowMdUp(); @@ -81,9 +77,7 @@ export default function RightRailPage() {
-
+
>, + confirmState: "Unselected" | "Selected", + onInteraction?: () => void, +) { + const touch = () => onInteraction?.(); + return { + onAddClick: () => { + touch(); + setList((prev) => [ + ...prev, + { id: crypto.randomUUID(), label: "", state: "Custom" }, + ]); + }, + onCustomChipConfirm: (chipId: string, value: string) => { + touch(); + setList((prev) => + prev.map((opt) => + opt.id === chipId + ? { ...opt, label: value, state: confirmState } + : opt, + ), + ); + }, + onCustomChipClose: (chipId: string) => { + touch(); + setList((prev) => prev.filter((o) => o.id !== chipId)); + }, + }; +} + +function chipRowsFromLabels( + rows: readonly { label: string }[], +): ChipOption[] { + return rows.map((row, i) => ({ + id: String(i + 1), + label: row.label, + state: "Unselected" as const, + })); +} + +function selectedIdsFromOptions(options: ChipOption[]): string[] { + return options + .filter((o) => o.state === "Selected") + .map((o) => o.id); +} + +/** Create Community — frame 3 (Figma 20094-18244). */ +export function CommunitySizeSelectScreen() { + const m = useMessages(); + const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); + const mdUp = useCreateFlowMdUp(); + const t = useTranslation("create.communitySize"); + + const [communitySizeOptions, setCommunitySizeOptions] = useState< + ChipOption[] + >(() => { + const base = chipRowsFromLabels(m.create.communitySize.communitySizes); + const selected = new Set(state.selectedCommunitySizeIds ?? []); + return base.map((opt) => ({ + ...opt, + state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const), + })); + }); + + useEffect(() => { + const selected = new Set(state.selectedCommunitySizeIds ?? []); + setCommunitySizeOptions((prev) => + prev.map((opt) => + opt.state === "Custom" + ? opt + : { + ...opt, + state: selected.has(opt.id) + ? ("Selected" as const) + : ("Unselected" as const), + }, + ), + ); + }, [state.selectedCommunitySizeIds]); + + const communityCustomHandlers = useMemo( + () => + createListCustomHandlers( + setCommunitySizeOptions, + "Unselected", + markCreateFlowInteraction, + ), + [markCreateFlowInteraction], + ); + + const persistSelection = (next: ChipOption[]) => { + markCreateFlowInteraction(); + setCommunitySizeOptions(next); + updateState({ + selectedCommunitySizeIds: selectedIdsFromOptions(next), + }); + }; + + const handleCommunitySizeClick = (chipId: string) => { + const next: ChipOption[] = communitySizeOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: + opt.state === "Selected" + ? ("Unselected" as const) + : ("Selected" as const), + } + : opt, + ); + persistSelection(next); + }; + + const multiLabel = t("multiSelect.label"); + const addText = t("multiSelect.addButtonText"); + + const multiSelectBlock = ( + + ); + + return ( + + {mdUp ? ( +
+
+ +
+
+ {multiSelectBlock} +
+
+ ) : ( +
+ + {multiSelectBlock} +
+ )} +
+ ); +} diff --git a/app/create/select/page.tsx b/app/create/screens/select/CommunityStructureSelectScreen.tsx similarity index 58% rename from app/create/select/page.tsx rename to app/create/screens/select/CommunityStructureSelectScreen.tsx index 316238f..7878dea 100644 --- a/app/create/select/page.tsx +++ b/app/create/screens/select/CommunityStructureSelectScreen.tsx @@ -3,16 +3,17 @@ import { useState, useMemo, + useEffect, type Dispatch, type SetStateAction, } from "react"; -import MultiSelect from "../../components/controls/MultiSelect"; -import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types"; -import { useMessages, useTranslation } from "../../contexts/MessagesContext"; -import { useCreateFlow } from "../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; -import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; -import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; +import MultiSelect from "../../../components/controls/MultiSelect"; +import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types"; +import { useMessages, useTranslation } from "../../../contexts/MessagesContext"; +import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; function createListCustomHandlers( setList: Dispatch>, @@ -55,40 +56,60 @@ function chipRowsFromLabels( })); } -/** - * Select page for the create flow - * - * Displays selection options using HeaderLockup and MultiSelect components. - * Responsive layout: two-column at `md` and up, single column below (see `--breakpoint-md` in `app/tailwind.css`). - * Lockup sizing via `CreateFlowHeaderLockup`. MultiSelect stays `S`. - */ -export default function SelectPage() { - const m = useMessages(); - const { markCreateFlowInteraction } = useCreateFlow(); - const mdUp = useCreateFlowMdUp(); - const t = useTranslation("create.select"); +function applySavedSelection( + options: ChipOption[], + saved: string[] | undefined, +): ChipOption[] { + const selected = new Set(saved ?? []); + return options.map((opt) => + opt.state === "Custom" + ? opt + : { + ...opt, + state: selected.has(opt.id) + ? ("Selected" as const) + : ("Unselected" as const), + }, + ); +} - const [communitySizeOptions, setCommunitySizeOptions] = useState< - ChipOption[] - >(() => chipRowsFromLabels(m.create.select.communitySizes)); +/** Create Community — frame 5 (Figma 20094-41317). */ +export function CommunityStructureSelectScreen() { + const m = useMessages(); + const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); + const mdUp = useCreateFlowMdUp(); + const t = useTranslation("create.communityStructure"); const [organizationTypeOptions, setOrganizationTypeOptions] = useState< ChipOption[] - >(() => chipRowsFromLabels(m.create.select.organizationTypes)); + >(() => + applySavedSelection( + chipRowsFromLabels(m.create.communityStructure.organizationTypes), + state.selectedOrganizationTypeIds, + ), + ); const [governanceStyleOptions, setGovernanceStyleOptions] = useState< ChipOption[] - >(() => chipRowsFromLabels(m.create.select.governanceStyles)); - - const communityCustomHandlers = useMemo( - () => - createListCustomHandlers( - setCommunitySizeOptions, - "Unselected", - markCreateFlowInteraction, - ), - [markCreateFlowInteraction], + >(() => + applySavedSelection( + chipRowsFromLabels(m.create.communityStructure.governanceStyles), + state.selectedGovernanceStyleIds, + ), ); + + useEffect(() => { + setOrganizationTypeOptions((prev) => + applySavedSelection(prev, state.selectedOrganizationTypeIds), + ); + }, [state.selectedOrganizationTypeIds]); + + useEffect(() => { + setGovernanceStyleOptions((prev) => + applySavedSelection(prev, state.selectedGovernanceStyleIds), + ); + }, [state.selectedGovernanceStyleIds]); + const organizationCustomHandlers = useMemo( () => createListCustomHandlers( @@ -108,46 +129,54 @@ export default function SelectPage() { [markCreateFlowInteraction], ); - const handleCommunitySizeClick = (chipId: string) => { + const persistOrg = (next: ChipOption[]) => { markCreateFlowInteraction(); - setCommunitySizeOptions((prev) => - prev.map((opt) => - opt.id === chipId - ? { - ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", - } - : opt, - ), - ); + setOrganizationTypeOptions(next); + updateState({ + selectedOrganizationTypeIds: next + .filter((o) => o.state === "Selected") + .map((o) => o.id), + }); + }; + + const persistGov = (next: ChipOption[]) => { + markCreateFlowInteraction(); + setGovernanceStyleOptions(next); + updateState({ + selectedGovernanceStyleIds: next + .filter((o) => o.state === "Selected") + .map((o) => o.id), + }); }; const handleOrganizationTypeClick = (chipId: string) => { - markCreateFlowInteraction(); - setOrganizationTypeOptions((prev) => - prev.map((opt) => - opt.id === chipId - ? { - ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", - } - : opt, - ), + const next: ChipOption[] = organizationTypeOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: + opt.state === "Selected" + ? ("Unselected" as const) + : ("Selected" as const), + } + : opt, ); + persistOrg(next); }; const handleGovernanceStyleClick = (chipId: string) => { - markCreateFlowInteraction(); - setGovernanceStyleOptions((prev) => - prev.map((opt) => - opt.id === chipId - ? { - ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", - } - : opt, - ), + const next: ChipOption[] = governanceStyleOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: + opt.state === "Selected" + ? ("Unselected" as const) + : ("Selected" as const), + } + : opt, ); + persistGov(next); }; const multiLabel = t("multiSelect.label"); @@ -155,15 +184,6 @@ export default function SelectPage() { const multiSelectBlock = ( <> - - typeof state.title === "string" ? state.title : "", - ); + const t = useTranslation(messageNamespace); + + const readFromState = (): string => { + const raw = state[stateField]; + return typeof raw === "string" ? raw : ""; + }; + + const [value, setValue] = useState(() => readFromState()); useEffect(() => { - const incoming = state.title; - if (typeof incoming !== "string" || incoming.length === 0) return; - // eslint-disable-next-line react-hooks/set-state-in-effect -- sync controlled field when context hydrates from server/local + const incoming = readFromState(); + if (incoming.length === 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local setValue((prev) => (prev === "" ? incoming : prev)); - }, [state.title]); + }, [state, stateField]); - const maxLength = 48; const characterCount = value.length; const hint = t("characterCountTemplate") .replace("{current}", String(characterCount)) @@ -52,7 +62,7 @@ export default function TextPage() { const v = e.target.value; setValue(v); markCreateFlowInteraction(); - updateState({ title: v }); + updateState({ [stateField]: v } as Record); }} inputSize={mdUp ? "medium" : "small"} formHeader={false} diff --git a/app/create/upload/page.tsx b/app/create/screens/upload/CommunityUploadScreen.tsx similarity index 50% rename from app/create/upload/page.tsx rename to app/create/screens/upload/CommunityUploadScreen.tsx index 05ab2ed..e479df8 100644 --- a/app/create/upload/page.tsx +++ b/app/create/screens/upload/CommunityUploadScreen.tsx @@ -1,27 +1,20 @@ "use client"; -import Upload from "../../components/controls/Upload"; -import { useTranslation } from "../../contexts/MessagesContext"; -import { useCreateFlow } from "../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; -import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; -import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; +import Upload from "../../../components/controls/Upload"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; -/** - * Upload page for the create flow - * - * Displays upload functionality using HeaderLockup and Upload components. - * Responsive layout: centered at `md` and up, left-aligned below. - * Lockup sizing via `CreateFlowHeaderLockup`. - */ -export default function UploadPage() { +/** Create Community — frame 6 (Figma 20094-41524). */ +export function CommunityUploadScreen() { const { markCreateFlowInteraction } = useCreateFlow(); const mdUp = useCreateFlowMdUp(); - const t = useTranslation("create.upload"); + const t = useTranslation("create.communityUpload"); const handleUploadClick = () => { markCreateFlowInteraction(); - // TODO: Handle upload button click (e.g. open file picker) }; return ( diff --git a/app/create/types.ts b/app/create/types.ts index ff91f13..66a08e2 100644 --- a/app/create/types.ts +++ b/app/create/types.ts @@ -6,13 +6,17 @@ */ /** - * Valid step IDs for the create rule flow + * Valid step IDs for the create rule flow (URL segment after `/create/`). + * Create Community order matches Figma; `review` closes that stage per design. */ export type CreateFlowStep = | "informational" - | "text" - | "select" - | "upload" + | "community-name" + | "community-size" + | "community-context" + | "community-structure" + | "community-upload" + | "community-reflection" | "review" | "cards" | "right-rail" @@ -20,6 +24,13 @@ export type CreateFlowStep = | "final-review" | "completed"; +/** String keys used by generic text-field steps for `CreateFlowState`. */ +export type CreateFlowTextStateField = + | "title" + | "summary" + | "communityContext" + | "communityReflection"; + /** * Flow state for inputs across create-flow steps. * Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks). @@ -28,6 +39,15 @@ export type CreateFlowStep = export interface CreateFlowState { title?: string; summary?: string; + /** Additional copy fields for multi-step Create Community text frames (Figma). */ + communityContext?: string; + communityReflection?: string; + /** Selected chip ids from `community-size` (MultiSelect). */ + selectedCommunitySizeIds?: string[]; + /** Selected chip ids from `community-structure` (organization types). */ + selectedOrganizationTypeIds?: string[]; + /** Selected chip ids from `community-structure` (governance styles). */ + selectedGovernanceStyleIds?: string[]; currentStep?: CreateFlowStep; /** Section drafts; structure will tighten as steps persist real shapes. */ sections?: Record[]; @@ -51,7 +71,7 @@ export interface CreateFlowContextValue { clearState: () => void; /** * True after the user edits any template control (pages use local state until wired to `state`). - * Drives Save & Exit visibility together with `hasCreateFlowUserInput(state)`. + * Drives Save & Exit visibility together with hasCreateFlowUserInput (utils/hasCreateFlowUserInput.ts). */ interactionTouched: boolean; markCreateFlowInteraction: () => void; diff --git a/app/create/anonymousDraftStorage.ts b/app/create/utils/anonymousDraftStorage.ts similarity index 98% rename from app/create/anonymousDraftStorage.ts rename to app/create/utils/anonymousDraftStorage.ts index 9026bbb..f11569c 100644 --- a/app/create/anonymousDraftStorage.ts +++ b/app/create/utils/anonymousDraftStorage.ts @@ -1,4 +1,4 @@ -import type { CreateFlowState } from "./types"; +import type { CreateFlowState } from "../types"; /** Anonymous in-progress create flow (local only until magic-link transfer). */ export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const; diff --git a/app/create/utils/createFlowScreenRegistry.ts b/app/create/utils/createFlowScreenRegistry.ts new file mode 100644 index 0000000..6ef23ad --- /dev/null +++ b/app/create/utils/createFlowScreenRegistry.ts @@ -0,0 +1,123 @@ +import type { CreateFlowStep } from "../types"; + +/** + * Figma layout families for the create flow (not encoded in the URL). + * Registry and `app/create/screens/` are organized by these kinds. + */ +export type CreateFlowLayoutKind = + | "informational" + | "text" + | "select" + | "upload" + | "review" + | "card" + | "right-rail" + | "completed"; + +export interface CreateFlowScreenDefinition { + layoutKind: CreateFlowLayoutKind; + /** Figma node id (file Community-Rule-System), dev mode. */ + figmaNodeId: string; + /** + * Namespace for `useTranslation`, e.g. `create.communityName`. + * Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere). + */ + messageNamespace: string; + /** Match legacy `text` step: main area vertically centered below `md`. */ + centeredBodyBelowMd: boolean; +} + +/** + * Registry: **distinct URL (`CreateFlowStep`) → Figma + layout**. + * Source of truth for product order remains `FLOW_STEP_ORDER` in `flowSteps.ts`. + */ +export const CREATE_FLOW_SCREEN_REGISTRY: Record< + CreateFlowStep, + CreateFlowScreenDefinition +> = { + informational: { + layoutKind: "informational", + figmaNodeId: "20094-16005", + messageNamespace: "create.informational", + centeredBodyBelowMd: false, + }, + "community-name": { + layoutKind: "text", + figmaNodeId: "20094-18187", + messageNamespace: "create.communityName", + centeredBodyBelowMd: true, + }, + "community-size": { + layoutKind: "select", + figmaNodeId: "20094-18244", + messageNamespace: "create.communitySize", + centeredBodyBelowMd: false, + }, + "community-context": { + layoutKind: "text", + figmaNodeId: "20094-41243", + messageNamespace: "create.communityContext", + centeredBodyBelowMd: true, + }, + "community-structure": { + layoutKind: "select", + figmaNodeId: "20094-41317", + messageNamespace: "create.communityStructure", + centeredBodyBelowMd: false, + }, + "community-upload": { + layoutKind: "upload", + figmaNodeId: "20094-41524", + messageNamespace: "create.communityUpload", + centeredBodyBelowMd: false, + }, + "community-reflection": { + layoutKind: "text", + figmaNodeId: "20097-14948", + messageNamespace: "create.communityReflection", + centeredBodyBelowMd: true, + }, + review: { + layoutKind: "review", + figmaNodeId: "19706-12135", + messageNamespace: "create.review", + centeredBodyBelowMd: false, + }, + cards: { + layoutKind: "card", + figmaNodeId: "TBD-cards", + messageNamespace: "create.communication", + centeredBodyBelowMd: false, + }, + "right-rail": { + layoutKind: "right-rail", + figmaNodeId: "TBD-right-rail", + messageNamespace: "create.rightRail", + centeredBodyBelowMd: false, + }, + "confirm-stakeholders": { + layoutKind: "select", + figmaNodeId: "21104-46594", + messageNamespace: "create.confirmStakeholders", + centeredBodyBelowMd: false, + }, + "final-review": { + layoutKind: "review", + figmaNodeId: "20907-212767", + messageNamespace: "create.finalReview", + centeredBodyBelowMd: false, + }, + completed: { + layoutKind: "completed", + figmaNodeId: "20907-213286", + messageNamespace: "create.completed", + centeredBodyBelowMd: false, + }, +}; + +export function createFlowStepUsesCenteredTextLayout( + step: CreateFlowStep | null, +): boolean { + if (!step) return false; + return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd; +} diff --git a/app/create/utils/flowSteps.ts b/app/create/utils/flowSteps.ts index 474cc51..bd06f8e 100644 --- a/app/create/utils/flowSteps.ts +++ b/app/create/utils/flowSteps.ts @@ -2,6 +2,7 @@ * Step definitions and helpers for the Create Rule Flow * * Single source of truth for step order and navigation helpers. + * Order matches Figma Create Community (frames 1–8) then later stages. */ import type { CreateFlowStep } from "../types"; @@ -11,9 +12,12 @@ import type { CreateFlowStep } from "../types"; */ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [ "informational", - "text", - "select", - "upload", + "community-name", + "community-size", + "community-context", + "community-structure", + "community-upload", + "community-reflection", "review", "cards", "right-rail", @@ -75,3 +79,23 @@ export function isValidStep( (VALID_STEPS as readonly string[]).includes(step) ); } + +/** + * Parses `/create/{screenId}` (and optional trailing segments) from pathname. + * Returns null for non-wizard paths (e.g. `/create/review-template/...`). + */ +export function parseCreateFlowScreenFromPathname( + pathname: string | null, +): CreateFlowStep | null { + if (!pathname || pathname.length === 0) return null; + if (pathname.includes("/create/review-template/")) return null; + + const parts = pathname.split("/").filter(Boolean); + const createIdx = parts.indexOf("create"); + if (createIdx === -1 || createIdx >= parts.length - 1) return null; + + const segment = parts[createIdx + 1]; + if (segment === "review-template") return null; + + return isValidStep(segment) ? segment : null; +} diff --git a/app/create/hasCreateFlowUserInput.ts b/app/create/utils/hasCreateFlowUserInput.ts similarity index 94% rename from app/create/hasCreateFlowUserInput.ts rename to app/create/utils/hasCreateFlowUserInput.ts index 0632665..f0391fc 100644 --- a/app/create/hasCreateFlowUserInput.ts +++ b/app/create/utils/hasCreateFlowUserInput.ts @@ -1,4 +1,4 @@ -import type { CreateFlowState } from "./types"; +import type { CreateFlowState } from "../types"; const IGNORED_KEYS = new Set(["currentStep"]); diff --git a/docs/backend-linear-tickets.md b/docs/backend-linear-tickets.md index d322773..a1eb795 100644 --- a/docs/backend-linear-tickets.md +++ b/docs/backend-linear-tickets.md @@ -190,7 +190,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Implementation:** 1. **Hydration:** **Done:** [SignedInDraftHydration](app/create/SignedInDraftHydration.tsx) + [messages/en/create/draftHydration.json](messages/en/create/draftHydration.json); skips `?syncDraft=1` / transfer-pending (PostLogin owns that). Wired in [layout](app/create/layout.tsx). -2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional. +2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/utils/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional. 3. **Save failures (API surface):** **Done (CR-76):** [saveDraftToServer](lib/create/api.ts) returns `SaveDraftResult` with parsed API `message`; wired in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx). 4. **Save failures (UX):** **Done (CR-76):** Dismissible banner with server `message` (no second confirm to leave); post-login transfer shows reason; unit tests in `tests/unit/saveDraftToServer.test.ts`. Retry/backoff remains optional. 5. **Tests:** `saveDraftToServer` unit tests; [draftHydrationUtils](lib/create/draftHydrationUtils.ts) unit tests. Playwright against Next standalone + route mocks for `/api/auth/session` was flaky here; cover hydration with **manual QA** (signed in + sync on + server draft) or add a future E2E with a dedicated auth fixture. @@ -210,7 +210,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Goal:** Completing the flow persists a **PublishedRule** via existing [publishRule](lib/create/api.ts). -**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on [app/create/final-review/page.tsx](app/create/final-review/page.tsx) or [completed/page.tsx](app/create/completed/page.tsx) must call it with `{ title, summary?, document }` derived from `CreateFlowState`. +**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on the `final-review` / `completed` steps (see [app/create/screens/CreateFlowScreenView.tsx](app/create/screens/CreateFlowScreenView.tsx) and `app/create/screens/`) must call it with `{ title, summary?, document }` derived from `CreateFlowState`. **Implementation:** @@ -258,7 +258,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi **Goal:** Home or create entry surfaces use live template data instead of only static i18n JSON. -**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and [app/create/[step]/page.tsx](app/create/[step]/page.tsx) placeholders reference future template work (CR-51–55). +**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and create entry surfaces reference future template work. Wizard URLs are static segments under `app/create/`; see [`docs/create-flow.md`](create-flow.md) and **Ticket 17** for the canonical custom flow. **Implementation:** @@ -271,7 +271,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi - [ ] Changing a template row in Prisma Studio reflects after refresh (or revalidate). - [ ] No layout shift regression on LCP-critical pages (use skeletons). -**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), [app/create/[step]/page.tsx](app/create/[step]/page.tsx) or related, possibly new `lib/templates/fetchTemplates.ts`. +**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), create-flow entry routes under [app/create/](app/create/), possibly new `lib/templates/fetchTemplates.ts`. **Follow-up:** **Ticket 16** — dynamic recommendations from authoring spreadsheets and create-flow answers. @@ -305,6 +305,37 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi --- +## Ticket 17 — Canon custom create-rule wizard (routes, resume, progress) + docs + +**Depends on:** none for documentation; soft optional **CR-73**, **CR-76**, **CR-77** for payload/resume/publish alignment. + +**Goal:** Establish the **official custom** create-rule flow (ordered steps, URLs, persistence, entry points, **Figma three-stage framing**) in repo docs and close gaps between that spec and the implementation (routing clutter, progress UI, step source of truth, resume vs URL). + +**Context:** Step order lives in [`app/create/utils/flowSteps.ts`](app/create/utils/flowSteps.ts). Wizard screens render from [`app/create/[screenId]/page.tsx`](app/create/[screenId]/page.tsx) plus [`CREATE_FLOW_SCREEN_REGISTRY`](app/create/utils/createFlowScreenRegistry.ts) (Figma node + layout family per slug). [`docs/create-flow.md`](create-flow.md) is the **canonical** URL/persistence summary: **Create Community** (eight semantic steps ending at `review`) → **Create Custom CommunityRule** → **Review and complete**. **Full create-from-template** will likely use **additional route(s)** when product defines it; **`/create/review-template/[slug]`** remains auxiliary preview only. **Template → `final-review` or mid-wizard prefill** is **out of scope** here (future ticket); `/create/informational?template=` is a **no-op** until then. + +**Implementation:** + +1. Keep [`docs/create-flow.md`](create-flow.md) in sync with product/Figma (stage ↔ step mapping, future template routes). +2. ~~Remove legacy [`app/create/[step]/page.tsx`](app/create/[step]/page.tsx)~~ — replaced by [`app/create/[screenId]/page.tsx`](app/create/[screenId]/page.tsx) with real screens; unknown slugs `notFound()`. +3. Unify **step source of truth**: URL via [`useCreateFlowNavigation`](app/create/hooks/useCreateFlowNavigation.ts) vs unused [`CreateFlowContext`](app/create/context/CreateFlowContext.tsx) `currentStep` — pick one model; align [`useCreateFlowExit`](app/create/hooks/useCreateFlowExit.ts) / draft payload if needed. +4. **Resume:** After [`SignedInDraftHydration`](app/create/SignedInDraftHydration.tsx), decide redirect to `/create/${state.currentStep}` vs stay on current URL; test or document. +5. Wire [`CreateFlowFooter`](app/components/utility/CreateFlowFooter/) `ProportionBar` to step progress from `FLOW_STEP_ORDER` (and `review-template` / `completed` exceptions per design); optional **two-level progress** (stage + step within stage) when design specifies. +6. When Figma hands off, surface **stage labels** in create shell (top nav, footer, or step chrome) using the mapping in `create-flow.md`. + +**Acceptance criteria:** + +- [ ] [`docs/create-flow.md`](create-flow.md) matches shipped behavior or lists known gaps, including **Figma three-stage** mapping and **future template route** note. +- [ ] No misleading dynamic step placeholder for valid wizard URLs. +- [ ] Footer progress reflects step index **or** doc/issue records a deliberate deferral with design sign-off. +- [ ] Hydration + `currentStep` behavior is verified (redirect vs stay). +- [ ] `?template=` documented as deferred; no implied “template customize → full wizard” parity. + +**Files:** [`docs/create-flow.md`](create-flow.md), [`app/create/`](app/create/), [`app/components/utility/CreateFlowFooter/`](app/components/utility/CreateFlowFooter/), optionally [`docs/backend-roadmap.md`](backend-roadmap.md) §12 cross-links. + +**Linear:** [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) (**Backlog**). **Parallel** to templates (7–8) and publish (6); not part of **CR-72 → CR-83**. + +--- + ## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM) **Depends on:** none (orthogonal). @@ -509,14 +540,15 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi | 14 | 14 | Session lifecycle + cleanup | | 15 | 15 | Profile + account (Figma profile) | | 16 | 16 | Template matrix + xlsx ingestion | +| 17 | 17 | Canon create-flow (custom path) | -Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**. +Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Ticket 17** (**[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) and tracks UX/code alignment (progress bar, resume URL, `[step]` cleanup); **parallel** to publish and templates. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**. --- ## Linear (Community-rule team) -**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), not in the CR-72–83 sequence. +**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), **CR-89** / Ticket 17 (canon create-flow + implementation gaps), not in the CR-72–83 sequence. | Doc ticket | Linear | Title (short) | | ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | @@ -536,6 +568,7 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts + | 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup | | 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | | 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) | Template matrix + xlsx ingestion | +| 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) | --- diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index b49afba..f4c7593 100644 --- a/docs/backend-roadmap.md +++ b/docs/backend-roadmap.md @@ -9,7 +9,7 @@ Temporary working notes for building the backend. Safe to delete once the stack - **Next.js 16** single repo ([`package.json`](package.json)). - **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals). - **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.). -- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users start a **fresh** in-memory session per “Create rule”; **Save & Exit** (from `select` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. +- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users get a **fresh** in-memory session per “Create rule” entry, but with sync on the layout may **hydrate** from **`GET /api/drafts/me`** via [`SignedInDraftHydration`](app/create/SignedInDraftHydration.tsx); **Save & Exit** (from `community-size` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. **Step order and URLs:** [`docs/create-flow.md`](docs/create-flow.md) and [`app/create/utils/flowSteps.ts`](app/create/utils/flowSteps.ts). - **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production). - **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition. @@ -138,7 +138,7 @@ Match the current API behavior; tighten as product evolves: **Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7. -**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md). +**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **canon create-flow alignment** (Ticket 17 / [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) — progress bar, resume URL, `[step]` cleanup; spec in [`docs/create-flow.md`](create-flow.md)), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md). --- @@ -218,7 +218,7 @@ npm run dev ## 12. Frontend hook-up -**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** “Create rule” does **not** auto-load server drafts yet (profile “open draft” is future). +**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** users: when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**, the create layout may **hydrate** in-memory flow state from **`GET /api/drafts/me`** once per session ([`SignedInDraftHydration`](../app/create/SignedInDraftHydration.tsx)), including conflict handling if anonymous storage also has data. Without sync, signed-in progress stays **in memory** until **Save & Exit** (no automatic server read on entry). **Canonical wizard step order, URLs, and Figma product stages** (**Create Community** → **Create Custom CommunityRule** → **Review and complete**) are documented in [`docs/create-flow.md`](create-flow.md). The route **`/create/review-template/[slug]`** is an **auxiliary** template preview (not a numbered wizard step); a **full create-from-template** path will likely be **separate route(s)** when defined. **Prefilling the wizard or landing on `final-review` from a template** is **not** shipped yet — see **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** / Ticket 17 in [docs/backend-linear-tickets.md](backend-linear-tickets.md). **Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to enable **PUT** on **Save & Exit** and after **magic-link transfer** from the save-progress exit modal. diff --git a/docs/create-flow.md b/docs/create-flow.md new file mode 100644 index 0000000..46cd5dd --- /dev/null +++ b/docs/create-flow.md @@ -0,0 +1,81 @@ +# Create rule flow (custom wizard) — canonical reference + +Product/engineering reference for the **custom** “Create rule” experience: URL order, persistence, and entry points. **Implementation work** to align code with this doc (progress bar, resume redirects, etc.) is tracked in Linear **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** and [docs/backend-linear-tickets.md](backend-linear-tickets.md) **Ticket 17**. + +--- + +## Product stages (Figma) + +The Figma **Create Community** sequence is the **source of truth** for the first segment of the wizard (eight frames). After **`review`**, the flow continues with **Create Custom CommunityRule** and **Review and complete** stages. The shipped URL sequence in [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts) **follows that trajectory**; stages are a **product** slice of that linear order, not separate routers today. + +| Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) | +| --- | --- | --- | +| **Create Community** | Intro, naming, size, context, structure, upload, reflection, then community review. | `informational` → `community-name` → `community-size` → `community-context` → `community-structure` → `community-upload` → `community-reflection` → `review` | +| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `cards` → `right-rail` | +| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` | + +Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed** — `CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens). + +**Create from template (future):** A full **template-driven** create path is **not** finalized; it will likely live on **additional route(s)** (and may reuse these stages where it overlaps the custom trajectory). Today, **`/create/review-template/[slug]`** is only an auxiliary **preview** in the create shell; it is **not** a Figma stage and not the final template-create entry. See **Out of scope** in [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo). + +--- + +## Step order and URLs + +Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts) and the [`CreateFlowStep`](../app/create/types.ts) type. Wizard steps use a **single dynamic route**: [`app/create/[screenId]/page.tsx`](../app/create/[screenId]/page.tsx), which validates `screenId` and renders [`CreateFlowScreenView`](../app/create/screens/CreateFlowScreenView.tsx). Implementation files are grouped under [`app/create/screens/`](../app/create/screens/) by Figma **layout kind** (subfolders: informational, text, select, upload, review, card, right-rail, completed). **`/create`** redirects to the first step. + +| Order | Figma stage | Step ID (`screenId`) | Path | +| ----: | ----------- | -------------------- | ---- | +| 1 | Create Community | `informational` | `/create/informational` | +| 2 | Create Community | `community-name` | `/create/community-name` | +| 3 | Create Community | `community-size` | `/create/community-size` | +| 4 | Create Community | `community-context` | `/create/community-context` | +| 5 | Create Community | `community-structure` | `/create/community-structure` | +| 6 | Create Community | `community-upload` | `/create/community-upload` | +| 7 | Create Community | `community-reflection` | `/create/community-reflection` | +| 8 | Create Community (review frame) | `review` | `/create/review` | +| 9 | Create Custom CommunityRule | `cards` | `/create/cards` | +| 10 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` | +| 11 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` | +| 12 | Review and complete | `final-review` | `/create/final-review` | +| 13 | Review and complete | `completed` | `/create/completed` | + +**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)). + +Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/create/hooks/useCreateFlowNavigation.ts). + +--- + +## Auxiliary route (not a wizard step or Figma stage) + +| Path | Purpose | +| --- | --- | +| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. | + +From that page, **Customize** currently navigates to `/create/informational?template=`. The **`template` query parameter is reserved**; the informational step **does not** yet read it to prefill `CreateFlowState`. **Starting the wizard from a template at `final-review` or any mid-flow step** is **out of scope** until a dedicated product ticket ships. A **full create-from-template** experience will **likely use separate route(s)** when product and eng define it (may still align conceptually with the same three stages where behavior overlaps the custom path). + +--- + +## Persistence and exit + +| Mode | Where progress lives | Save & Exit / server draft | +| --- | --- | --- | +| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [backend-linear-tickets.md](backend-linear-tickets.md)). | +| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-size`** step onward (step index ≥ `community-size`) may **PUT** `/api/drafts/me` when sync is on. **Sign out** is on profile, not in the create top nav. | + +Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/backend-roadmap.md`](backend-roadmap.md) §12. + +--- + +## Known implementation gaps (tracked on CR-89) + +- **URL vs `currentStep` in saved draft:** hydration may merge server JSON without redirecting to `state.currentStep`; confirm product behavior and fix or document. +- **Footer progress:** `ProportionBar` is not yet driven by step index vs `FLOW_STEP_ORDER`. +- **Inner “text/select shells”:** deferred until Create Community is stable; screens use **`CreateFlowStepShell`** only for Stage 1. + +--- + +## Related docs + +- [docs/backend-roadmap.md](backend-roadmap.md) §12 — Frontend hook-up +- [docs/backend-linear-tickets.md](backend-linear-tickets.md) — Tickets 4, 5, 6, 17 diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index f4a7868..156be48 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -50,11 +50,20 @@ export function buildPublishPayload( return { ok: false, error: "missingCommunityName" }; } - let summary: string | undefined; - if (typeof state.summary === "string") { - const t = state.summary.trim(); - if (t.length > 0) summary = t; - } + const firstNonEmpty = (...candidates: unknown[]): string | undefined => { + for (const c of candidates) { + if (typeof c !== "string") continue; + const t = c.trim(); + if (t.length > 0) return t; + } + return undefined; + }; + + let summary = firstNonEmpty( + state.summary, + state.communityContext, + state.communityReflection, + ); let sections = parseSectionsFromCreateFlowState(state); if (sections.length === 0) { diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index c5c1476..f35ebde 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -29,6 +29,11 @@ export const createFlowStateSchema = z .object({ title: z.string().max(500).optional(), summary: z.string().max(8000).optional(), + communityContext: z.string().max(8000).optional(), + communityReflection: z.string().max(8000).optional(), + selectedCommunitySizeIds: z.array(z.string()).optional(), + selectedOrganizationTypeIds: z.array(z.string()).optional(), + selectedGovernanceStyleIds: z.array(z.string()).optional(), currentStep: createFlowStepSchema.optional(), sections: z.array(z.unknown()).optional(), stakeholders: z.array(z.unknown()).optional(), diff --git a/messages/en/create/communityContext.json b/messages/en/create/communityContext.json new file mode 100644 index 0000000..41043e0 --- /dev/null +++ b/messages/en/create/communityContext.json @@ -0,0 +1,6 @@ +{ + "title": "Tell us more about your community", + "description": "Share context that will help shape your CommunityRule.", + "placeholder": "Describe your community", + "characterCountTemplate": "{current}/{max}" +} diff --git a/messages/en/create/communityName.json b/messages/en/create/communityName.json new file mode 100644 index 0000000..2e06ffe --- /dev/null +++ b/messages/en/create/communityName.json @@ -0,0 +1,6 @@ +{ + "title": "What is your community called?", + "description": "This will be the name of your community", + "placeholder": "Enter your community name", + "characterCountTemplate": "{current}/{max}" +} diff --git a/messages/en/create/communityReflection.json b/messages/en/create/communityReflection.json new file mode 100644 index 0000000..e789258 --- /dev/null +++ b/messages/en/create/communityReflection.json @@ -0,0 +1,6 @@ +{ + "title": "Anything else we should know?", + "description": "Optional details before you review your progress.", + "placeholder": "Add notes (optional)", + "characterCountTemplate": "{current}/{max}" +} diff --git a/messages/en/create/communitySize.json b/messages/en/create/communitySize.json new file mode 100644 index 0000000..41e69e4 --- /dev/null +++ b/messages/en/create/communitySize.json @@ -0,0 +1,19 @@ +{ + "header": { + "title": "How large is your community?", + "description": "Choose the size that best matches your group." + }, + "multiSelect": { + "label": "Label", + "addButtonText": "Add organization type" + }, + "communitySizes": [ + { "label": "1 member" }, + { "label": "2-10 members" }, + { "label": "10-24 members" }, + { "label": "24-64 members" }, + { "label": "64-128 members" }, + { "label": "125-1000 members" }, + { "label": "1000+ members" } + ] +} diff --git a/messages/en/create/communityStructure.json b/messages/en/create/communityStructure.json new file mode 100644 index 0000000..2cd657b --- /dev/null +++ b/messages/en/create/communityStructure.json @@ -0,0 +1,22 @@ +{ + "header": { + "title": "How is your community organized?", + "description": "Select the options that best describe your group." + }, + "multiSelect": { + "label": "Label", + "addButtonText": "Add organization type" + }, + "organizationTypes": [ + { "label": "Non-profit" }, + { "label": "For-profit" }, + { "label": "Community" }, + { "label": "Educational" } + ], + "governanceStyles": [ + { "label": "Democratic" }, + { "label": "Consensus" }, + { "label": "Hierarchical" }, + { "label": "Flat" } + ] +} diff --git a/messages/en/create/communityUpload.json b/messages/en/create/communityUpload.json new file mode 100644 index 0000000..64fa00a --- /dev/null +++ b/messages/en/create/communityUpload.json @@ -0,0 +1,4 @@ +{ + "title": "How should conflicts be resolved?", + "description": "Upload supporting materials or examples that help describe how your community handles conflict." +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 67e191c..fb22667 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -20,9 +20,12 @@ import navigation from "./navigation.json"; import metadata from "./metadata.json"; import communication from "./create/communication.json"; import createInformational from "./create/informational.json"; -import createText from "./create/text.json"; -import createSelect from "./create/select.json"; -import createUpload from "./create/upload.json"; +import createCommunityName from "./create/communityName.json"; +import createCommunitySize from "./create/communitySize.json"; +import createCommunityContext from "./create/communityContext.json"; +import createCommunityStructure from "./create/communityStructure.json"; +import createCommunityUpload from "./create/communityUpload.json"; +import createCommunityReflection from "./create/communityReflection.json"; import createReview from "./create/review.json"; import createConfirmStakeholders from "./create/confirmStakeholders.json"; import createFinalReview from "./create/finalReview.json"; @@ -58,9 +61,12 @@ export default { create: { communication, informational: createInformational, - text: createText, - select: createSelect, - upload: createUpload, + communityName: createCommunityName, + communitySize: createCommunitySize, + communityContext: createCommunityContext, + communityStructure: createCommunityStructure, + communityUpload: createCommunityUpload, + communityReflection: createCommunityReflection, review: createReview, confirmStakeholders: createConfirmStakeholders, finalReview: createFinalReview, diff --git a/stories/pages/CardsPage.stories.js b/stories/pages/CardsPage.stories.js index c40fef8..b9260a4 100644 --- a/stories/pages/CardsPage.stories.js +++ b/stories/pages/CardsPage.stories.js @@ -1,8 +1,8 @@ -import CardsPage from "../../app/create/cards/page"; +import { CardsScreen } from "../../app/create/screens/card/CardsScreen"; export default { title: "Pages/Create Flow/Cards", - component: CardsPage, + component: CardsScreen, parameters: { layout: "fullscreen", docs: { diff --git a/stories/pages/CompletedPage.stories.js b/stories/pages/CompletedPage.stories.js index fbeb474..21128b9 100644 --- a/stories/pages/CompletedPage.stories.js +++ b/stories/pages/CompletedPage.stories.js @@ -1,8 +1,8 @@ -import CompletedPage from "../../app/create/completed/page"; +import { CompletedScreen } from "../../app/create/screens/completed/CompletedScreen"; export default { title: "Pages/Create Flow/Completed", - component: CompletedPage, + component: CompletedScreen, parameters: { layout: "fullscreen", docs: { diff --git a/stories/pages/ConfirmStakeholdersPage.stories.js b/stories/pages/ConfirmStakeholdersPage.stories.js index 0f38732..adb22e0 100644 --- a/stories/pages/ConfirmStakeholdersPage.stories.js +++ b/stories/pages/ConfirmStakeholdersPage.stories.js @@ -1,8 +1,8 @@ -import ConfirmStakeholdersPage from "../../app/create/confirm-stakeholders/page"; +import { ConfirmStakeholdersScreen } from "../../app/create/screens/select/ConfirmStakeholdersScreen"; export default { title: "Pages/Create Flow/Confirm stakeholders", - component: ConfirmStakeholdersPage, + component: ConfirmStakeholdersScreen, parameters: { layout: "fullscreen", docs: { diff --git a/stories/pages/FinalReviewPage.stories.js b/stories/pages/FinalReviewPage.stories.js index 77d4bc4..0653fe9 100644 --- a/stories/pages/FinalReviewPage.stories.js +++ b/stories/pages/FinalReviewPage.stories.js @@ -1,8 +1,8 @@ -import FinalReviewPage from "../../app/create/final-review/page"; +import { FinalReviewScreen } from "../../app/create/screens/review/FinalReviewScreen"; export default { title: "Pages/Create Flow/Final review", - component: FinalReviewPage, + component: FinalReviewScreen, parameters: { layout: "fullscreen", docs: { diff --git a/stories/pages/InformationalPage.stories.js b/stories/pages/InformationalPage.stories.js index 7b0d9ab..21f4d34 100644 --- a/stories/pages/InformationalPage.stories.js +++ b/stories/pages/InformationalPage.stories.js @@ -1,35 +1,9 @@ -import InformationalPage from "../../app/create/informational/page"; +import { InformationalScreen } from "../../app/create/screens/informational/InformationalScreen"; export default { - title: "Pages/Create Flow/Informational", - component: InformationalPage, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: - "Create flow entry: HeaderLockup + NumberedList. Responsive L/M and M/S at 640px.", - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], + title: "Pages/Create/Informational", + component: InformationalScreen, + parameters: { layout: "fullscreen" }, }; -export const Desktop = { - parameters: { - viewport: { defaultViewport: "desktop" }, - }, -}; - -export const Mobile = { - parameters: { - viewport: { defaultViewport: "mobile1" }, - }, -}; +export const Default = {}; diff --git a/stories/pages/ReviewPage.stories.js b/stories/pages/ReviewPage.stories.js index e86d37c..b68ee0a 100644 --- a/stories/pages/ReviewPage.stories.js +++ b/stories/pages/ReviewPage.stories.js @@ -1,39 +1,9 @@ -import ReviewPage from "../../app/create/review/page"; +import { CommunityReviewScreen } from "../../app/create/screens/review/CommunityReviewScreen"; export default { - title: "Pages/Create Flow/Review", - component: ReviewPage, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: - "Mid-flow review step (after upload). 640px+: HeaderLockup left (L), RuleCard right (L, collapsed). Below 640px: single column with HeaderLockup M and RuleCard M. Figma: 19688-13891, 19706-12120.", - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], + title: "Pages/Create/Review", + component: CommunityReviewScreen, + parameters: { layout: "fullscreen" }, }; -export const Desktop = { - parameters: { - viewport: { - defaultViewport: "desktop", - }, - }, -}; - -export const Mobile = { - parameters: { - viewport: { - defaultViewport: "mobile1", - }, - }, -}; +export const Default = {}; diff --git a/stories/pages/RightRailPage.stories.js b/stories/pages/RightRailPage.stories.js index 772e1bc..888e126 100644 --- a/stories/pages/RightRailPage.stories.js +++ b/stories/pages/RightRailPage.stories.js @@ -1,8 +1,8 @@ -import RightRailPage from "../../app/create/right-rail/page"; +import { RightRailScreen } from "../../app/create/screens/right-rail/RightRailScreen"; export default { title: "Pages/Create Flow/Right rail", - component: RightRailPage, + component: RightRailScreen, parameters: { layout: "fullscreen", docs: { diff --git a/stories/pages/SelectPage.stories.js b/stories/pages/SelectPage.stories.js index 86e69e5..a0c5307 100644 --- a/stories/pages/SelectPage.stories.js +++ b/stories/pages/SelectPage.stories.js @@ -1,35 +1,9 @@ -import SelectPage from "../../app/create/select/page"; +import { CommunitySizeSelectScreen } from "../../app/create/screens/select/CommunitySizeSelectScreen"; export default { - title: "Pages/Create Flow/Select", - component: SelectPage, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: - "Multi-select template: two columns at 640px+, stacked below. MultiSelect with add → custom chip.", - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], + title: "Pages/Create/CommunitySize", + component: CommunitySizeSelectScreen, + parameters: { layout: "fullscreen" }, }; -export const Desktop = { - parameters: { - viewport: { defaultViewport: "desktop" }, - }, -}; - -export const Mobile = { - parameters: { - viewport: { defaultViewport: "mobile1" }, - }, -}; +export const Default = {}; diff --git a/stories/pages/TextPage.stories.js b/stories/pages/TextPage.stories.js index 15debca..1510cea 100644 --- a/stories/pages/TextPage.stories.js +++ b/stories/pages/TextPage.stories.js @@ -1,35 +1,15 @@ -import TextPage from "../../app/create/text/page"; +import { CreateFlowTextFieldScreen } from "../../app/create/screens/text/CreateFlowTextFieldScreen"; export default { - title: "Pages/Create Flow/Text", - component: TextPage, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: - "Community name step: HeaderLockup + TextInput. Responsive sizing at 640px.", - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], + title: "Pages/Create/CommunityName", + component: CreateFlowTextFieldScreen, + parameters: { layout: "fullscreen" }, }; -export const Desktop = { - parameters: { - viewport: { defaultViewport: "desktop" }, - }, -}; - -export const Mobile = { - parameters: { - viewport: { defaultViewport: "mobile1" }, +export const Default = { + args: { + messageNamespace: "create.communityName", + stateField: "title", + maxLength: 48, }, }; diff --git a/stories/pages/UploadPage.stories.js b/stories/pages/UploadPage.stories.js index 4736497..387e999 100644 --- a/stories/pages/UploadPage.stories.js +++ b/stories/pages/UploadPage.stories.js @@ -1,35 +1,9 @@ -import UploadPage from "../../app/create/upload/page"; +import { CommunityUploadScreen } from "../../app/create/screens/upload/CommunityUploadScreen"; export default { - title: "Pages/Create Flow/Upload", - component: UploadPage, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: - "Upload step: HeaderLockup + Upload control. Centered lockup at 640px+.", - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], + title: "Pages/Create/CommunityUpload", + component: CommunityUploadScreen, + parameters: { layout: "fullscreen" }, }; -export const Desktop = { - parameters: { - viewport: { defaultViewport: "desktop" }, - }, -}; - -export const Mobile = { - parameters: { - viewport: { defaultViewport: "mobile1" }, - }, -}; +export const Default = {}; diff --git a/tests/components/AuthModalContext.test.tsx b/tests/components/AuthModalContext.test.tsx index 73bb798..b36266f 100644 --- a/tests/components/AuthModalContext.test.tsx +++ b/tests/components/AuthModalContext.test.tsx @@ -31,10 +31,10 @@ vi.mock("../../lib/create/api", () => ({ requestMagicLink: vi.fn(), })); -vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => { +vi.mock("../../app/create/utils/anonymousDraftStorage", async (importOriginal) => { const actual = await importOriginal< - typeof import("../../app/create/anonymousDraftStorage") + typeof import("../../app/create/utils/anonymousDraftStorage") >(); return { ...actual, @@ -43,7 +43,7 @@ vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => { }); import { requestMagicLink } from "../../lib/create/api"; -import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage"; +import { setTransferPendingFlag } from "../../app/create/utils/anonymousDraftStorage"; function LoginTrigger() { const { openLogin, closeLogin } = useAuthModal(); @@ -57,7 +57,7 @@ function LoginTrigger() { onClick={() => openLogin({ variant: "saveProgress", - nextPath: "/create/select?syncDraft=1", + nextPath: "/create/community-size?syncDraft=1", }) } > @@ -143,7 +143,7 @@ describe("AuthModalProvider (header overlay)", () => { await waitFor(() => { expect(requestMagicLink).toHaveBeenCalledWith( "guest@example.com", - "/create/select?syncDraft=1", + "/create/community-size?syncDraft=1", ); }); expect(setTransferPendingFlag).toHaveBeenCalled(); diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx index e12f201..4cc7474 100644 --- a/tests/components/CompletedPage.test.tsx +++ b/tests/components/CompletedPage.test.tsx @@ -1,16 +1,16 @@ import { describe, it, expect } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import CompletedPage from "../../app/create/completed/page"; +import { CompletedScreen } from "../../app/create/screens/completed/CompletedScreen"; -describe("CompletedPage", () => { +describe("CompletedScreen", () => { it("renders without crashing", () => { - render(); + render(); expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); }); it("renders HeaderLockup with expected title", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "Mutual Aid Mondays", @@ -19,7 +19,7 @@ describe("CompletedPage", () => { }); it("renders HeaderLockup with expected description", () => { - render(); + render(); expect( screen.getByText( /Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i, @@ -28,7 +28,7 @@ describe("CompletedPage", () => { }); it("renders Community Rule document with section labels", () => { - render(); + render(); expect(screen.getByText("Values")).toBeInTheDocument(); expect(screen.getByText("Communication")).toBeInTheDocument(); expect(screen.getByText("Membership")).toBeInTheDocument(); @@ -37,7 +37,7 @@ describe("CompletedPage", () => { }); it("renders document entry titles", () => { - render(); + render(); expect(screen.getByText("Solidarity Forever")).toBeInTheDocument(); expect(screen.getByText("Shared Leadership")).toBeInTheDocument(); expect(screen.getByText("Organizing Offline")).toBeInTheDocument(); @@ -45,7 +45,7 @@ describe("CompletedPage", () => { }); it("renders toast alert when page loads", () => { - render(); + render(); expect( screen.getByText( "This is what folks see when you share your CommunityRule", @@ -59,7 +59,7 @@ describe("CompletedPage", () => { }); it("renders toast with role status", () => { - render(); + render(); const statusRegions = screen.getAllByRole("status"); expect(statusRegions.length).toBeGreaterThanOrEqual(1); expect( diff --git a/tests/components/ConfirmStakeholdersPage.test.tsx b/tests/components/ConfirmStakeholdersPage.test.tsx index b560dfc..cdde162 100644 --- a/tests/components/ConfirmStakeholdersPage.test.tsx +++ b/tests/components/ConfirmStakeholdersPage.test.tsx @@ -2,11 +2,11 @@ import { describe, it, expect } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import ConfirmStakeholdersPage from "../../app/create/confirm-stakeholders/page"; +import { ConfirmStakeholdersScreen } from "../../app/create/screens/select/ConfirmStakeholdersScreen"; -describe("ConfirmStakeholdersPage", () => { +describe("ConfirmStakeholdersScreen", () => { it("renders title and description", () => { - render(); + render(); expect( screen.getByRole("heading", { name: /Do other stakeholders need to be involved/i, @@ -20,7 +20,7 @@ describe("ConfirmStakeholdersPage", () => { }); it("renders Add stakeholder control", () => { - render(); + render(); expect( screen.getByRole("button", { name: "Add stakeholder" }), ).toBeInTheDocument(); @@ -28,7 +28,7 @@ describe("ConfirmStakeholdersPage", () => { it("shows draft toast and can dismiss it", async () => { const user = userEvent.setup(); - render(); + render(); expect( screen.getByText(/Congratulations! You've drafted your CommunityRule!/i), ).toBeInTheDocument(); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index e30fe39..231bb15 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -6,7 +6,7 @@ import { waitFor, } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import FinalReviewPage from "../../app/create/final-review/page"; +import { FinalReviewScreen } from "../../app/create/screens/review/FinalReviewScreen"; import { useCreateFlow } from "../../app/create/context/CreateFlowContext"; const FALLBACK_CARD_TITLE = "Your community"; @@ -24,17 +24,17 @@ function FinalReviewWithFlowState({ useLayoutEffect(() => { replaceState({ title, ...(summary !== undefined ? { summary } : {}) }); }, [replaceState, title, summary]); - return ; + return ; } -describe("FinalReviewPage", () => { +describe("FinalReviewScreen", () => { it("renders without crashing", () => { - render(); + render(); expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); }); it("renders lockup title", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "Review your CommunityRule", @@ -43,7 +43,7 @@ describe("FinalReviewPage", () => { }); it("renders lockup description", () => { - render(); + render(); expect( screen.getByText( /Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again./i, @@ -52,12 +52,12 @@ describe("FinalReviewPage", () => { }); it("renders RuleCard with fallback title when context has no name", () => { - render(); + render(); expect(screen.getByText(FALLBACK_CARD_TITLE)).toBeInTheDocument(); }); it("renders RuleCard with fallback description when context has no summary", () => { - render(); + render(); expect( screen.getByText(new RegExp(FALLBACK_CARD_DESCRIPTION_SNIPPET, "i")), ).toBeInTheDocument(); @@ -76,7 +76,7 @@ describe("FinalReviewPage", () => { }); it("renders RuleCard as a button (card is interactive)", () => { - render(); + render(); const buttons = screen.getAllByRole("button"); expect(buttons.length).toBeGreaterThanOrEqual(1); expect( @@ -85,7 +85,7 @@ describe("FinalReviewPage", () => { }); it("renders expanded RuleCard with category labels", () => { - render(); + render(); expect(screen.getByText("Values")).toBeInTheDocument(); expect(screen.getByText("Communication")).toBeInTheDocument(); expect(screen.getByText("Membership")).toBeInTheDocument(); @@ -94,7 +94,7 @@ describe("FinalReviewPage", () => { }); it("renders category chips", () => { - render(); + render(); expect(screen.getByText("Consciousness")).toBeInTheDocument(); expect(screen.getByText("Signal")).toBeInTheDocument(); expect(screen.getByText("Open Admission")).toBeInTheDocument(); diff --git a/tests/components/InformationalPage.test.tsx b/tests/components/InformationalPage.test.tsx index 9e8d50d..7c03ed0 100644 --- a/tests/components/InformationalPage.test.tsx +++ b/tests/components/InformationalPage.test.tsx @@ -1,11 +1,11 @@ import { describe, it, expect } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import InformationalPage from "../../app/create/informational/page"; +import { InformationalScreen } from "../../app/create/screens/informational/InformationalScreen"; -describe("InformationalPage", () => { +describe("InformationalScreen", () => { it("renders without crashing", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "How CommunityRule helps groups like yours", @@ -14,7 +14,7 @@ describe("InformationalPage", () => { }); it("renders lockup description", () => { - render(); + render(); expect( screen.getByText( /This flow will give you recommendations to improve your community/i, @@ -23,7 +23,7 @@ describe("InformationalPage", () => { }); it("renders first numbered list item title", () => { - render(); + render(); expect( screen.getByText("Tell us about your organization"), ).toBeInTheDocument(); diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 759c12e..76b33b1 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -32,10 +32,10 @@ vi.mock("../../lib/create/api", () => ({ requestMagicLink: vi.fn(), })); -vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => { +vi.mock("../../app/create/utils/anonymousDraftStorage", async (importOriginal) => { const actual = await importOriginal< - typeof import("../../app/create/anonymousDraftStorage") + typeof import("../../app/create/utils/anonymousDraftStorage") >(); return { ...actual, @@ -44,7 +44,7 @@ vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => { }); import { requestMagicLink } from "../../lib/create/api"; -import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage"; +import { setTransferPendingFlag } from "../../app/create/utils/anonymousDraftStorage"; function renderLoginForm() { return renderWithProviders( @@ -119,7 +119,7 @@ describe("LoginForm", () => { , ); @@ -133,7 +133,7 @@ describe("LoginForm", () => { await waitFor(() => { expect(requestMagicLink).toHaveBeenCalledWith( "save@example.com", - "/create/select?syncDraft=1", + "/create/community-size?syncDraft=1", ); }); expect(setTransferPendingFlag).toHaveBeenCalled(); diff --git a/tests/components/ReviewPage.test.tsx b/tests/components/ReviewPage.test.tsx index 3569303..612f2f9 100644 --- a/tests/components/ReviewPage.test.tsx +++ b/tests/components/ReviewPage.test.tsx @@ -1,16 +1,16 @@ import { describe, it, expect } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import ReviewPage from "../../app/create/review/page"; +import { CommunityReviewScreen } from "../../app/create/screens/review/CommunityReviewScreen"; -describe("ReviewPage", () => { +describe("CommunityReviewScreen", () => { it("renders without crashing", () => { - render(); + render(); expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); }); it("renders HeaderLockup with expected title", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "Your community is added - congrats!", @@ -19,7 +19,7 @@ describe("ReviewPage", () => { }); it("renders HeaderLockup with expected description", () => { - render(); + render(); expect( screen.getByText( /In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared./i, @@ -28,12 +28,12 @@ describe("ReviewPage", () => { }); it("renders RuleCard with title", () => { - render(); + render(); expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument(); }); it("renders RuleCard with description", () => { - render(); + render(); expect( screen.getByText( /Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i, @@ -42,7 +42,7 @@ describe("ReviewPage", () => { }); it("renders RuleCard as a button (card is interactive)", () => { - render(); + render(); const buttons = screen.getAllByRole("button"); expect(buttons.length).toBeGreaterThanOrEqual(1); expect( diff --git a/tests/components/SelectPage.test.tsx b/tests/components/SelectPage.test.tsx index 0f0439b..09270f1 100644 --- a/tests/components/SelectPage.test.tsx +++ b/tests/components/SelectPage.test.tsx @@ -1,20 +1,20 @@ import { describe, it, expect } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import SelectPage from "../../app/create/select/page"; +import { CommunitySizeSelectScreen } from "../../app/create/screens/select/CommunitySizeSelectScreen"; -describe("SelectPage", () => { +describe("CommunitySizeSelectScreen", () => { it("renders HeaderLockup title", () => { - render(); + render(); expect( screen.getByRole("heading", { - name: "What is your community called?", + name: "How large is your community?", }), ).toBeInTheDocument(); }); it("renders MultiSelect add control", () => { - render(); + render(); const addButtons = screen.getAllByRole("button", { name: "Add organization type", }); @@ -22,8 +22,8 @@ describe("SelectPage", () => { }); it("renders preset chip labels", () => { - render(); + render(); expect(screen.getByText("1 member")).toBeInTheDocument(); - expect(screen.getByText("Non-profit")).toBeInTheDocument(); + expect(screen.getByText("2-10 members")).toBeInTheDocument(); }); }); diff --git a/tests/components/TextPage.test.tsx b/tests/components/TextPage.test.tsx index b37b0b2..b7d2b17 100644 --- a/tests/components/TextPage.test.tsx +++ b/tests/components/TextPage.test.tsx @@ -1,11 +1,17 @@ import { describe, it, expect } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import TextPage from "../../app/create/text/page"; +import { CreateFlowTextFieldScreen } from "../../app/create/screens/text/CreateFlowTextFieldScreen"; -describe("TextPage", () => { +describe("CreateFlowTextFieldScreen (community name)", () => { it("renders main heading", () => { - render(); + render( + , + ); expect( screen.getByRole("heading", { name: "What is your community called?", @@ -14,7 +20,13 @@ describe("TextPage", () => { }); it("renders description and text field", () => { - render(); + render( + , + ); expect( screen.getByText("This will be the name of your community"), ).toBeInTheDocument(); diff --git a/tests/components/UploadPage.test.tsx b/tests/components/UploadPage.test.tsx index 91d63b4..504a6fd 100644 --- a/tests/components/UploadPage.test.tsx +++ b/tests/components/UploadPage.test.tsx @@ -1,11 +1,11 @@ import { describe, it, expect } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; -import UploadPage from "../../app/create/upload/page"; +import { CommunityUploadScreen } from "../../app/create/screens/upload/CommunityUploadScreen"; -describe("UploadPage", () => { +describe("CommunityUploadScreen", () => { it("renders HeaderLockup", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "How should conflicts be resolved?", @@ -14,7 +14,7 @@ describe("UploadPage", () => { }); it("renders Upload control and helper copy", () => { - render(); + render(); expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); expect( screen.getByText(/Add images, PDFs, and other files to the policy/i), diff --git a/tests/pages/cards.test.jsx b/tests/pages/cards.test.jsx index 03bc636..ef0efc9 100644 --- a/tests/pages/cards.test.jsx +++ b/tests/pages/cards.test.jsx @@ -6,7 +6,7 @@ import { } from "../utils/test-utils"; import userEvent from "@testing-library/user-event"; import { describe, test, expect, afterEach } from "vitest"; -import CardsPage from "../../app/create/cards/page"; +import { CardsScreen } from "../../app/create/screens/card/CardsScreen"; afterEach(() => { cleanup(); @@ -15,7 +15,7 @@ afterEach(() => { describe("Create flow cards page", () => { test("clicking a card opens the Create modal", async () => { const user = userEvent.setup(); - render(); + render(); const signalCards = screen.getAllByRole("button", { name: /Signal: Encrypted messaging/, @@ -29,7 +29,7 @@ describe("Create flow cards page", () => { }); test("renders without error", () => { - render(); + render(); expect( screen.getByText( @@ -39,7 +39,7 @@ describe("Create flow cards page", () => { }); test("renders HeaderLockup and CardStack content", () => { - render(); + render(); expect( screen.getByText( @@ -53,7 +53,7 @@ describe("Create flow cards page", () => { test("toggle expands and shows Show less", async () => { const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole("button", { name: "See all communication approaches", diff --git a/tests/pages/right-rail.test.jsx b/tests/pages/right-rail.test.jsx index cea21f1..2b1386f 100644 --- a/tests/pages/right-rail.test.jsx +++ b/tests/pages/right-rail.test.jsx @@ -6,7 +6,7 @@ import { } from "../utils/test-utils"; import userEvent from "@testing-library/user-event"; import { describe, test, expect, afterEach } from "vitest"; -import RightRailPage from "../../app/create/right-rail/page"; +import { RightRailScreen } from "../../app/create/screens/right-rail/RightRailScreen"; afterEach(() => { cleanup(); @@ -14,7 +14,7 @@ afterEach(() => { describe("Create flow right-rail page", () => { test("renders without error", () => { - render(); + render(); expect( screen.getByRole("heading", { @@ -24,7 +24,7 @@ describe("Create flow right-rail page", () => { }); test("renders sidebar description with add link", () => { - render(); + render(); const description = screen.getByText((content, element) => { if (element?.tagName !== "P") return false; @@ -39,7 +39,7 @@ describe("Create flow right-rail page", () => { }); test("renders message box with title and checkboxes", () => { - render(); + render(); const region = screen.getByRole("region", { name: "Consider defining approaches to steward key resources:", @@ -65,7 +65,7 @@ describe("Create flow right-rail page", () => { }); test("renders card stack with See all decision approaches toggle", () => { - render(); + render(); expect( screen.getByRole("button", { name: "See all decision approaches" }), @@ -73,7 +73,7 @@ describe("Create flow right-rail page", () => { }); test("renders recommended approach cards", () => { - render(); + render(); expect( screen.getByRole("button", { @@ -94,7 +94,7 @@ describe("Create flow right-rail page", () => { test("toggle expands and shows Show less", async () => { const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole("button", { name: "See all decision approaches", @@ -108,7 +108,7 @@ describe("Create flow right-rail page", () => { test("expanded view shows Label cards", async () => { const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole("button", { name: "See all decision approaches", @@ -121,7 +121,7 @@ describe("Create flow right-rail page", () => { test("clicking a card toggles selection", async () => { const user = userEvent.setup(); - render(); + render(); const mediationCard = screen.getByRole("button", { name: /Mediation: Collaborative work to reach a resolution/, @@ -133,7 +133,7 @@ describe("Create flow right-rail page", () => { test("message box checkboxes are interactive", async () => { const user = userEvent.setup(); - render(); + render(); const amendCheckbox = screen.getByRole("checkbox", { name: "Amend your CommunityRule", diff --git a/tests/pages/templates.test.jsx b/tests/pages/templates.test.jsx index fcbe335..713a67c 100644 --- a/tests/pages/templates.test.jsx +++ b/tests/pages/templates.test.jsx @@ -5,7 +5,7 @@ import { } from "../utils/test-utils"; import userEvent from "@testing-library/user-event"; import { describe, test, expect, afterEach, beforeEach } from "vitest"; -import TemplatesPage from "../../app/(marketing)/templates/page"; +import TemplatesPageClient from "../../app/(marketing)/templates/TemplatesPageClient"; import { testRouter } from "../mocks/navigation"; import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog"; @@ -19,7 +19,9 @@ afterEach(() => { describe("Templates page (/templates)", () => { test("renders title, intro, and full catalog", () => { - render(); + render( + , + ); expect( screen.getByRole("heading", { name: "Templates", level: 1 }), @@ -35,7 +37,9 @@ describe("Templates page (/templates)", () => { test("each template card navigates to review flow for its slug", async () => { const user = userEvent.setup(); - render(); + render( + , + ); const consensusCard = screen.getByText("Consensus").closest("div"); await user.click(consensusCard); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index 47e5675..a56092a 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -34,7 +34,7 @@ describe("flowSteps", () => { }); it("isValidStep reflects FLOW_STEP_ORDER membership", () => { - expect(isValidStep("select")).toBe(true); + expect(isValidStep("community-size")).toBe(true); expect(isValidStep("confirm-stakeholders")).toBe(true); expect(isValidStep("nope")).toBe(false); expect(isValidStep(null)).toBe(false); diff --git a/tests/unit/hasCreateFlowUserInput.test.ts b/tests/unit/hasCreateFlowUserInput.test.ts index 3ccaee8..dd71b0d 100644 --- a/tests/unit/hasCreateFlowUserInput.test.ts +++ b/tests/unit/hasCreateFlowUserInput.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { hasCreateFlowUserInput } from "../../app/create/hasCreateFlowUserInput"; +import { hasCreateFlowUserInput } from "../../app/create/utils/hasCreateFlowUserInput"; describe("hasCreateFlowUserInput", () => { it("returns false for empty state", () => { @@ -7,7 +7,9 @@ describe("hasCreateFlowUserInput", () => { }); it("ignores currentStep alone", () => { - expect(hasCreateFlowUserInput({ currentStep: "text" })).toBe(false); + expect(hasCreateFlowUserInput({ currentStep: "informational" })).toBe( + false, + ); }); it("returns true for non-empty title", () => {