Create custom flow UI
This commit is contained in:
+1
-1
@@ -44,7 +44,7 @@ Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can
|
||||
|
||||
### Create flow URLs (custom wizard)
|
||||
|
||||
The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`cards`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md).
|
||||
The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`communication-methods`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md).
|
||||
|
||||
## Frontend / tests
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ProportionBarState =
|
||||
| "2-0"
|
||||
| "2-1"
|
||||
| "2-2"
|
||||
| "2-3"
|
||||
| "3-0"
|
||||
| "3-1"
|
||||
| "3-2";
|
||||
@@ -20,7 +21,9 @@ export interface ProportionBarProps {
|
||||
progress?: ProportionBarState;
|
||||
className?: string;
|
||||
/**
|
||||
* `segmented` (Figma: create-flow footer): pill-shaped partial fills inside each segment.
|
||||
* Kept for backwards compatibility. Both `default` and `segmented` render the
|
||||
* same fill geometry (square leading edges, matching Figma). Future variants
|
||||
* can differentiate here without API changes.
|
||||
*/
|
||||
variant?: ProportionBarVariant;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import type { ProportionBarViewProps } from "./ProportionBar.types";
|
||||
|
||||
/**
|
||||
* Per-step fill ratio for the second (middle) segment at `2-X` progress states.
|
||||
* Values are taken directly from Figma (`17861:33241`, `18861:15250`, `21434:17632`)
|
||||
* and are intentionally non-uniform — they are NOT `X/3`.
|
||||
*/
|
||||
const SECOND_SEGMENT_FILL_RATIO: Record<number, number> = {
|
||||
0: 0,
|
||||
1: 1 / 4,
|
||||
2: 1 / 2,
|
||||
3: 3 / 4,
|
||||
};
|
||||
|
||||
function getSecondSegmentFillRatio(partial: number): number {
|
||||
return SECOND_SEGMENT_FILL_RATIO[partial] ?? 0;
|
||||
}
|
||||
|
||||
export function ProportionBarView({
|
||||
progress,
|
||||
className,
|
||||
barClasses,
|
||||
variant,
|
||||
// `variant` is kept in the prop API for callers, but both `default` and
|
||||
// `segmented` now render identical fill geometry (square leading edges).
|
||||
variant: _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
|
||||
// - For 2-X: first section full, second section filled per Figma ratios (see `SECOND_SEGMENT_FILL_RATIO`)
|
||||
// - For 3-X: first two sections full, third section X/3 filled
|
||||
// Max is 3 full segments = 9 units
|
||||
let totalProgress = 0;
|
||||
if (fullSegments === 1) {
|
||||
totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section
|
||||
} else if (fullSegments === 2) {
|
||||
totalProgress = 1 + partialSegment / 3; // 1 full + 0/3 to 2/3 of second
|
||||
totalProgress = 1 + getSecondSegmentFillRatio(partialSegment);
|
||||
} else if (fullSegments === 3) {
|
||||
totalProgress = 2 + partialSegment / 3; // 2 full + 0/3 to 2/3 of third
|
||||
}
|
||||
@@ -55,33 +72,32 @@ export function ProportionBarView({
|
||||
</div>
|
||||
|
||||
{/* Fill layer - always show 3 sections, fill amount varies */}
|
||||
{/*
|
||||
The leading (right) edge of every partial fill is a straight (square) edge —
|
||||
only the outermost left/right edges of the whole bar can round to match the
|
||||
background capsule.
|
||||
*/}
|
||||
<div className="absolute inset-0 flex gap-[var(--spacing-scale-008)] px-[4px] overflow-hidden">
|
||||
{/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */}
|
||||
<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)] ${
|
||||
segmented && partialSegment < 5
|
||||
? "rounded-r-[var(--radius-full)]"
|
||||
: ""
|
||||
}`.trim()}
|
||||
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
|
||||
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
|
||||
/>
|
||||
) : fullSegments >= 2 ? (
|
||||
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" />
|
||||
) : null}
|
||||
</div>
|
||||
{/* Second section - for 2-X: X/3 filled, for 3-X: fully filled, otherwise empty */}
|
||||
{/* Second section — for 2-X: Figma ratio fill (see `SECOND_SEGMENT_FILL_RATIO`); for 3-X: full; otherwise empty. */}
|
||||
<div className="flex-1 h-full relative">
|
||||
{fullSegments === 2 ? (
|
||||
partialSegment > 0 ? (
|
||||
<div
|
||||
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}%` }}
|
||||
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
|
||||
style={{
|
||||
width: `${getSecondSegmentFillRatio(partialSegment) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
) : fullSegments >= 3 ? (
|
||||
@@ -89,16 +105,12 @@ export function ProportionBarView({
|
||||
) : null}
|
||||
</div>
|
||||
{/* Third section - for 3-X: X/3 filled, otherwise empty */}
|
||||
{/* Round right corner when at 100% (third section fully filled, partialSegment === 3) */}
|
||||
{/* Round right corner only when the fill reaches the absolute right edge of the bar (partialSegment >= 3) */}
|
||||
<div className="flex-1 h-full relative">
|
||||
{fullSegments === 3 && partialSegment > 0 ? (
|
||||
<div
|
||||
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)]"
|
||||
: partialSegment >= 3
|
||||
? "rounded-r-[var(--radius-full)]"
|
||||
: ""
|
||||
partialSegment >= 3 ? "rounded-r-[var(--radius-full)]" : ""
|
||||
}`.trim()}
|
||||
style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,10 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
title = "",
|
||||
description = "",
|
||||
layout = "default",
|
||||
compactRecommendedLimit = 5,
|
||||
compactDesktopLayout: compactDesktopLayoutProp = "grid",
|
||||
headerLockupSize,
|
||||
toggleAlignment = "center",
|
||||
className = "",
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -75,7 +78,10 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
title={title}
|
||||
description={description}
|
||||
layout={layout}
|
||||
compactRecommendedLimit={compactRecommendedLimit}
|
||||
compactDesktopLayout={compactDesktopLayoutProp}
|
||||
headerLockupSize={headerLockupSize}
|
||||
toggleAlignment={toggleAlignment}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,8 +21,19 @@ export interface CardStackProps {
|
||||
description?: string;
|
||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
||||
layout?: "default" | "singleStack";
|
||||
/**
|
||||
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
|
||||
*/
|
||||
compactRecommendedLimit?: number;
|
||||
/**
|
||||
* At `md+`, how compact recommended cards are laid out. `flexWrap` matches Figma Flow — Compact Card Stack (three cards in a row).
|
||||
* `pyramidFive` = two rows (3 + 2) centered for five recommended cards (membership step).
|
||||
*/
|
||||
compactDesktopLayout?: "grid" | "flexWrap" | "pyramidFive";
|
||||
/** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */
|
||||
headerLockupSize?: HeaderLockupSizeValue;
|
||||
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
|
||||
toggleAlignment?: "center" | "end";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +49,9 @@ export interface CardStackViewProps {
|
||||
title: string;
|
||||
description: string;
|
||||
layout: "default" | "singleStack";
|
||||
compactRecommendedLimit: number;
|
||||
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
|
||||
headerLockupSize: HeaderLockupSizeValue | undefined;
|
||||
toggleAlignment: "center" | "end";
|
||||
className: string;
|
||||
}
|
||||
|
||||
@@ -16,13 +16,18 @@ export function CardStackView({
|
||||
title,
|
||||
description,
|
||||
layout,
|
||||
compactRecommendedLimit,
|
||||
compactDesktopLayout,
|
||||
headerLockupSize,
|
||||
toggleAlignment,
|
||||
className,
|
||||
}: CardStackViewProps) {
|
||||
const lockupSize = headerLockupSize ?? "L";
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: recommended only (up to 5). Expanded: all cards.
|
||||
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5);
|
||||
// Compact: recommended only (default up to 5). Expanded: all cards.
|
||||
const compactCards = cards
|
||||
.filter((c) => c.recommended ?? false)
|
||||
.slice(0, compactRecommendedLimit);
|
||||
|
||||
// Single stack: always one column; expand reveals more in same stack (scrollable)
|
||||
if (layout === "singleStack") {
|
||||
@@ -39,7 +44,7 @@ export function CardStackView({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-[8px] w-full min-w-0">
|
||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
||||
{displayedCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
@@ -58,7 +63,9 @@ export function CardStackView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none self-center cursor-pointer"
|
||||
className={`font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none cursor-pointer ${
|
||||
toggleAlignment === "end" ? "self-end" : "self-center"
|
||||
}`}
|
||||
>
|
||||
{expanded ? showLessLabel : toggleLabel}
|
||||
</button>
|
||||
@@ -81,7 +88,7 @@ export function CardStackView({
|
||||
) : null}
|
||||
|
||||
{expanded ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6 w-full">
|
||||
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||
{cards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
@@ -96,10 +103,215 @@ export function CardStackView({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : compactDesktopLayout === "pyramidFive" ? (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="min-h-[142px]"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
|
||||
{/*
|
||||
lg+: fixed 3 + 2 rows (no flex-wrap on the top row — avoids 2+1+2 when the first row wraps).
|
||||
md–lg: same shell as the 3-card step — each row is `flex justify-center gap-2` so cards
|
||||
stay a tight cluster with gap-2 until lg expands to the 3+2 pyramid.
|
||||
*/}
|
||||
<div className="hidden flex-col gap-2 lg:flex">
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(0, 3).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{compactCards.length > 3 ? (
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards
|
||||
.slice(3, compactRecommendedLimit)
|
||||
.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden flex-col gap-2 md:flex lg:hidden">
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(0, 2).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(2, 4).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{compactCards[4] ? (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Card
|
||||
id={compactCards[4].id}
|
||||
label={compactCards[4].label}
|
||||
supportText={compactCards[4].supportText}
|
||||
recommended={compactCards[4].recommended ?? false}
|
||||
selected={isSelected(compactCards[4].id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(compactCards[4].id)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : compactDesktopLayout === "flexWrap" ? (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="min-h-[142px]"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* md–lg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
|
||||
{compactCards.length === 3 ? (
|
||||
<>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-col gap-2 md:flex lg:hidden">
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(0, 2).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Card
|
||||
id={compactCards[2].id}
|
||||
label={compactCards[2].label}
|
||||
supportText={compactCards[2].supportText}
|
||||
recommended={compactCards[2].recommended ?? false}
|
||||
selected={isSelected(compactCards[2].id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(compactCards[2].id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 lg:flex">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 md:flex">
|
||||
{compactCards.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex w-full min-w-0 shrink-0 justify-center md:w-[281px] md:max-w-[281px]"
|
||||
>
|
||||
<Card
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-full max-w-[281px]"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Compact under 640: single column, up to 5 recommended cards */}
|
||||
<div className="flex flex-col gap-6 w-full md:hidden">
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
|
||||
@@ -14,7 +14,10 @@ import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
||||
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry";
|
||||
import {
|
||||
createFlowStepUsesCenteredTextLayout,
|
||||
createFlowStepUsesCardLayout,
|
||||
} from "./utils/createFlowScreenRegistry";
|
||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||
import Button from "../components/buttons/Button";
|
||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
||||
@@ -299,36 +302,34 @@ function CreateFlowLayoutContent({
|
||||
}, [state.communitySaveEmail, tLogin, updateState]);
|
||||
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const isRightRailStep = currentStep === "decision-approaches";
|
||||
const isFinalReviewStep = currentStep === "final-review";
|
||||
const isCardsStep = currentStep === "cards";
|
||||
/** Two-column select steps: at `lg+` only the right column scrolls; main must not scroll the full page. */
|
||||
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||
const isSelectSplitScrollStep =
|
||||
currentStep === "community-size" ||
|
||||
currentStep === "community-structure" ||
|
||||
currentStep === "core-values";
|
||||
currentStep === "core-values" ||
|
||||
currentStep === "decision-approaches";
|
||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
|
||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||
const mainContentClass = isCompletedStep
|
||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||
: isRightRailStep
|
||||
? "items-stretch overflow-hidden"
|
||||
: isSelectSplitScrollStep
|
||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
|
||||
? "items-start justify-center overflow-y-auto"
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
: isSelectSplitScrollStep
|
||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||
: isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
|
||||
? "items-start justify-center overflow-y-auto"
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
|
||||
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
|
||||
const mainMaxMdJustify =
|
||||
isTextStep && !isCompletedStep && !isRightRailStep
|
||||
? "max-md:justify-center"
|
||||
: "max-md:justify-start";
|
||||
const mainMaxMdCross =
|
||||
isCompletedStep || isRightRailStep
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainMaxMdCross = isCompletedStep
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||
const saveDraftOnExit =
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
@@ -575,6 +576,70 @@ function CreateFlowLayoutContent({
|
||||
>
|
||||
{footer.confirmCoreValues}
|
||||
</Button>
|
||||
) : currentStep === "communication-methods" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedCommunicationMethodIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmCommunication}
|
||||
</Button>
|
||||
) : currentStep === "membership-methods" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedMembershipMethodIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmMembership}
|
||||
</Button>
|
||||
) : currentStep === "decision-approaches" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedDecisionApproachIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmRightRail}
|
||||
</Button>
|
||||
) : currentStep === "conflict-management" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedConflictManagementIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmConflictManagement}
|
||||
</Button>
|
||||
) : nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { notFound, useRouter } from "next/navigation";
|
||||
import { use, useEffect } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
||||
import { isValidStep } from "../utils/flowSteps";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
/**
|
||||
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
|
||||
*
|
||||
* Only **canonical** `screenId` values from `CreateFlowStep` are valid. Old placeholder
|
||||
* segments from pre-product shells are not redirected — unknown slugs `notFound()`.
|
||||
*/
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ screenId: string }>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
export default async function CreateFlowScreenPage({ params }: PageProps) {
|
||||
const { screenId: raw } = await params;
|
||||
|
||||
if (!isValidStep(raw)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const screenId = raw as CreateFlowStep;
|
||||
return <CreateFlowScreenView screenId={screenId} />;
|
||||
return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||
import {
|
||||
CreateFlowStepShell,
|
||||
type CreateFlowContentTopBelowMd,
|
||||
} from "./CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
|
||||
|
||||
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||
@@ -9,23 +12,29 @@ export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||
interface CreateFlowTwoColumnSelectShellProps {
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
|
||||
* (Figma Flow — Right Rail).
|
||||
*/
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
/**
|
||||
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
|
||||
* `"start"` = top-weighted layout with a scrollable right column (core values): uses `items-stretch`
|
||||
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
|
||||
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
|
||||
*/
|
||||
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column layout for create-flow select steps (community size/structure, core values).
|
||||
* Below `lg`, layout and scrolling match the previous single-column behavior (full page scroll).
|
||||
* Two-column layout for create-flow select steps (community size/structure, core values) and
|
||||
* {@link RightRailScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
|
||||
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
||||
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
||||
*/
|
||||
export function CreateFlowTwoColumnSelectShell({
|
||||
header,
|
||||
children,
|
||||
contentTopBelowMd = "space-1400",
|
||||
lgVerticalAlign = "center",
|
||||
}: CreateFlowTwoColumnSelectShellProps) {
|
||||
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
|
||||
@@ -38,7 +47,7 @@ export function CreateFlowTwoColumnSelectShell({
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
contentTopBelowMd={contentTopBelowMd}
|
||||
className={
|
||||
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
|
||||
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
|
||||
|
||||
@@ -8,3 +8,11 @@ export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
||||
|
||||
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
||||
|
||||
/**
|
||||
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
|
||||
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
|
||||
* Card–card gap uses `gap-2` in `CardStack` (same on mobile and md+).
|
||||
*/
|
||||
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
||||
|
||||
@@ -11,12 +11,18 @@ import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CardsScreen } from "./card/CardsScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { RightRailScreen } from "./right-rail/RightRailScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
|
||||
/**
|
||||
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/).
|
||||
* Maps each wizard `screenId` to its screen component.
|
||||
*
|
||||
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
|
||||
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
|
||||
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
|
||||
*/
|
||||
export function CreateFlowScreenView({
|
||||
screenId,
|
||||
@@ -65,10 +71,14 @@ export function CreateFlowScreenView({
|
||||
return <CommunityReviewScreen />;
|
||||
case "core-values":
|
||||
return <CoreValuesSelectScreen />;
|
||||
case "cards":
|
||||
return <CardsScreen />;
|
||||
case "right-rail":
|
||||
case "communication-methods":
|
||||
return <CommunicationMethodsScreen />;
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <RightRailScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
case "final-review":
|
||||
|
||||
+51
-11
@@ -1,5 +1,14 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `communication-methods` step — Figma “Flow — Compact Card Stack” (node `20246-15828`).
|
||||
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
|
||||
*
|
||||
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
|
||||
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
||||
* reuse `CardStack` / `CreateFlowStepShell` as needed.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
@@ -9,7 +18,10 @@ 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";
|
||||
import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
const IN_PERSON_CARD_ID = "in-person-meetings";
|
||||
const SIGNAL_CARD_ID = "signal";
|
||||
@@ -129,16 +141,24 @@ function isAddPlatformCard(cardId: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function CardsScreen() {
|
||||
export function CommunicationMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedCommunicationMethodIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
() =>
|
||||
COMMUNICATION_CARD_ORDER.map((id) => {
|
||||
@@ -154,9 +174,25 @@ export function CardsScreen() {
|
||||
);
|
||||
|
||||
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||
const description = expanded
|
||||
? comm.page.expandedDescription
|
||||
: comm.page.compactDescription;
|
||||
|
||||
const description = expanded ? (
|
||||
comm.page.expandedDescription
|
||||
) : (
|
||||
<>
|
||||
{comm.page.compactDescriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{comm.page.compactDescriptionLinkLabel}
|
||||
</button>
|
||||
{comm.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in comm.modals
|
||||
@@ -198,13 +234,15 @@ export function CardsScreen() {
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId]);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -219,7 +257,7 @@ export function CardsScreen() {
|
||||
justification="center"
|
||||
/>
|
||||
</div>
|
||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
@@ -231,6 +269,8 @@ export function CardsScreen() {
|
||||
}}
|
||||
hasMore={true}
|
||||
toggleLabel={comm.page.seeAllLink}
|
||||
compactRecommendedLimit={3}
|
||||
compactDesktopLayout="flexWrap"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
const CONFLICT_CARD_ORDER = [
|
||||
"peer-mediation",
|
||||
"conflict-resolution-council",
|
||||
"facilitated-negotiation",
|
||||
"ad-hoc-arbitration",
|
||||
"conflict-workshops",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
] as const;
|
||||
|
||||
export function ConflictManagementScreen() {
|
||||
const m = useMessages();
|
||||
const cm = m.create.conflictManagement;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedConflictManagementIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
() =>
|
||||
CONFLICT_CARD_ORDER.map((id) => {
|
||||
const row = cm.cards[id as keyof typeof cm.cards];
|
||||
return {
|
||||
id,
|
||||
label: row.label,
|
||||
supportText: row.supportText,
|
||||
recommended: true,
|
||||
};
|
||||
}),
|
||||
[cm],
|
||||
);
|
||||
|
||||
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
||||
|
||||
const description = expanded ? (
|
||||
cm.page.expandedDescription
|
||||
) : (
|
||||
<>
|
||||
{cm.page.compactDescriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{cm.page.compactDescriptionLinkLabel}
|
||||
</button>
|
||||
{cm.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in cm.modals
|
||||
? (() => {
|
||||
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
>
|
||||
<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={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardClick}
|
||||
expanded={expanded}
|
||||
onToggleExpand={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
hasMore={true}
|
||||
toggleLabel={cm.page.seeAllLink}
|
||||
compactRecommendedLimit={5}
|
||||
compactDesktopLayout="pyramidFive"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
/>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
const MEMBERSHIP_CARD_ORDER = [
|
||||
"open-access",
|
||||
"orientation-required",
|
||||
"invitation-only",
|
||||
"contribution-based",
|
||||
"mentorship",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
] as const;
|
||||
|
||||
export function MembershipMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const mem = m.create.membership;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedMembershipMethodIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
() =>
|
||||
MEMBERSHIP_CARD_ORDER.map((id) => {
|
||||
const row = mem.cards[id as keyof typeof mem.cards];
|
||||
return {
|
||||
id,
|
||||
label: row.label,
|
||||
supportText: row.supportText,
|
||||
recommended: true,
|
||||
};
|
||||
}),
|
||||
[mem],
|
||||
);
|
||||
|
||||
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
||||
|
||||
const description = expanded ? (
|
||||
mem.page.expandedDescription
|
||||
) : (
|
||||
<>
|
||||
{mem.page.compactDescriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{mem.page.compactDescriptionLinkLabel}
|
||||
</button>
|
||||
{mem.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in mem.modals
|
||||
? (() => {
|
||||
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
>
|
||||
<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={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardClick}
|
||||
expanded={expanded}
|
||||
onToggleExpand={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
hasMore={true}
|
||||
toggleLabel={mem.page.seeAllLink}
|
||||
compactRecommendedLimit={5}
|
||||
compactDesktopLayout="pyramidFive"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
/>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
|
||||
*
|
||||
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
|
||||
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
@@ -8,22 +16,27 @@ 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";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
|
||||
export function RightRailScreen() {
|
||||
const m = useMessages();
|
||||
const rr = m.create.rightRail;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedDecisionApproachIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||
() =>
|
||||
rr.messageBox.items.map((item) => ({
|
||||
@@ -47,7 +60,16 @@ export function RightRailScreen() {
|
||||
const sidebarDescription = (
|
||||
<>
|
||||
{rr.sidebar.descriptionBefore}
|
||||
<span className="underline">{rr.sidebar.descriptionLink}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{rr.sidebar.descriptionLinkLabel}
|
||||
</button>
|
||||
{rr.sidebar.descriptionAfter}
|
||||
</>
|
||||
);
|
||||
@@ -65,11 +87,13 @@ export function RightRailScreen() {
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
setSelectedIds(
|
||||
selectedIds.includes(id)
|
||||
? selectedIds.filter((x) => x !== id)
|
||||
: [...selectedIds, id],
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
[markCreateFlowInteraction, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
@@ -78,48 +102,40 @@ export function RightRailScreen() {
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
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 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}
|
||||
messageBoxTitle={rr.messageBox.title}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size={mdUp ? "L" : "M"}
|
||||
justification={mdUp ? "left" : "center"}
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel={rr.cardStack.toggleSeeAll}
|
||||
showLessLabel={rr.cardStack.toggleShowLess}
|
||||
title={rr.cardStack.emptyTitle}
|
||||
description={rr.cardStack.emptyDescription}
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
contentTopBelowMd="space-800"
|
||||
lgVerticalAlign="start"
|
||||
header={
|
||||
<DecisionMakingSidebar
|
||||
title={rr.sidebar.title}
|
||||
description={sidebarDescription}
|
||||
messageBoxTitle={rr.messageBox.title}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size={mdUp ? "L" : "M"}
|
||||
justification={mdUp ? "left" : "center"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel={rr.cardStack.toggleSeeAll}
|
||||
showLessLabel={rr.cardStack.toggleShowLess}
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
compactRecommendedLimit={5}
|
||||
className="w-full"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-2
@@ -19,8 +19,10 @@ export type CreateFlowStep =
|
||||
| "community-save"
|
||||
| "review"
|
||||
| "core-values"
|
||||
| "cards"
|
||||
| "right-rail"
|
||||
| "communication-methods"
|
||||
| "membership-methods"
|
||||
| "decision-approaches"
|
||||
| "conflict-management"
|
||||
| "confirm-stakeholders"
|
||||
| "final-review"
|
||||
| "completed";
|
||||
@@ -83,6 +85,14 @@ export interface CreateFlowState {
|
||||
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
|
||||
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
|
||||
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
|
||||
/** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.communication` presets. */
|
||||
selectedCommunicationMethodIds?: string[];
|
||||
/** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.membership` presets. */
|
||||
selectedMembershipMethodIds?: string[];
|
||||
/** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.rightRail` presets. */
|
||||
selectedDecisionApproachIds?: string[];
|
||||
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.conflictManagement` presets. */
|
||||
selectedConflictManagementIds?: string[];
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
|
||||
@@ -16,8 +16,10 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
|
||||
"2-0", // community-save
|
||||
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
|
||||
"2-0", // core-values (same segment as review / end of Create Community)
|
||||
"2-2", // cards
|
||||
"3-0", // right-rail
|
||||
"2-1", // communication-methods (Figma — Compact Card Stack)
|
||||
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
|
||||
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
|
||||
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
|
||||
"3-1", // confirm-stakeholders
|
||||
"3-2", // final-review
|
||||
"3-2", // completed
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { CreateFlowStep } from "../types";
|
||||
|
||||
/**
|
||||
* Figma layout families for the create flow (not encoded in the URL).
|
||||
* Registry and `app/create/screens/` are organized by these kinds.
|
||||
* `app/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
|
||||
* `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
|
||||
*/
|
||||
export type CreateFlowLayoutKind =
|
||||
| "informational"
|
||||
@@ -90,18 +91,30 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
messageNamespace: "create.coreValues",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
cards: {
|
||||
"communication-methods": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "TBD-cards",
|
||||
figmaNodeId: "20246-15828",
|
||||
messageNamespace: "create.communication",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"right-rail": {
|
||||
"membership-methods": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20858-13947",
|
||||
messageNamespace: "create.membership",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"decision-approaches": {
|
||||
layoutKind: "right-rail",
|
||||
figmaNodeId: "TBD-right-rail",
|
||||
figmaNodeId: "20523-23509",
|
||||
messageNamespace: "create.rightRail",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"conflict-management": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20879-15979",
|
||||
messageNamespace: "create.conflictManagement",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"confirm-stakeholders": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "21104-46594",
|
||||
@@ -128,3 +141,11 @@ export function createFlowStepUsesCenteredTextLayout(
|
||||
if (!step) return false;
|
||||
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
|
||||
}
|
||||
|
||||
/** Steps whose main area uses the CardStack-style layout (`layoutKind: "card"`). */
|
||||
export function createFlowStepUsesCardLayout(
|
||||
step: CreateFlowStep | null,
|
||||
): boolean {
|
||||
if (!step) return false;
|
||||
return CREATE_FLOW_SCREEN_REGISTRY[step].layoutKind === "card";
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
||||
"community-save",
|
||||
"review",
|
||||
"core-values",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"communication-methods",
|
||||
"membership-methods",
|
||||
"decision-approaches",
|
||||
"conflict-management",
|
||||
"confirm-stakeholders",
|
||||
"final-review",
|
||||
"completed",
|
||||
|
||||
+7
-6
@@ -11,7 +11,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, 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` |
|
||||
| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `core-values` → `communication-methods` → `right-rail` (further card-stack steps get their own `screenId` and `screens/card/*Screen.tsx`; `right-rail` uses `layoutKind: "right-rail"`) |
|
||||
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
|
||||
|
||||
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed** — `CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
|
||||
@@ -34,11 +34,12 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts
|
||||
| 6 | Create Community | `community-upload` | `/create/community-upload` |
|
||||
| 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` |
|
||||
| 11 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
|
||||
| 12 | Review and complete | `final-review` | `/create/final-review` |
|
||||
| 13 | Review and complete | `completed` | `/create/completed` |
|
||||
| 9 | Create Custom CommunityRule | `core-values` | `/create/core-values` |
|
||||
| 10 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` |
|
||||
| 11 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
|
||||
| 12 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
|
||||
| 13 | Review and complete | `final-review` | `/create/final-review` |
|
||||
| 14 | Review and complete | `completed` | `/create/completed` |
|
||||
|
||||
**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)).
|
||||
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
|
||||
/** Legacy `currentStep` values mapped to the current `CreateFlowStep` id. */
|
||||
const LEGACY_CREATE_FLOW_STEP_RENAMES: Readonly<Record<string, string>> = {
|
||||
"right-rail": "decision-approaches",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps pre-rename draft keys and step ids (`community-reflection` → `community-save`).
|
||||
* Safe to run on any parsed draft payload before merging into context.
|
||||
* Normalizes parsed draft JSON before merging into create-flow context.
|
||||
* Renames deprecated step ids so old drafts and bookmarks stay valid.
|
||||
*/
|
||||
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;
|
||||
const step = raw.currentStep;
|
||||
if (typeof step === "string") {
|
||||
const next = LEGACY_CREATE_FLOW_STEP_RENAMES[step];
|
||||
if (next !== undefined) {
|
||||
return { ...raw, currentStep: next } as CreateFlowState;
|
||||
}
|
||||
}
|
||||
delete next.communityReflection;
|
||||
if (next.currentStep === "community-reflection") {
|
||||
next.currentStep = "community-save";
|
||||
}
|
||||
return next as CreateFlowState;
|
||||
return raw as CreateFlowState;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ export const createFlowStateSchema = z
|
||||
coreValueDetailsByChipId: z
|
||||
.record(coreValueDetailEntrySchema)
|
||||
.optional(),
|
||||
selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(),
|
||||
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
|
||||
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
|
||||
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"_comment": "Create flow – communication step: page, cards, and add-platform modals",
|
||||
"page": {
|
||||
"compactTitle": "How should this community communicate with each-other?",
|
||||
"compactDescription": "You can select multiple methods for different needs or add your own",
|
||||
"compactDescriptionBefore": "You can select multiple methods for different needs or ",
|
||||
"compactDescriptionLinkLabel": "add",
|
||||
"compactDescriptionAfter": " your own",
|
||||
"expandedTitle": "What method should this community use to communicate with eachother?",
|
||||
"expandedDescription": "You can select multiple methods for different needs or add your own",
|
||||
"seeAllLink": "See all communication approaches"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"_comment": "Create flow — conflict management (Figma Flow — Compact Card Stack `20879:15979`)",
|
||||
"page": {
|
||||
"compactTitle": "How should conflicts be managed\nin your group?",
|
||||
"compactDescriptionBefore": "You can also combine or ",
|
||||
"compactDescriptionLinkLabel": "add",
|
||||
"compactDescriptionAfter": " new approaches to the list",
|
||||
"expandedTitle": "How should conflicts be managed in your group?",
|
||||
"expandedDescription": "You can also combine or add new approaches to the list",
|
||||
"seeAllLink": "See all conflict management approaches"
|
||||
},
|
||||
"confirmModal": {
|
||||
"title": "Confirm selection",
|
||||
"description": "Confirm to select this option.",
|
||||
"nextButtonText": "Confirm"
|
||||
},
|
||||
"cards": {
|
||||
"peer-mediation": {
|
||||
"label": "Peer Mediation",
|
||||
"supportText": "Trained members within the organization mediate disputes among peers."
|
||||
},
|
||||
"conflict-resolution-council": {
|
||||
"label": "Conflict Resolution Council",
|
||||
"supportText": "Senior members with institutional knowledge provide guidance or decisions."
|
||||
},
|
||||
"facilitated-negotiation": {
|
||||
"label": "Facilitated Negotiation",
|
||||
"supportText": "A neutral facilitator helps guide the negotiation process."
|
||||
},
|
||||
"ad-hoc-arbitration": {
|
||||
"label": "Ad Hoc Arbitration",
|
||||
"supportText": "Arbitrators are chosen specifically for a particular case."
|
||||
},
|
||||
"conflict-workshops": {
|
||||
"label": "Conflict Workshops",
|
||||
"supportText": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
|
||||
},
|
||||
"6": {
|
||||
"label": "Label",
|
||||
"supportText": "Additional conflict management approach."
|
||||
},
|
||||
"7": {
|
||||
"label": "Label",
|
||||
"supportText": "Additional conflict management approach."
|
||||
},
|
||||
"8": {
|
||||
"label": "Label",
|
||||
"supportText": "Additional conflict management approach."
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"peer-mediation": {
|
||||
"title": "Peer Mediation",
|
||||
"description": "Trained members within the organization mediate disputes among peers."
|
||||
},
|
||||
"conflict-resolution-council": {
|
||||
"title": "Conflict Resolution Council",
|
||||
"description": "Senior members with institutional knowledge provide guidance or decisions."
|
||||
},
|
||||
"facilitated-negotiation": {
|
||||
"title": "Facilitated Negotiation",
|
||||
"description": "A neutral facilitator helps guide the negotiation process."
|
||||
},
|
||||
"ad-hoc-arbitration": {
|
||||
"title": "Ad Hoc Arbitration",
|
||||
"description": "Arbitrators are chosen specifically for a particular case."
|
||||
},
|
||||
"conflict-workshops": {
|
||||
"title": "Conflict Workshops",
|
||||
"description": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
|
||||
},
|
||||
"6": { "title": "Label", "description": "Additional conflict management approach." },
|
||||
"7": { "title": "Label", "description": "Additional conflict management approach." },
|
||||
"8": { "title": "Label", "description": "Additional conflict management approach." }
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,9 @@
|
||||
"confirmMembers": "Confirm members",
|
||||
"finalizeCommunityRule": "Finalize CommunityRule",
|
||||
"confirmStakeholders": "Confirm Stakeholders",
|
||||
"confirmCoreValues": "Confirm values"
|
||||
"confirmCoreValues": "Confirm values",
|
||||
"confirmCommunication": "Confirm",
|
||||
"confirmMembership": "Confirm",
|
||||
"confirmRightRail": "Confirm",
|
||||
"confirmConflictManagement": "Confirm"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"_comment": "Create flow — membership / how members join (compact card stack)",
|
||||
"page": {
|
||||
"compactTitle": "How do new members join\nand get connected?",
|
||||
"compactDescriptionBefore": "You can select multiple methods for different needs or ",
|
||||
"compactDescriptionLinkLabel": "add",
|
||||
"compactDescriptionAfter": " your own",
|
||||
"expandedTitle": "How should new members join and get connected?",
|
||||
"expandedDescription": "You can select multiple methods for different needs or add your own",
|
||||
"seeAllLink": "See all membership approaches"
|
||||
},
|
||||
"confirmModal": {
|
||||
"title": "Confirm selection",
|
||||
"description": "Confirm to select this option.",
|
||||
"nextButtonText": "Confirm"
|
||||
},
|
||||
"cards": {
|
||||
"open-access": {
|
||||
"label": "Open Access",
|
||||
"supportText": "Maximum inclusion. Anyone can join immediately by simply showing up."
|
||||
},
|
||||
"orientation-required": {
|
||||
"label": "Orientation Required",
|
||||
"supportText": "Newcomers must attend a training or orientation session."
|
||||
},
|
||||
"invitation-only": {
|
||||
"label": "Invitation Only",
|
||||
"supportText": "New members can only join if they are 'vouched for' by existing members."
|
||||
},
|
||||
"contribution-based": {
|
||||
"label": "Contribution Based",
|
||||
"supportText": "Membership is reserved for people contributing their labor."
|
||||
},
|
||||
"mentorship": {
|
||||
"label": "Mentorship",
|
||||
"supportText": "New members are paired with 'Mentors' to guide them through a probationary period."
|
||||
},
|
||||
"6": {
|
||||
"label": "Label",
|
||||
"supportText": "Additional membership approach."
|
||||
},
|
||||
"7": {
|
||||
"label": "Label",
|
||||
"supportText": "Additional membership approach."
|
||||
},
|
||||
"8": {
|
||||
"label": "Label",
|
||||
"supportText": "Additional membership approach."
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"open-access": {
|
||||
"title": "Open Access",
|
||||
"description": "Maximum inclusion. Anyone can join immediately by simply showing up."
|
||||
},
|
||||
"orientation-required": {
|
||||
"title": "Orientation Required",
|
||||
"description": "Newcomers must attend a training or orientation session."
|
||||
},
|
||||
"invitation-only": {
|
||||
"title": "Invitation Only",
|
||||
"description": "New members can only join if they are 'vouched for' by existing members."
|
||||
},
|
||||
"contribution-based": {
|
||||
"title": "Contribution Based",
|
||||
"description": "Membership is reserved for people contributing their labor."
|
||||
},
|
||||
"mentorship": {
|
||||
"title": "Mentorship",
|
||||
"description": "New members are paired with 'Mentors' to guide them through a probationary period."
|
||||
},
|
||||
"6": { "title": "Label", "description": "Additional membership approach." },
|
||||
"7": { "title": "Label", "description": "Additional membership approach." },
|
||||
"8": { "title": "Label", "description": "Additional membership approach." }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"_comment": "Create flow — right rail / decision approaches (Figma Flow — Right Rail `20523:23509`)",
|
||||
"sidebar": {
|
||||
"title": "How should conflicts be resolved?",
|
||||
"descriptionBefore": "You can also combine or ",
|
||||
"descriptionLink": "add",
|
||||
"descriptionAfter": " new approaches to the list"
|
||||
"title": "How should this community make difficult decisions?",
|
||||
"descriptionBefore": "Select as many as you need to describe how your group makes decisions. You can also ",
|
||||
"descriptionLinkLabel": "add",
|
||||
"descriptionAfter": " new decision making approaches or interact with the categories below to filter."
|
||||
},
|
||||
"messageBox": {
|
||||
"title": "Consider defining approaches to steward key resources:",
|
||||
@@ -16,99 +17,97 @@
|
||||
},
|
||||
"cardStack": {
|
||||
"toggleSeeAll": "See all decision approaches",
|
||||
"toggleShowLess": "Show less",
|
||||
"emptyTitle": "",
|
||||
"emptyDescription": ""
|
||||
"toggleShowLess": "Show less"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"id": "mediation",
|
||||
"label": "Mediation",
|
||||
"supportText": "Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
"id": "lazy-consensus",
|
||||
"label": "Lazy Consensus",
|
||||
"supportText": "A decision is assumed approved unless objections are raised within a specified timeframe.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "facilitation",
|
||||
"label": "Facilitated dialogue",
|
||||
"supportText": "Structured sessions where parties collaboratively resolve disputes.",
|
||||
"id": "do-ocracy",
|
||||
"label": "Do-ocracy",
|
||||
"supportText": "Decisions are made by those who take initiative and carry out the work.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "invite-only",
|
||||
"label": "Invite-only",
|
||||
"supportText": "Private discussions with selected participants.",
|
||||
"id": "consensus-decision-making",
|
||||
"label": "Consensus Decision-Making",
|
||||
"supportText": "All members must agree. Best for important decisions in small groups. Does not work well for low stakes decisions.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "arbitration",
|
||||
"label": "Arbitration",
|
||||
"supportText": "Arbitrators are chosen specifically for a particular case.",
|
||||
"id": "rotational-leadership",
|
||||
"label": "Rotational Leadership",
|
||||
"supportText": "Decision-making responsibilities rotate among members.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "direct-dialogue",
|
||||
"label": "Direct dialogue",
|
||||
"supportText": "Encouraging direct, respectful dialogue between those involved.",
|
||||
"id": "modified-consensus",
|
||||
"label": "Modified Consensus",
|
||||
"supportText": "Attempts to reach full agreement first, but falls back to voting if consensus isn’t possible.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "label-1",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-2",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-3",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-4",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-5",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-6",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-7",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-8",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-9",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-10",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"supportText": "Additional decision approach.",
|
||||
"recommended": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ import profile from "./pages/profile.json";
|
||||
import navigation from "./navigation.json";
|
||||
import metadata from "./metadata.json";
|
||||
import communication from "./create/communication.json";
|
||||
import createMembership from "./create/membership.json";
|
||||
import createConflictManagement from "./create/conflictManagement.json";
|
||||
import createInformational from "./create/informational.json";
|
||||
import createCommunityName from "./create/communityName.json";
|
||||
import createCommunitySize from "./create/communitySize.json";
|
||||
@@ -61,6 +63,8 @@ export default {
|
||||
},
|
||||
create: {
|
||||
communication,
|
||||
membership: createMembership,
|
||||
conflictManagement: createConflictManagement,
|
||||
informational: createInformational,
|
||||
communityName: createCommunityName,
|
||||
communitySize: createCommunitySize,
|
||||
|
||||
@@ -97,6 +97,15 @@ const nextConfig = {
|
||||
|
||||
return config;
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/create/right-rail",
|
||||
destination: "/create/decision-approaches",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const withMDX = createMDX({
|
||||
|
||||
+5
-5
@@ -1,20 +1,20 @@
|
||||
import { CardsScreen } from "../../app/create/screens/card/CardsScreen";
|
||||
import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
|
||||
|
||||
export default {
|
||||
title: "Pages/Create Flow/Cards",
|
||||
component: CardsScreen,
|
||||
title: "Pages/Create Flow/Communication methods",
|
||||
component: CommunicationMethodsScreen,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Communication / card selection step with modals and responsive layout.",
|
||||
"Communication methods step (`/create/communication-methods`): card stack, modals, responsive layout.",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center bg-black">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
@@ -31,6 +31,7 @@ export default {
|
||||
"2-0",
|
||||
"2-1",
|
||||
"2-2",
|
||||
"2-3",
|
||||
"3-0",
|
||||
"3-1",
|
||||
"3-2",
|
||||
@@ -86,6 +87,7 @@ export const AllStates = {
|
||||
<ProportionBar {..._args} progress="2-0" />
|
||||
<ProportionBar {..._args} progress="2-1" />
|
||||
<ProportionBar {..._args} progress="2-2" />
|
||||
<ProportionBar {..._args} progress="2-3" />
|
||||
<ProportionBar {..._args} progress="3-0" />
|
||||
<ProportionBar {..._args} progress="3-1" />
|
||||
<ProportionBar {..._args} progress="3-2" />
|
||||
|
||||
@@ -40,8 +40,8 @@ describe("ProportionBar (behavioral tests)", () => {
|
||||
it("renders proportion bar with correct progress value", () => {
|
||||
render(<ProportionBar progress="2-1" />);
|
||||
const progressbar = screen.getByRole("progressbar");
|
||||
// 2-1: First section full (1) + second section 1/3 filled = 1 + 1/3 ≈ 1.333
|
||||
expect(progressbar).toHaveAttribute("aria-valuenow", "1.3333333333333333");
|
||||
// 2-1 (Figma `17861:33241`): first section full + second section 1/4 filled = 1.25.
|
||||
expect(progressbar).toHaveAttribute("aria-valuenow", "1.25");
|
||||
expect(progressbar).toHaveAttribute("aria-valuemin", "0");
|
||||
expect(progressbar).toHaveAttribute("aria-valuemax", "3");
|
||||
});
|
||||
@@ -63,7 +63,8 @@ describe("ProportionBar (behavioral tests)", () => {
|
||||
{ progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled
|
||||
{ progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled)
|
||||
{ progress: "2-0" as const, expected: 1 }, // First section full, second empty
|
||||
{ progress: "2-2" as const, expected: 1 + 2 / 3 }, // First section full, second section 2/3 filled
|
||||
{ progress: "2-2" as const, expected: 1 + 1 / 2 }, // 1 + 1/2 per Figma `18861:15250`
|
||||
{ progress: "2-3" as const, expected: 1 + 3 / 4 }, // 1 + 3/4 per Figma `21434:17632`
|
||||
{ progress: "3-0" as const, expected: 2 }, // First two sections full, third empty
|
||||
{ progress: "3-2" as const, expected: 2 + 2 / 3 }, // First two sections full, third section 2/3 filled
|
||||
];
|
||||
|
||||
@@ -6,16 +6,16 @@ import {
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, test, expect, afterEach } from "vitest";
|
||||
import { CardsScreen } from "../../app/create/screens/card/CardsScreen";
|
||||
import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Create flow cards page", () => {
|
||||
describe("Create flow communication-methods page", () => {
|
||||
test("clicking a card opens the Create modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CardsScreen />);
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
const signalCards = screen.getAllByRole("button", {
|
||||
name: /Signal: Encrypted messaging/,
|
||||
@@ -29,7 +29,7 @@ describe("Create flow cards page", () => {
|
||||
});
|
||||
|
||||
test("renders without error", () => {
|
||||
render(<CardsScreen />);
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -39,13 +39,12 @@ describe("Create flow cards page", () => {
|
||||
});
|
||||
|
||||
test("renders HeaderLockup and CardStack content", () => {
|
||||
render(<CardsScreen />);
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"You can select multiple methods for different needs or add your own",
|
||||
),
|
||||
screen.getByText(/You can select multiple methods for different needs or/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "See all communication approaches" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -53,7 +52,7 @@ describe("Create flow cards page", () => {
|
||||
|
||||
test("toggle expands and shows Show less", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CardsScreen />);
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: "See all communication approaches",
|
||||
@@ -18,7 +18,7 @@ describe("Create flow right-rail page", () => {
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "How should conflicts be resolved?",
|
||||
name: "How should this community make difficult decisions?",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
@@ -30,9 +30,9 @@ describe("Create flow right-rail page", () => {
|
||||
if (element?.tagName !== "P") return false;
|
||||
const text = element.textContent ?? "";
|
||||
return (
|
||||
text.includes("You can also combine or") &&
|
||||
text.includes("Select as many as you need") &&
|
||||
text.includes("add") &&
|
||||
text.includes("new approaches to the list")
|
||||
text.includes("new decision making approaches")
|
||||
);
|
||||
});
|
||||
expect(description).toBeInTheDocument();
|
||||
@@ -77,17 +77,17 @@ describe("Create flow right-rail page", () => {
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /Mediation: Collaborative work to reach a resolution/,
|
||||
name: /Lazy Consensus: A decision is assumed approved/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /Facilitated dialogue: Structured sessions/,
|
||||
name: /Do-ocracy: Decisions are made by those who take initiative/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /Invite-only: Private discussions with selected participants/,
|
||||
name: /Consensus Decision-Making: All members must agree/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
@@ -123,10 +123,10 @@ describe("Create flow right-rail page", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RightRailScreen />);
|
||||
|
||||
const mediationCard = screen.getByRole("button", {
|
||||
name: /Mediation: Collaborative work to reach a resolution/,
|
||||
const card = screen.getByRole("button", {
|
||||
name: /Lazy Consensus: A decision is assumed approved/,
|
||||
});
|
||||
await user.click(mediationCard);
|
||||
await user.click(card);
|
||||
|
||||
expect(screen.getByText("SELECTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -26,4 +26,28 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
|
||||
"2-0",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 2-1 on communication-methods", () => {
|
||||
expect(
|
||||
getProportionBarProgressForCreateFlowStep("communication-methods"),
|
||||
).toBe("2-1");
|
||||
});
|
||||
|
||||
it("uses 2-2 on membership-methods", () => {
|
||||
expect(
|
||||
getProportionBarProgressForCreateFlowStep("membership-methods"),
|
||||
).toBe("2-2");
|
||||
});
|
||||
|
||||
it("uses 2-3 on decision-approaches (Figma Flow — Right Rail)", () => {
|
||||
expect(
|
||||
getProportionBarProgressForCreateFlowStep("decision-approaches"),
|
||||
).toBe("2-3");
|
||||
});
|
||||
|
||||
it("uses 3-0 on conflict-management (start of Review segment)", () => {
|
||||
expect(
|
||||
getProportionBarProgressForCreateFlowStep("conflict-management"),
|
||||
).toBe("3-0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("createFlowStateSchema", () => {
|
||||
it("accepts known fields and passthrough keys", () => {
|
||||
const r = createFlowStateSchema.safeParse({
|
||||
title: "My rule",
|
||||
currentStep: "cards",
|
||||
currentStep: "communication-methods",
|
||||
customField: { nested: [1, 2] },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
|
||||
@@ -16,7 +16,10 @@ describe("flowSteps", () => {
|
||||
});
|
||||
|
||||
it("getNextStep returns next step in order", () => {
|
||||
expect(getNextStep("right-rail")).toBe("confirm-stakeholders");
|
||||
expect(getNextStep("communication-methods")).toBe("membership-methods");
|
||||
expect(getNextStep("membership-methods")).toBe("decision-approaches");
|
||||
expect(getNextStep("decision-approaches")).toBe("conflict-management");
|
||||
expect(getNextStep("conflict-management")).toBe("confirm-stakeholders");
|
||||
expect(getNextStep("confirm-stakeholders")).toBe("final-review");
|
||||
});
|
||||
|
||||
@@ -67,6 +70,6 @@ describe("flowSteps", () => {
|
||||
const opts = { skipCommunitySave: true } as const;
|
||||
expect(getNextStep("community-size", opts)).toBe("community-upload");
|
||||
expect(getNextStep("review", opts)).toBe("core-values");
|
||||
expect(getPreviousStep("cards", opts)).toBe("core-values");
|
||||
expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,27 +2,12 @@ import { describe, it, expect } from "vitest";
|
||||
import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState";
|
||||
|
||||
describe("migrateLegacyCreateFlowState", () => {
|
||||
it("maps communityReflection to communitySaveEmail when save email empty", () => {
|
||||
it("passes through object payloads", () => {
|
||||
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",
|
||||
currentStep: "community-save",
|
||||
});
|
||||
expect(out.title).toBe("T");
|
||||
expect(out.currentStep).toBe("community-save");
|
||||
});
|
||||
|
||||
@@ -30,4 +15,13 @@ describe("migrateLegacyCreateFlowState", () => {
|
||||
expect(migrateLegacyCreateFlowState(null)).toEqual({});
|
||||
expect(migrateLegacyCreateFlowState(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("renames legacy right-rail step to decision-approaches", () => {
|
||||
const out = migrateLegacyCreateFlowState({
|
||||
currentStep: "right-rail",
|
||||
title: "T",
|
||||
});
|
||||
expect(out.currentStep).toBe("decision-approaches");
|
||||
expect(out.title).toBe("T");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user