From ec5afd146450aa9feede5c505a8a2dfe0edc1ca7 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:17:52 -0600 Subject: [PATCH 1/7] RuleTemplate seed and create flow --- CONTRIBUTING.md | 3 +- app/(dev)/components-preview/page.tsx | 4 +- app/(marketing)/templates/page.tsx | 47 ++ .../cards/RuleCard/RuleCard.view.tsx | 22 +- .../TemplateReviewCard/TemplateReviewCard.tsx | 66 +++ .../cards/TemplateReviewCard/index.ts | 2 + .../GovernanceTemplateGrid.tsx | 99 ++++ .../sections/GovernanceTemplateGrid/index.ts | 2 + .../RuleStack/RuleStack.container.tsx | 12 +- .../sections/RuleStack/RuleStack.types.ts | 2 +- .../sections/RuleStack/RuleStack.view.tsx | 136 +---- app/create/CreateFlowLayoutClient.tsx | 116 +++- app/create/review-template/[slug]/page.tsx | 130 +++++ lib/create/fetchTemplates.ts | 45 ++ lib/create/templateReviewMapping.ts | 68 +++ lib/templates/governanceTemplateCatalog.ts | 161 ++++++ messages/en/create/templateReview.json | 17 + messages/en/index.ts | 4 + messages/en/pages/templates.json | 5 + package-lock.json | 21 + package.json | 4 + prisma/seed.ts | 539 ++++++++++++++++++ public/assets/Icon_Consensus.svg | 10 - public/assets/Icon_ElectedBoard.svg | 9 - public/assets/Icon_Petition.svg | 13 - .../template-mark/benevolent-dictator.svg | 3 + .../consensus-clusters.svg} | 0 public/assets/template-mark/consensus.svg | 10 + public/assets/template-mark/devolution.svg | 11 + public/assets/template-mark/do-ocracy.svg | 3 + public/assets/template-mark/elected-board.svg | 9 + .../template-mark/federated-clusters.svg | 3 + .../assets/template-mark/liquid-democracy.svg | 8 + public/assets/template-mark/petition.svg | 13 + .../template-mark/quadratic-governance.svg | 11 + .../template-mark/self-appointed-board.svg | 6 + .../template-mark/solidarity-network.svg | 15 + .../assets/template-mark/sortition-jury.svg | 3 + stories/cards/RuleCard.stories.js | 18 +- tests/e2e/critical-journeys.spec.ts | 2 +- tests/mocks/navigation.ts | 20 + tests/pages/page-flow.test.jsx | 15 +- tests/pages/templates.test.jsx | 53 ++ tests/pages/user-journey.test.jsx | 4 +- tests/unit/RuleStack.test.jsx | 177 +++--- tests/unit/templateReviewMapping.test.ts | 49 ++ vitest.setup.ts | 1 + 47 files changed, 1706 insertions(+), 265 deletions(-) create mode 100644 app/(marketing)/templates/page.tsx create mode 100644 app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx create mode 100644 app/components/cards/TemplateReviewCard/index.ts create mode 100644 app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx create mode 100644 app/components/sections/GovernanceTemplateGrid/index.ts create mode 100644 app/create/review-template/[slug]/page.tsx create mode 100644 lib/create/fetchTemplates.ts create mode 100644 lib/create/templateReviewMapping.ts create mode 100644 lib/templates/governanceTemplateCatalog.ts create mode 100644 messages/en/create/templateReview.json create mode 100644 messages/en/pages/templates.json create mode 100644 prisma/seed.ts delete mode 100644 public/assets/Icon_Consensus.svg delete mode 100644 public/assets/Icon_ElectedBoard.svg delete mode 100644 public/assets/Icon_Petition.svg create mode 100644 public/assets/template-mark/benevolent-dictator.svg rename public/assets/{Icon_Sociocracy.svg => template-mark/consensus-clusters.svg} (100%) create mode 100644 public/assets/template-mark/consensus.svg create mode 100644 public/assets/template-mark/devolution.svg create mode 100644 public/assets/template-mark/do-ocracy.svg create mode 100644 public/assets/template-mark/elected-board.svg create mode 100644 public/assets/template-mark/federated-clusters.svg create mode 100644 public/assets/template-mark/liquid-democracy.svg create mode 100644 public/assets/template-mark/petition.svg create mode 100644 public/assets/template-mark/quadratic-governance.svg create mode 100644 public/assets/template-mark/self-appointed-board.svg create mode 100644 public/assets/template-mark/solidarity-network.svg create mode 100644 public/assets/template-mark/sortition-jury.svg create mode 100644 tests/mocks/navigation.ts create mode 100644 tests/pages/templates.test.jsx create mode 100644 tests/unit/templateReviewMapping.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 909ba75..d45b7a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ 2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`). 3. Install dependencies: `npm ci` 4. Apply migrations: `npx prisma migrate dev` -5. Run the app: `npm run dev` +5. (Optional) Seed curated rule templates: `npx prisma db seed` — requires `DATABASE_URL` and applied migrations. Safe to re-run; rows are upserted by `slug` so duplicates are not created. +6. Run the app: `npm run dev` Use `npx prisma studio` to inspect the database. diff --git a/app/(dev)/components-preview/page.tsx b/app/(dev)/components-preview/page.tsx index f448360..5bf532a 100644 --- a/app/(dev)/components-preview/page.tsx +++ b/app/(dev)/components-preview/page.tsx @@ -904,7 +904,7 @@ export default function ComponentsPreview() { className="w-[525px]" icon={ Sociocracy +
+
+ +
+
+ { + router.push( + `/create/review-template/${encodeURIComponent(slug)}`, + ); + }} + /> +
+
+ + ); +} diff --git a/app/components/cards/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx index dd7af0c..a00b5db 100644 --- a/app/components/cards/RuleCard/RuleCard.view.tsx +++ b/app/components/cards/RuleCard/RuleCard.view.tsx @@ -184,7 +184,7 @@ export function RuleCardView({ {/* Outermost container with bottom border - taller to match Figma */}
{/* Inner container for header text with padding */} @@ -232,7 +232,7 @@ export function RuleCardView({ `} >

{title}

@@ -279,8 +279,12 @@ export function RuleCardView({ )} {/* Footer: Description */} {description && ( -
-

{description}

+
+

+ {description} +

)} @@ -288,7 +292,9 @@ export function RuleCardView({ /* Collapsed State: Description */ description && (
-

+

{description}

diff --git a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx new file mode 100644 index 0000000..de7ef98 --- /dev/null +++ b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Image from "next/image"; +import RuleCard from "../RuleCard"; +import { getAssetPath } from "../../../../lib/assetUtils"; +import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates"; +import { + templateBodyToCategories, + templateSummaryFromBody, +} from "../../../../lib/create/templateReviewMapping"; +import { + getGovernanceTemplateCatalogEntry, + governanceTemplateIconPath, +} from "../../../../lib/templates/governanceTemplateCatalog"; + +const FALLBACK_PRESENTATION = { + iconPath: governanceTemplateIconPath("consensus"), + backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]", +}; + +export interface TemplateReviewCardProps { + template: RuleTemplateDto; + /** Merged onto RuleCard `className` (e.g. final-review desktop vs mobile radius/padding). */ + ruleCardClassName?: string; +} + +/** + * Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435); + * tag rows from API `body`. + */ +export function TemplateReviewCard({ + template, + ruleCardClassName = "", +}: TemplateReviewCardProps) { + const catalog = getGovernanceTemplateCatalogEntry(template.slug); + const pres = catalog ?? FALLBACK_PRESENTATION; + const categories = templateBodyToCategories(template.body); + const summary = templateSummaryFromBody(template.description, template.body); + + return ( + {}} + icon={ + {template.title} + } + /> + ); +} diff --git a/app/components/cards/TemplateReviewCard/index.ts b/app/components/cards/TemplateReviewCard/index.ts new file mode 100644 index 0000000..8470005 --- /dev/null +++ b/app/components/cards/TemplateReviewCard/index.ts @@ -0,0 +1,2 @@ +export { TemplateReviewCard } from "./TemplateReviewCard"; +export type { TemplateReviewCardProps } from "./TemplateReviewCard"; diff --git a/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx new file mode 100644 index 0000000..beadfb9 --- /dev/null +++ b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGrid.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import { useMediaQuery } from "../../../hooks/useMediaQuery"; +import RuleCard from "../../cards/RuleCard"; +import { getAssetPath } from "../../../../lib/assetUtils"; +import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/governanceTemplateCatalog"; + +export interface GovernanceTemplateGridProps { + entries: GovernanceTemplateCatalogEntry[]; + onTemplateClick: (_slug: string) => void; +} + +export function GovernanceTemplateGrid({ + entries, + onTemplateClick, +}: GovernanceTemplateGridProps) { + const [isMounted, setIsMounted] = useState(false); + + const isMax639 = useMediaQuery("(max-width: 639px)"); + const isMin640Max1023 = useMediaQuery( + "(min-width: 640px) and (max-width: 1023px)", + ); + const isMin1024Max1439 = useMediaQuery( + "(min-width: 1024px) and (max-width: 1439px)", + ); + const isMin1440 = useMediaQuery("(min-width: 1440px)"); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- breakpoint sizing after mount (matches SSR default "M") + setIsMounted(true); + }, []); + + const cardSize = isMounted + ? isMax639 + ? "XS" + : isMin640Max1023 + ? "S" + : isMin1024Max1439 + ? "M" + : isMin1440 + ? "L" + : "M" + : "M"; + + return ( +
+ {entries.map((card) => ( + + } + backgroundColor={card.backgroundColor} + onClick={() => { + onTemplateClick(card.slug); + }} + /> + ))} +
+ ); +} diff --git a/app/components/sections/GovernanceTemplateGrid/index.ts b/app/components/sections/GovernanceTemplateGrid/index.ts new file mode 100644 index 0000000..7fddbec --- /dev/null +++ b/app/components/sections/GovernanceTemplateGrid/index.ts @@ -0,0 +1,2 @@ +export { GovernanceTemplateGrid } from "./GovernanceTemplateGrid"; +export type { GovernanceTemplateGridProps } from "./GovernanceTemplateGrid"; diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 6c4fb25..88fdae9 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useRouter } from "next/navigation"; import { logger } from "../../../../lib/logger"; import { RuleStackView } from "./RuleStack.view"; import type { RuleStackProps } from "./RuleStack.types"; @@ -19,21 +20,24 @@ declare global { } const RuleStackContainer = memo(({ className = "" }) => { - const handleTemplateClick = (templateName: string) => { + const router = useRouter(); + + const handleTemplateClick = (slug: string) => { // Basic analytics tracking if (typeof window !== "undefined") { if (window.gtag) { window.gtag("event", "template_click", { - template_name: templateName, + template_slug: slug, }); } if (window.analytics) { window.analytics.track("Template Clicked", { - templateName: templateName, + templateSlug: slug, }); } } - logger.debug(`${templateName} template clicked`); + logger.debug(`${slug} template clicked`); + router.push(`/create/review-template/${encodeURIComponent(slug)}`); }; return ( diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts index fd635c1..62e0969 100644 --- a/app/components/sections/RuleStack/RuleStack.types.ts +++ b/app/components/sections/RuleStack/RuleStack.types.ts @@ -4,5 +4,5 @@ export interface RuleStackProps { export interface RuleStackViewProps { className: string; - onTemplateClick: (_templateName: string) => void; + onTemplateClick: (_slug: string) => void; } diff --git a/app/components/sections/RuleStack/RuleStack.view.tsx b/app/components/sections/RuleStack/RuleStack.view.tsx index 54e3b9c..536d1e9 100644 --- a/app/components/sections/RuleStack/RuleStack.view.tsx +++ b/app/components/sections/RuleStack/RuleStack.view.tsx @@ -1,91 +1,21 @@ "use client"; -import { useState, useEffect } from "react"; -import Image from "next/image"; import { useTranslation } from "../../../contexts/MessagesContext"; -import { useMediaQuery } from "../../../hooks/useMediaQuery"; -import RuleCard from "../../cards/RuleCard"; import SectionHeader from "../SectionHeader"; import Button from "../../buttons/Button"; -import { getAssetPath } from "../../../../lib/assetUtils"; +import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid"; +import { getGovernanceTemplatesForHome } from "../../../../lib/templates/governanceTemplateCatalog"; import type { RuleStackViewProps } from "./RuleStack.types"; +const homeFeaturedTemplates = getGovernanceTemplatesForHome(); + export function RuleStackView({ className, onTemplateClick, }: RuleStackViewProps) { const t = useTranslation("pages.home.ruleStack"); - const [isMounted, setIsMounted] = useState(false); - - // Debug: Log button text to ensure translation works const buttonText = t("button.seeAllTemplates"); - // Determine current breakpoint for RuleCard size - // 320-639: XS, 640-767: S, 768-1023: S, 1024-1439: M, 1440+: L - const isMax639 = useMediaQuery("(max-width: 639px)"); - const isMin640Max1023 = useMediaQuery( - "(min-width: 640px) and (max-width: 1023px)", - ); - const isMin1024Max1439 = useMediaQuery( - "(min-width: 1024px) and (max-width: 1439px)", - ); - const isMin1440 = useMediaQuery("(min-width: 1440px)"); - - // Handle hydration: only use media queries after mount - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer breakpoint until after mount to avoid hydration mismatch - setIsMounted(true); - }, []); - - // Use CSS classes for responsive sizing to avoid hydration mismatch - // Default to M size for SSR, then let CSS handle the responsive sizing - const cardSize = isMounted - ? isMax639 - ? "XS" - : isMin640Max1023 - ? "S" - : isMin1024Max1439 - ? "M" - : isMin1440 - ? "L" - : "M" - : "M"; - - // Icon sizes: XS=40px, S=56px, M=56px, L=90px - // Use a large default (90px) and let CSS handle responsive sizing - - // Card data - const cards = [ - { - title: t("cards.consensusClusters.title"), - description: t("cards.consensusClusters.description"), - iconAlt: t("cards.consensusClusters.iconAlt"), - iconPath: "assets/Icon_Sociocracy.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-lime)]", - }, - { - title: t("cards.consensus.title"), - description: t("cards.consensus.description"), - iconAlt: t("cards.consensus.iconAlt"), - iconPath: "assets/Icon_Consensus.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-rust)]", - }, - { - title: t("cards.electedBoard.title"), - description: t("cards.electedBoard.description"), - iconAlt: t("cards.electedBoard.iconAlt"), - iconPath: "assets/Icon_ElectedBoard.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-red)]", - }, - { - title: t("cards.petition.title"), - description: t("cards.petition.description"), - iconAlt: t("cards.petition.iconAlt"), - iconPath: "assets/Icon_Petition.svg", - backgroundColor: "bg-[var(--color-surface-default-brand-teal)]", - }, - ]; - return (
- {/* Section Header */} - {/* Cards Container */} -
- {cards.map((card, index) => ( - - } - backgroundColor={card.backgroundColor} - onClick={() => onTemplateClick(card.title)} - /> - ))} -
+ - {/* See all templates button */}
-
diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index 290c433..2145a73 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -18,6 +18,10 @@ import Button from "../components/buttons/Button"; import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; import { fetchAuthSession, publishRule } from "../../lib/create/api"; import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule"; +import { + fetchTemplateBySlug, + type RuleTemplateDto, +} from "../../lib/create/fetchTemplates"; import messages from "../../messages/en/index"; import { useAuthModal } from "../contexts/AuthModalContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; @@ -89,6 +93,18 @@ function CreateFlowLayoutContent({ string | null >(null); const [isPublishing, setIsPublishing] = useState(false); + const [templateReviewApplyError, setTemplateReviewApplyError] = useState< + string | null + >(null); + const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); + + const templateReviewMatch = pathname?.match( + /^\/create\/review-template\/([^/]+)$/, + ); + const templateReviewSlug = templateReviewMatch?.[1] + ? decodeURIComponent(templateReviewMatch[1]) + : null; + const isTemplateReviewRoute = Boolean(templateReviewSlug); const handleFinalize = useCallback(async () => { setPublishBannerMessage(null); @@ -134,6 +150,39 @@ function CreateFlowLayoutContent({ ); }, [state, router, openLogin]); + const handleUseTemplateWithoutChanges = useCallback(async () => { + if (!templateReviewSlug) return; + setTemplateReviewApplyError(null); + setIsApplyingTemplate(true); + const result = await fetchTemplateBySlug(templateReviewSlug); + setIsApplyingTemplate(false); + if (result === null) { + setTemplateReviewApplyError(messages.create.templateReview.errors.notFound); + return; + } + if ("error" in result) { + setTemplateReviewApplyError(result.error); + return; + } + const template: RuleTemplateDto = result; + const doc = template.body; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); + return; + } + const summaryRaw = + typeof template.description === "string" + ? template.description.trim() + : ""; + writeLastPublishedRule({ + id: `template:${template.slug}`, + title: template.title, + summary: summaryRaw.length > 0 ? summaryRaw : null, + document: doc as Record, + }); + router.push("/create/completed"); + }, [router, templateReviewSlug]); + const runAuthenticatedExit = useCreateFlowExit({ state, currentStep, @@ -149,9 +198,15 @@ function CreateFlowLayoutContent({ if (sessionUser === null) { if (saveDraft) return; + const returnToTemplateReview = + templateReviewSlug != null + ? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1` + : null; openLogin({ variant: "saveProgress", - nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`, + nextPath: + returnToTemplateReview ?? + `${pathname ?? "/create/informational"}?syncDraft=1`, backdropVariant: "blurredYellow", }); return; @@ -169,7 +224,9 @@ function CreateFlowLayoutContent({ Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; const hasErrorOverlays = - Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage); + Boolean(draftSaveBannerMessage) || + Boolean(publishBannerMessage) || + Boolean(templateReviewApplyError); return (
@@ -202,6 +259,18 @@ function CreateFlowLayoutContent({ />
) : null} + {templateReviewApplyError ? ( +
+ setTemplateReviewApplyError(null)} + className="w-full" + /> +
+ ) : null}
) : null} @@ -243,8 +312,41 @@ function CreateFlowLayoutContent({ {!isCompletedStep && ( + + +
+ ) : nextStep ? ( )} @@ -56,10 +56,10 @@ export function CreateFlowTopNavView({ palette={buttonPalette} size="xsmall" onClick={onExport} - ariaLabel="Export" + ariaLabel={t("exportAriaLabel")} className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]" > - Export + {t("export")} - Edit + {t("edit")} )} diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index 2145a73..54cdeba 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -24,6 +24,7 @@ import { } from "../../lib/create/fetchTemplates"; import messages from "../../messages/en/index"; import { useAuthModal } from "../contexts/AuthModalContext"; +import { useTranslation } from "../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; import Alert from "../components/modals/Alert"; @@ -76,6 +77,7 @@ function CreateFlowLayoutContent({ sessionUser: { id: string; email: string } | null | undefined; sessionResolved: boolean; }) { + const tFooter = useTranslation("create.footer"); const router = useRouter(); const pathname = usePathname(); const { openLogin } = useAuthModal(); @@ -99,12 +101,15 @@ function CreateFlowLayoutContent({ const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const templateReviewMatch = pathname?.match( - /^\/create\/review-template\/([^/]+)$/, + /\/create\/review-template\/([^/?#]+)/, ); const templateReviewSlug = templateReviewMatch?.[1] ? decodeURIComponent(templateReviewMatch[1]) : null; - const isTemplateReviewRoute = Boolean(templateReviewSlug); + /** Match anywhere in path so locale/basePath variants still get template footer + layout. */ + const isTemplateReviewRoute = Boolean( + pathname?.includes("/create/review-template/"), + ); const handleFinalize = useCallback(async () => { setPublishBannerMessage(null); @@ -218,8 +223,29 @@ function CreateFlowLayoutContent({ const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "right-rail"; - const useFullHeightMain = isCompletedStep || isRightRailStep; + const isFinalReviewStep = currentStep === "final-review"; + const isCardsStep = currentStep === "cards"; const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; + + /** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ + const mainContentClass = isCompletedStep + ? "items-stretch overflow-y-auto md:overflow-hidden" + : isRightRailStep + ? "items-stretch overflow-hidden" + : isFinalReviewStep || isCardsStep || isTemplateReviewRoute + ? "items-start justify-center overflow-y-auto" + : "items-start justify-center overflow-y-auto md:items-center"; + + const isTextStep = currentStep === "text"; + const mainMaxMdJustify = + isTextStep && !isCompletedStep && !isRightRailStep + ? "max-md:justify-center" + : "max-md:justify-start"; + const mainMaxMdCross = + isCompletedStep || isRightRailStep + ? "max-md:flex-col max-md:items-stretch" + : "max-md:flex-col max-md:items-center"; + const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const saveDraftOnExit = Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; @@ -299,20 +325,14 @@ function CreateFlowLayoutContent({ }`.trim()} />
{children}
{!isCompletedStep && ( @@ -364,10 +384,10 @@ function CreateFlowLayoutContent({ {currentStep === "final-review" ? isPublishing ? messages.create.publish.finalizeButtonPublishing - : "Finalize CommunityRule" + : tFooter("finalizeCommunityRule") : currentStep === "confirm-stakeholders" - ? "Confirm Stakeholders" - : "Next"} + ? tFooter("confirmStakeholders") + : tFooter("next")} ) : null } diff --git a/app/create/[step]/page.tsx b/app/create/[step]/page.tsx index 94517c2..0d05913 100644 --- a/app/create/[step]/page.tsx +++ b/app/create/[step]/page.tsx @@ -24,7 +24,7 @@ export default function CreateFlowStepPage({ params }: PageProps) { // Placeholder content - templates will be implemented in CR-51-55 return ( -
+

Create Flow Step: {step} diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx index ef87873..00fd170 100644 --- a/app/create/cards/page.tsx +++ b/app/create/cards/page.tsx @@ -1,91 +1,41 @@ "use client"; -import { useState, useCallback } from "react"; -import HeaderLockup from "../../components/type/HeaderLockup"; +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"; -const COMPACT_TITLE = "How should this community communicate with each-other?"; -const COMPACT_DESCRIPTION = - "You can select multiple methods for different needs or add your own"; -const EXPANDED_TITLE = - "What method should this community use to communicate with eachother?"; -const EXPANDED_DESCRIPTION = COMPACT_DESCRIPTION; - -/** Create is a shell; which variant shows is determined by which card was clicked; we pass different props and children by pendingCardId. */ - -/** Card ids for "Add platform" Create modal variants. */ const IN_PERSON_CARD_ID = "in-person-meetings"; const SIGNAL_CARD_ID = "signal"; const VIDEO_MEETINGS_CARD_ID = "video-meetings"; -/** Copy for the default confirm modal (non–add-platform cards). */ -const CONFIRM_MODAL = { - title: "Confirm selection", - description: "Confirm to select this option.", - nextButtonText: "Confirm", - showBackButton: false, - currentStep: undefined, - totalSteps: undefined, -} as const; - -/** - * "Add platform" variants share the same header pattern and "Add Platform" button. - * Each has its own title, description, and body (three TextArea sections). - */ -const ADD_PLATFORM_MODALS: Record< - string, - { title: string; description: string; nextButtonText: string } -> = { - [IN_PERSON_CARD_ID]: { - title: "In-Person Meetings", - description: - "Physical gatherings for high-bandwidth communication and relationship building.", - nextButtonText: "Add Platform", - }, - [SIGNAL_CARD_ID]: { - title: "Signal", - description: - "End-to-end encrypted messaging ideal for small, security-minded groups", - nextButtonText: "Add Platform", - }, - [VIDEO_MEETINGS_CARD_ID]: { - title: "Video Meetings", - description: "Synchronous video calls for remote face-to-face interaction.", - nextButtonText: "Add Platform", - }, -}; - -const SECTION_KEYS = [ - "Core Principle & Scope", - "Logistics, Admin & Norms", - "Code of Conduct", +const ADD_PLATFORM_CARD_IDS = [ + IN_PERSON_CARD_ID, + SIGNAL_CARD_ID, + VIDEO_MEETINGS_CARD_ID, ] as const; -type SectionKey = (typeof SECTION_KEYS)[number]; -/** Default section text per platform (Figma 20647-18271, 20647-18273, 20736-12668). */ -const ADD_PLATFORM_SECTION_DEFAULTS: Record< - string, - Record -> = { - [IN_PERSON_CARD_ID]: { - "Core Principle & Scope": `We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.`, - "Logistics, Admin & Norms": `Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.`, - "Code of Conduct": `We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry.`, - }, - [SIGNAL_CARD_ID]: { - "Core Principle & Scope": `We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.`, - "Logistics, Admin & Norms": `We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use "Emoji Reactions" (👍, ♥️) to acknowledge messages rather than typing "thanks," which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.`, - "Code of Conduct": `This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse.`, - }, - [VIDEO_MEETINGS_CARD_ID]: { - "Core Principle & Scope": `We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.`, - "Logistics, Admin & Norms": `The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the "Raise Hand" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.`, - "Code of Conduct": `We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. "Zoom-bombing" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk.`, - }, -}; +const SECTION_FIELDS = [ + "corePrinciple", + "logisticsAdmin", + "codeOfConduct", +] as const; +type SectionField = (typeof SECTION_FIELDS)[number]; + +const COMMUNICATION_CARD_ORDER = [ + IN_PERSON_CARD_ID, + SIGNAL_CARD_ID, + VIDEO_MEETINGS_CARD_ID, + "4", + "5", + "6", + "7", +] as const; /** * Section with heading + info icon and an editable TextArea. @@ -132,119 +82,101 @@ function AddPlatformModalContent({ platformCardId: string; }) { const { markCreateFlowInteraction } = useCreateFlow(); - const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId]; + const m = useMessages(); + const comm = m.create.communication; + const modal = comm.modals[platformCardId as keyof typeof comm.modals]; + if (!modal || !("sections" in modal)) return null; + + const defaults = modal.sections; const [sectionValues, setSectionValues] = useState< - Record - >( - defaults ?? { - "Core Principle & Scope": "", - "Logistics, Admin & Norms": "", - "Code of Conduct": "", - }, - ); + Record + >(() => ({ + corePrinciple: defaults.corePrinciple, + logisticsAdmin: defaults.logisticsAdmin, + codeOfConduct: defaults.codeOfConduct, + })); const updateSection = useCallback( - (key: SectionKey, value: string) => { + (key: SectionField, value: string) => { markCreateFlowInteraction(); setSectionValues((prev) => ({ ...prev, [key]: value })); }, [markCreateFlowInteraction], ); - if (!defaults) return null; - return (
- {SECTION_KEYS.map((key) => ( + {SECTION_FIELDS.map((field) => ( updateSection(key, v)} + key={field} + title={comm.sectionHeadings[field]} + value={sectionValues[field]} + onChange={(v) => updateSection(field, v)} /> ))}
); } -/** Communication method cards (Figma 20246-15828). First three are recommended. */ -const SAMPLE_CARDS = [ - { - id: IN_PERSON_CARD_ID, - label: "In-Person Meetings", - supportText: - "Physical gatherings for high-bandwidth communication and relationship building.", - recommended: true, - }, - { - id: SIGNAL_CARD_ID, - label: "Signal", - supportText: "Encrypted messaging for high-security, private coordination.", - recommended: true, - }, - { - id: VIDEO_MEETINGS_CARD_ID, - label: "Video Meetings", - supportText: "Synchronous video calls for remote face-to-face interaction.", - recommended: true, - }, - { - id: "4", - label: "Label", - supportText: - "Collaborative work to reach a resolution that all parties can agree upon.", - recommended: true, - }, - { - id: "5", - label: "Label", - supportText: - "Structured sessions where parties collaboratively resolve disputes.", - recommended: true, - }, - { - id: "6", - label: "Label", - supportText: "Members vote to resolve a dispute democratically.", - recommended: true, - }, - { - id: "7", - label: "Label", - supportText: "Invite-only", - recommended: true, - }, -]; - -/** Whether this card id uses the "Add platform" modal (shared header, platform-specific body). */ -function isAddPlatformCard(cardId: string | null): cardId is string { - return cardId !== null && cardId in ADD_PLATFORM_MODALS; -} - -/** Resolve Create modal header/buttons: Add platform variant or default confirm. */ -function getCreateModalConfig(pendingCardId: string | null) { - if (isAddPlatformCard(pendingCardId)) { - return { - ...ADD_PLATFORM_MODALS[pendingCardId], - showBackButton: false, - currentStep: undefined, - totalSteps: undefined, - }; - } - return CONFIRM_MODAL; +function isAddPlatformCard(cardId: string | null): boolean { + return ( + cardId !== null && + (ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId) + ); } /** Create flow card stack step: compact grid with optional expand to full list. */ export default function CardsPage() { + const m = useMessages(); + const comm = m.create.communication; + const mdUp = useCreateFlowMdUp(); const { markCreateFlowInteraction } = useCreateFlow(); const [expanded, setExpanded] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); - const title = expanded ? EXPANDED_TITLE : COMPACT_TITLE; - const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION; - const modalConfig = getCreateModalConfig(pendingCardId); + const sampleCards = useMemo( + () => + COMMUNICATION_CARD_ORDER.map((id) => { + const row = comm.cards[id as keyof typeof comm.cards]; + return { + id, + label: row.label, + supportText: row.supportText, + recommended: true, + }; + }), + [comm.cards], + ); + + const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle; + const description = expanded + ? comm.page.expandedDescription + : comm.page.compactDescription; + + const modalConfig = + pendingCardId && pendingCardId in comm.modals + ? (() => { + const modal = + comm.modals[pendingCardId as keyof typeof comm.modals]; + return { + title: modal.title, + description: modal.description, + nextButtonText: comm.addPlatform.nextButtonText, + showBackButton: false as const, + currentStep: undefined, + totalSteps: undefined, + }; + })() + : { + title: comm.confirmModal.title, + description: comm.confirmModal.description, + nextButtonText: comm.confirmModal.nextButtonText, + showBackButton: false as const, + currentStep: undefined, + totalSteps: undefined, + }; const handleCardClick = useCallback( (id: string) => { @@ -272,19 +204,21 @@ export default function CardsPage() { }, [markCreateFlowInteraction, pendingCardId]); return ( -
-
+ +
-
!prev); }} hasMore={true} + toggleLabel={comm.page.seeAllLink} + headerLockupSize={mdUp ? "L" : "M"} />
@@ -308,10 +244,13 @@ export default function CardsPage() { currentStep={modalConfig.currentStep} totalSteps={modalConfig.totalSteps} > - {isAddPlatformCard(pendingCardId) ? ( - + {isAddPlatformCard(pendingCardId) && pendingCardId ? ( + ) : null} -
+ ); } diff --git a/app/create/completed/page.tsx b/app/create/completed/page.tsx index 5b86299..1c2e955 100644 --- a/app/create/completed/page.tsx +++ b/app/create/completed/page.tsx @@ -1,111 +1,39 @@ "use client"; -import { useState, useEffect } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; +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"; - -/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */ -const FALLBACK_TITLE = "Mutual Aid Mondays"; -const FALLBACK_DESCRIPTION = - "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness."; - -const TOAST_TITLE = "This is what folks see when you share your CommunityRule"; -const TOAST_DESCRIPTION = - "Your group can use this document as an operating manual."; - -const SOLIDARITY_BODY = - "Food Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth."; - -/** Static sections for the completed Community Rule document (placeholder data). */ -const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [ - { - categoryName: "Values", - entries: [ - { title: "Solidarity Forever", body: SOLIDARITY_BODY }, - { - title: "Shared Leadership", - body: "We operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader.", - }, - { - title: "Organizing Offline", - body: "We use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face connection and resist the pull of digital metrics.", - }, - { - title: "Circular Food Systems", - body: "We intervene in the ecological crisis by addressing food waste and food recovery. We redirect surplus food to where it is needed and model a circular economy at the scale of our communities.", - }, - ], - }, - { - categoryName: "Communication", - entries: [ - { - title: "Signal", - body: "We use Signal for sensitive coordination. Encrypted messaging helps protect our members and our plans from surveillance.", - }, - ], - }, - { - categoryName: "Membership", - entries: [ - { - title: "Open Admission", - body: "Anyone who shares our values and is willing to contribute is welcome. We do not require applications or approval processes for general participation.", - }, - ], - }, - { - categoryName: "Decision-making", - entries: [ - { - title: "Lazy Consensus", - body: "We use lazy consensus for most decisions: proposals move forward unless someone raises a blocking concern. This keeps us moving without requiring everyone to approve every detail.", - }, - { - title: "Modified Consensus", - body: "For larger or more consequential decisions we use modified consensus, with clear timelines and a fallback to a supermajority vote if needed.", - }, - ], - }, - { - categoryName: "Conflict management", - entries: [ - { - title: "Code of Conduct", - body: "We have a code of conduct that sets expectations for behavior and outlines how we address harm.", - }, - { - title: "Restorative Justice", - body: "When conflict arises we prioritize restoration and learning over punishment. We use facilitated circles and other restorative practices where appropriate.", - }, - ], - }, -]; +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() { - const [isMounted, setIsMounted] = useState(false); + const mdUp = useCreateFlowMdUp(); + const m = useMessages(); + const completed = m.create.completed; + + const fallbackSections = useMemo( + () => + [...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[], + [completed.fallbackDocumentSections], + ); + const [toastDismissed, setToastDismissed] = useState(false); - const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE); + const [headerTitle, setHeaderTitle] = useState( + () => completed.fallbackTitle, + ); const [headerDescription, setHeaderDescription] = useState< string | undefined - >(FALLBACK_DESCRIPTION); + >(() => completed.fallbackDescription); const [documentSections, setDocumentSections] = - useState(COMPLETED_RULE_SECTIONS); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); + useState(fallbackSections); useEffect(() => { const stored = readLastPublishedRule(); @@ -119,99 +47,54 @@ export default function CompletedPage() { setHeaderDescription(sum.length > 0 ? sum : undefined); }, []); - const showDesktopLayout = !isMounted || isMdOrLarger; - - if (showDesktopLayout) { - return ( -
-
-
- {/* Left column: community title + header, centered, does not scroll */} -
- -
- {/* Right column: Community Rule document — this column scrolls independently; padding inside scroll so content isn't clipped */} -
- {/* Soft fade at top: gradient wash only (no blur) so no sharp cutoff line */} -
-
- -
-
-
-
- - {!toastDismissed && ( -
- setToastDismissed(true)} - className="w-full" - /> -
- )} -
- ); - } + const toast = !toastDismissed ? ( +
+ setToastDismissed(true)} + className="w-full" + /> +
+ ) : null; return ( <> -
-
- - +
+
+
+ +
+
+
+
+ +
+
- - {!toastDismissed && ( -
- setToastDismissed(true)} - className="w-full" - /> -
- )} + {toast} ); } diff --git a/app/create/components/CreateFlowHeaderLockup.tsx b/app/create/components/CreateFlowHeaderLockup.tsx new file mode 100644 index 0000000..301cb94 --- /dev/null +++ b/app/create/components/CreateFlowHeaderLockup.tsx @@ -0,0 +1,22 @@ +"use client"; + +import HeaderLockup from "../../components/type/HeaderLockup"; +import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types"; +import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; + +export type CreateFlowHeaderLockupProps = Omit & { + /** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */ + size?: HeaderLockupProps["size"]; +}; + +/** + * Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly. + */ +export function CreateFlowHeaderLockup({ + size: sizeProp, + ...rest +}: CreateFlowHeaderLockupProps) { + const mdUp = useCreateFlowMdUp(); + const size = sizeProp ?? (mdUp ? "L" : "M"); + return ; +} diff --git a/app/create/components/CreateFlowLockupCardStepShell.tsx b/app/create/components/CreateFlowLockupCardStepShell.tsx new file mode 100644 index 0000000..e75a001 --- /dev/null +++ b/app/create/components/CreateFlowLockupCardStepShell.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { ReactNode } from "react"; +import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "./CreateFlowStepShell"; + +/** Shared `RuleCard` / template card chrome: matches final-review desktop + mobile padding and radius. */ +export const CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS = + "w-full min-w-0 rounded-[12px] p-4 md:rounded-[24px] md:!max-w-full md:!w-full md:p-0"; + +type CreateFlowLockupCardStepShellProps = { + lockupTitle: string; + lockupDescription?: string; + 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. + */ +export function CreateFlowLockupCardStepShell({ + lockupTitle, + lockupDescription, + children, +}: CreateFlowLockupCardStepShellProps) { + return ( + +
+
+ +
+
{children}
+
+
+ ); +} diff --git a/app/create/components/CreateFlowStepShell.tsx b/app/create/components/CreateFlowStepShell.tsx new file mode 100644 index 0000000..0bf3903 --- /dev/null +++ b/app/create/components/CreateFlowStepShell.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { ReactNode } from "react"; + +export type CreateFlowStepShellVariant = + | "centeredNarrow" + | "centeredNarrowBottomPad" + | "wideGrid" + | "wideGridLoosePadding" + | "bare"; + +/** Top padding below `md` between top nav and step content (semantic space tokens). */ +export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800"; + +const outerByVariant: Record = { + centeredNarrow: + "flex w-full min-w-0 flex-col items-center px-5 md:px-16", + centeredNarrowBottomPad: + "flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32", + wideGrid: "w-full min-w-0 max-w-[1280px] shrink-0 px-5 md:px-12", + wideGridLoosePadding: + "w-full min-w-0 max-w-[1280px] 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)]", +}; + +interface CreateFlowStepShellProps { + children: ReactNode; + variant?: CreateFlowStepShellVariant; + /** Padding-top below `md` only; `text` step uses `none`. */ + contentTopBelowMd?: CreateFlowContentTopBelowMd; + className?: string; +} + +/** + * Shared horizontal padding and width constraints for create-flow step pages. + * Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`). + */ +export function CreateFlowStepShell({ + children, + variant = "centeredNarrow", + contentTopBelowMd = "none", + className = "", +}: CreateFlowStepShellProps) { + const topClass = contentTopBelowMdClass[contentTopBelowMd]; + return ( +
+ {children} +
+ ); +} diff --git a/app/create/confirm-stakeholders/page.tsx b/app/create/confirm-stakeholders/page.tsx index 92eb7e7..5fdcd2d 100644 --- a/app/create/confirm-stakeholders/page.tsx +++ b/app/create/confirm-stakeholders/page.tsx @@ -1,20 +1,13 @@ "use client"; -import { useState, useEffect } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; +import { useState } from "react"; import MultiSelect from "../../components/controls/MultiSelect"; import Alert from "../../components/modals/Alert"; import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types"; +import { useTranslation } from "../../contexts/MessagesContext"; import { useCreateFlow } from "../context/CreateFlowContext"; - -const TITLE = - "Do other stakeholders need to be involved in creating your community?"; - -const DESCRIPTION = - "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals."; - -const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!"; +import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; /** * Confirm stakeholders step — stacked lockup + MultiSelect (not split columns). @@ -22,19 +15,11 @@ const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!"; */ export default function ConfirmStakeholdersPage() { const { markCreateFlowInteraction } = useCreateFlow(); - const [isMounted, setIsMounted] = useState(false); + const t = useTranslation("create.confirmStakeholders"); const [toastDismissed, setToastDismissed] = useState(false); const [stakeholderOptions, setStakeholderOptions] = useState( [], ); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); - - const effectiveMdOrLarger = !isMounted || isMdOrLarger; const handleAddStakeholder = () => { markCreateFlowInteraction(); @@ -65,14 +50,16 @@ export default function ConfirmStakeholdersPage() { return ( <> -
-
+ +
-
-
+ {!toastDismissed && (
setToastDismissed(true)} diff --git a/app/create/final-review/page.tsx b/app/create/final-review/page.tsx index f00fe84..1e32aea 100644 --- a/app/create/final-review/page.tsx +++ b/app/create/final-review/page.tsx @@ -1,136 +1,70 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; +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 { + CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS, + CreateFlowLockupCardStepShell, +} from "../components/CreateFlowLockupCardStepShell"; -const TITLE = "Review your CommunityRule"; -const DESCRIPTION = - "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."; - -const RULE_CARD_TITLE_FALLBACK = "Your community"; -const RULE_CARD_DESCRIPTION_FALLBACK = - "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name."; - -/** Static categories for final review (read-only display). */ -const FINAL_REVIEW_CATEGORIES: Category[] = [ - { - name: "Values", - chipOptions: [ - { id: "v1", label: "Consciousness", state: "unselected" }, - { id: "v2", label: "Ecology", state: "unselected" }, - { id: "v3", label: "Abundance", state: "unselected" }, - { id: "v4", label: "Art", state: "unselected" }, - { id: "v5", label: "Decisiveness", state: "unselected" }, - ], - }, - { - name: "Communication", - chipOptions: [{ id: "c1", label: "Signal", state: "unselected" }], - }, - { - name: "Membership", - chipOptions: [{ id: "m1", label: "Open Admission", state: "unselected" }], - }, - { - name: "Decision-making", - chipOptions: [ - { id: "d1", label: "Lazy Consensus", state: "unselected" }, - { id: "d2", label: "Modified Consensus", state: "unselected" }, - ], - }, - { - name: "Conflict management", - chipOptions: [ - { id: "cf1", label: "Code of Conduct", state: "unselected" }, - { id: "cf2", label: "Restorative Justice", state: "unselected" }, - ], - }, -]; +function buildFinalReviewCategories( + rows: { name: string; chips: string[] }[], +): Category[] { + return rows.map((cat) => ({ + name: cat.name, + chipOptions: cat.chips.map((label, idx) => ({ + id: `${cat.name}-${idx}`, + label, + state: "unselected" as const, + })), + })); +} /** * Final review step (right before completed). - * Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint). + * Figma: 20907-212767 (full-size), 20976-220705 (below `md`). */ export default function FinalReviewPage() { const { state } = useCreateFlow(); - const [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); + const t = useTranslation("create.finalReview"); + const m = useMessages(); + + const finalReviewCategories = useMemo( + () => buildFinalReviewCategories(m.create.finalReview.categories), + [m.create.finalReview.categories], + ); const ruleCardTitle = useMemo(() => { - const t = typeof state.title === "string" ? state.title.trim() : ""; - return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK; - }, [state.title]); + const raw = typeof state.title === "string" ? state.title.trim() : ""; + return raw.length > 0 ? raw : t("ruleCardTitleFallback"); + }, [state.title, t]); const ruleCardDescription = useMemo(() => { - const s = typeof state.summary === "string" ? state.summary.trim() : ""; - return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK; - }, [state.summary]); - - // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); - - const showDesktopLayout = !isMounted || isMdOrLarger; - - if (showDesktopLayout) { - return ( -
-
-
- -
-
- {}} - /> -
-
-
- ); - } + const raw = + typeof state.summary === "string" ? state.summary.trim() : ""; + return raw.length > 0 ? raw : t("ruleCardDescriptionFallback"); + }, [state.summary, t]); return ( -
-
- - {}} - /> -
-
+ + {}} + /> + ); } diff --git a/app/create/hooks/useCreateFlowMdUp.ts b/app/create/hooks/useCreateFlowMdUp.ts new file mode 100644 index 0000000..04843ea --- /dev/null +++ b/app/create/hooks/useCreateFlowMdUp.ts @@ -0,0 +1,29 @@ +"use client"; + +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. + */ +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. + */ +export function useCreateFlowMdUp(): boolean { + const [isMounted, setIsMounted] = useState(false); + const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment + setIsMounted(true); + }, []); + + return !isMounted || isMdOrLarger; +} diff --git a/app/create/informational/page.tsx b/app/create/informational/page.tsx index 280a41a..baca2ce 100644 --- a/app/create/informational/page.tsx +++ b/app/create/informational/page.tsx @@ -1,60 +1,49 @@ "use client"; -import { useState, useEffect } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; 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. - * Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint. + * Lockup sizing via `CreateFlowHeaderLockup`. NumberedList: S / M by breakpoint. */ export default function InformationalPage() { - const [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - - // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); - - const effectiveMdOrLarger = !isMounted || isMdOrLarger; + const mdUp = useCreateFlowMdUp(); + const t = useTranslation("create.informational"); const items = [ { - title: "Tell us about your organization", - description: - "Start by providing your group's name, description, and profile image.", + title: t("steps.0.title"), + description: t("steps.0.description"), }, { - title: "Define your group's CommunityRule.", - description: - "Outline decision-making processes, conflict resolution methods, and membership practices. Get recommendations.", + title: t("steps.1.title"), + description: t("steps.1.description"), }, { - title: "Share and evolve over time", - description: - "Review and refine your community framework before putting it into action and adapting it over time.", + title: t("steps.2.title"), + description: t("steps.2.description"), }, ]; return ( -
-
- {/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */} - +
+ - - {/* NumberedList: M size at 640px+, S size below 640px */} - +
-
+ ); } diff --git a/app/create/review-template/[slug]/page.tsx b/app/create/review-template/[slug]/page.tsx index 543b5d8..6c3f004 100644 --- a/app/create/review-template/[slug]/page.tsx +++ b/app/create/review-template/[slug]/page.tsx @@ -1,16 +1,19 @@ "use client"; import { use, useEffect, useState } from "react"; -import HeaderLockup from "../../../components/type/HeaderLockup"; import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard"; import { useTranslation } from "../../../contexts/MessagesContext"; -import { useMediaQuery } from "../../../hooks/useMediaQuery"; import { fetchTemplateBySlug, type RuleTemplateDto, } from "../../../../lib/create/fetchTemplates"; import messages from "../../../../messages/en/index"; import Alert from "../../../components/modals/Alert"; +import { + CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS, + CreateFlowLockupCardStepShell, +} from "../../components/CreateFlowLockupCardStepShell"; +import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; interface PageProps { params: Promise<{ slug: string }>; @@ -28,13 +31,6 @@ export default function ReviewTemplatePage({ params }: PageProps) { const [template, setTemplate] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - const [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- match final-review: defer breakpoint until mount - setIsMounted(true); - }, []); useEffect(() => { let cancelled = false; @@ -62,69 +58,43 @@ export default function ReviewTemplatePage({ params }: PageProps) { }; }, [slug]); - const showDesktopLayout = !isMounted || isMdOrLarger; - if (loading) { return ( -
-

- {t("loading")} -

-
+ +
+

+ {t("loading")} +

+
+
); } if (error || !template) { return ( -
- -
- ); - } - - if (showDesktopLayout) { - return ( -
-
-
- -
-
- -
+ +
+
-
+ ); } return ( -
-
- - -
-
+ + + ); } diff --git a/app/create/review/page.tsx b/app/create/review/page.tsx index 6407d98..2ed87e7 100644 --- a/app/create/review/page.tsx +++ b/app/create/review/page.tsx @@ -1,34 +1,40 @@ "use client"; -import HeaderLockup from "../../components/type/HeaderLockup"; import RuleCard from "../../components/cards/RuleCard"; +import { useTranslation } from "../../contexts/MessagesContext"; +import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; /** Mid-flow review step (after upload, before cards). */ export default function ReviewPage() { + const t = useTranslation("create.review"); + return ( -
-
+ +
-
-
+ ); } diff --git a/app/create/right-rail/page.tsx b/app/create/right-rail/page.tsx index 8c9b95e..b5da495 100644 --- a/app/create/right-rail/page.tsx +++ b/app/create/right-rail/page.tsx @@ -1,100 +1,56 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; +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"; - -const SIDEBAR_TITLE = "How should conflicts be resolved?"; - -const SIDEBAR_DESCRIPTION = ( - <> - You can also combine or add new - approaches to the list - -); - -const MESSAGE_BOX_TITLE = - "Consider defining approaches to steward key resources:"; - -const MESSAGE_BOX_ITEMS: InfoMessageBoxItem[] = [ - { id: "amend", label: "Amend your CommunityRule" }, - { id: "finances", label: "Steward finances" }, - { id: "project", label: "Project level decisions" }, - { id: "discipline", label: "Discipline and member termination" }, -]; - -const SAMPLE_CARDS: CardStackItem[] = [ - { - id: "mediation", - label: "Mediation", - supportText: - "Collaborative work to reach a resolution that all parties can agree upon.", - recommended: true, - }, - { - id: "facilitation", - label: "Facilitated dialogue", - supportText: - "Structured sessions where parties collaboratively resolve disputes.", - recommended: true, - }, - { - id: "invite-only", - label: "Invite-only", - supportText: "Private discussions with selected participants.", - recommended: true, - }, - { - id: "arbitration", - label: "Arbitration", - supportText: "Arbitrators are chosen specifically for a particular case.", - recommended: true, - }, - { - id: "direct-dialogue", - label: "Direct dialogue", - supportText: - "Encouraging direct, respectful dialogue between those involved.", - recommended: true, - }, - // Extra cards to test scrolling (only visible when "See all" is expanded) - { id: "label-1", label: "Label", supportText: "", recommended: false }, - { id: "label-2", label: "Label", supportText: "", recommended: false }, - { id: "label-3", label: "Label", supportText: "", recommended: false }, - { id: "label-4", label: "Label", supportText: "", recommended: false }, - { id: "label-5", label: "Label", supportText: "", recommended: false }, - { id: "label-6", label: "Label", supportText: "", recommended: false }, - { id: "label-7", label: "Label", supportText: "", recommended: false }, - { id: "label-8", label: "Label", supportText: "", recommended: false }, - { id: "label-9", label: "Label", supportText: "", recommended: false }, - { id: "label-10", label: "Label", supportText: "", recommended: false }, -]; +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() { + const m = useMessages(); + const rr = m.create.rightRail; + const mdUp = useCreateFlowMdUp(); const { markCreateFlowInteraction } = useCreateFlow(); - const [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState( [], ); const [selectedIds, setSelectedIds] = useState([]); const [expanded, setExpanded] = useState(false); - // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); + const messageBoxItems: InfoMessageBoxItem[] = useMemo( + () => + rr.messageBox.items.map((item) => ({ + id: item.id, + label: item.label, + })), + [rr.messageBox.items], + ); - const showDesktopLayout = !isMounted || isMdOrLarger; + const sampleCards: CardStackItem[] = useMemo( + () => + rr.cards.map((c) => ({ + id: c.id, + label: c.label, + supportText: c.supportText, + recommended: c.recommended, + })), + [rr.cards], + ); + + const sidebarDescription = ( + <> + {rr.sidebar.descriptionBefore} + {rr.sidebar.descriptionLink} + {rr.sidebar.descriptionAfter} + + ); const handleMessageBoxCheckboxChange = useCallback( (id: string, checked: boolean) => { @@ -121,79 +77,45 @@ export default function RightRailPage() { setExpanded((prev) => !prev); }, [markCreateFlowInteraction]); - if (showDesktopLayout) { - return ( -
-
-
- {/* Left column: sidebar stays put, does not scroll */} -
- +
+
+
+ +
+
+
+
- {/* Right column: card stack — this column scrolls independently */} -
-
- -
-
- ); - } - - return ( -
-
- -
- -
-
); } diff --git a/app/create/select/page.tsx b/app/create/select/page.tsx index 3a230a5..316238f 100644 --- a/app/create/select/page.tsx +++ b/app/create/select/page.tsx @@ -2,16 +2,17 @@ import { useState, - useEffect, useMemo, type Dispatch, type SetStateAction, } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; import MultiSelect from "../../components/controls/MultiSelect"; import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types"; +import { 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>, @@ -44,55 +45,40 @@ function createListCustomHandlers( }; } +function chipRowsFromLabels( + rows: readonly { label: string }[], +): ChipOption[] { + return rows.map((row, i) => ({ + id: String(i + 1), + label: row.label, + state: "Unselected" as const, + })); +} + /** * Select page for the create flow * * Displays selection options using HeaderLockup and MultiSelect components. - * Responsive layout: two-column at 640px+, single column below 640px. - * Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint. + * 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 [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - - // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); - - const effectiveMdOrLarger = !isMounted || isMdOrLarger; + const mdUp = useCreateFlowMdUp(); + const t = useTranslation("create.select"); const [communitySizeOptions, setCommunitySizeOptions] = useState< ChipOption[] - >([ - { id: "1", label: "1 member", state: "Unselected" }, - { id: "2", label: "2-10 members", state: "Unselected" }, - { id: "3", label: "10-24 members", state: "Unselected" }, - { id: "4", label: "24-64 members", state: "Unselected" }, - { id: "5", label: "64-128 members", state: "Unselected" }, - { id: "6", label: "125-1000 members", state: "Unselected" }, - { id: "7", label: "1000+ members", state: "Unselected" }, - ]); + >(() => chipRowsFromLabels(m.create.select.communitySizes)); const [organizationTypeOptions, setOrganizationTypeOptions] = useState< ChipOption[] - >([ - { id: "1", label: "Non-profit", state: "Unselected" }, - { id: "2", label: "For-profit", state: "Unselected" }, - { id: "3", label: "Community", state: "Unselected" }, - { id: "4", label: "Educational", state: "Unselected" }, - ]); + >(() => chipRowsFromLabels(m.create.select.organizationTypes)); const [governanceStyleOptions, setGovernanceStyleOptions] = useState< ChipOption[] - >([ - { id: "1", label: "Democratic", state: "Unselected" }, - { id: "2", label: "Consensus", state: "Unselected" }, - { id: "3", label: "Hierarchical", state: "Unselected" }, - { id: "4", label: "Flat", state: "Unselected" }, - ]); + >(() => chipRowsFromLabels(m.create.select.governanceStyles)); const communityCustomHandlers = useMemo( () => @@ -164,93 +150,69 @@ export default function SelectPage() { ); }; + const multiLabel = t("multiSelect.label"); + const addText = t("multiSelect.addButtonText"); + + const multiSelectBlock = ( + <> + + + + + ); + return ( -
- {effectiveMdOrLarger ? ( - // Two-column layout for 640px+ -
- {/* Left column: HeaderLockup */} -
- + {mdUp ? ( +
+
+
- - {/* Right column: Three MultiSelect components */} -
- - - +
+ {multiSelectBlock}
) : ( - // Single column layout below 640px -
- {/* HeaderLockup */} - + - - {/* Three MultiSelect components */} - - - + {multiSelectBlock}
)} -
+ ); } diff --git a/app/create/text/page.tsx b/app/create/text/page.tsx index 945a1d1..44b7ef0 100644 --- a/app/create/text/page.tsx +++ b/app/create/text/page.tsx @@ -1,21 +1,24 @@ "use client"; import { useState, useEffect } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; import TextInput from "../../components/controls/TextInput"; +import { useTranslation } from "../../contexts/MessagesContext"; import { useCreateFlow } from "../context/CreateFlowContext"; +import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp"; +import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup"; +import { CreateFlowStepShell } from "../components/CreateFlowStepShell"; /** * Text page for the create flow * * Displays a text input field for user input using HeaderLockup and TextInput components. - * Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint. + * Lockup sizing via `CreateFlowHeaderLockup`. TextInput: small / medium by breakpoint. + * Below `md`, this step stays vertically centered in the main area (see `CreateFlowLayoutClient`). */ export default function TextPage() { const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); - const [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); + const mdUp = useCreateFlowMdUp(); + const t = useTranslation("create.text"); const [value, setValue] = useState(() => typeof state.title === "string" ? state.title : "", ); @@ -27,32 +30,23 @@ export default function TextPage() { setValue((prev) => (prev === "" ? incoming : prev)); }, [state.title]); - // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); - - const effectiveMdOrLarger = !isMounted || isMdOrLarger; - const maxLength = 48; const characterCount = value.length; + const hint = t("characterCountTemplate") + .replace("{current}", String(characterCount)) + .replace("{max}", String(maxLength)); return ( -
-
- {/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */} - +
+ - - {/* TextInput: medium size at 640px+, small size below 640px */}
{ const v = e.target.value; @@ -60,13 +54,13 @@ export default function TextPage() { markCreateFlowInteraction(); updateState({ title: v }); }} - inputSize={effectiveMdOrLarger ? "medium" : "small"} + inputSize={mdUp ? "medium" : "small"} formHeader={false} - textHint={`${characterCount}/${maxLength}`} + textHint={hint} maxLength={maxLength} />
-
+ ); } diff --git a/app/create/upload/page.tsx b/app/create/upload/page.tsx index 981f46e..05ab2ed 100644 --- a/app/create/upload/page.tsx +++ b/app/create/upload/page.tsx @@ -1,30 +1,23 @@ "use client"; -import { useState, useEffect } from "react"; -import { useMediaQuery } from "../../hooks/useMediaQuery"; -import HeaderLockup from "../../components/type/HeaderLockup"; 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 640px+, left-aligned below 640px. - * Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint. + * Responsive layout: centered at `md` and up, left-aligned below. + * Lockup sizing via `CreateFlowHeaderLockup`. */ export default function UploadPage() { const { markCreateFlowInteraction } = useCreateFlow(); - const [isMounted, setIsMounted] = useState(false); - const isMdOrLarger = useMediaQuery("(min-width: 640px)"); - - // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash - setIsMounted(true); - }, []); - - const effectiveMdOrLarger = !isMounted || isMdOrLarger; + const mdUp = useCreateFlowMdUp(); + const t = useTranslation("create.upload"); const handleUploadClick = () => { markCreateFlowInteraction(); @@ -32,17 +25,16 @@ export default function UploadPage() { }; return ( -
-
- {/* HeaderLockup: Center justification at 640px+, left below 640px */} - +
+ - - {/* Upload component: no label in create flow, max width 474px */}
-
+ ); } diff --git a/messages/en/create/completed.json b/messages/en/create/completed.json new file mode 100644 index 0000000..32bc9a5 --- /dev/null +++ b/messages/en/create/completed.json @@ -0,0 +1,73 @@ +{ + "fallbackTitle": "Mutual Aid Mondays", + "fallbackDescription": "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.", + "toastTitle": "This is what folks see when you share your CommunityRule", + "toastDescription": "Your group can use this document as an operating manual.", + "fallbackDocumentSections": [ + { + "categoryName": "Values", + "entries": [ + { + "title": "Solidarity Forever", + "body": "Food Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth." + }, + { + "title": "Shared Leadership", + "body": "We operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader." + }, + { + "title": "Organizing Offline", + "body": "We use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face connection and resist the pull of digital metrics." + }, + { + "title": "Circular Food Systems", + "body": "We intervene in the ecological crisis by addressing food waste and food recovery. We redirect surplus food to where it is needed and model a circular economy at the scale of our communities." + } + ] + }, + { + "categoryName": "Communication", + "entries": [ + { + "title": "Signal", + "body": "We use Signal for sensitive coordination. Encrypted messaging helps protect our members and our plans from surveillance." + } + ] + }, + { + "categoryName": "Membership", + "entries": [ + { + "title": "Open Admission", + "body": "Anyone who shares our values and is willing to contribute is welcome. We do not require applications or approval processes for general participation." + } + ] + }, + { + "categoryName": "Decision-making", + "entries": [ + { + "title": "Lazy Consensus", + "body": "We use lazy consensus for most decisions: proposals move forward unless someone raises a blocking concern. This keeps us moving without requiring everyone to approve every detail." + }, + { + "title": "Modified Consensus", + "body": "For larger or more consequential decisions we use modified consensus, with clear timelines and a fallback to a supermajority vote if needed." + } + ] + }, + { + "categoryName": "Conflict management", + "entries": [ + { + "title": "Code of Conduct", + "body": "We have a code of conduct that sets expectations for behavior and outlines how we address harm." + }, + { + "title": "Restorative Justice", + "body": "When conflict arises we prioritize restoration and learning over punishment. We use facilitated circles and other restorative practices where appropriate." + } + ] + } + ] +} diff --git a/messages/en/create/confirmStakeholders.json b/messages/en/create/confirmStakeholders.json new file mode 100644 index 0000000..c7bdb2e --- /dev/null +++ b/messages/en/create/confirmStakeholders.json @@ -0,0 +1,6 @@ +{ + "title": "Do other stakeholders need to be involved in creating your community?", + "description": "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.", + "addStakeholder": "Add stakeholder", + "draftToastTitle": "Congratulations! You've drafted your CommunityRule!" +} diff --git a/messages/en/create/finalReview.json b/messages/en/create/finalReview.json new file mode 100644 index 0000000..863d09d --- /dev/null +++ b/messages/en/create/finalReview.json @@ -0,0 +1,28 @@ +{ + "title": "Review your CommunityRule", + "description": "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.", + "ruleCardTitleFallback": "Your community", + "ruleCardDescriptionFallback": "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.", + "categories": [ + { + "name": "Values", + "chips": ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"] + }, + { + "name": "Communication", + "chips": ["Signal"] + }, + { + "name": "Membership", + "chips": ["Open Admission"] + }, + { + "name": "Decision-making", + "chips": ["Lazy Consensus", "Modified Consensus"] + }, + { + "name": "Conflict management", + "chips": ["Code of Conduct", "Restorative Justice"] + } + ] +} diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json new file mode 100644 index 0000000..bfe3711 --- /dev/null +++ b/messages/en/create/footer.json @@ -0,0 +1,5 @@ +{ + "next": "Next", + "finalizeCommunityRule": "Finalize CommunityRule", + "confirmStakeholders": "Confirm Stakeholders" +} diff --git a/messages/en/create/informational.json b/messages/en/create/informational.json new file mode 100644 index 0000000..176b342 --- /dev/null +++ b/messages/en/create/informational.json @@ -0,0 +1,18 @@ +{ + "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.", + "steps": { + "0": { + "title": "Tell us about your organization", + "description": "Start by providing your group's name, description, and profile image." + }, + "1": { + "title": "Define your group's CommunityRule.", + "description": "Outline decision-making processes, conflict resolution methods, and membership practices. Get recommendations." + }, + "2": { + "title": "Share and evolve over time", + "description": "Review and refine your community framework before putting it into action and adapting it over time." + } + } +} diff --git a/messages/en/create/review.json b/messages/en/create/review.json new file mode 100644 index 0000000..efff3e7 --- /dev/null +++ b/messages/en/create/review.json @@ -0,0 +1,11 @@ +{ + "header": { + "title": "Your community is added - congrats!", + "description": "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." + }, + "ruleCard": { + "title": "Mutual Aid Mondays", + "description": "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.", + "logoAlt": "Mutual Aid Mondays" + } +} diff --git a/messages/en/create/rightRail.json b/messages/en/create/rightRail.json new file mode 100644 index 0000000..a9a1753 --- /dev/null +++ b/messages/en/create/rightRail.json @@ -0,0 +1,115 @@ +{ + "sidebar": { + "title": "How should conflicts be resolved?", + "descriptionBefore": "You can also combine or ", + "descriptionLink": "add", + "descriptionAfter": " new approaches to the list" + }, + "messageBox": { + "title": "Consider defining approaches to steward key resources:", + "items": [ + { "id": "amend", "label": "Amend your CommunityRule" }, + { "id": "finances", "label": "Steward finances" }, + { "id": "project", "label": "Project level decisions" }, + { "id": "discipline", "label": "Discipline and member termination" } + ] + }, + "cardStack": { + "toggleSeeAll": "See all decision approaches", + "toggleShowLess": "Show less", + "emptyTitle": "", + "emptyDescription": "" + }, + "cards": [ + { + "id": "mediation", + "label": "Mediation", + "supportText": "Collaborative work to reach a resolution that all parties can agree upon.", + "recommended": true + }, + { + "id": "facilitation", + "label": "Facilitated dialogue", + "supportText": "Structured sessions where parties collaboratively resolve disputes.", + "recommended": true + }, + { + "id": "invite-only", + "label": "Invite-only", + "supportText": "Private discussions with selected participants.", + "recommended": true + }, + { + "id": "arbitration", + "label": "Arbitration", + "supportText": "Arbitrators are chosen specifically for a particular case.", + "recommended": true + }, + { + "id": "direct-dialogue", + "label": "Direct dialogue", + "supportText": "Encouraging direct, respectful dialogue between those involved.", + "recommended": true + }, + { + "id": "label-1", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-2", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-3", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-4", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-5", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-6", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-7", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-8", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-9", + "label": "Label", + "supportText": "", + "recommended": false + }, + { + "id": "label-10", + "label": "Label", + "supportText": "", + "recommended": false + } + ] +} diff --git a/messages/en/create/select.json b/messages/en/create/select.json new file mode 100644 index 0000000..74986c2 --- /dev/null +++ b/messages/en/create/select.json @@ -0,0 +1,31 @@ +{ + "header": { + "title": "What is your community called?", + "description": "This will be the name of your community" + }, + "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" } + ], + "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/text.json b/messages/en/create/text.json new file mode 100644 index 0000000..2e06ffe --- /dev/null +++ b/messages/en/create/text.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/topNav.json b/messages/en/create/topNav.json index 16f61c5..7574805 100644 --- a/messages/en/create/topNav.json +++ b/messages/en/create/topNav.json @@ -1,6 +1,12 @@ { "saveAndExit": "Save & Exit", "exit": "Exit", + "share": "Share", + "export": "Export", + "edit": "Edit", + "shareAriaLabel": "Share", + "exportAriaLabel": "Export", + "editAriaLabel": "Edit", "leaveConfirmLoss": "Leave create flow? Your progress will be lost.", "draftSaveBannerTitle": "Couldn't save draft", "postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}" diff --git a/messages/en/create/upload.json b/messages/en/create/upload.json new file mode 100644 index 0000000..64fa00a --- /dev/null +++ b/messages/en/create/upload.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 3fcf718..67e191c 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -19,6 +19,16 @@ import profile from "./pages/profile.json"; 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 createReview from "./create/review.json"; +import createConfirmStakeholders from "./create/confirmStakeholders.json"; +import createFinalReview from "./create/finalReview.json"; +import createCompleted from "./create/completed.json"; +import createRightRail from "./create/rightRail.json"; +import createFooter from "./create/footer.json"; import createTopNav from "./create/topNav.json"; import createDraftHydration from "./create/draftHydration.json"; import createPublish from "./create/publish.json"; @@ -47,6 +57,16 @@ export default { }, create: { communication, + informational: createInformational, + text: createText, + select: createSelect, + upload: createUpload, + review: createReview, + confirmStakeholders: createConfirmStakeholders, + finalReview: createFinalReview, + completed: createCompleted, + rightRail: createRightRail, + footer: createFooter, topNav: createTopNav, draftHydration: createDraftHydration, publish: createPublish, -- 2.43.0 From 60d4ae6dfde415210d0f62ebd952cd778830e5df Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:29:15 -0600 Subject: [PATCH 3/7] Create flow cleanup --- app/create/cards/page.tsx | 14 +++++++++++--- app/create/completed/page.tsx | 13 ++++++++----- tests/components/FinalReviewPage.test.tsx | 4 ++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx index 00fd170..80ad3db 100644 --- a/app/create/cards/page.tsx +++ b/app/create/cards/page.tsx @@ -85,9 +85,15 @@ function AddPlatformModalContent({ const m = useMessages(); const comm = m.create.communication; const modal = comm.modals[platformCardId as keyof typeof comm.modals]; - if (!modal || !("sections" in modal)) return null; + const defaults = + modal && "sections" in modal + ? modal.sections + : { + corePrinciple: "", + logisticsAdmin: "", + codeOfConduct: "", + }; - const defaults = modal.sections; const [sectionValues, setSectionValues] = useState< Record >(() => ({ @@ -104,6 +110,8 @@ function AddPlatformModalContent({ [markCreateFlowInteraction], ); + if (!modal || !("sections" in modal)) return null; + return (
{SECTION_FIELDS.map((field) => ( @@ -147,7 +155,7 @@ export default function CardsPage() { recommended: true, }; }), - [comm.cards], + [comm], ); const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle; diff --git a/app/create/completed/page.tsx b/app/create/completed/page.tsx index 1c2e955..599fb3c 100644 --- a/app/create/completed/page.tsx +++ b/app/create/completed/page.tsx @@ -40,11 +40,14 @@ export default function CompletedPage() { if (!stored) return; const parsed = parseDocumentSectionsForDisplay(stored.document); if (parsed.length === 0) return; - setDocumentSections(parsed); - setHeaderTitle(stored.title); - const sum = - typeof stored.summary === "string" ? stored.summary.trim() : ""; - setHeaderDescription(sum.length > 0 ? sum : undefined); + // One-shot hydration from client-only storage after mount. + queueMicrotask(() => { + setDocumentSections(parsed); + setHeaderTitle(stored.title); + const sum = + typeof stored.summary === "string" ? stored.summary.trim() : ""; + setHeaderDescription(sum.length > 0 ? sum : undefined); + }); }, []); const toast = !toastDismissed ? ( diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 4906f9c..e30fe39 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -33,7 +33,7 @@ describe("FinalReviewPage", () => { expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); }); - it("renders HeaderLockup with expected title", () => { + it("renders lockup title", () => { render(); expect( screen.getByRole("heading", { @@ -42,7 +42,7 @@ describe("FinalReviewPage", () => { ).toBeInTheDocument(); }); - it("renders HeaderLockup with expected description", () => { + it("renders lockup description", () => { render(); expect( screen.getByText( -- 2.43.0 From cae4df261e964fb8f8836eb32b2001784c916a38 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:43:07 -0600 Subject: [PATCH 4/7] Update rule card --- .../cards/RuleCard/RuleCard.view.tsx | 26 +++++++++++++------ .../TemplateReviewCard/TemplateReviewCard.tsx | 6 ++++- .../CreateFlowLockupCardStepShell.tsx | 4 +-- app/create/final-review/page.tsx | 4 ++- app/create/review-template/[slug]/page.tsx | 3 +++ app/create/review/page.tsx | 4 ++- 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/app/components/cards/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx index a00b5db..c58c3a9 100644 --- a/app/components/cards/RuleCard/RuleCard.view.tsx +++ b/app/components/cards/RuleCard/RuleCard.view.tsx @@ -39,13 +39,19 @@ export function RuleCardView({ className?.includes("pb-["); const hasResponsiveGap = className?.includes("gap-["); - const cardPadding = hasResponsivePadding - ? "" // If className has responsive padding, don't add size-based padding - : isLarge || isSmall - ? "p-[24px]" - : isMedium - ? "p-[16px]" - : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding + // Expanded + size: uniform padding on all sides (overrides conflicting utilities from `className`). + const cardPadding = + expanded && isLarge + ? "!p-[24px]" + : expanded && isMedium + ? "!p-[16px]" + : hasResponsivePadding + ? "" + : isLarge || isSmall + ? "p-[24px]" + : isMedium + ? "p-[16px]" + : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding const cardGap = expanded ? "gap-[16px]" : hasResponsiveGap @@ -245,7 +251,11 @@ export function RuleCardView({ <> {/* Categories Section - Using MultiSelect */} {categories && categories.length > 0 && ( -
+
{categories.map((category, categoryIndex) => ( ; @@ -26,6 +27,7 @@ interface PageProps { export default function ReviewTemplatePage({ params }: PageProps) { const { slug: rawSlug } = use(params); const slug = decodeURIComponent(rawSlug); + const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.templateReview"); const [template, setTemplate] = useState(null); @@ -94,6 +96,7 @@ export default function ReviewTemplatePage({ params }: PageProps) { ); diff --git a/app/create/review/page.tsx b/app/create/review/page.tsx index 2ed87e7..6d704da 100644 --- a/app/create/review/page.tsx +++ b/app/create/review/page.tsx @@ -3,10 +3,12 @@ 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() { + const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.review"); return ( @@ -26,7 +28,7 @@ export default function ReviewPage() { Date: Sun, 12 Apr 2026 21:56:34 -0600 Subject: [PATCH 5/7] Load rule templates from API --- app/(marketing)/MarketingRuleStackSection.tsx | 23 + app/(marketing)/page.tsx | 17 +- .../templates/TemplatesPageClient.tsx | 54 ++ app/(marketing)/templates/page.tsx | 52 +- app/api/templates/route.ts | 15 +- .../TemplateReviewCard/TemplateReviewCard.tsx | 9 +- .../GovernanceTemplateGridSkeleton.tsx | 33 ++ .../RuleStack/RuleStack.container.tsx | 60 ++- .../sections/RuleStack/RuleStack.types.ts | 9 + .../sections/RuleStack/RuleStack.view.tsx | 17 +- app/create/review-template/[slug]/page.tsx | 34 +- lib/create/fetchTemplates.ts | 36 +- lib/server/ruleTemplates.ts | 30 ++ lib/templates/templateGridPresentation.ts | 103 ++++ prisma/seed.ts | 492 +++++++----------- stories/sections/RuleStack.stories.js | 36 ++ tests/unit/RuleStack.test.jsx | 107 +++- 17 files changed, 698 insertions(+), 429 deletions(-) create mode 100644 app/(marketing)/MarketingRuleStackSection.tsx create mode 100644 app/(marketing)/templates/TemplatesPageClient.tsx create mode 100644 app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx create mode 100644 lib/server/ruleTemplates.ts create mode 100644 lib/templates/templateGridPresentation.ts diff --git a/app/(marketing)/MarketingRuleStackSection.tsx b/app/(marketing)/MarketingRuleStackSection.tsx new file mode 100644 index 0000000..5672fd0 --- /dev/null +++ b/app/(marketing)/MarketingRuleStackSection.tsx @@ -0,0 +1,23 @@ +import dynamic from "next/dynamic"; +import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates"; +import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog"; +import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation"; + +const RuleStack = dynamic(() => import("../components/sections/RuleStack"), { + loading: () => ( +
+ ), + ssr: true, +}); + +/** + * Server-loaded “Popular templates” row so the first paint has card data without a client fetch. + */ +export async function MarketingRuleStackSection() { + const rows = await listRuleTemplatesFromDb(); + const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback( + rows, + GOVERNANCE_TEMPLATE_HOME_SLUGS, + ); + return ; +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 7daabfd..ce576cf 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,8 +1,10 @@ import dynamic from "next/dynamic"; +import { Suspense } from "react"; import messages from "../../messages/en/index"; import { getTranslation } from "../../lib/i18n/getTranslation"; import HeroBanner from "../components/sections/HeroBanner"; import AskOrganizer from "../components/sections/AskOrganizer"; +import { MarketingRuleStackSection } from "./MarketingRuleStackSection"; // Code split below-the-fold components to reduce initial bundle size const LogoWall = dynamic(() => import("../components/sections/LogoWall"), { @@ -22,13 +24,6 @@ const NumberedCards = dynamic( }, ); -const RuleStack = dynamic(() => import("../components/sections/RuleStack"), { - loading: () => ( -
- ), - ssr: true, -}); - const FeatureGrid = dynamic( () => import("../components/sections/FeatureGrid"), { @@ -98,7 +93,13 @@ export default function Page() { - + + } + > + + diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx new file mode 100644 index 0000000..09e6353 --- /dev/null +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import HeaderLockup from "../../components/type/HeaderLockup"; +import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; +import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; +import { useTranslation } from "../../contexts/MessagesContext"; + +export interface TemplatesPageClientProps { + initialGridEntries: TemplateGridCardEntry[]; +} + +/** + * Full templates index — Figma 22142-898446 (title, intro, 2-col card grid). + * `initialGridEntries` is computed on the server to avoid a client-side loading flash. + */ +export default function TemplatesPageClient({ + initialGridEntries, +}: TemplatesPageClientProps) { + const router = useRouter(); + const t = useTranslation("pages.templates"); + + return ( +
+
+
+ +
+
+ { + router.push( + `/create/review-template/${encodeURIComponent(slug)}`, + ); + }} + /> +
+
+
+ ); +} diff --git a/app/(marketing)/templates/page.tsx b/app/(marketing)/templates/page.tsx index f70e242..29c005f 100644 --- a/app/(marketing)/templates/page.tsx +++ b/app/(marketing)/templates/page.tsx @@ -1,47 +1,9 @@ -"use client"; +import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; +import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation"; +import TemplatesPageClient from "./TemplatesPageClient"; -import { useRouter } from "next/navigation"; -import HeaderLockup from "../../components/type/HeaderLockup"; -import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; -import { GOVERNANCE_TEMPLATE_CATALOG } from "../../../lib/templates/governanceTemplateCatalog"; -import { useTranslation } from "../../contexts/MessagesContext"; - -/** - * Full templates index — Figma 22142-898446 (title, intro, 2-col card grid). - */ -export default function TemplatesPage() { - const router = useRouter(); - const t = useTranslation("pages.templates"); - - return ( -
-
-
- -
-
- { - router.push( - `/create/review-template/${encodeURIComponent(slug)}`, - ); - }} - /> -
-
-
- ); +export default async function TemplatesPage() { + const rows = await listRuleTemplatesFromDb(); + const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows); + return ; } diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index 28ddada..b28b7ec 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { prisma } from "../../../lib/server/db"; import { isDatabaseConfigured } from "../../../lib/server/env"; +import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; import { dbUnavailable } from "../../../lib/server/responses"; /** @@ -11,18 +11,7 @@ export async function GET() { return dbUnavailable(); } - const templates = await prisma.ruleTemplate.findMany({ - orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }], - select: { - id: true, - slug: true, - title: true, - category: true, - description: true, - body: true, - featured: true, - }, - }); + const templates = await listRuleTemplatesFromDb(); return NextResponse.json({ templates }); } diff --git a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx index 81977b8..95a2896 100644 --- a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx +++ b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx @@ -11,13 +11,8 @@ import { } from "../../../../lib/create/templateReviewMapping"; import { getGovernanceTemplateCatalogEntry, - governanceTemplateIconPath, } from "../../../../lib/templates/governanceTemplateCatalog"; - -const FALLBACK_PRESENTATION = { - iconPath: governanceTemplateIconPath("consensus"), - backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]", -}; +import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation"; export interface TemplateReviewCardProps { template: RuleTemplateDto; @@ -37,7 +32,7 @@ export function TemplateReviewCard({ size = "L", }: TemplateReviewCardProps) { const catalog = getGovernanceTemplateCatalogEntry(template.slug); - const pres = catalog ?? FALLBACK_PRESENTATION; + const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION; const categories = templateBodyToCategories(template.body); const summary = templateSummaryFromBody(template.description, template.body); diff --git a/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx new file mode 100644 index 0000000..2f47595 --- /dev/null +++ b/app/components/sections/GovernanceTemplateGrid/GovernanceTemplateGridSkeleton.tsx @@ -0,0 +1,33 @@ +/** + * Placeholder grid matching GovernanceTemplateGrid layout (loading state). + */ +export function GovernanceTemplateGridSkeleton({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 88fdae9..0cb28bd 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -1,8 +1,15 @@ "use client"; -import { memo } from "react"; +import { memo, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { logger } from "../../../../lib/logger"; +import { + fetchTemplates, + isTemplatesFetchAborted, +} from "../../../../lib/create/fetchTemplates"; +import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../../lib/templates/governanceTemplateCatalog"; +import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../../lib/templates/templateGridPresentation"; +import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation"; import { RuleStackView } from "./RuleStack.view"; import type { RuleStackProps } from "./RuleStack.types"; @@ -19,8 +26,53 @@ declare global { } } -const RuleStackContainer = memo(({ className = "" }) => { +const RuleStackContainer = memo( + ({ className = "", initialGridEntries }) => { const router = useRouter(); + const [gridEntries, setGridEntries] = useState( + () => initialGridEntries ?? null, + ); + + useEffect(() => { + if (initialGridEntries !== undefined) { + return; + } + const ac = new AbortController(); + let cancelled = false; + void (async () => { + try { + const result = await fetchTemplates({ signal: ac.signal }); + if (cancelled) return; + if ("error" in result) { + setGridEntries( + gridEntriesForSlugOrderWithCatalogFallback( + [], + GOVERNANCE_TEMPLATE_HOME_SLUGS, + ), + ); + return; + } + setGridEntries( + gridEntriesForSlugOrderWithCatalogFallback( + result, + GOVERNANCE_TEMPLATE_HOME_SLUGS, + ), + ); + } catch (e) { + if (cancelled || isTemplatesFetchAborted(e)) return; + setGridEntries( + gridEntriesForSlugOrderWithCatalogFallback( + [], + GOVERNANCE_TEMPLATE_HOME_SLUGS, + ), + ); + } + })(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [initialGridEntries]); const handleTemplateClick = (slug: string) => { // Basic analytics tracking @@ -44,9 +96,11 @@ const RuleStackContainer = memo(({ className = "" }) => { ); -}); + }, +); RuleStackContainer.displayName = "RuleStack"; diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts index 62e0969..6e0248d 100644 --- a/app/components/sections/RuleStack/RuleStack.types.ts +++ b/app/components/sections/RuleStack/RuleStack.types.ts @@ -1,8 +1,17 @@ +import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation"; + export interface RuleStackProps { className?: string; + /** + * When set (e.g. from a Server Component), first paint uses this data and + * the client skips the `/api/templates` request. + */ + initialGridEntries?: TemplateGridCardEntry[]; } export interface RuleStackViewProps { className: string; onTemplateClick: (_slug: string) => void; + /** `null` while loading curated templates from the API. */ + gridEntries: TemplateGridCardEntry[] | null; } diff --git a/app/components/sections/RuleStack/RuleStack.view.tsx b/app/components/sections/RuleStack/RuleStack.view.tsx index 536d1e9..ce041f9 100644 --- a/app/components/sections/RuleStack/RuleStack.view.tsx +++ b/app/components/sections/RuleStack/RuleStack.view.tsx @@ -4,14 +4,13 @@ import { useTranslation } from "../../../contexts/MessagesContext"; import SectionHeader from "../SectionHeader"; import Button from "../../buttons/Button"; import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid"; -import { getGovernanceTemplatesForHome } from "../../../../lib/templates/governanceTemplateCatalog"; +import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton"; import type { RuleStackViewProps } from "./RuleStack.types"; -const homeFeaturedTemplates = getGovernanceTemplatesForHome(); - export function RuleStackView({ className, onTemplateClick, + gridEntries, }: RuleStackViewProps) { const t = useTranslation("pages.home.ruleStack"); const buttonText = t("button.seeAllTemplates"); @@ -37,10 +36,14 @@ export function RuleStackView({ variant="multi-line" /> - + {gridEntries === null ? ( + + ) : ( + + )}
{ + const ac = new AbortController(); let cancelled = false; void (async () => { if (!cancelled) { setLoading(true); setError(null); } - const result = await fetchTemplateBySlug(slug); - if (cancelled) return; - if (result === null) { - setError(messages.create.templateReview.errors.notFound); + try { + const result = await fetchTemplateBySlug(slug, { + signal: ac.signal, + }); + if (cancelled) return; + if (result === null) { + setError(messages.create.templateReview.errors.notFound); + setTemplate(null); + } else if ("error" in result) { + setError(result.error); + setTemplate(null); + } else { + setTemplate(result); + setError(null); + } + } catch (e) { + if (cancelled || isTemplatesFetchAborted(e)) return; + setError(messages.create.templateReview.errors.loadFailed); setTemplate(null); - } else if ("error" in result) { - setError(result.error); - setTemplate(null); - } else { - setTemplate(result); - setError(null); + } finally { + if (!cancelled) setLoading(false); } - setLoading(false); })(); return () => { cancelled = true; + ac.abort(); }; }, [slug]); diff --git a/lib/create/fetchTemplates.ts b/lib/create/fetchTemplates.ts index 5252d09..3d2787e 100644 --- a/lib/create/fetchTemplates.ts +++ b/lib/create/fetchTemplates.ts @@ -9,16 +9,36 @@ export type RuleTemplateDto = { category: string | null; description: string | null; body: unknown; + sortOrder: number; featured: boolean; }; type TemplatesResponse = { templates?: RuleTemplateDto[] }; -export async function fetchTemplates(): Promise< - RuleTemplateDto[] | { error: string } -> { +export type FetchTemplatesOptions = { + signal?: AbortSignal; +}; + +function isAbortError(e: unknown): boolean { + return ( + (e instanceof DOMException && e.name === "AbortError") || + (e instanceof Error && e.name === "AbortError") + ); +} + +/** For callers that `catch` around `fetchTemplates` / `fetchTemplateBySlug`. */ +export function isTemplatesFetchAborted(e: unknown): boolean { + return isAbortError(e); +} + +export async function fetchTemplates( + options?: FetchTemplatesOptions, +): Promise { try { - const res = await fetch("/api/templates", { credentials: "include" }); + const res = await fetch("/api/templates", { + credentials: "include", + signal: options?.signal, + }); const data = (await res.json()) as TemplatesResponse & { error?: string }; if (!res.ok) { return { @@ -29,15 +49,19 @@ export async function fetchTemplates(): Promise< }; } return Array.isArray(data.templates) ? data.templates : []; - } catch { + } catch (e) { + if (isAbortError(e)) { + throw e; + } return { error: "Could not load templates" }; } } export async function fetchTemplateBySlug( slug: string, + options?: FetchTemplatesOptions, ): Promise { - const result = await fetchTemplates(); + const result = await fetchTemplates(options); if ("error" in result) { return result; } diff --git a/lib/server/ruleTemplates.ts b/lib/server/ruleTemplates.ts new file mode 100644 index 0000000..ba2d8f5 --- /dev/null +++ b/lib/server/ruleTemplates.ts @@ -0,0 +1,30 @@ +import type { RuleTemplateDto } from "../create/fetchTemplates"; +import { prisma } from "./db"; +import { isDatabaseConfigured } from "./env"; + +/** + * Curated templates for public list UIs (same query as GET /api/templates). + * Returns [] when the database is not configured or on query failure. + */ +export async function listRuleTemplatesFromDb(): Promise { + if (!isDatabaseConfigured()) { + return []; + } + try { + return await prisma.ruleTemplate.findMany({ + orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }], + select: { + id: true, + slug: true, + title: true, + category: true, + description: true, + body: true, + sortOrder: true, + featured: true, + }, + }); + } catch { + return []; + } +} diff --git a/lib/templates/templateGridPresentation.ts b/lib/templates/templateGridPresentation.ts new file mode 100644 index 0000000..aa85095 --- /dev/null +++ b/lib/templates/templateGridPresentation.ts @@ -0,0 +1,103 @@ +import type { RuleTemplateDto } from "../create/fetchTemplates"; +import { templateSummaryFromBody } from "../create/templateReviewMapping"; +import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog"; +import { + GOVERNANCE_TEMPLATE_CATALOG, + getGovernanceTemplateCatalogEntry, + governanceTemplateIconPath, +} from "./governanceTemplateCatalog"; + +/** Matches TemplateReviewCard when slug is absent from the Figma catalog. */ +export const TEMPLATE_GRID_FALLBACK_PRESENTATION = { + iconPath: governanceTemplateIconPath("consensus"), + backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]", +} as const; + +export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry; + +function presentationForSlug(slug: string): Pick< + GovernanceTemplateCatalogEntry, + "iconPath" | "backgroundColor" +> { + const catalog = getGovernanceTemplateCatalogEntry(slug); + return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION; +} + +/** + * One grid card: API copy + Figma icon/surface from catalog (or fallback). + */ +export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGridCardEntry { + const pres = presentationForSlug(template.slug); + const description = templateSummaryFromBody(template.description, template.body); + return { + slug: template.slug, + title: template.title, + description, + iconPath: pres.iconPath, + backgroundColor: pres.backgroundColor, + }; +} + +const bySlug = (templates: RuleTemplateDto[]) => + new Map(templates.map((t) => [t.slug, t] as const)); + +/** + * Ordered subset for home: follow `slugOrder`; skip missing slugs. + */ +export function gridEntriesForSlugOrder( + templates: RuleTemplateDto[], + slugOrder: readonly string[], +): TemplateGridCardEntry[] { + const map = bySlug(templates); + const out: TemplateGridCardEntry[] = []; + for (const slug of slugOrder) { + const t = map.get(slug); + if (t) out.push(ruleTemplateToGridEntry(t)); + } + return out; +} + +/** + * Home row: prefer API row per slug; if missing, use static Figma catalog entry. + */ +export function gridEntriesForSlugOrderWithCatalogFallback( + templates: RuleTemplateDto[], + slugOrder: readonly string[], +): TemplateGridCardEntry[] { + const map = bySlug(templates); + const out: TemplateGridCardEntry[] = []; + for (const slug of slugOrder) { + const t = map.get(slug); + if (t) { + out.push(ruleTemplateToGridEntry(t)); + continue; + } + const cat = getGovernanceTemplateCatalogEntry(slug); + if (cat) out.push(cat); + } + return out; +} + +/** + * Full templates index: `featured` first, then `sortOrder`, then title. + */ +export function gridEntriesForFullCatalog(templates: RuleTemplateDto[]): TemplateGridCardEntry[] { + const withSort = [...templates].sort((a, b) => { + if (a.featured !== b.featured) return a.featured ? -1 : 1; + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder; + return a.title.localeCompare(b.title); + }); + return withSort.map(ruleTemplateToGridEntry); +} + +/** + * Marketing `/templates`: use API order when rows exist; otherwise static catalog. + */ +export function gridEntriesForFullCatalogWithFallback( + templates: RuleTemplateDto[], +): TemplateGridCardEntry[] { + if (templates.length === 0) { + return [...GOVERNANCE_TEMPLATE_CATALOG]; + } + return gridEntriesForFullCatalog(templates); +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 4e30b8b..1986c42 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -6,7 +6,10 @@ import { PrismaClient, type Prisma } from "@prisma/client"; * DB titles/descriptions should stay aligned with `governanceTemplateCatalog.ts`. * `body.sections` use the same category row labels as the final-review RuleCard * (Values, Communication, Membership, Decision-making, Conflict management) so - * template review matches that layout; `entries[].title` = chip labels, `body` = long text for documents. + * template review matches that layout; `entries[].title` = chip labels, `body` = supporting text. + * Chip titles per template are sourced from the product **Template Composition** workbook (xlsx column + * layout: Decision-making, Membership Policies, Values, Communication, Conflict Management), mapped in + * `COMPOSITION_BY_SLUG` below. */ /** Starter `body` for catalog templates — five category rows match template review / final-review layout. */ @@ -57,6 +60,165 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue { }; } +/** Chip copy from Template Composition.xlsx (Decision-making, Membership, Values, Communication, Conflict). */ +const COMPOSITION_CHIP_BODY = + "Suggested focus for this governance area. Replace with your own language in the create flow."; + +function entriesFromCompositionCell(cell: string): { title: string; body: string }[] { + const trimmed = cell.trim(); + if (!trimmed) return []; + return trimmed + .split(/,\s*/) + .map((title) => title.trim()) + .filter(Boolean) + .map((title) => ({ title, body: COMPOSITION_CHIP_BODY })); +} + +function bodyFromXlsxComposition(row: { + decisionMaking: string; + membership: string; + values: string; + communication: string; + conflict: string; +}): Prisma.InputJsonValue { + return { + sections: [ + { categoryName: "Values", entries: entriesFromCompositionCell(row.values) }, + { + categoryName: "Communication", + entries: entriesFromCompositionCell(row.communication), + }, + { + categoryName: "Membership", + entries: entriesFromCompositionCell(row.membership), + }, + { + categoryName: "Decision-making", + entries: entriesFromCompositionCell(row.decisionMaking), + }, + { + categoryName: "Conflict management", + entries: entriesFromCompositionCell(row.conflict), + }, + ], + }; +} + +/** + * Curated template chip rows — sourced from product Template Composition.xlsx + * (Governance Template × category columns). + */ +const COMPOSITION_BY_SLUG: Record< + string, + { + decisionMaking: string; + membership: string; + values: string; + communication: string; + conflict: string; + } +> = { + consensus: { + decisionMaking: "Consensus Decision-Making, Modified Consensus", + membership: "Consensus or Vote-Based Approval, Peer Sponsorship", + values: "Consensus, Community Care, Horizontalism", + communication: "In-Person Meetings, Loomio", + conflict: "Consensus Building, Facilitated Negotiation", + }, + "consensus-clusters": { + decisionMaking: "Sociocracy, Holacracy", + membership: "Contribution Based, Orientation Required", + values: "Decentralization, Adaptability, Autonomy", + communication: "Slack, Matrix / Element", + conflict: "Circle Processes, Restorative Practices", + }, + "solidarity-network": { + decisionMaking: "Do-ocracy, Modified Consensus", + membership: "Open Access, Peer Sponsorship", + values: "Solidarity, Mutual Aid, Anti-oppression", + communication: "Signal, Matrix / Element", + conflict: "Peer Mediation, Restorative Practices", + }, + "sortition-jury": { + decisionMaking: "Lottery/Sortition, Deliberative Polling", + membership: "Lottery / Sortition", + values: "Fairness, Equity, Transparency", + communication: "In-Person Meetings, Video Meetings", + conflict: "Lottery/Sortition, Rotational Judging", + }, + "liquid-democracy": { + decisionMaking: "Delegated Decision-Making, Continuous Voting", + membership: "Identity Verification, Open Access", + values: "Agency, Flexibility, Transparency", + communication: "Loomio, Discourse (Forum)", + conflict: "Ad Hoc Arbitration, Peer Mediation", + }, + "do-ocracy": { + decisionMaking: "Do-ocracy, Lazy Consensus", + membership: "Contribution Based, Skill-Based Contribution", + values: "Agency, Autonomy, Voluntarism", + communication: "GitHub / GitLab, Discord", + conflict: "Peer Mediation, Facilitated Negotiation", + }, + "quadratic-governance": { + decisionMaking: "Quadratic Voting", + membership: "Identity Verification, Pay-to-Join", + values: "Fairness, Innovation, Independence", + communication: "Discourse (Forum), Discord", + conflict: "Supermajority Vote, Conflict Resolution Council", + }, + "federated-clusters": { + decisionMaking: "Consensus Seeking with Delegates", + membership: "Hybrid Approval Process, Membership Agreement or Pledge", + values: "Interoperability, Localism, Interdependence", + communication: "Matrix / Element, Discourse (Forum)", + conflict: "Internal Tribunal, Ad Hoc Arbitration", + }, + devolution: { + decisionMaking: "Autocratic Decision-Making, Delegated Decision-Making", + membership: "Invitation Only, Open Access", + values: "Capacity Building, Education, Maintenance", + communication: "Discord, GitHub / GitLab", + conflict: "Conflict Workshops, Managerial Decision", + }, + "benevolent-dictator": { + decisionMaking: "Autocratic Decision-Making, Hierarchical Decision-Making", + membership: "Invitation Only, Mentorship", + values: "Reliability, Stewardship, Leadership", + communication: "Email Distribution List, GitHub / GitLab", + conflict: "Managerial Decision, Binding Contracts", + }, + petition: { + decisionMaking: "Ranked Choice Voting, Majority Rule", + membership: "Open Access, Identity Verification", + values: "Freedom of Information, Accountability, Participation", + communication: "Loomio, Discourse (Forum)", + conflict: "Supermajority Vote, Binding Arbitration", + }, + "self-appointed-board": { + decisionMaking: "Advisory Committees, Executive Committees", + membership: "Invitation Only, Application & Review", + values: "Stewardship, Resilience, Reliability", + communication: "Video Meetings, Email Distribution List", + conflict: "Judicial Committees, Internal Tribunal", + }, + "elected-board": { + decisionMaking: "Elected Board of Directors, Majority Rule", + membership: "Application & Review, Membership Agreement or Pledge", + values: "Accountability, Transparency, Trust", + communication: "Email Distribution List, Slack", + conflict: "Conflict Resolution Council, Mediation", + }, +}; + +function bodyFromSlugComposition(slug: string): Prisma.InputJsonValue { + const row = COMPOSITION_BY_SLUG[slug]; + if (!row) { + return governancePatternBody("Template composition pending."); + } + return bodyFromXlsxComposition(row); +} + const TEMPLATES: { slug: string; title: string; @@ -74,239 +236,27 @@ const TEMPLATES: { "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.", sortOrder: 0, featured: true, - body: { - sections: [ - { - categoryName: "Values", - entries: [ - { - title: "Distributed authority", - body: "Authority lives in Circles close to the work. Domains are explicit so power is visible and negotiable.", - }, - { - title: "Transparency", - body: "Decisions, roles, and metrics that affect members are easy to find and updated regularly.", - }, - { - title: "Equivalence", - body: "Policy affects people in proportion to their stake; no silent vetoes from outside a domain.", - }, - ], - }, - { - categoryName: "Communication", - entries: [ - { - title: "Circle channels", - body: "Each Circle maintains channels for async updates and synchronous sense-making.", - }, - { - title: "Council cadence", - body: "The Council meets on a fixed rhythm to align strategy, resolve overlaps, and hear escalations.", - }, - ], - }, - { - categoryName: "Membership", - entries: [ - { - title: "Circle membership", - body: "People join Circles by agreement of that Circle and clarity on domain contribution.", - }, - { - title: "Link roles", - body: "Members link Circles as delegates or representatives when decisions span domains.", - }, - ], - }, - { - categoryName: "Decision-making", - entries: [ - { - title: "Consent within Circles", - body: "Circles act when there is no reasoned objection from anyone in the Circle with a stake.", - }, - { - title: "Cross-domain consent", - body: "When work spans Circles, proposals include impacted domains and integrate their concerns.", - }, - ], - }, - { - categoryName: "Conflict management", - entries: [ - { - title: "Objection testing", - body: "Objections must show how a proposal fails the aim or creates harm; the group integrates or adapts.", - }, - { - title: "Mediation", - body: "Facilitators help parties hear each other before escalating to Council or broader processes.", - }, - ], - }, - ], - }, + body: bodyFromSlugComposition("consensus-clusters"), }, { slug: "consensus", title: "Consensus", category: "Governance pattern", description: - "Important decisions require broad agreement. Proposals move forward when serious objections are resolved and the group can stand behind the outcome.", + "Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.", sortOrder: 1, featured: true, - body: { - sections: [ - { - categoryName: "Values", - entries: [ - { - title: "Consciousness", - body: "We make decisions with awareness of impact on people, ecosystems, and future generations.", - }, - { - title: "Ecology", - body: "We design governance to reduce harm and regenerate the systems we depend on.", - }, - { - title: "Abundance", - body: "We assume enough for needs when resources are shared fairly and waste is reduced.", - }, - { - title: "Art", - body: "We leave room for creativity, culture, and expression in how we organize.", - }, - { - title: "Decisiveness", - body: "We balance care with forward motion—clear timelines and roles prevent endless loops.", - }, - ], - }, - { - categoryName: "Communication", - entries: [ - { - title: "Signal", - body: "We use Signal (or equivalent) for sensitive coordination and timely member updates.", - }, - ], - }, - { - categoryName: "Membership", - entries: [ - { - title: "Open Admission", - body: "People who share our values and agree to practices can participate without unnecessary gatekeeping.", - }, - ], - }, - { - categoryName: "Decision-making", - entries: [ - { - title: "Lazy Consensus", - body: "Proposals advance if no blocking concern is raised within the discussion window.", - }, - { - title: "Modified Consensus", - body: "For larger decisions we use structured consensus with documented objections and integration steps.", - }, - ], - }, - { - categoryName: "Conflict management", - entries: [ - { - title: "Code of Conduct", - body: "We uphold a code of conduct that sets expectations and pathways for accountability.", - }, - { - title: "Restorative Justice", - body: "When harm occurs we prioritize repair, learning, and changed conditions over punishment.", - }, - ], - }, - ], - }, + body: bodyFromSlugComposition("consensus"), }, { slug: "elected-board", title: "Elected Board", category: "Governance pattern", description: - "Members elect a board to steward policy and operations. The board coordinates implementation and remains accountable through transparent reporting and elections.", + "An elected board determines policies and organizes their implementation.", sortOrder: 2, featured: true, - body: { - sections: [ - { - categoryName: "Values", - entries: [ - { - title: "Accountability", - body: "The board answers to the membership through elections, published decisions, and recall where applicable.", - }, - { - title: "Service", - body: "Board service is a temporary duty with term limits and clarity on scope of authority.", - }, - ], - }, - { - categoryName: "Communication", - entries: [ - { - title: "Board minutes", - body: "Minutes summarize decisions, rationales, and next steps; members can access them on a regular cadence.", - }, - { - title: "Member forums", - body: "The board hosts open sessions for questions, priorities, and feedback from the membership.", - }, - ], - }, - { - categoryName: "Membership", - entries: [ - { - title: "Eligibility to vote", - body: "Voting members are defined clearly; associate or advisory roles are distinguished from full votes.", - }, - { - title: "Board terms", - body: "Staggered terms keep continuity while refreshing leadership on a predictable schedule.", - }, - ], - }, - { - categoryName: "Decision-making", - entries: [ - { - title: "Board vote", - body: "The board decides matters in its charter by majority or supermajority as specified.", - }, - { - title: "Member ratification", - body: "Major structural changes require member approval according to your bylaws.", - }, - ], - }, - { - categoryName: "Conflict management", - entries: [ - { - title: "Recusal", - body: "Directors recuse themselves when personal or financial conflicts appear.", - }, - { - title: "Appeals", - body: "Members can appeal board decisions through a defined, fair process.", - }, - ], - }, - ], - }, + body: bodyFromSlugComposition("elected-board"), }, { slug: "petition", @@ -316,75 +266,7 @@ const TEMPLATES: { "Any participant can propose a rule change. If enough sign it, it goes to a general vote.", sortOrder: 3, featured: true, - body: { - sections: [ - { - categoryName: "Values", - entries: [ - { - title: "Open participation", - body: "Legitimate voices can bring proposals without needing informal gatekeepers.", - }, - { - title: "Legitimacy", - body: "Outcomes are trusted when process, quorum, and notice rules are followed consistently.", - }, - ], - }, - { - categoryName: "Communication", - entries: [ - { - title: "Discussion period", - body: "Every proposal has a visible discussion window before voting closes.", - }, - { - title: "Announcements", - body: "Calls to vote and results are posted where all participants can see them.", - }, - ], - }, - { - categoryName: "Membership", - entries: [ - { - title: "Voting pool", - body: "Who may vote is explicit (e.g. members in good standing for 30 days).", - }, - { - title: "Quorum", - body: "Votes count only when quorum is met so decisions reflect an engaged subset.", - }, - ], - }, - { - categoryName: "Decision-making", - entries: [ - { - title: "Petition threshold", - body: "Sponsors or seconders may be required so proposals show a minimal base of support.", - }, - { - title: "Majority rules", - body: "Adoption thresholds (simple majority, supermajority) are defined per decision type.", - }, - ], - }, - { - categoryName: "Conflict management", - entries: [ - { - title: "Good faith", - body: "Debate focuses on substance; harassment or bad-faith tactics are addressed under conduct policies.", - }, - { - title: "Ombuds", - body: "A neutral contact helps people navigate disputes about process or interpretation.", - }, - ], - }, - ], - }, + body: bodyFromSlugComposition("petition"), }, { slug: "solidarity-network", @@ -394,9 +276,7 @@ const TEMPLATES: { 'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.', sortOrder: 4, featured: false, - body: governancePatternBody( - 'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.', - ), + body: bodyFromSlugComposition("solidarity-network"), }, { slug: "sortition-jury", @@ -406,9 +286,7 @@ const TEMPLATES: { "A representative sample of the community is chosen by lottery to form a temporary council.", sortOrder: 5, featured: false, - body: governancePatternBody( - "A representative sample of the community is chosen by lottery to form a temporary council.", - ), + body: bodyFromSlugComposition("sortition-jury"), }, { slug: "liquid-democracy", @@ -418,9 +296,7 @@ const TEMPLATES: { "Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.", sortOrder: 6, featured: false, - body: governancePatternBody( - "Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.", - ), + body: bodyFromSlugComposition("liquid-democracy"), }, { slug: "do-ocracy", @@ -430,9 +306,7 @@ const TEMPLATES: { "Authority is granted to those doing the work. If you do the task, you decide how it gets done.", sortOrder: 7, featured: false, - body: governancePatternBody( - "Authority is granted to those doing the work. If you do the task, you decide how it gets done.", - ), + body: bodyFromSlugComposition("do-ocracy"), }, { slug: "quadratic-governance", @@ -442,9 +316,7 @@ const TEMPLATES: { "Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.", sortOrder: 8, featured: false, - body: governancePatternBody( - "Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.", - ), + body: bodyFromSlugComposition("quadratic-governance"), }, { slug: "federated-clusters", @@ -454,9 +326,7 @@ const TEMPLATES: { "Independent groups share a central brand/charter but have total autonomy over internal rules.", sortOrder: 9, featured: false, - body: governancePatternBody( - "Independent groups share a central brand/charter but have total autonomy over internal rules.", - ), + body: bodyFromSlugComposition("federated-clusters"), }, { slug: "devolution", @@ -466,9 +336,7 @@ const TEMPLATES: { "Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.", sortOrder: 10, featured: false, - body: governancePatternBody( - "Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.", - ), + body: bodyFromSlugComposition("devolution"), }, { slug: "benevolent-dictator", @@ -478,9 +346,7 @@ const TEMPLATES: { "A single individual holds ultimate power, usually intended as a temporary state until the project is stable.", sortOrder: 11, featured: false, - body: governancePatternBody( - "A single individual holds ultimate power, usually intended as a temporary state until the project is stable.", - ), + body: bodyFromSlugComposition("benevolent-dictator"), }, { slug: "self-appointed-board", @@ -490,9 +356,7 @@ const TEMPLATES: { "An existing board selects its own successors to preserve a specific mission over time.", sortOrder: 12, featured: false, - body: governancePatternBody( - "An existing board selects its own successors to preserve a specific mission over time.", - ), + body: bodyFromSlugComposition("self-appointed-board"), }, ]; diff --git a/stories/sections/RuleStack.stories.js b/stories/sections/RuleStack.stories.js index 1da655d..e554a37 100644 --- a/stories/sections/RuleStack.stories.js +++ b/stories/sections/RuleStack.stories.js @@ -1,8 +1,44 @@ +import React from "react"; import RuleStack from "../../app/components/sections/RuleStack"; +import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog"; + +function buildStoryTemplatesPayload() { + return GOVERNANCE_TEMPLATE_CATALOG.map((c, i) => ({ + id: `story-${c.slug}`, + slug: c.slug, + title: c.title, + category: "Governance pattern", + description: c.description, + body: { sections: [] }, + sortOrder: i, + featured: i < 4, + })); +} + +function withMockTemplatesApi(Story) { + React.useLayoutEffect(() => { + const prev = global.fetch; + global.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.url; + if (String(url).includes("/api/templates")) { + return new Response( + JSON.stringify({ templates: buildStoryTemplatesPayload() }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return prev(input, init); + }; + return () => { + global.fetch = prev; + }; + }, []); + return ; +} export default { title: "Components/Sections/RuleStack", component: RuleStack, + decorators: [withMockTemplatesApi], parameters: { layout: "fullscreen", docs: { diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx index b6456b5..70fa471 100644 --- a/tests/unit/RuleStack.test.jsx +++ b/tests/unit/RuleStack.test.jsx @@ -2,6 +2,7 @@ import { renderWithProviders as render, screen, cleanup, + waitFor, } from "../utils/test-utils"; import userEvent from "@testing-library/user-event"; import { vi, describe, test, expect, afterEach, beforeEach } from "vitest"; @@ -10,22 +11,70 @@ import RuleStack from "../../app/components/sections/RuleStack"; import { testRouter } from "../mocks/navigation"; import { GOVERNANCE_TEMPLATE_CATALOG, + GOVERNANCE_TEMPLATE_HOME_SLUGS, getGovernanceTemplatesForHome, } from "../../lib/templates/governanceTemplateCatalog"; const homeFeatured = getGovernanceTemplatesForHome(); +function mockTemplatesApiSuccess() { + const templatesPayload = GOVERNANCE_TEMPLATE_HOME_SLUGS.map((slug, i) => { + const cat = GOVERNANCE_TEMPLATE_CATALOG.find((e) => e.slug === slug); + if (!cat) throw new Error(`missing catalog slug ${slug}`); + return { + id: `test-${slug}`, + slug, + title: cat.title, + category: "Governance pattern", + description: cat.description, + body: { sections: [] }, + sortOrder: i, + featured: true, + }; + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input) => { + const url = typeof input === "string" ? input : input.url; + if (url.endsWith("/api/templates")) { + return new Response(JSON.stringify({ templates: templatesPayload }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not Found", { status: 404 }); + }), + ); +} + beforeEach(() => { testRouter.push.mockClear(); + mockTemplatesApiSuccess(); }); afterEach(() => { + vi.unstubAllGlobals(); cleanup(); }); +async function waitForRuleStackCards() { + await waitFor(() => { + expect(screen.getByText("Circles")).toBeInTheDocument(); + }); +} + describe("RuleStack Component", () => { - test("renders four featured governance template cards on the home row", () => { + test("skips client fetch when initialGridEntries is provided (SSR path)", () => { + const fetchMock = vi.mocked(global.fetch); + const callsBefore = fetchMock.mock.calls.length; + render(); + expect(screen.getByText("Circles")).toBeInTheDocument(); + expect(fetchMock.mock.calls.length).toBe(callsBefore); + }); + + test("renders four featured governance template cards on the home row", async () => { render(); + await waitForRuleStackCards(); for (const entry of homeFeatured) { expect(screen.getByText(entry.title)).toBeInTheDocument(); @@ -38,15 +87,17 @@ describe("RuleStack Component", () => { ).not.toBeInTheDocument(); }); - test("renders with custom className", () => { + test("renders with custom className", async () => { render(); + await waitForRuleStackCards(); const section = document.querySelector("section"); expect(section).toHaveClass("custom-class"); }); - test("renders sample rule card descriptions from featured catalog", () => { + test("renders sample rule card descriptions from featured catalog", async () => { render(); + await waitForRuleStackCards(); expect( screen.getByText(/Units called Circles have the ability to decide/), @@ -66,8 +117,9 @@ describe("RuleStack Component", () => { ).toBeInTheDocument(); }); - test("renders rule card icons with image assets", () => { + test("renders rule card icons with image assets", async () => { const { container } = render(); + await waitForRuleStackCards(); const imgs = container.querySelectorAll("img"); const circles = [...imgs].find((el) => { @@ -90,23 +142,26 @@ describe("RuleStack Component", () => { expect(consensus).toBeTruthy(); }); - test("renders see-all-templates link to full templates page", () => { + test("renders see-all-templates link to full templates page", async () => { render(); + await waitForRuleStackCards(); const link = screen.getByRole("link", { name: "See all templates" }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute("href", "/templates"); }); - test("applies correct CSS classes", () => { + test("applies correct CSS classes", async () => { render(); + await waitForRuleStackCards(); const section = document.querySelector("section"); expect(section).toHaveClass("w-full", "bg-transparent"); }); - test("renders with design tokens", () => { + test("renders with design tokens", async () => { render(); + await waitForRuleStackCards(); const section = document.querySelector("section"); expect(section).toHaveClass("px-[20px]", "py-[32px]"); @@ -114,15 +169,17 @@ describe("RuleStack Component", () => { expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); }); - test("applies responsive grid layout", () => { + test("applies responsive grid layout", async () => { render(); + await waitForRuleStackCards(); const grid = document.querySelector('[class*="flex flex-col gap-[18px]"]'); expect(grid).toHaveClass("min-[768px]:grid", "min-[768px]:grid-cols-2"); }); - test("renders RuleCard components with catalog surface colors", () => { + test("renders RuleCard components with catalog surface colors", async () => { render(); + await waitForRuleStackCards(); const circlesCard = screen .getByText("Circles") @@ -142,6 +199,7 @@ describe("RuleStack Component", () => { .mockImplementation(() => undefined); render(); + await waitForRuleStackCards(); const consensusCard = screen.getByText("Consensus").closest("div"); await user.click(consensusCard); @@ -154,8 +212,9 @@ describe("RuleStack Component", () => { debugSpy.mockRestore(); }); - test("renders with proper semantic structure", () => { + test("renders with proper semantic structure", async () => { render(); + await waitForRuleStackCards(); const section = document.querySelector("section"); expect(section).toBeInTheDocument(); @@ -164,16 +223,18 @@ describe("RuleStack Component", () => { expect(headings).toHaveLength(1 + homeFeatured.length); }); - test("applies responsive spacing", () => { + test("applies responsive spacing", async () => { render(); + await waitForRuleStackCards(); const section = document.querySelector("section"); expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); expect(section?.className).toMatch(/min-\[1024px\]:py-\[64px\]/); }); - test("renders icons with correct attributes", () => { + test("renders icons with correct attributes", async () => { const { container } = render(); + await waitForRuleStackCards(); const imgs = container.querySelectorAll("img"); const circlesIcon = [...imgs].find((el) => { @@ -197,8 +258,9 @@ describe("RuleStack Component", () => { expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/); }); - test("applies different background colors to featured cards", () => { + test("applies different background colors to featured cards", async () => { render(); + await waitForRuleStackCards(); const buttons = document.querySelectorAll('[role="button"]'); const templateSurfaces = [...buttons].filter((el) => @@ -207,16 +269,18 @@ describe("RuleStack Component", () => { expect(templateSurfaces.length).toBe(homeFeatured.length); }); - test("renders with proper see-all link styling", () => { + test("renders with proper see-all link styling", async () => { render(); + await waitForRuleStackCards(); const link = screen.getByRole("link", { name: "See all templates" }); expect(link?.className).toMatch(/bg-transparent/); expect(link?.className).toMatch(/border/); }); - test("applies flex layout for see-all link container", () => { + test("applies flex layout for see-all link container", async () => { render(); + await waitForRuleStackCards(); const linkContainer = screen .getByRole("link", { name: "See all templates" }) @@ -224,6 +288,18 @@ describe("RuleStack Component", () => { expect(linkContainer).toHaveClass("flex", "justify-center"); }); + test("falls back to static catalog when templates API errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("Server error", { status: 500 })), + ); + render(); + await waitForRuleStackCards(); + for (const entry of homeFeatured) { + expect(screen.getByText(entry.title)).toBeInTheDocument(); + } + }); + test("handles analytics tracking", async () => { const user = userEvent.setup(); const gtagSpy = vi.fn(); @@ -239,6 +315,7 @@ describe("RuleStack Component", () => { }); render(); + await waitForRuleStackCards(); const electedBoardCard = screen.getByText("Elected Board").closest("div"); await user.click(electedBoardCard); -- 2.43.0 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 6/7] 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", () => { -- 2.43.0 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 7/7] 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({
diff --git a/app/components/progress/ProportionBar/ProportionBar.container.tsx b/app/components/progress/ProportionBar/ProportionBar.container.tsx index 517aa02..62cd255 100644 --- a/app/components/progress/ProportionBar/ProportionBar.container.tsx +++ b/app/components/progress/ProportionBar/ProportionBar.container.tsx @@ -1,11 +1,13 @@ "use client"; import { memo } from "react"; +import { normalizeProportionBarVariant } from "../../../../lib/propNormalization"; import { ProportionBarView } from "./ProportionBar.view"; import type { ProportionBarProps } from "./ProportionBar.types"; const ProportionBarContainer = memo( - ({ progress = "3-2", className = "" }) => { + ({ progress = "3-2", className = "", variant: variantProp }) => { + const variant = normalizeProportionBarVariant(variantProp); const barClasses = `h-[8px] relative w-full`; return ( @@ -13,6 +15,7 @@ const ProportionBarContainer = memo( progress={progress} className={className} barClasses={barClasses} + variant={variant} /> ); }, diff --git a/app/components/progress/ProportionBar/ProportionBar.types.ts b/app/components/progress/ProportionBar/ProportionBar.types.ts index 3a6510e..2223bf8 100644 --- a/app/components/progress/ProportionBar/ProportionBar.types.ts +++ b/app/components/progress/ProportionBar/ProportionBar.types.ts @@ -1,3 +1,5 @@ +import type { ProportionBarVariantValue } from "../../../../lib/propNormalization"; + export type ProportionBarState = | "1-0" | "1-1" @@ -12,13 +14,20 @@ export type ProportionBarState = | "3-1" | "3-2"; +export type ProportionBarVariant = ProportionBarVariantValue; + export interface ProportionBarProps { progress?: ProportionBarState; className?: string; + /** + * `segmented` (Figma: create-flow footer): pill-shaped partial fills inside each segment. + */ + variant?: ProportionBarVariant; } export interface ProportionBarViewProps { progress: ProportionBarState; className: string; barClasses: string; + variant: "default" | "segmented"; } diff --git a/app/components/progress/ProportionBar/ProportionBar.view.tsx b/app/components/progress/ProportionBar/ProportionBar.view.tsx index ea01386..9ac4315 100644 --- a/app/components/progress/ProportionBar/ProportionBar.view.tsx +++ b/app/components/progress/ProportionBar/ProportionBar.view.tsx @@ -4,9 +4,11 @@ export function ProportionBarView({ progress, className, barClasses, + variant, }: ProportionBarViewProps) { // Proportion bar type const [fullSegments, partialSegment] = progress.split("-").map(Number); + const segmented = variant === "segmented"; // Calculate total progress: // - For 1-X: first section is (X+1)/6 filled // - For 2-X: first section full, second section X/3 filled @@ -58,7 +60,11 @@ export function ProportionBarView({
{fullSegments === 1 ? (
) : fullSegments >= 2 ? ( @@ -70,7 +76,11 @@ export function ProportionBarView({ {fullSegments === 2 ? ( partialSegment > 0 ? (
) : null @@ -84,8 +94,12 @@ export function ProportionBarView({ {fullSegments === 3 && partialSegment > 0 ? (
= 3 ? "rounded-r-[var(--radius-full)]" : "" - }`} + segmented + ? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]" + : partialSegment >= 3 + ? "rounded-r-[var(--radius-full)]" + : "" + }`.trim()} style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }} /> ) : null} diff --git a/app/components/type/HeaderLockup/HeaderLockup.types.ts b/app/components/type/HeaderLockup/HeaderLockup.types.ts index cac76f2..44d07fa 100644 --- a/app/components/type/HeaderLockup/HeaderLockup.types.ts +++ b/app/components/type/HeaderLockup/HeaderLockup.types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + export type HeaderLockupJustificationValue = | "left" | "center" @@ -16,9 +18,9 @@ export interface HeaderLockupProps { */ title: string; /** - * Description text (optional) + * Description (optional). String for plain copy, or ReactNode for rich inline content (e.g. linked words). */ - description?: string; + description?: ReactNode; /** * Text justification. Accepts both PascalCase (Figma) and lowercase (codebase). * Figma uses PascalCase, codebase uses lowercase - both are supported. @@ -38,7 +40,7 @@ export interface HeaderLockupProps { export interface HeaderLockupViewProps { title: string; - description?: string; + description?: ReactNode; justification: "left" | "center"; size: "L" | "M"; palette: "default" | "inverse"; diff --git a/app/components/type/HeaderLockup/HeaderLockup.view.tsx b/app/components/type/HeaderLockup/HeaderLockup.view.tsx index b68ffae..798634a 100644 --- a/app/components/type/HeaderLockup/HeaderLockup.view.tsx +++ b/app/components/type/HeaderLockup/HeaderLockup.view.tsx @@ -43,17 +43,18 @@ function HeaderLockupView({
{/* Description */} - {description && ( -

- {description} -

- )} + {description != null && + !(typeof description === "string" && description.length === 0) && ( +

+ {description} +

+ )}
); } diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx b/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx index 4b964fd..ff67759 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx @@ -5,11 +5,20 @@ import { CreateFlowFooterView } from "./CreateFlowFooter.view"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; const CreateFlowFooterContainer = memo( - ({ secondButton, progressBar = true, onBackClick, className = "" }) => { + ({ + secondButton, + progressBar = true, + proportionBarProgress, + proportionBarVariant, + onBackClick, + className = "", + }) => { return ( diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts b/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts index 0ca62bb..ad169d1 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts @@ -1,3 +1,8 @@ +import type { + ProportionBarState, + ProportionBarVariant, +} from "../../progress/ProportionBar/ProportionBar.types"; + /** * Type definitions for CreateFlowFooter component * @@ -13,6 +18,16 @@ export interface CreateFlowFooterProps { * @default true */ progressBar?: boolean; + /** + * `ProportionBar` state when the bar is shown (driven by create-flow step). + * @default "1-0" + */ + proportionBarProgress?: ProportionBarState; + /** + * `ProportionBar` layout variant (Figma create-flow footer uses `segmented`). + * @default "default" + */ + proportionBarVariant?: ProportionBarVariant; /** * Callback function for Back button click */ diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx b/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx index 4929653..4ddf793 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx @@ -1,3 +1,4 @@ +import { normalizeProportionBarVariant } from "../../../../lib/propNormalization"; import ProportionBar from "../../progress/ProportionBar"; import Button from "../../buttons/Button"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; @@ -5,9 +6,14 @@ import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; export function CreateFlowFooterView({ secondButton, progressBar = true, + proportionBarProgress = "1-0", + proportionBarVariant: proportionBarVariantProp, onBackClick, className = "", }: CreateFlowFooterProps) { + const proportionBarVariant = normalizeProportionBarVariant( + proportionBarVariantProp, + ); return (
- +
)} diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index e14dae6..2ee4581 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -12,12 +12,20 @@ import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; -import { getStepIndex } from "./utils/flowSteps"; +import { getNextStep, getStepIndex } from "./utils/flowSteps"; +import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry"; import CreateFlowFooter from "../components/utility/CreateFlowFooter"; import Button from "../components/buttons/Button"; import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; -import { fetchAuthSession, publishRule } from "../../lib/create/api"; +import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail"; +import { + fetchAuthSession, + publishRule, + requestMagicLink, +} from "../../lib/create/api"; +import { safeInternalPath } from "../../lib/safeInternalPath"; +import { setTransferPendingFlag } from "./utils/anonymousDraftStorage"; import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule"; import { fetchTemplateBySlug, @@ -25,7 +33,7 @@ import { } from "../../lib/create/fetchTemplates"; import messages from "../../messages/en/index"; import { useAuthModal } from "../contexts/AuthModalContext"; -import { useTranslation } from "../contexts/MessagesContext"; +import { useMessages, useTranslation } from "../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; import Alert from "../components/modals/Alert"; @@ -35,7 +43,7 @@ import { } from "./context/CreateFlowDraftSaveBannerContext"; /** First step where Save & Exit is offered (first Create Community select per Figma). */ -const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-size"); +const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure"); function CreateFlowSessionShell({ children }: { children: ReactNode }) { const [sessionUser, setSessionUser] = useState< @@ -78,7 +86,10 @@ function CreateFlowLayoutContent({ sessionUser: { id: string; email: string } | null | undefined; sessionResolved: boolean; }) { - const tFooter = useTranslation("create.footer"); + const { create } = useMessages(); + const footer = create.footer; + const communitySaveMessages = create.communitySave; + const tLogin = useTranslation("pages.login"); const router = useRouter(); const pathname = usePathname(); const { openLogin } = useAuthModal(); @@ -89,7 +100,7 @@ function CreateFlowLayoutContent({ goToNextStep, goToPreviousStep, } = useCreateFlowNavigation(); - const { state, clearState } = useCreateFlow(); + const { state, clearState, updateState } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [publishBannerMessage, setPublishBannerMessage] = useState< @@ -100,6 +111,13 @@ function CreateFlowLayoutContent({ string | null >(null); const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); + const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = + useState(false); + const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState< + string | null + >(null); + const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = + useState(false); const templateReviewMatch = pathname?.match( /\/create\/review-template\/([^/?#]+)/, @@ -222,6 +240,51 @@ function CreateFlowLayoutContent({ await runAuthenticatedExit(opts); }; + useEffect(() => { + if (currentStep !== "community-save") { + setCommunitySaveMagicLinkError(null); + setCommunitySaveMagicLinkSuccess(false); + setCommunitySaveMagicLinkSubmitting(false); + } + }, [currentStep]); + + const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { + setCommunitySaveMagicLinkError(null); + setCommunitySaveMagicLinkSuccess(false); + const raw = state.communitySaveEmail; + const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (!isValidCreateFlowSaveEmail(trimmed)) return; + + setCommunitySaveMagicLinkSubmitting(true); + try { + const stepAfterSave = getNextStep("community-save"); + const segment = stepAfterSave ?? "review"; + const rawNext = `/create/${segment}?syncDraft=1`; + const nextPath = safeInternalPath(rawNext); + const result = await requestMagicLink(trimmed, nextPath); + if (result.ok === false) { + if (result.retryAfterMs != null && result.retryAfterMs > 0) { + const seconds = Math.ceil(result.retryAfterMs / 1000); + setCommunitySaveMagicLinkError( + tLogin("errors.rateLimited").replace("{seconds}", String(seconds)), + ); + } else { + setCommunitySaveMagicLinkError( + result.error || tLogin("errors.generic"), + ); + } + return; + } + setTransferPendingFlag(); + updateState({ communitySaveEmail: trimmed }); + setCommunitySaveMagicLinkSuccess(true); + } catch { + setCommunitySaveMagicLinkError(tLogin("errors.network")); + } finally { + setCommunitySaveMagicLinkSubmitting(false); + } + }, [state.communitySaveEmail, tLogin, updateState]); + const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "right-rail"; const isFinalReviewStep = currentStep === "final-review"; @@ -250,14 +313,23 @@ function CreateFlowLayoutContent({ const saveDraftOnExit = Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; - const hasErrorOverlays = + const proportionBarProgress = getProportionBarProgressForCreateFlowStep( + currentStep, + ); + + const footerPrimaryButtonClass = + "md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"; + + const hasTopOverlays = Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage) || - Boolean(templateReviewApplyError); + Boolean(templateReviewApplyError) || + Boolean(communitySaveMagicLinkError) || + Boolean(communitySaveMagicLinkSuccess); return (
- {hasErrorOverlays ? ( + {hasTopOverlays ? (
) : null} + {communitySaveMagicLinkError ? ( +
+ setCommunitySaveMagicLinkError(null)} + className="w-full" + /> +
+ ) : null} + {communitySaveMagicLinkSuccess ? ( +
+ setCommunitySaveMagicLinkSuccess(false)} + className="w-full" + /> +
+ ) : null}
) : null} @@ -334,6 +430,8 @@ function CreateFlowLayoutContent({ @@ -367,13 +465,101 @@ function CreateFlowLayoutContent({ {messages.create.templateReview.footer.customize}
+ ) : 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({}); + }); +}); -- 2.43.0