Create Community stage implemented

This commit is contained in:
adilallo
2026-04-14 09:22:03 -06:00
parent a0de78c020
commit f8255bc2c7
73 changed files with 1105 additions and 392 deletions
@@ -92,7 +92,7 @@ export interface TextAreaViewProps {
handleChange: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleFocus: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
textHint?: boolean;
textHint?: boolean | string;
formHeader?: boolean;
showHelpIcon?: boolean;
appearance?: "default" | "embedded";
@@ -78,13 +78,13 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
{...props}
/>
</div>
{textHint && (
{textHint ? (
<div className="flex items-start relative shrink-0 w-full">
<p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]">
Hint text here
{typeof textHint === "string" ? textHint : "Hint text here"}
</p>
</div>
)}
) : null}
</div>
);
},
@@ -5,12 +5,20 @@ import UploadView from "./Upload.view";
import type { UploadProps } from "./Upload.types";
const UploadContainer = memo<UploadProps>(
({ active = true, label, showHelpIcon = true, onClick, className = "" }) => {
({
active = true,
label,
showHelpIcon = true,
hintText = "Add image from your device",
onClick,
className = "",
}) => {
return (
<UploadView
active={active}
label={label}
showHelpIcon={showHelpIcon}
hintText={hintText}
onClick={onClick}
className={className}
/>
@@ -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;
}
@@ -8,6 +8,7 @@ function UploadView({
active = true,
label,
showHelpIcon = true,
hintText,
onClick,
className = "",
}: UploadViewProps) {
@@ -54,7 +55,7 @@ function UploadView({
<button
type="button"
onClick={onClick}
className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip p-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
aria-label="Upload"
>
{/* Upload icon */}
@@ -105,9 +106,7 @@ function UploadView({
<div
className={`flex flex-[1_0_0] flex-col font-inter font-normal h-[32px] justify-center leading-[0] min-h-px min-w-px relative text-[length:var(--sizing-350,14px)] ${descriptionTextColor}`}
>
<p className="leading-[20px] whitespace-pre-wrap">
Add images, PDFs, and other files to the policy
</p>
<p className="leading-[20px] whitespace-pre-wrap">{hintText}</p>
</div>
</div>
</div>
@@ -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<ProportionBarProps>(
({ 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<ProportionBarProps>(
progress={progress}
className={className}
barClasses={barClasses}
variant={variant}
/>
);
},
@@ -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";
}
@@ -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({
<div className="flex-1 h-full relative">
{fullSegments === 1 ? (
<div
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)] ${
segmented && partialSegment < 5
? "rounded-r-[var(--radius-full)]"
: ""
}`.trim()}
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
/>
) : fullSegments >= 2 ? (
@@ -70,7 +76,11 @@ export function ProportionBarView({
{fullSegments === 2 ? (
partialSegment > 0 ? (
<div
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
segmented
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
: ""
}`.trim()}
style={{ width: `${(partialSegment / 3) * 100}%` }}
/>
) : null
@@ -84,8 +94,12 @@ export function ProportionBarView({
{fullSegments === 3 && partialSegment > 0 ? (
<div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
partialSegment >= 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}
@@ -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";
@@ -43,17 +43,18 @@ function HeaderLockupView({
</div>
{/* Description */}
{description && (
<p
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
isLeft ? "" : "text-center"
} ${
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
}`}
>
{description}
</p>
)}
{description != null &&
!(typeof description === "string" && description.length === 0) && (
<p
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
isLeft ? "" : "text-center"
} ${
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
}`}
>
{description}
</p>
)}
</div>
);
}
@@ -5,11 +5,20 @@ import { CreateFlowFooterView } from "./CreateFlowFooter.view";
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
({ secondButton, progressBar = true, onBackClick, className = "" }) => {
({
secondButton,
progressBar = true,
proportionBarProgress,
proportionBarVariant,
onBackClick,
className = "",
}) => {
return (
<CreateFlowFooterView
secondButton={secondButton}
progressBar={progressBar}
proportionBarProgress={proportionBarProgress}
proportionBarVariant={proportionBarVariant}
onBackClick={onBackClick}
className={className}
/>
@@ -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
*/
@@ -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 (
<footer
className={`bg-black w-full ${className}`}
@@ -17,7 +23,10 @@ export function CreateFlowFooterView({
{/* Progress Bar - Top */}
{progressBar && (
<div className="px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] pt-[var(--spacing-measures-spacing-300,12px)]">
<ProportionBar progress="1-0" />
<ProportionBar
progress={proportionBarProgress}
variant={proportionBarVariant}
/>
</div>
)}
+205 -13
View File
@@ -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 (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
{hasErrorOverlays ? (
{hasTopOverlays ? (
<div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
aria-live="polite"
@@ -298,6 +370,30 @@ function CreateFlowLayoutContent({
/>
</div>
) : null}
{communitySaveMagicLinkError ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={communitySaveMessages.magicLinkErrorTitle}
description={communitySaveMagicLinkError}
onClose={() => setCommunitySaveMagicLinkError(null)}
className="w-full"
/>
</div>
) : null}
{communitySaveMagicLinkSuccess ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="positive"
title={communitySaveMessages.magicLinkSuccessTitle}
description={communitySaveMessages.magicLinkSuccessDescription}
onClose={() => setCommunitySaveMagicLinkSuccess(false)}
className="w-full"
/>
</div>
) : null}
</div>
) : null}
<Suspense fallback={null}>
@@ -334,6 +430,8 @@ function CreateFlowLayoutContent({
<CreateFlowFooter
className="shrink-0"
progressBar={!isTemplateReviewRoute && !isFinalReviewStep}
proportionBarProgress={proportionBarProgress}
proportionBarVariant="segmented"
secondButton={
isTemplateReviewRoute ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
@@ -367,13 +465,101 @@ function CreateFlowLayoutContent({
{messages.create.templateReview.footer.customize}
</Button>
</div>
) : currentStep === "community-name" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.next}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmName}
</Button>
</div>
) : currentStep === "community-save" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.saveLater}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
communitySaveMagicLinkSubmitting ||
communitySaveMagicLinkSuccess ||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
className={footerPrimaryButtonClass}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
>
{communitySaveMagicLinkSubmitting
? footer.submitEmailSending
: footer.submitEmail}
</Button>
</div>
) : currentStep === "review" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.createCustom}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
router.push("/templates");
}}
>
{footer.createFromTemplate}
</Button>
</div>
) : nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
className={footerPrimaryButtonClass}
onClick={() => {
if (currentStep === "final-review") {
void handleFinalize();
@@ -385,10 +571,16 @@ function CreateFlowLayoutContent({
{currentStep === "final-review"
? isPublishing
? messages.create.publish.finalizeButtonPublishing
: tFooter("finalizeCommunityRule")
: footer.finalizeCommunityRule
: currentStep === "confirm-stakeholders"
? tFooter("confirmStakeholders")
: tFooter("next")}
? footer.confirmStakeholders
: currentStep === "community-context"
? footer.confirmDescription
: currentStep === "community-structure"
? footer.confirmDetails
: currentStep === "community-size"
? footer.confirmMembers
: footer.next}
</Button>
) : null
}
+13 -2
View File
@@ -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();
@@ -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 (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div className="flex w-full min-w-0 flex-col gap-4 md:grid md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)]">
<div className="flex min-w-0 flex-col justify-start md:justify-center">
<div
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex min-w-0 flex-col justify-start md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={lockupTitle}
description={lockupDescription}
justification="left"
/>
</div>
<div className="flex min-w-0 w-full flex-col items-stretch">{children}</div>
<div
className={`flex min-w-0 flex-col items-stretch ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
@@ -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<CreateFlowStepShellVariant, string> = {
@@ -17,22 +17,24 @@ const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
"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<CreateFlowContentTopBelowMd, string> = {
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;
}
@@ -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]";
+20
View File
@@ -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;
}
+2 -11
View File
@@ -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);
+8 -6
View File
@@ -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 (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div className="flex w-full shrink-0 items-center justify-start pb-16">
<div
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
{t("loading")}
</p>
@@ -87,7 +87,9 @@ export default function ReviewTemplatePage({ params }: PageProps) {
if (error || !template) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div className="flex w-full max-w-[640px] shrink-0 flex-col gap-4 pb-8">
<div
className={`flex shrink-0 flex-col gap-4 pb-8 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<Alert
type="banner"
status="danger"
+14 -9
View File
@@ -33,26 +33,31 @@ export function CreateFlowScreenView({
maxLength={48}
/>
);
case "community-size":
return <CommunitySizeSelectScreen />;
case "community-structure":
return <CommunityStructureSelectScreen />;
case "community-context":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityContext"
stateField="communityContext"
maxLength={2000}
maxLength={48}
mainAlign="center"
/>
);
case "community-structure":
return <CommunityStructureSelectScreen />;
case "community-size":
return <CommunitySizeSelectScreen />;
case "community-upload":
return <CommunityUploadScreen />;
case "community-reflection":
case "community-save":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityReflection"
stateField="communityReflection"
maxLength={2000}
messageNamespace="create.communitySave"
stateField="communitySaveEmail"
maxLength={254}
mainAlign="center"
inputType="email"
showCharacterCount={false}
headerJustification="center"
/>
);
case "review":
+4 -3
View File
@@ -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"
>
<div className="flex w-full min-w-0 flex-col gap-6">
<div className="min-w-0">
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className="min-w-0 w-full">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
@@ -9,6 +9,10 @@ import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPub
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
export function CompletedScreen() {
const mdUp = useCreateFlowMdUp();
@@ -67,8 +71,12 @@ export function CompletedScreen() {
return (
<>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div className="mx-auto grid min-h-0 w-full max-w-[1280px] grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0">
<div className="flex min-w-0 flex-col justify-start overflow-hidden md:justify-center md:pb-8">
<div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col justify-start overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={headerTitle}
description={headerDescription}
@@ -77,12 +85,14 @@ export function CompletedScreen() {
palette="inverse"
/>
</div>
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden md:overflow-y-auto">
<div
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
aria-hidden
/>
<div className="min-w-0 py-0 md:pb-8">
<div className="w-full min-w-0 py-0 md:pb-8">
<CommunityRuleDocument
sections={documentSections}
useCardStyle={!mdUp}
@@ -1,40 +1,63 @@
"use client";
import type { ReactNode } from "react";
import NumberedList from "../../../components/type/NumberedList";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
/** Create Community — frame 1 (Figma 20094-16005). */
/**
* Create Community — frame 1 (Figma [20094-16005](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20094-16005)).
* URL: /create/informational
*/
export function InformationalScreen() {
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.informational");
const copy = useMessages().create.informational;
const items = [
{
title: t("steps.0.title"),
description: t("steps.0.description"),
title: copy.steps["0"].title,
description: copy.steps["0"].description,
},
{
title: t("steps.1.title"),
description: t("steps.1.description"),
title: copy.steps["1"].title,
description: copy.steps["1"].description,
},
{
title: t("steps.2.title"),
description: t("steps.2.description"),
title: copy.steps["2"].title,
description: copy.steps["2"].description,
},
];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-inter font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div className="flex w-full max-w-[640px] flex-col items-center gap-12">
<div
className={`flex flex-col items-center gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
title={copy.title}
description={description}
justification="left"
/>
<NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -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 (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-1400"
>
<div className="flex w-full min-w-0 flex-col gap-4 md:grid md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)]">
<div className="min-w-0">
<div
className={`flex w-full min-w-0 flex-col items-center gap-6 lg:mx-auto lg:w-full lg:grid lg:grid-cols-2 lg:items-center lg:justify-items-center lg:gap-x-[var(--measures-spacing-1200,48px)] lg:gap-y-6 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col justify-center lg:min-h-[212px] ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="min-w-0 w-full">
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}>
<RuleCard
title={t("ruleCard.title")}
description={t("ruleCard.description")}
size={mdUp ? "L" : "M"}
title={cardTitle}
description={cardDescription}
size={lgUp ? "L" : "M"}
expanded={false}
backgroundColor="bg-[#c9fef9]"
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={t("ruleCard.logoAlt")}
className="rounded-[16px]"
logoAlt={cardTitle}
className="rounded-[24px]"
/>
</div>
</div>
@@ -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 (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full">
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12">
<div className="mx-auto grid h-auto min-h-0 w-full max-w-[1280px] shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-12 md:pb-8">
<div className="flex min-w-0 flex-col items-stretch justify-start overflow-hidden md:justify-center">
<div
className={`mx-auto grid h-auto min-h-0 w-full shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-12 md:pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col items-stretch justify-start overflow-hidden md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
@@ -89,8 +97,10 @@ export function RightRailScreen() {
justification={mdUp ? "left" : "center"}
/>
</div>
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto">
<div className="flex min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
<div
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
@@ -1,44 +1,13 @@
"use client";
import { useState, useMemo, useEffect, type Dispatch, type SetStateAction } from "react";
import { useState, useEffect } 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";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
confirmState: "Unselected" | "Selected",
onInteraction?: () => void,
) {
const touch = () => onInteraction?.();
return {
onAddClick: () => {
touch();
setList((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
]);
},
onCustomChipConfirm: (chipId: string, value: string) => {
touch();
setList((prev) =>
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: confirmState }
: opt,
),
);
},
onCustomChipClose: (chipId: string) => {
touch();
setList((prev) => prev.filter((o) => o.id !== chipId));
},
};
}
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 = (
<MultiSelect
label={multiLabel}
size="S"
formHeader={false}
size="M"
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
{...communityCustomHandlers}
addButton={true}
addButtonText={addText}
addButton={false}
/>
);
@@ -143,29 +96,22 @@ export function CommunitySizeSelectScreen() {
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
{mdUp ? (
<div className="flex w-full max-w-[1280px] items-center justify-center gap-[var(--measures-spacing-1200,48px)]">
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start justify-center gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start gap-[var(--measures-spacing-800,32px)]">
{multiSelectBlock}
</div>
</div>
) : (
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-400,16px)]">
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
</div>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
{multiSelectBlock}
</div>
)}
</div>
</CreateFlowStepShell>
);
}
@@ -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<SetStateAction<ChipOption[]>>,
@@ -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<ChipOption[]>(() =>
applySavedSelection(
chipRowsFromLabels(m.create.communityStructure.governanceStyles),
state.selectedGovernanceStyleIds,
chipRowsFromLabels(cs.scaleOptions),
state.selectedScaleIds,
),
);
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() =>
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 = (
<>
<MultiSelect
label={multiLabel}
label={cs.organizationMultiSelect.label}
showHelpIcon
size="S"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
{...organizationCustomHandlers}
addButton={true}
addButtonText={addText}
addButton
addButtonText={cs.organizationMultiSelect.addButtonText}
/>
<MultiSelect
label={multiLabel}
label={cs.scaleMultiSelect.label}
showHelpIcon
size="S"
options={governanceStyleOptions}
onChipClick={handleGovernanceStyleClick}
{...governanceCustomHandlers}
addButton={true}
addButtonText={addText}
options={scaleOptions}
onChipClick={handleScaleClick}
{...scaleCustomHandlers}
addButton
addButtonText={cs.scaleMultiSelect.addButtonText}
/>
<MultiSelect
label={cs.maturityMultiSelect.label}
showHelpIcon
size="S"
options={maturityOptions}
onChipClick={handleMaturityClick}
{...maturityCustomHandlers}
addButton
addButtonText={cs.maturityMultiSelect.addButtonText}
/>
</>
);
@@ -210,29 +258,22 @@ export function CommunityStructureSelectScreen() {
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
{mdUp ? (
<div className="flex w-full max-w-[1280px] items-center justify-center gap-[var(--measures-spacing-1200,48px)]">
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start justify-center gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start gap-[var(--measures-spacing-800,32px)]">
{multiSelectBlock}
</div>
</div>
) : (
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-400,16px)]">
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
</div>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
{multiSelectBlock}
</div>
)}
</div>
</CreateFlowStepShell>
);
}
@@ -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"
>
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-300,12px)]">
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("title")}
@@ -1,18 +1,30 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../components/controls/TextInput";
import type { HeaderLockupJustificationValue } from "../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
messageNamespace: string;
stateField: CreateFlowTextStateField;
maxLength: number;
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute;
showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
contentTopBelowMd?: CreateFlowContentTopBelowMd;
};
/**
@@ -22,6 +34,11 @@ export function CreateFlowTextFieldScreen({
messageNamespace,
stateField,
maxLength,
mainAlign = "start",
inputType = "text",
showCharacterCount = true,
headerJustification = "left",
contentTopBelowMd = "space-1400",
}: Props) {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
@@ -42,20 +59,35 @@ export function CreateFlowTextFieldScreen({
}, [state, stateField]);
const characterCount = value.length;
const hint = t("characterCountTemplate")
.replace("{current}", String(characterCount))
.replace("{max}", String(maxLength));
const hint =
showCharacterCount === false
? false
: t("characterCountTemplate")
.replace("{current}", String(characterCount))
.replace("{max}", String(maxLength));
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
return (
<CreateFlowStepShell variant="centeredNarrow">
<div className="flex w-full max-w-[640px] flex-col items-start gap-[18px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd={contentTopBelowMd}
>
<div
className={`flex flex-col gap-[18px] ${mainItems} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="w-full">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification={headerJustification}
/>
</div>
<div className="w-full">
<TextInput
className="!transition-none"
type={inputType}
placeholder={t("placeholder")}
value={value}
onChange={(e) => {
@@ -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"
>
<div className="flex w-full max-w-[640px] flex-col items-center gap-[18px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification={mdUp ? "center" : "left"}
/>
<div className="w-full max-w-[474px]">
<div
className={`flex flex-col items-center gap-[18px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="w-full">
<CreateFlowHeaderLockup
title={u.title}
description={u.description}
justification="center"
/>
</div>
<div className="w-full">
<Upload
active={true}
showHelpIcon={true}
showHelpIcon={false}
hintText={u.hintText}
onClick={handleUploadClick}
/>
</div>
+8 -5
View File
@@ -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<string, unknown>[];
+5 -2
View File
@@ -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<string, unknown>;
return typeof parsed === "object" && parsed !== null
? migrateLegacyCreateFlowState(parsed)
: {};
} catch {
return {};
}
@@ -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";
}
+5 -4
View File
@@ -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: {
+4 -3
View File
@@ -3,6 +3,7 @@
*
* Single source of truth for step order and navigation helpers.
* Order matches Figma Create Community (frames 18) then later stages.
* `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap).
*/
import type { CreateFlowStep } from "../types";
@@ -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",
+1 -1
View File
@@ -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.
+5 -6
View File
@@ -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 45 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.
---
+4 -1
View File
@@ -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<CreateFlowState | null> {
if (!data.draft?.payload || typeof data.draft.payload !== "object") {
return null;
}
return data.draft.payload as CreateFlowState;
return migrateLegacyCreateFlowState(
data.draft.payload as Record<string, unknown>,
);
}
const DRAFT_SAVE_NETWORK_ERROR =
+1 -5
View File
@@ -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) {
+9
View File
@@ -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);
}
@@ -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<string, unknown> | null | undefined,
): CreateFlowState {
if (!raw || typeof raw !== "object") return {};
const next: Record<string, unknown> = { ...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;
}
+24
View File
@@ -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;
}
+4 -3
View File
@@ -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(),
+2 -2
View File
@@ -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 youd like the organization to be described to other users. Try and describe your mission, goals, and scope.",
"placeholder": "Describe your community",
"characterCountTemplate": "{current}/{max}"
}
+1 -1
View File
@@ -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}"
}
@@ -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}"
}
+9
View File
@@ -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"
}
+6 -12
View File
@@ -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" }
]
}
+29 -13
View File
@@ -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": "Workers 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" }
]
}
+3 -2
View File
@@ -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"
}
+9
View File
@@ -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"
}
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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,
+27
View File
@@ -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) => (
<div className="w-full max-w-[640px] bg-black p-4">
<ProportionBar {...args} />
</div>
),
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) => (
+2 -2
View File
@@ -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();
@@ -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(
<CreateFlowFooter
progressBar={true}
proportionBarProgress="1-1"
proportionBarVariant="segmented"
/>,
);
expect(screen.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow",
String(2 / 6),
);
});
it("does not render progress bar when progressBar is false", () => {
+16
View File
@@ -49,6 +49,22 @@ describe("HeaderLockup (behavioral tests)", () => {
expect(screen.getByText("Test description")).toBeInTheDocument();
});
it("renders ReactNode description (rich inline)", () => {
render(
<HeaderLockup
title="Test Title"
description={
<>
Before <span className="underline">link</span> 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(<HeaderLockup title="Test Title" />);
const description = container.querySelector("p");
@@ -22,6 +22,13 @@ describe("InformationalScreen", () => {
).toBeInTheDocument();
});
it("renders workshop as a link (URL TBD) with underline per Figma", () => {
render(<InformationalScreen />);
const workshop = screen.getByRole("link", { name: "workshop" });
expect(workshop).toHaveAttribute("href", "#");
expect(workshop.className).toMatch(/underline/);
});
it("renders first numbered list item title", () => {
render(<InformationalScreen />);
expect(
+2 -2
View File
@@ -119,7 +119,7 @@ describe("LoginForm", () => {
<Suspense fallback={null}>
<LoginForm
variant="saveProgress"
magicLinkNextPath="/create/community-size?syncDraft=1"
magicLinkNextPath="/create/community-structure?syncDraft=1"
/>
</Suspense>,
);
@@ -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();
+1
View File
@@ -22,6 +22,7 @@ const config: ComponentTestSuiteConfig<ProportionBarProps> = {
optionalProps: {
progress: "3-2",
className: "custom-class",
variant: "segmented",
},
primaryRole: "progressbar",
testCases: {
+3 -11
View File
@@ -8,22 +8,14 @@ describe("CommunitySizeSelectScreen", () => {
render(<CommunitySizeSelectScreen />);
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(<CommunitySizeSelectScreen />);
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(<CommunitySizeSelectScreen />);
expect(screen.getByText("1 member")).toBeInTheDocument();
expect(screen.getByText("2-10 members")).toBeInTheDocument();
expect(screen.getByText("2-5 members")).toBeInTheDocument();
});
});
+1 -1
View File
@@ -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();
});
});
+4 -3
View File
@@ -20,6 +20,7 @@ componentTestSuite<UploadProps>({
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(<Upload label="Upload" />);
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(<Upload label="Upload" active={true} />);
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(<Upload label="Upload" active={false} />);
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(
+4 -2
View File
@@ -8,7 +8,7 @@ describe("CommunityUploadScreen", () => {
render(<CommunityUploadScreen />);
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(<CommunityUploadScreen />);
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();
});
});
+18
View File
@@ -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]");
});
});
@@ -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");
});
});
+7
View File
@@ -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", () => {
+22
View File
@@ -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");
});
});
+3 -1
View File
@@ -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,
);
});
});
+9
View File
@@ -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");
});
});
@@ -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({});
});
});