From f8255bc2c7de6e99b86290c4af8aa6bf7e611e0f Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:22:03 -0600 Subject: [PATCH] Create Community stage implemented --- .../controls/TextArea/TextArea.types.ts | 2 +- .../controls/TextArea/TextArea.view.tsx | 6 +- .../controls/Upload/Upload.container.tsx | 10 +- .../controls/Upload/Upload.types.ts | 6 + .../controls/Upload/Upload.view.tsx | 7 +- .../ProportionBar/ProportionBar.container.tsx | 5 +- .../ProportionBar/ProportionBar.types.ts | 9 + .../ProportionBar/ProportionBar.view.tsx | 22 +- .../type/HeaderLockup/HeaderLockup.types.ts | 8 +- .../type/HeaderLockup/HeaderLockup.view.tsx | 23 +- .../CreateFlowFooter.container.tsx | 11 +- .../CreateFlowFooter.types.ts | 15 ++ .../CreateFlowFooter.view.tsx | 11 +- app/create/CreateFlowLayoutClient.tsx | 218 ++++++++++++++++-- app/create/[screenId]/page.tsx | 15 +- .../CreateFlowLockupCardStepShell.tsx | 25 +- app/create/components/CreateFlowStepShell.tsx | 14 +- .../components/createFlowLayoutTokens.ts | 10 + app/create/hooks/useCreateFlowLgUp.ts | 20 ++ app/create/hooks/useCreateFlowMdUp.ts | 13 +- app/create/review-template/[slug]/page.tsx | 14 +- app/create/screens/CreateFlowScreenView.tsx | 23 +- app/create/screens/card/CardsScreen.tsx | 7 +- .../screens/completed/CompletedScreen.tsx | 18 +- .../informational/InformationalScreen.tsx | 47 +++- .../screens/review/CommunityReviewScreen.tsx | 45 ++-- .../screens/right-rail/RightRailScreen.tsx | 18 +- .../select/CommunitySizeSelectScreen.tsx | 94 ++------ .../select/CommunityStructureSelectScreen.tsx | 201 +++++++++------- .../select/ConfirmStakeholdersScreen.tsx | 5 +- .../text/CreateFlowTextFieldScreen.tsx | 56 ++++- .../screens/upload/CommunityUploadScreen.tsx | 31 +-- app/create/types.ts | 13 +- app/create/utils/anonymousDraftStorage.ts | 7 +- .../utils/createFlowProportionProgress.ts | 37 +++ app/create/utils/createFlowScreenRegistry.ts | 9 +- app/create/utils/flowSteps.ts | 7 +- docs/backend-roadmap.md | 2 +- docs/create-flow.md | 11 +- lib/create/api.ts | 5 +- lib/create/buildPublishPayload.ts | 6 +- lib/create/isValidCreateFlowSaveEmail.ts | 9 + lib/create/migrateLegacyCreateFlowState.ts | 25 ++ lib/propNormalization.ts | 24 ++ lib/server/validation/createFlowSchemas.ts | 7 +- messages/en/create/communityContext.json | 4 +- messages/en/create/communityName.json | 2 +- messages/en/create/communityReflection.json | 6 - messages/en/create/communitySave.json | 9 + messages/en/create/communitySize.json | 18 +- messages/en/create/communityStructure.json | 42 ++-- messages/en/create/communityUpload.json | 5 +- messages/en/create/footer.json | 9 + messages/en/create/informational.json | 4 +- messages/en/index.ts | 4 +- stories/progress/ProportionBar.stories.js | 27 +++ tests/components/AuthModalContext.test.tsx | 4 +- tests/components/CreateFlowFooter.test.tsx | 16 ++ tests/components/HeaderLockup.test.tsx | 16 ++ tests/components/InformationalPage.test.tsx | 7 + tests/components/LoginForm.test.tsx | 4 +- tests/components/ProportionBar.test.tsx | 1 + tests/components/SelectPage.test.tsx | 14 +- tests/components/TextPage.test.tsx | 2 +- tests/components/Upload.test.tsx | 7 +- tests/components/UploadPage.test.tsx | 6 +- tests/unit/createFlowLayoutTokens.test.ts | 18 ++ .../unit/createFlowProportionProgress.test.ts | 26 +++ tests/unit/createFlowValidation.test.ts | 7 + tests/unit/createFooterMessages.test.ts | 22 ++ tests/unit/draftHydrationUtils.test.ts | 4 +- tests/unit/flowSteps.test.ts | 9 + .../unit/migrateLegacyCreateFlowState.test.ts | 33 +++ 73 files changed, 1105 insertions(+), 392 deletions(-) create mode 100644 app/create/components/createFlowLayoutTokens.ts create mode 100644 app/create/hooks/useCreateFlowLgUp.ts create mode 100644 app/create/utils/createFlowProportionProgress.ts create mode 100644 lib/create/isValidCreateFlowSaveEmail.ts create mode 100644 lib/create/migrateLegacyCreateFlowState.ts delete mode 100644 messages/en/create/communityReflection.json create mode 100644 messages/en/create/communitySave.json create mode 100644 tests/unit/createFlowLayoutTokens.test.ts create mode 100644 tests/unit/createFlowProportionProgress.test.ts create mode 100644 tests/unit/createFooterMessages.test.ts create mode 100644 tests/unit/migrateLegacyCreateFlowState.test.ts diff --git a/app/components/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index facdf21..0997525 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -92,7 +92,7 @@ export interface TextAreaViewProps { handleChange: (_e: React.ChangeEvent) => void; handleFocus: (_e: React.FocusEvent) => void; handleBlur: (_e: React.FocusEvent) => void; - textHint?: boolean; + textHint?: boolean | string; formHeader?: boolean; showHelpIcon?: boolean; appearance?: "default" | "embedded"; diff --git a/app/components/controls/TextArea/TextArea.view.tsx b/app/components/controls/TextArea/TextArea.view.tsx index cf8783c..8b6e788 100644 --- a/app/components/controls/TextArea/TextArea.view.tsx +++ b/app/components/controls/TextArea/TextArea.view.tsx @@ -78,13 +78,13 @@ export const TextAreaView = forwardRef( {...props} /> - {textHint && ( + {textHint ? (

- Hint text here + {typeof textHint === "string" ? textHint : "Hint text here"}

- )} + ) : null} ); }, diff --git a/app/components/controls/Upload/Upload.container.tsx b/app/components/controls/Upload/Upload.container.tsx index 8f220e2..9b73ef2 100644 --- a/app/components/controls/Upload/Upload.container.tsx +++ b/app/components/controls/Upload/Upload.container.tsx @@ -5,12 +5,20 @@ import UploadView from "./Upload.view"; import type { UploadProps } from "./Upload.types"; const UploadContainer = memo( - ({ active = true, label, showHelpIcon = true, onClick, className = "" }) => { + ({ + active = true, + label, + showHelpIcon = true, + hintText = "Add image from your device", + onClick, + className = "", + }) => { return ( diff --git a/app/components/controls/Upload/Upload.types.ts b/app/components/controls/Upload/Upload.types.ts index d9ce7ad..9940390 100644 --- a/app/components/controls/Upload/Upload.types.ts +++ b/app/components/controls/Upload/Upload.types.ts @@ -15,6 +15,11 @@ export interface UploadProps { * @default true */ showHelpIcon?: boolean; + /** + * Copy beside the upload button (Figma Flow — Upload `20094:41524`). + * @default "Add image from your device" + */ + hintText?: string; /** * Callback when upload button is clicked */ @@ -29,6 +34,7 @@ export interface UploadViewProps { active: boolean; label?: string; showHelpIcon: boolean; + hintText: string; onClick?: () => void; className: string; } diff --git a/app/components/controls/Upload/Upload.view.tsx b/app/components/controls/Upload/Upload.view.tsx index 065f560..96748bc 100644 --- a/app/components/controls/Upload/Upload.view.tsx +++ b/app/components/controls/Upload/Upload.view.tsx @@ -8,6 +8,7 @@ function UploadView({ active = true, label, showHelpIcon = true, + hintText, onClick, className = "", }: UploadViewProps) { @@ -54,7 +55,7 @@ function UploadView({ + ) : currentStep === "community-name" && nextStep ? ( +
+ + +
+ ) : currentStep === "community-save" && nextStep ? ( +
+ + +
+ ) : currentStep === "review" && nextStep ? ( +
+ + +
) : nextStep ? ( ) : null } diff --git a/app/create/[screenId]/page.tsx b/app/create/[screenId]/page.tsx index 611a4a1..2992f30 100644 --- a/app/create/[screenId]/page.tsx +++ b/app/create/[screenId]/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { notFound } from "next/navigation"; -import { use } from "react"; +import { notFound, useRouter } from "next/navigation"; +import { use, useEffect } from "react"; import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; import { isValidStep } from "../utils/flowSteps"; import type { CreateFlowStep } from "../types"; @@ -12,6 +12,17 @@ interface PageProps { export default function CreateFlowScreenPage({ params }: PageProps) { const { screenId: raw } = use(params); + const router = useRouter(); + + useEffect(() => { + if (raw === "community-reflection") { + router.replace("/create/community-save"); + } + }, [raw, router]); + + if (raw === "community-reflection") { + return null; + } if (!isValidStep(raw)) { notFound(); diff --git a/app/create/components/CreateFlowLockupCardStepShell.tsx b/app/create/components/CreateFlowLockupCardStepShell.tsx index 49860f3..52b1690 100644 --- a/app/create/components/CreateFlowLockupCardStepShell.tsx +++ b/app/create/components/CreateFlowLockupCardStepShell.tsx @@ -3,10 +3,14 @@ import type { ReactNode } from "react"; import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup"; import { CreateFlowStepShell } from "./CreateFlowStepShell"; +import { + CREATE_FLOW_MD_UP_GRID_CELL_CLASS, + CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, +} from "./createFlowLayoutTokens"; /** Shared `RuleCard` / template card chrome: width + radius; padding comes from `RuleCard` (L+expanded = 24px). */ export const CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS = - "w-full min-w-0 rounded-[12px] md:rounded-[24px] md:!max-w-full md:!w-full"; + "w-full min-w-0 rounded-[12px] md:rounded-[24px] md:max-w-[640px]"; type CreateFlowLockupCardStepShellProps = { lockupTitle: string; @@ -14,10 +18,7 @@ type CreateFlowLockupCardStepShellProps = { children: ReactNode; }; -/** - * Final-review-style create-flow step: `wideGrid` shell, two-column grid at `md+`, - * left `CreateFlowHeaderLockup` (vertically centered in column), right column for card content. - */ +/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */ export function CreateFlowLockupCardStepShell({ lockupTitle, lockupDescription, @@ -25,15 +26,23 @@ export function CreateFlowLockupCardStepShell({ }: CreateFlowLockupCardStepShellProps) { return ( -
-
+
+
-
{children}
+
+ {children} +
); diff --git a/app/create/components/CreateFlowStepShell.tsx b/app/create/components/CreateFlowStepShell.tsx index 0bf3903..bf5aab2 100644 --- a/app/create/components/CreateFlowStepShell.tsx +++ b/app/create/components/CreateFlowStepShell.tsx @@ -9,7 +9,7 @@ export type CreateFlowStepShellVariant = | "wideGridLoosePadding" | "bare"; -/** Top padding below `md` between top nav and step content (semantic space tokens). */ +/** Semantic top padding below create-flow top nav (applied at all breakpoints; name is legacy). */ export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800"; const outerByVariant: Record = { @@ -17,22 +17,24 @@ const outerByVariant: Record = { "flex w-full min-w-0 flex-col items-center px-5 md:px-16", centeredNarrowBottomPad: "flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32", - wideGrid: "w-full min-w-0 max-w-[1280px] shrink-0 px-5 md:px-12", + /** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */ + wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12", + /** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */ wideGridLoosePadding: - "w-full min-w-0 max-w-[1280px] shrink-0 px-5 md:px-16", + "w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16", bare: "w-full min-w-0", }; const contentTopBelowMdClass: Record = { none: "", - "space-1400": "max-md:pt-[var(--space-1400)]", - "space-800": "max-md:pt-[var(--space-800)]", + "space-1400": "pt-[var(--space-1400)]", + "space-800": "pt-[var(--space-800)]", }; interface CreateFlowStepShellProps { children: ReactNode; variant?: CreateFlowStepShellVariant; - /** Padding-top below `md` only; `text` step uses `none`. */ + /** Top spacing below top chrome (`CreateFlowTextFieldScreen` defaults to `space-1400`). */ contentTopBelowMd?: CreateFlowContentTopBelowMd; className?: string; } diff --git a/app/create/components/createFlowLayoutTokens.ts b/app/create/components/createFlowLayoutTokens.ts new file mode 100644 index 0000000..4f1924c --- /dev/null +++ b/app/create/components/createFlowLayoutTokens.ts @@ -0,0 +1,10 @@ +/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */ +export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS = + "w-full min-w-0 md:max-w-[640px]"; + +/** Grid cell: same cap as column max, centered when the track is wider than 640px. */ +export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS = + "w-full min-w-0 md:mx-auto md:max-w-[640px]"; + +/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */ +export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]"; diff --git a/app/create/hooks/useCreateFlowLgUp.ts b/app/create/hooks/useCreateFlowLgUp.ts new file mode 100644 index 0000000..903bee9 --- /dev/null +++ b/app/create/hooks/useCreateFlowLgUp.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; + +/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */ +const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)"; + +/** True at viewport ≥1024px (e.g. review grid column split with Tailwind `lg:`). */ +export function useCreateFlowLgUp(): boolean { + const [isMounted, setIsMounted] = useState(false); + const isLgOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_LG); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment + setIsMounted(true); + }, []); + + return !isMounted || isLgOrLarger; +} diff --git a/app/create/hooks/useCreateFlowMdUp.ts b/app/create/hooks/useCreateFlowMdUp.ts index 04843ea..9b8aaf1 100644 --- a/app/create/hooks/useCreateFlowMdUp.ts +++ b/app/create/hooks/useCreateFlowMdUp.ts @@ -3,19 +3,10 @@ import { useEffect, useState } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; -/** - * Matches design-system `md` (`--breakpoint-md`, 640px in `app/tailwind.css`). - * Use with Tailwind `md:` / `max-md:` utilities in create-flow pages. - */ +/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */ const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)"; -/** - * True at or above the create-flow `md` breakpoint (desktop-oriented layout). - * - * `useMediaQuery` initializes to `false` on the server and first client render - * to avoid hydration mismatches. We combine it with a post-mount flag so the - * first paint matches the intended desktop layout until `matchMedia` runs. - */ +/** True at viewport ≥640px (pairs with Tailwind `md:` on create-flow screens). */ export function useCreateFlowMdUp(): boolean { const [isMounted, setIsMounted] = useState(false); const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD); diff --git a/app/create/review-template/[slug]/page.tsx b/app/create/review-template/[slug]/page.tsx index fba6344..0816015 100644 --- a/app/create/review-template/[slug]/page.tsx +++ b/app/create/review-template/[slug]/page.tsx @@ -15,16 +15,14 @@ import { CreateFlowLockupCardStepShell, } from "../../components/CreateFlowLockupCardStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; +import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; interface PageProps { params: Promise<{ slug: string }>; } -/** - * Template review: same responsive grid and RuleCard chrome as final-review; - * copy from Figma 22142-898702 (intro + dynamic card from API). - */ +/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */ export default function ReviewTemplatePage({ params }: PageProps) { const { slug: rawSlug } = use(params); const slug = decodeURIComponent(rawSlug); @@ -75,7 +73,9 @@ export default function ReviewTemplatePage({ params }: PageProps) { if (loading) { return ( -
+

{t("loading")}

@@ -87,7 +87,9 @@ export default function ReviewTemplatePage({ params }: PageProps) { if (error || !template) { return ( -
+
); - case "community-size": - return ; + case "community-structure": + return ; case "community-context": return ( ); - case "community-structure": - return ; + case "community-size": + return ; case "community-upload": return ; - case "community-reflection": + case "community-save": return ( ); case "review": diff --git a/app/create/screens/card/CardsScreen.tsx b/app/create/screens/card/CardsScreen.tsx index e904cb1..4c9ce83 100644 --- a/app/create/screens/card/CardsScreen.tsx +++ b/app/create/screens/card/CardsScreen.tsx @@ -9,6 +9,7 @@ import CardStack from "../../../components/utility/CardStack"; import Create from "../../../components/modals/Create"; import TextArea from "../../../components/controls/TextArea"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; +import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; const IN_PERSON_CARD_ID = "in-person-meetings"; const SIGNAL_CARD_ID = "signal"; @@ -210,15 +211,15 @@ export function CardsScreen() { variant="wideGridLoosePadding" contentTopBelowMd="space-800" > -
-
+
+
-
+
-
-
+
+
-
+
-
+
+ {copy.descriptionLead}{" "} + { + e.preventDefault(); + }} + > + {copy.workshopLabel} + {" "} + {copy.descriptionTrail} + + ); + return ( -
+
diff --git a/app/create/screens/review/CommunityReviewScreen.tsx b/app/create/screens/review/CommunityReviewScreen.tsx index ad08d0a..96f6d3c 100644 --- a/app/create/screens/review/CommunityReviewScreen.tsx +++ b/app/create/screens/review/CommunityReviewScreen.tsx @@ -3,37 +3,56 @@ import RuleCard from "../../../components/cards/RuleCard"; import { useTranslation } from "../../../contexts/MessagesContext"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; -import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; +import { + CREATE_FLOW_MD_UP_GRID_CELL_CLASS, + CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, +} from "../../components/createFlowLayoutTokens"; -/** Create Community — frame 8 (Figma 19706-12135); URL segment `review`. */ +/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */ export function CommunityReviewScreen() { - const mdUp = useCreateFlowMdUp(); + const lgUp = useCreateFlowLgUp(); const t = useTranslation("create.review"); + const { state } = useCreateFlow(); + + const cardTitle = + typeof state.title === "string" && state.title.trim().length > 0 + ? state.title.trim() + : t("ruleCard.title"); + const cardDescription = + typeof state.communityContext === "string" && + state.communityContext.trim().length > 0 + ? state.communityContext.trim() + : t("ruleCard.description"); return ( -
-
+
+
-
+
diff --git a/app/create/screens/right-rail/RightRailScreen.tsx b/app/create/screens/right-rail/RightRailScreen.tsx index b592951..a76dc22 100644 --- a/app/create/screens/right-rail/RightRailScreen.tsx +++ b/app/create/screens/right-rail/RightRailScreen.tsx @@ -8,6 +8,10 @@ import type { CardStackItem } from "../../../components/utility/CardStack/CardSt import { useMessages } from "../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { + CREATE_FLOW_MD_UP_GRID_CELL_CLASS, + CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, +} from "../../components/createFlowLayoutTokens"; export function RightRailScreen() { const m = useMessages(); @@ -76,8 +80,12 @@ export function RightRailScreen() { return (
-
-
+
+
-
-
+
+
>, - 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)); - }, - }; -} +import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; function chipRowsFromLabels( rows: readonly { label: string }[], @@ -56,17 +25,16 @@ function selectedIdsFromOptions(options: ChipOption[]): string[] { .map((o) => o.id); } -/** Create Community — frame 3 (Figma 20094-18244). */ +/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */ export function CommunitySizeSelectScreen() { const m = useMessages(); + const cs = m.create.communitySize; const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); - const mdUp = useCreateFlowMdUp(); - const t = useTranslation("create.communitySize"); const [communitySizeOptions, setCommunitySizeOptions] = useState< ChipOption[] >(() => { - const base = chipRowsFromLabels(m.create.communitySize.communitySizes); + const base = chipRowsFromLabels(cs.communitySizes); const selected = new Set(state.selectedCommunitySizeIds ?? []); return base.map((opt) => ({ ...opt, @@ -90,16 +58,6 @@ export function CommunitySizeSelectScreen() { ); }, [state.selectedCommunitySizeIds]); - const communityCustomHandlers = useMemo( - () => - createListCustomHandlers( - setCommunitySizeOptions, - "Unselected", - markCreateFlowInteraction, - ), - [markCreateFlowInteraction], - ); - const persistSelection = (next: ChipOption[]) => { markCreateFlowInteraction(); setCommunitySizeOptions(next); @@ -123,18 +81,13 @@ export function CommunitySizeSelectScreen() { persistSelection(next); }; - const multiLabel = t("multiSelect.label"); - const addText = t("multiSelect.addButtonText"); - const multiSelectBlock = ( ); @@ -143,29 +96,22 @@ export function CommunitySizeSelectScreen() { variant="centeredNarrow" contentTopBelowMd="space-1400" > - {mdUp ? ( -
-
- -
-
- {multiSelectBlock} -
-
- ) : ( -
+
+
+
+
{multiSelectBlock}
- )} +
); } diff --git a/app/create/screens/select/CommunityStructureSelectScreen.tsx b/app/create/screens/select/CommunityStructureSelectScreen.tsx index 7878dea..eb465b2 100644 --- a/app/create/screens/select/CommunityStructureSelectScreen.tsx +++ b/app/create/screens/select/CommunityStructureSelectScreen.tsx @@ -9,11 +9,11 @@ import { } from "react"; import MultiSelect from "../../../components/controls/MultiSelect"; import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types"; -import { useMessages, useTranslation } from "../../../contexts/MessagesContext"; +import { useMessages } from "../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; +import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; function createListCustomHandlers( setList: Dispatch>, @@ -73,28 +73,38 @@ function applySavedSelection( ); } -/** Create Community — frame 5 (Figma 20094-41317). */ +function selectedIdsFromOptions(options: ChipOption[]): string[] { + return options + .filter((o) => o.state === "Selected") + .map((o) => o.id); +} + +/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */ export function CommunityStructureSelectScreen() { const m = useMessages(); + const cs = m.create.communityStructure; const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); - const mdUp = useCreateFlowMdUp(); - const t = useTranslation("create.communityStructure"); const [organizationTypeOptions, setOrganizationTypeOptions] = useState< ChipOption[] >(() => applySavedSelection( - chipRowsFromLabels(m.create.communityStructure.organizationTypes), + chipRowsFromLabels(cs.organizationTypes), state.selectedOrganizationTypeIds, ), ); - const [governanceStyleOptions, setGovernanceStyleOptions] = useState< - ChipOption[] - >(() => + const [scaleOptions, setScaleOptions] = useState(() => applySavedSelection( - chipRowsFromLabels(m.create.communityStructure.governanceStyles), - state.selectedGovernanceStyleIds, + chipRowsFromLabels(cs.scaleOptions), + state.selectedScaleIds, + ), + ); + + const [maturityOptions, setMaturityOptions] = useState(() => + applySavedSelection( + chipRowsFromLabels(cs.maturityOptions), + state.selectedMaturityIds, ), ); @@ -105,10 +115,14 @@ export function CommunityStructureSelectScreen() { }, [state.selectedOrganizationTypeIds]); useEffect(() => { - setGovernanceStyleOptions((prev) => - applySavedSelection(prev, state.selectedGovernanceStyleIds), + setScaleOptions((prev) => applySavedSelection(prev, state.selectedScaleIds)); + }, [state.selectedScaleIds]); + + useEffect(() => { + setMaturityOptions((prev) => + applySavedSelection(prev, state.selectedMaturityIds), ); - }, [state.selectedGovernanceStyleIds]); + }, [state.selectedMaturityIds]); const organizationCustomHandlers = useMemo( () => @@ -119,10 +133,19 @@ export function CommunityStructureSelectScreen() { ), [markCreateFlowInteraction], ); - const governanceCustomHandlers = useMemo( + const scaleCustomHandlers = useMemo( () => createListCustomHandlers( - setGovernanceStyleOptions, + setScaleOptions, + "Unselected", + markCreateFlowInteraction, + ), + [markCreateFlowInteraction], + ); + const maturityCustomHandlers = useMemo( + () => + createListCustomHandlers( + setMaturityOptions, "Unselected", markCreateFlowInteraction, ), @@ -132,75 +155,100 @@ export function CommunityStructureSelectScreen() { const persistOrg = (next: ChipOption[]) => { markCreateFlowInteraction(); setOrganizationTypeOptions(next); - updateState({ - selectedOrganizationTypeIds: next - .filter((o) => o.state === "Selected") - .map((o) => o.id), - }); + updateState({ selectedOrganizationTypeIds: selectedIdsFromOptions(next) }); }; - const persistGov = (next: ChipOption[]) => { + const persistScale = (next: ChipOption[]) => { markCreateFlowInteraction(); - setGovernanceStyleOptions(next); - updateState({ - selectedGovernanceStyleIds: next - .filter((o) => o.state === "Selected") - .map((o) => o.id), - }); + setScaleOptions(next); + updateState({ selectedScaleIds: selectedIdsFromOptions(next) }); + }; + + const persistMaturity = (next: ChipOption[]) => { + markCreateFlowInteraction(); + setMaturityOptions(next); + updateState({ selectedMaturityIds: selectedIdsFromOptions(next) }); }; const handleOrganizationTypeClick = (chipId: string) => { - const next: ChipOption[] = organizationTypeOptions.map((opt) => - opt.id === chipId - ? { - ...opt, - state: - opt.state === "Selected" - ? ("Unselected" as const) - : ("Selected" as const), - } - : opt, + persistOrg( + organizationTypeOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: + opt.state === "Selected" + ? ("Unselected" as const) + : ("Selected" as const), + } + : opt, + ), ); - persistOrg(next); }; - const handleGovernanceStyleClick = (chipId: string) => { - const next: ChipOption[] = governanceStyleOptions.map((opt) => - opt.id === chipId - ? { - ...opt, - state: - opt.state === "Selected" - ? ("Unselected" as const) - : ("Selected" as const), - } - : opt, + const handleScaleClick = (chipId: string) => { + persistScale( + scaleOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: + opt.state === "Selected" + ? ("Unselected" as const) + : ("Selected" as const), + } + : opt, + ), ); - persistGov(next); }; - const multiLabel = t("multiSelect.label"); - const addText = t("multiSelect.addButtonText"); + const handleMaturityClick = (chipId: string) => { + persistMaturity( + maturityOptions.map((opt) => + opt.id === chipId + ? { + ...opt, + state: + opt.state === "Selected" + ? ("Unselected" as const) + : ("Selected" as const), + } + : opt, + ), + ); + }; const multiSelectBlock = ( <> + ); @@ -210,29 +258,22 @@ export function CommunityStructureSelectScreen() { variant="centeredNarrow" contentTopBelowMd="space-1400" > - {mdUp ? ( -
-
- -
-
- {multiSelectBlock} -
-
- ) : ( -
+
+
+
+
{multiSelectBlock}
- )} +
); } diff --git a/app/create/screens/select/ConfirmStakeholdersScreen.tsx b/app/create/screens/select/ConfirmStakeholdersScreen.tsx index 221370b..116d8b8 100644 --- a/app/create/screens/select/ConfirmStakeholdersScreen.tsx +++ b/app/create/screens/select/ConfirmStakeholdersScreen.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; +import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; export function ConfirmStakeholdersScreen() { const { markCreateFlowInteraction } = useCreateFlow(); @@ -50,7 +51,9 @@ export function ConfirmStakeholdersScreen() { variant="centeredNarrowBottomPad" contentTopBelowMd="space-1400" > -
+
-
- + +
+
+ +
{ diff --git a/app/create/screens/upload/CommunityUploadScreen.tsx b/app/create/screens/upload/CommunityUploadScreen.tsx index e479df8..0beb278 100644 --- a/app/create/screens/upload/CommunityUploadScreen.tsx +++ b/app/create/screens/upload/CommunityUploadScreen.tsx @@ -1,17 +1,17 @@ "use client"; import Upload from "../../../components/controls/Upload"; -import { useTranslation } from "../../../contexts/MessagesContext"; +import { useMessages } from "../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; -import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; +import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; -/** Create Community — frame 6 (Figma 20094-41524). */ +/** Create Community — Figma Flow — Upload `20094:41524`. */ export function CommunityUploadScreen() { + const m = useMessages(); + const u = m.create.communityUpload; const { markCreateFlowInteraction } = useCreateFlow(); - const mdUp = useCreateFlowMdUp(); - const t = useTranslation("create.communityUpload"); const handleUploadClick = () => { markCreateFlowInteraction(); @@ -22,16 +22,21 @@ export function CommunityUploadScreen() { variant="centeredNarrow" contentTopBelowMd="space-1400" > -
- -
+
+
+ +
+
diff --git a/app/create/types.ts b/app/create/types.ts index 66a08e2..fa45dbc 100644 --- a/app/create/types.ts +++ b/app/create/types.ts @@ -16,7 +16,7 @@ export type CreateFlowStep = | "community-context" | "community-structure" | "community-upload" - | "community-reflection" + | "community-save" | "review" | "cards" | "right-rail" @@ -29,7 +29,7 @@ export type CreateFlowTextStateField = | "title" | "summary" | "communityContext" - | "communityReflection"; + | "communitySaveEmail"; /** * Flow state for inputs across create-flow steps. @@ -41,13 +41,16 @@ export interface CreateFlowState { summary?: string; /** Additional copy fields for multi-step Create Community text frames (Figma). */ communityContext?: string; - communityReflection?: string; + /** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */ + communitySaveEmail?: string; /** Selected chip ids from `community-size` (MultiSelect). */ selectedCommunitySizeIds?: string[]; /** Selected chip ids from `community-structure` (organization types). */ selectedOrganizationTypeIds?: string[]; - /** Selected chip ids from `community-structure` (governance styles). */ - selectedGovernanceStyleIds?: string[]; + /** Selected chip ids from `community-structure` (scale). */ + selectedScaleIds?: string[]; + /** Selected chip ids from `community-structure` (maturity). */ + selectedMaturityIds?: string[]; currentStep?: CreateFlowStep; /** Section drafts; structure will tighten as steps persist real shapes. */ sections?: Record[]; diff --git a/app/create/utils/anonymousDraftStorage.ts b/app/create/utils/anonymousDraftStorage.ts index f11569c..abe9694 100644 --- a/app/create/utils/anonymousDraftStorage.ts +++ b/app/create/utils/anonymousDraftStorage.ts @@ -1,4 +1,5 @@ import type { CreateFlowState } from "../types"; +import { migrateLegacyCreateFlowState } from "../../../lib/create/migrateLegacyCreateFlowState"; /** Anonymous in-progress create flow (local only until magic-link transfer). */ export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const; @@ -23,8 +24,10 @@ export function readAnonymousCreateFlowState(): CreateFlowState { try { const raw = window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY); if (!raw) return {}; - const parsed = JSON.parse(raw) as CreateFlowState; - return typeof parsed === "object" && parsed !== null ? parsed : {}; + const parsed = JSON.parse(raw) as Record; + return typeof parsed === "object" && parsed !== null + ? migrateLegacyCreateFlowState(parsed) + : {}; } catch { return {}; } diff --git a/app/create/utils/createFlowProportionProgress.ts b/app/create/utils/createFlowProportionProgress.ts new file mode 100644 index 0000000..9b649a1 --- /dev/null +++ b/app/create/utils/createFlowProportionProgress.ts @@ -0,0 +1,37 @@ +import type { ProportionBarState } from "../../components/progress/ProportionBar/ProportionBar.types"; +import type { CreateFlowStep } from "../types"; +import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps"; + +/** + * One `ProportionBarState` per index in `FLOW_STEP_ORDER` (same length). + * Third Create Community step (`community-structure`) uses `1-2` per Figma. + */ +const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [ + "1-0", // informational + "1-1", // community-name + "1-2", // community-structure + "1-3", // community-context + "1-4", // community-size + "1-5", // community-upload + "2-0", // community-save + "2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community) + "2-2", // cards + "3-0", // right-rail + "3-1", // confirm-stakeholders + "3-2", // final-review + "3-2", // completed +] as const; + +if (PROPORTION_BY_STEP_INDEX.length !== FLOW_STEP_ORDER.length) { + throw new Error( + "createFlowProportionProgress: PROPORTION_BY_STEP_INDEX length must match FLOW_STEP_ORDER", + ); +} + +export function getProportionBarProgressForCreateFlowStep( + step: CreateFlowStep | null | undefined, +): ProportionBarState { + const idx = getStepIndex(step); + if (idx < 0) return "1-0"; + return PROPORTION_BY_STEP_INDEX[idx] ?? "1-0"; +} diff --git a/app/create/utils/createFlowScreenRegistry.ts b/app/create/utils/createFlowScreenRegistry.ts index 6ef23ad..9959541 100644 --- a/app/create/utils/createFlowScreenRegistry.ts +++ b/app/create/utils/createFlowScreenRegistry.ts @@ -35,6 +35,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< CreateFlowStep, CreateFlowScreenDefinition > = { + /** Figma: Flow — Informational (node 20094-16005). */ informational: { layoutKind: "informational", figmaNodeId: "20094-16005", @@ -49,7 +50,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< }, "community-size": { layoutKind: "select", - figmaNodeId: "20094-18244", + figmaNodeId: "20094-41317", messageNamespace: "create.communitySize", centeredBodyBelowMd: false, }, @@ -61,7 +62,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< }, "community-structure": { layoutKind: "select", - figmaNodeId: "20094-41317", + figmaNodeId: "20094-18244", messageNamespace: "create.communityStructure", centeredBodyBelowMd: false, }, @@ -71,10 +72,10 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< messageNamespace: "create.communityUpload", centeredBodyBelowMd: false, }, - "community-reflection": { + "community-save": { layoutKind: "text", figmaNodeId: "20097-14948", - messageNamespace: "create.communityReflection", + messageNamespace: "create.communitySave", centeredBodyBelowMd: true, }, review: { diff --git a/app/create/utils/flowSteps.ts b/app/create/utils/flowSteps.ts index bd06f8e..b8138df 100644 --- a/app/create/utils/flowSteps.ts +++ b/app/create/utils/flowSteps.ts @@ -3,6 +3,7 @@ * * Single source of truth for step order and navigation helpers. * Order matches Figma Create Community (frames 1–8) then later stages. + * `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap). */ import type { CreateFlowStep } from "../types"; @@ -13,11 +14,11 @@ import type { CreateFlowStep } from "../types"; export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [ "informational", "community-name", - "community-size", - "community-context", "community-structure", + "community-context", + "community-size", "community-upload", - "community-reflection", + "community-save", "review", "cards", "right-rail", diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index f4c7593..70b6242 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 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). +- **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-structure` 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. diff --git a/docs/create-flow.md b/docs/create-flow.md index 46cd5dd..031ad0f 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -10,7 +10,7 @@ The Figma **Create Community** sequence is the **source of truth** for the first | 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 Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `community-upload` → `community-save` → `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` | @@ -28,11 +28,11 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts | ----: | ----------- | -------------------- | ---- | | 1 | Create Community | `informational` | `/create/informational` | | 2 | Create Community | `community-name` | `/create/community-name` | -| 3 | Create Community | `community-size` | `/create/community-size` | +| 3 | Create Community | `community-structure` | `/create/community-structure` | | 4 | Create Community | `community-context` | `/create/community-context` | -| 5 | Create Community | `community-structure` | `/create/community-structure` | +| 5 | Create Community | `community-size` | `/create/community-size` | | 6 | Create Community | `community-upload` | `/create/community-upload` | -| 7 | Create Community | `community-reflection` | `/create/community-reflection` | +| 7 | Create Community | `community-save` | `/create/community-save` | | 8 | Create Community (review frame) | `review` | `/create/review` | | 9 | Create Custom CommunityRule | `cards` | `/create/cards` | | 10 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` | @@ -61,7 +61,7 @@ From that page, **Customize** currently navigates to `/create/informational?temp | 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. | +| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) 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. @@ -70,7 +70,6 @@ Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticke ## 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. --- diff --git a/lib/create/api.ts b/lib/create/api.ts index ca2acc6..f7b9fbc 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -1,4 +1,5 @@ import type { CreateFlowState } from "../../app/create/types"; +import { migrateLegacyCreateFlowState } from "./migrateLegacyCreateFlowState"; const jsonHeaders = { "Content-Type": "application/json" }; @@ -77,7 +78,9 @@ export async function fetchDraftFromServer(): Promise { if (!data.draft?.payload || typeof data.draft.payload !== "object") { return null; } - return data.draft.payload as CreateFlowState; + return migrateLegacyCreateFlowState( + data.draft.payload as Record, + ); } const DRAFT_SAVE_NETWORK_ERROR = diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index 156be48..dec080a 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -59,11 +59,7 @@ export function buildPublishPayload( return undefined; }; - let summary = firstNonEmpty( - state.summary, - state.communityContext, - state.communityReflection, - ); + let summary = firstNonEmpty(state.summary, state.communityContext); let sections = parseSectionsFromCreateFlowState(state); if (sections.length === 0) { diff --git a/lib/create/isValidCreateFlowSaveEmail.ts b/lib/create/isValidCreateFlowSaveEmail.ts new file mode 100644 index 0000000..5186e73 --- /dev/null +++ b/lib/create/isValidCreateFlowSaveEmail.ts @@ -0,0 +1,9 @@ +const EMAIL_MAX_LEN = 254; + +/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */ +export function isValidCreateFlowSaveEmail(value: unknown): boolean { + if (typeof value !== "string") return false; + const t = value.trim(); + if (t.length === 0 || t.length > EMAIL_MAX_LEN) return false; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t); +} diff --git a/lib/create/migrateLegacyCreateFlowState.ts b/lib/create/migrateLegacyCreateFlowState.ts new file mode 100644 index 0000000..7c87e49 --- /dev/null +++ b/lib/create/migrateLegacyCreateFlowState.ts @@ -0,0 +1,25 @@ +import type { CreateFlowState } from "../../app/create/types"; + +/** + * Maps pre-rename draft keys and step ids (`community-reflection` → `community-save`). + * Safe to run on any parsed draft payload before merging into context. + */ +export function migrateLegacyCreateFlowState( + raw: Record | null | undefined, +): CreateFlowState { + if (!raw || typeof raw !== "object") return {}; + const next: Record = { ...raw }; + if (typeof next.communityReflection === "string") { + if ( + next.communitySaveEmail === undefined || + next.communitySaveEmail === "" + ) { + next.communitySaveEmail = next.communityReflection; + } + } + delete next.communityReflection; + if (next.currentStep === "community-reflection") { + next.currentStep = "community-save"; + } + return next as CreateFlowState; +} diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index 806ec1f..ff0559d 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -852,3 +852,27 @@ export type ButtonStateValue = | "Active" | "Hover" | "Disabled"; + +/** + * ProportionBar layout variant (Figma uses a segmented track in the create-flow footer). + */ +export type ProportionBarVariantValue = + | "default" + | "segmented" + | "Default" + | "Segmented"; + +/** + * Normalize ProportionBar variant (Figma PascalCase vs codebase lowercase). + */ +export function normalizeProportionBarVariant( + value: string | undefined, + defaultValue: "default" | "segmented" = "default", +): "default" | "segmented" { + if (!value) return defaultValue; + const normalized = value.toLowerCase(); + if (normalized === "default" || normalized === "segmented") { + return normalized; + } + return defaultValue; +} diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index f35ebde..fc14112 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -29,11 +29,12 @@ 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(), + communityContext: z.string().max(48).optional(), + communitySaveEmail: z.string().max(320).optional(), selectedCommunitySizeIds: z.array(z.string()).optional(), selectedOrganizationTypeIds: z.array(z.string()).optional(), - selectedGovernanceStyleIds: z.array(z.string()).optional(), + selectedScaleIds: z.array(z.string()).optional(), + selectedMaturityIds: 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 index 41043e0..9ac9afd 100644 --- a/messages/en/create/communityContext.json +++ b/messages/en/create/communityContext.json @@ -1,6 +1,6 @@ { - "title": "Tell us more about your community", - "description": "Share context that will help shape your CommunityRule.", + "title": "Why does your community exist?", + "description": "Edit or change the description to match how you’d like the organization to be described to other users. Try and describe your mission, goals, and scope.", "placeholder": "Describe your community", "characterCountTemplate": "{current}/{max}" } diff --git a/messages/en/create/communityName.json b/messages/en/create/communityName.json index 2e06ffe..2be4808 100644 --- a/messages/en/create/communityName.json +++ b/messages/en/create/communityName.json @@ -1,6 +1,6 @@ { "title": "What is your community called?", "description": "This will be the name of your community", - "placeholder": "Enter your community name", + "placeholder": "Enter community name", "characterCountTemplate": "{current}/{max}" } diff --git a/messages/en/create/communityReflection.json b/messages/en/create/communityReflection.json deleted file mode 100644 index e789258..0000000 --- a/messages/en/create/communityReflection.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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/communitySave.json b/messages/en/create/communitySave.json new file mode 100644 index 0000000..e81ae6c --- /dev/null +++ b/messages/en/create/communitySave.json @@ -0,0 +1,9 @@ +{ + "title": "Save your progress", + "description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.", + "placeholder": "email@domain.com", + "characterCountTemplate": "{current}/{max}", + "magicLinkSuccessTitle": "Check your email to log in!", + "magicLinkSuccessDescription": "Your account is created, now just check your email for a magic link", + "magicLinkErrorTitle": "Could not send link" +} diff --git a/messages/en/create/communitySize.json b/messages/en/create/communitySize.json index 41e69e4..9870526 100644 --- a/messages/en/create/communitySize.json +++ b/messages/en/create/communitySize.json @@ -1,19 +1,13 @@ { "header": { - "title": "How large is your community?", - "description": "Choose the size that best matches your group." - }, - "multiSelect": { - "label": "Label", - "addButtonText": "Add organization type" + "title": "How many people will be in your community in the near term?", + "description": "Choose how many people you think will be in your community in the next year or two. Your selection here will determine what governance patterns are recommended later in the process." }, "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" } + { "label": "2-5 members" }, + { "label": "6-12 members" }, + { "label": "13-100 members" }, + { "label": "100-100,000 members" } ] } diff --git a/messages/en/create/communityStructure.json b/messages/en/create/communityStructure.json index 2cd657b..e8bb5c1 100644 --- a/messages/en/create/communityStructure.json +++ b/messages/en/create/communityStructure.json @@ -1,22 +1,38 @@ { "header": { - "title": "How is your community organized?", - "description": "Select the options that best describe your group." + "title": "What kind of community would you like to improve?", + "description": "Choose tags the describe your community. You can also combine or add new values to the list." }, - "multiSelect": { - "label": "Label", + "organizationMultiSelect": { + "label": "Organization Type", "addButtonText": "Add organization type" }, + "scaleMultiSelect": { + "label": "Scale", + "addButtonText": "Add scale" + }, + "maturityMultiSelect": { + "label": "Maturity", + "addButtonText": "Add maturity" + }, "organizationTypes": [ - { "label": "Non-profit" }, - { "label": "For-profit" }, - { "label": "Community" }, - { "label": "Educational" } + { "label": "Worker’s coop" }, + { "label": "Mutual aid" }, + { "label": "Open source project" }, + { "label": "Nonprofit" }, + { "label": "For profit business" }, + { "label": "DAO" } ], - "governanceStyles": [ - { "label": "Democratic" }, - { "label": "Consensus" }, - { "label": "Hierarchical" }, - { "label": "Flat" } + "scaleOptions": [ + { "label": "Local" }, + { "label": "Regional" }, + { "label": "National" }, + { "label": "Global" } + ], + "maturityOptions": [ + { "label": "Early stage" }, + { "label": "Growth stage" }, + { "label": "Established" }, + { "label": "Enterprise" } ] } diff --git a/messages/en/create/communityUpload.json b/messages/en/create/communityUpload.json index 64fa00a..90f9491 100644 --- a/messages/en/create/communityUpload.json +++ b/messages/en/create/communityUpload.json @@ -1,4 +1,5 @@ { - "title": "How should conflicts be resolved?", - "description": "Upload supporting materials or examples that help describe how your community handles conflict." + "title": "Add a photo to identify your group", + "description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.", + "hintText": "Add image from your device" } diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json index bfe3711..3698c2a 100644 --- a/messages/en/create/footer.json +++ b/messages/en/create/footer.json @@ -1,5 +1,14 @@ { "next": "Next", + "saveLater": "Save Later", + "submitEmail": "Submit Email", + "submitEmailSending": "Sending link…", + "createCustom": "Create custom", + "createFromTemplate": "Create from template", + "confirmName": "Confirm name", + "confirmDetails": "Confirm details", + "confirmDescription": "Confirm description", + "confirmMembers": "Confirm members", "finalizeCommunityRule": "Finalize CommunityRule", "confirmStakeholders": "Confirm Stakeholders" } diff --git a/messages/en/create/informational.json b/messages/en/create/informational.json index 176b342..52656a4 100644 --- a/messages/en/create/informational.json +++ b/messages/en/create/informational.json @@ -1,6 +1,8 @@ { "title": "How CommunityRule helps groups like yours", - "description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a workshop that your group can use to go through the process it together.", + "descriptionLead": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a", + "workshopLabel": "workshop", + "descriptionTrail": "that your group can use to go through the process it together.", "steps": { "0": { "title": "Tell us about your organization", diff --git a/messages/en/index.ts b/messages/en/index.ts index fb22667..e1044fd 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -25,7 +25,7 @@ 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 createCommunitySave from "./create/communitySave.json"; import createReview from "./create/review.json"; import createConfirmStakeholders from "./create/confirmStakeholders.json"; import createFinalReview from "./create/finalReview.json"; @@ -66,7 +66,7 @@ export default { communityContext: createCommunityContext, communityStructure: createCommunityStructure, communityUpload: createCommunityUpload, - communityReflection: createCommunityReflection, + communitySave: createCommunitySave, review: createReview, confirmStakeholders: createConfirmStakeholders, finalReview: createFinalReview, diff --git a/stories/progress/ProportionBar.stories.js b/stories/progress/ProportionBar.stories.js index e1e0ff7..7b243af 100644 --- a/stories/progress/ProportionBar.stories.js +++ b/stories/progress/ProportionBar.stories.js @@ -13,6 +13,12 @@ export default { }, }, argTypes: { + variant: { + control: { type: "select" }, + options: ["default", "segmented", "Default", "Segmented"], + description: + "Segmented: pill-shaped partial fills (create-flow footer / Figma).", + }, progress: { control: { type: "select" }, options: [ @@ -46,6 +52,27 @@ export const Default = { ), }; +export const SegmentedCreateFlow = { + args: { + progress: "1-1", + variant: "segmented", + }, + render: (args) => ( +
+ +
+ ), + parameters: { + docs: { + description: { + story: + "Matches the create-flow footer: three segments with partial fill in the first segment (`1-1` on community name).", + }, + }, + backgrounds: { default: "dark" }, + }, +}; + export const AllStates = { args: {}, render: (_args) => ( diff --git a/tests/components/AuthModalContext.test.tsx b/tests/components/AuthModalContext.test.tsx index b36266f..70a2f7a 100644 --- a/tests/components/AuthModalContext.test.tsx +++ b/tests/components/AuthModalContext.test.tsx @@ -57,7 +57,7 @@ function LoginTrigger() { onClick={() => openLogin({ variant: "saveProgress", - nextPath: "/create/community-size?syncDraft=1", + nextPath: "/create/community-structure?syncDraft=1", }) } > @@ -143,7 +143,7 @@ describe("AuthModalProvider (header overlay)", () => { await waitFor(() => { expect(requestMagicLink).toHaveBeenCalledWith( "guest@example.com", - "/create/community-size?syncDraft=1", + "/create/community-structure?syncDraft=1", ); }); expect(setTransferPendingFlag).toHaveBeenCalled(); diff --git a/tests/components/CreateFlowFooter.test.tsx b/tests/components/CreateFlowFooter.test.tsx index b1798de..947d66e 100644 --- a/tests/components/CreateFlowFooter.test.tsx +++ b/tests/components/CreateFlowFooter.test.tsx @@ -48,6 +48,22 @@ describe("CreateFlowFooter (behavioral tests)", () => { name: "Create Flow Footer", }); expect(footer).toBeInTheDocument(); + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-valuenow", String(1 / 6)); + }); + + it("passes proportionBarProgress to the progress bar", () => { + render( + , + ); + expect(screen.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + String(2 / 6), + ); }); it("does not render progress bar when progressBar is false", () => { diff --git a/tests/components/HeaderLockup.test.tsx b/tests/components/HeaderLockup.test.tsx index 6357a78..3a8e273 100644 --- a/tests/components/HeaderLockup.test.tsx +++ b/tests/components/HeaderLockup.test.tsx @@ -49,6 +49,22 @@ describe("HeaderLockup (behavioral tests)", () => { expect(screen.getByText("Test description")).toBeInTheDocument(); }); + it("renders ReactNode description (rich inline)", () => { + render( + + Before link after + + } + />, + ); + expect(screen.getByText(/Before/)).toBeInTheDocument(); + expect(screen.getByText("link")).toBeInTheDocument(); + expect(screen.getByText(/after/)).toBeInTheDocument(); + }); + it("does not render description when not provided", () => { const { container } = render(); const description = container.querySelector("p"); diff --git a/tests/components/InformationalPage.test.tsx b/tests/components/InformationalPage.test.tsx index 7c03ed0..0d2f622 100644 --- a/tests/components/InformationalPage.test.tsx +++ b/tests/components/InformationalPage.test.tsx @@ -22,6 +22,13 @@ describe("InformationalScreen", () => { ).toBeInTheDocument(); }); + it("renders workshop as a link (URL TBD) with underline per Figma", () => { + render(); + const workshop = screen.getByRole("link", { name: "workshop" }); + expect(workshop).toHaveAttribute("href", "#"); + expect(workshop.className).toMatch(/underline/); + }); + it("renders first numbered list item title", () => { render(); expect( diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 76b33b1..8a56e58 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -119,7 +119,7 @@ describe("LoginForm", () => { , ); @@ -133,7 +133,7 @@ describe("LoginForm", () => { await waitFor(() => { expect(requestMagicLink).toHaveBeenCalledWith( "save@example.com", - "/create/community-size?syncDraft=1", + "/create/community-structure?syncDraft=1", ); }); expect(setTransferPendingFlag).toHaveBeenCalled(); diff --git a/tests/components/ProportionBar.test.tsx b/tests/components/ProportionBar.test.tsx index a3f40aa..182699b 100644 --- a/tests/components/ProportionBar.test.tsx +++ b/tests/components/ProportionBar.test.tsx @@ -22,6 +22,7 @@ const config: ComponentTestSuiteConfig = { optionalProps: { progress: "3-2", className: "custom-class", + variant: "segmented", }, primaryRole: "progressbar", testCases: { diff --git a/tests/components/SelectPage.test.tsx b/tests/components/SelectPage.test.tsx index 09270f1..438a0e5 100644 --- a/tests/components/SelectPage.test.tsx +++ b/tests/components/SelectPage.test.tsx @@ -8,22 +8,14 @@ describe("CommunitySizeSelectScreen", () => { render(); expect( screen.getByRole("heading", { - name: "How large is your community?", + name: "How many people will be in your community in the near term?", }), ).toBeInTheDocument(); }); - it("renders MultiSelect add control", () => { - render(); - const addButtons = screen.getAllByRole("button", { - name: "Add organization type", - }); - expect(addButtons.length).toBeGreaterThanOrEqual(1); - }); - - it("renders preset chip labels", () => { + it("renders preset size chips", () => { render(); expect(screen.getByText("1 member")).toBeInTheDocument(); - expect(screen.getByText("2-10 members")).toBeInTheDocument(); + expect(screen.getByText("2-5 members")).toBeInTheDocument(); }); }); diff --git a/tests/components/TextPage.test.tsx b/tests/components/TextPage.test.tsx index b7d2b17..8be403c 100644 --- a/tests/components/TextPage.test.tsx +++ b/tests/components/TextPage.test.tsx @@ -31,7 +31,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => { screen.getByText("This will be the name of your community"), ).toBeInTheDocument(); expect( - screen.getByPlaceholderText("Enter your community name"), + screen.getByPlaceholderText("Enter community name"), ).toBeInTheDocument(); }); }); diff --git a/tests/components/Upload.test.tsx b/tests/components/Upload.test.tsx index dbe342a..27a79a7 100644 --- a/tests/components/Upload.test.tsx +++ b/tests/components/Upload.test.tsx @@ -20,6 +20,7 @@ componentTestSuite({ label: "Upload", active: true, showHelpIcon: true, + hintText: "Add image from your device", }, primaryRole: "button", testCases: { @@ -81,14 +82,14 @@ describe("Upload (behavioral tests)", () => { it("displays description text", () => { render(); expect( - screen.getByText(/Add images, PDFs, and other files to the policy/i), + screen.getByText(/Add image from your device/i), ).toBeInTheDocument(); }); it("applies active state styles correctly", () => { render(); const descriptionText = screen.getByText( - /Add images, PDFs, and other files to the policy/i, + /Add image from your device/i, ); const descriptionContainer = descriptionText.parentElement; expect(descriptionContainer).toHaveClass( @@ -99,7 +100,7 @@ describe("Upload (behavioral tests)", () => { it("applies inactive state styles correctly", () => { render(); const descriptionText = screen.getByText( - /Add images, PDFs, and other files to the policy/i, + /Add image from your device/i, ); const descriptionContainer = descriptionText.parentElement; expect(descriptionContainer).toHaveClass( diff --git a/tests/components/UploadPage.test.tsx b/tests/components/UploadPage.test.tsx index 504a6fd..772ddf7 100644 --- a/tests/components/UploadPage.test.tsx +++ b/tests/components/UploadPage.test.tsx @@ -8,7 +8,7 @@ describe("CommunityUploadScreen", () => { render(); expect( screen.getByRole("heading", { - name: "How should conflicts be resolved?", + name: "Add a photo to identify your group", }), ).toBeInTheDocument(); }); @@ -17,7 +17,9 @@ describe("CommunityUploadScreen", () => { render(); expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); expect( - screen.getByText(/Add images, PDFs, and other files to the policy/i), + screen.getByText( + /This photo be used as a profile picture for your group/i, + ), ).toBeInTheDocument(); }); }); diff --git a/tests/unit/createFlowLayoutTokens.test.ts b/tests/unit/createFlowLayoutTokens.test.ts new file mode 100644 index 0000000..7e96ba6 --- /dev/null +++ b/tests/unit/createFlowLayoutTokens.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { + CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS, + CREATE_FLOW_MD_UP_GRID_CELL_CLASS, + CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, +} from "../../app/create/components/createFlowLayoutTokens"; + +describe("createFlowLayoutTokens", () => { + it("exports create-flow column and two-column max class strings", () => { + expect(CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS).toBe( + "w-full min-w-0 md:max-w-[640px]", + ); + expect(CREATE_FLOW_MD_UP_GRID_CELL_CLASS).toBe( + "w-full min-w-0 md:mx-auto md:max-w-[640px]", + ); + expect(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS).toBe("md:max-w-[1328px]"); + }); +}); diff --git a/tests/unit/createFlowProportionProgress.test.ts b/tests/unit/createFlowProportionProgress.test.ts new file mode 100644 index 0000000..3b463b7 --- /dev/null +++ b/tests/unit/createFlowProportionProgress.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { getProportionBarProgressForCreateFlowStep } from "../../app/create/utils/createFlowProportionProgress"; + +describe("getProportionBarProgressForCreateFlowStep", () => { + it("uses 1-2 on community-structure (third Create Community step)", () => { + expect(getProportionBarProgressForCreateFlowStep("community-structure")).toBe( + "1-2", + ); + }); + + it("advances proportion after structure for context and size", () => { + expect(getProportionBarProgressForCreateFlowStep("community-context")).toBe( + "1-3", + ); + expect(getProportionBarProgressForCreateFlowStep("community-size")).toBe( + "1-4", + ); + }); + + it("uses 2-0 on community-save and review (end of Create Community segment)", () => { + expect(getProportionBarProgressForCreateFlowStep("community-save")).toBe( + "2-0", + ); + expect(getProportionBarProgressForCreateFlowStep("review")).toBe("2-0"); + }); +}); diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts index 78b81a9..b435cad 100644 --- a/tests/unit/createFlowValidation.test.ts +++ b/tests/unit/createFlowValidation.test.ts @@ -71,6 +71,13 @@ describe("createFlowStateSchema", () => { const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) }); expect(r.success).toBe(false); }); + + it("rejects communitySaveEmail longer than 320 chars", () => { + const r = createFlowStateSchema.safeParse({ + communitySaveEmail: "x".repeat(321), + }); + expect(r.success).toBe(false); + }); }); describe("putDraftBodySchema", () => { diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts new file mode 100644 index 0000000..5ab1a44 --- /dev/null +++ b/tests/unit/createFooterMessages.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import messages from "../../messages/en/index"; + +describe("create footer messages", () => { + it("exposes confirmName for the community-name footer CTA", () => { + expect(messages.create.footer.confirmName).toBe("Confirm name"); + }); + + it("exposes confirmDetails for the community-structure footer CTA", () => { + expect(messages.create.footer.confirmDetails).toBe("Confirm details"); + }); + + it("exposes confirmDescription for the community-context footer CTA", () => { + expect(messages.create.footer.confirmDescription).toBe( + "Confirm description", + ); + }); + + it("exposes confirmMembers for the community-size footer CTA", () => { + expect(messages.create.footer.confirmMembers).toBe("Confirm members"); + }); +}); diff --git a/tests/unit/draftHydrationUtils.test.ts b/tests/unit/draftHydrationUtils.test.ts index de45a83..76514f7 100644 --- a/tests/unit/draftHydrationUtils.test.ts +++ b/tests/unit/draftHydrationUtils.test.ts @@ -8,6 +8,8 @@ describe("createFlowStateHasKeys", () => { it("returns true when any key is present", () => { expect(createFlowStateHasKeys({ title: "x" })).toBe(true); - expect(createFlowStateHasKeys({ currentStep: "text" })).toBe(true); + expect(createFlowStateHasKeys({ currentStep: "community-name" })).toBe( + true, + ); }); }); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index a56092a..1cf4b9f 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -46,4 +46,13 @@ describe("flowSteps", () => { // @ts-expect-error — invalid step id expect(getStepIndex("bogus")).toBe(-1); }); + + it("places community-structure before community-context and community-size (Figma order)", () => { + expect(getStepIndex("community-structure")).toBe(2); + expect(getStepIndex("community-context")).toBe(3); + expect(getStepIndex("community-size")).toBe(4); + expect(getNextStep("community-name")).toBe("community-structure"); + expect(getNextStep("community-structure")).toBe("community-context"); + expect(getNextStep("community-context")).toBe("community-size"); + }); }); diff --git a/tests/unit/migrateLegacyCreateFlowState.test.ts b/tests/unit/migrateLegacyCreateFlowState.test.ts new file mode 100644 index 0000000..acd2fae --- /dev/null +++ b/tests/unit/migrateLegacyCreateFlowState.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState"; + +describe("migrateLegacyCreateFlowState", () => { + it("maps communityReflection to communitySaveEmail when save email empty", () => { + const out = migrateLegacyCreateFlowState({ + title: "T", + communityReflection: "old@example.com", + }); + expect(out.communitySaveEmail).toBe("old@example.com"); + expect("communityReflection" in out).toBe(false); + }); + + it("does not overwrite existing communitySaveEmail", () => { + const out = migrateLegacyCreateFlowState({ + communityReflection: "old@example.com", + communitySaveEmail: "kept@example.com", + }); + expect(out.communitySaveEmail).toBe("kept@example.com"); + }); + + it("rewrites currentStep slug", () => { + const out = migrateLegacyCreateFlowState({ + currentStep: "community-reflection", + }); + expect(out.currentStep).toBe("community-save"); + }); + + it("returns empty object for nullish input", () => { + expect(migrateLegacyCreateFlowState(null)).toEqual({}); + expect(migrateLegacyCreateFlowState(undefined)).toEqual({}); + }); +});