Create custom flow UI
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user