Update create flow pages

This commit is contained in:
adilallo
2026-04-13 18:24:13 -06:00
parent a39b4aa04b
commit a0de78c020
66 changed files with 1028 additions and 538 deletions
@@ -0,0 +1,75 @@
"use client";
import type { ReactNode } from "react";
import type { CreateFlowStep } from "../types";
import { InformationalScreen } from "./informational/InformationalScreen";
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CardsScreen } from "./card/CardsScreen";
import { RightRailScreen } from "./right-rail/RightRailScreen";
import { CompletedScreen } from "./completed/CompletedScreen";
/**
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/).
*/
export function CreateFlowScreenView({
screenId,
}: {
screenId: CreateFlowStep;
}): ReactNode {
switch (screenId) {
case "informational":
return <InformationalScreen />;
case "community-name":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
stateField="title"
maxLength={48}
/>
);
case "community-size":
return <CommunitySizeSelectScreen />;
case "community-context":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityContext"
stateField="communityContext"
maxLength={2000}
/>
);
case "community-structure":
return <CommunityStructureSelectScreen />;
case "community-upload":
return <CommunityUploadScreen />;
case "community-reflection":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityReflection"
stateField="communityReflection"
maxLength={2000}
/>
);
case "review":
return <CommunityReviewScreen />;
case "cards":
return <CardsScreen />;
case "right-rail":
return <RightRailScreen />;
case "confirm-stakeholders":
return <ConfirmStakeholdersScreen />;
case "final-review":
return <FinalReviewScreen />;
case "completed":
return <CompletedScreen />;
default: {
const _exhaustive: never = screenId;
return _exhaustive;
}
}
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
const ADD_PLATFORM_CARD_IDS = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
] as const;
const SECTION_FIELDS = [
"corePrinciple",
"logisticsAdmin",
"codeOfConduct",
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const COMMUNICATION_CARD_ORDER = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
"4",
"5",
"6",
"7",
] as const;
function CreateModalSection({
title,
value: _value,
onChange,
}: {
title: string;
value: string;
onChange: (_value: string) => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold leading-tight text-[var(--color-content-default-primary)]">
{title}
</h3>
<span
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
aria-hidden
>
?
</span>
</div>
<TextArea
formHeader={false}
value={_value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={6}
appearance="embedded"
/>
</div>
);
}
function AddPlatformModalContent({
platformCardId,
}: {
platformCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const comm = m.create.communication;
const modal = comm.modals[platformCardId as keyof typeof comm.modals];
const defaults =
modal && "sections" in modal
? modal.sections
: {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
};
const [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
>(() => ({
corePrinciple: defaults.corePrinciple,
logisticsAdmin: defaults.logisticsAdmin,
codeOfConduct: defaults.codeOfConduct,
}));
const updateSection = useCallback(
(key: SectionField, value: string) => {
markCreateFlowInteraction();
setSectionValues((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
if (!modal || !("sections" in modal)) return null;
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<CreateModalSection
key={field}
title={comm.sectionHeadings[field]}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
))}
</div>
);
}
function isAddPlatformCard(cardId: string | null): boolean {
return (
cardId !== null &&
(ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId)
);
}
export function CardsScreen() {
const m = useMessages();
const comm = m.create.communication;
const mdUp = useCreateFlowMdUp();
const { 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 sampleCards = useMemo(
() =>
COMMUNICATION_CARD_ORDER.map((id) => {
const row = comm.cards[id as keyof typeof comm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[comm],
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded
? comm.page.expandedDescription
: comm.page.compactDescription;
const modalConfig =
pendingCardId && pendingCardId in comm.modals
? (() => {
const modal =
comm.modals[pendingCardId as keyof typeof comm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds((prev) =>
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col gap-6">
<div className="min-w-0">
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className="min-w-0 w-full">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
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}
>
{isAddPlatformCard(pendingCardId) && pendingCardId ? (
<AddPlatformModalContent
key={pendingCardId}
platformCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../../components/modals/Alert";
import { useMessages } from "../../../contexts/MessagesContext";
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
export function CompletedScreen() {
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.completed;
const fallbackSections = useMemo(
() =>
[...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[],
[completed.fallbackDocumentSections],
);
const [toastDismissed, setToastDismissed] = useState(false);
const [headerTitle, setHeaderTitle] = useState(
() => completed.fallbackTitle,
);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
>(() => completed.fallbackDescription);
const [documentSections, setDocumentSections] =
useState<CommunityRuleDocumentSection[]>(fallbackSections);
useEffect(() => {
const stored = readLastPublishedRule();
if (!stored) return;
const parsed = parseDocumentSectionsForDisplay(stored.document);
if (parsed.length === 0) return;
queueMicrotask(() => {
setDocumentSections(parsed);
setHeaderTitle(stored.title);
const sum =
typeof stored.summary === "string" ? stored.summary.trim() : "";
setHeaderDescription(sum.length > 0 ? sum : undefined);
});
}, []);
const toast = !toastDismissed ? (
<div
className="fixed bottom-0 left-0 right-0 z-10 w-full"
role="status"
aria-live="polite"
>
<Alert
type="toast"
status="default"
title={completed.toastTitle}
description={completed.toastDescription}
hasLeadingIcon
hasBodyText
onClose={() => setToastDismissed(true)}
className="w-full"
/>
</div>
) : null;
return (
<>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div className="mx-auto grid min-h-0 w-full max-w-[1280px] grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0">
<div className="flex min-w-0 flex-col justify-start overflow-hidden md:justify-center md:pb-8">
<CreateFlowHeaderLockup
title={headerTitle}
description={headerDescription}
justification="left"
size="L"
palette="inverse"
/>
</div>
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden md:overflow-y-auto">
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
aria-hidden
/>
<div className="min-w-0 py-0 md:pb-8">
<CommunityRuleDocument
sections={documentSections}
useCardStyle={!mdUp}
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
/>
</div>
</div>
</div>
</div>
{toast}
</>
);
}
@@ -0,0 +1,44 @@
"use client";
import NumberedList from "../../../components/type/NumberedList";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/** Create Community — frame 1 (Figma 20094-16005). */
export function InformationalScreen() {
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.informational");
const items = [
{
title: t("steps.0.title"),
description: t("steps.0.description"),
},
{
title: t("steps.1.title"),
description: t("steps.1.description"),
},
{
title: t("steps.2.title"),
description: t("steps.2.description"),
},
];
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div className="flex w-full max-w-[640px] flex-col items-center gap-12">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
<NumberedList items={items} size={mdUp ? "M" : "S"} />
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,42 @@
"use client";
import RuleCard from "../../../components/cards/RuleCard";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/** Create Community — frame 8 (Figma 19706-12135); URL segment `review`. */
export function CommunityReviewScreen() {
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.review");
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-1400"
>
<div className="flex w-full min-w-0 flex-col gap-4 md:grid md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)]">
<div className="min-w-0">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="min-w-0 w-full">
<RuleCard
title={t("ruleCard.title")}
description={t("ruleCard.description")}
size={mdUp ? "L" : "M"}
expanded={false}
backgroundColor="bg-[#c9fef9]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={t("ruleCard.logoAlt")}
className="rounded-[16px]"
/>
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useMemo } from "react";
import RuleCard from "../../../components/cards/RuleCard";
import type { Category } from "../../../components/cards/RuleCard/RuleCard.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
function buildFinalReviewCategories(
rows: { name: string; chips: string[] }[],
): Category[] {
return rows.map((cat) => ({
name: cat.name,
chipOptions: cat.chips.map((label, idx) => ({
id: `${cat.name}-${idx}`,
label,
state: "unselected" as const,
})),
}));
}
export function FinalReviewScreen() {
const { state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.finalReview");
const m = useMessages();
const finalReviewCategories = useMemo(
() => buildFinalReviewCategories(m.create.finalReview.categories),
[m.create.finalReview.categories],
);
const ruleCardTitle = useMemo(() => {
const raw = typeof state.title === "string" ? state.title.trim() : "";
return raw.length > 0 ? raw : t("ruleCardTitleFallback");
}, [state.title, t]);
const ruleCardDescription = useMemo(() => {
const raw =
typeof state.summary === "string" ? state.summary.trim() : "";
return raw.length > 0 ? raw : t("ruleCardDescriptionFallback");
}, [state.summary, t]);
return (
<CreateFlowLockupCardStepShell
lockupTitle={t("title")}
lockupDescription={t("description")}
>
<RuleCard
title={ruleCardTitle}
description={ruleCardDescription}
size={mdUp ? "L" : "M"}
expanded={true}
backgroundColor="bg-[#c9fef9]"
logoUrl="/assets/Vector_MutualAid.svg"
logoAlt={ruleCardTitle}
categories={finalReviewCategories}
className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
onClick={() => {}}
/>
</CreateFlowLockupCardStepShell>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
})),
[rr.cards],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<span className="underline">{rr.sidebar.descriptionLink}</span>
{rr.sidebar.descriptionAfter}
</>
);
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
},
[markCreateFlowInteraction],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, [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 max-w-[1280px] shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-12 md:pb-8">
<div className="flex min-w-0 flex-col items-stretch justify-start overflow-hidden md:justify-center">
<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 min-w-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto">
<div className="flex min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
<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>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState, useMemo, useEffect, type Dispatch, type SetStateAction } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
confirmState: "Unselected" | "Selected",
onInteraction?: () => void,
) {
const touch = () => onInteraction?.();
return {
onAddClick: () => {
touch();
setList((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
]);
},
onCustomChipConfirm: (chipId: string, value: string) => {
touch();
setList((prev) =>
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: confirmState }
: opt,
),
);
},
onCustomChipClose: (chipId: string) => {
touch();
setList((prev) => prev.filter((o) => o.id !== chipId));
},
};
}
function chipRowsFromLabels(
rows: readonly { label: string }[],
): ChipOption[] {
return rows.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "Unselected" as const,
}));
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "Selected")
.map((o) => o.id);
}
/** Create Community — frame 3 (Figma 20094-18244). */
export function CommunitySizeSelectScreen() {
const m = useMessages();
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.communitySize");
const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[]
>(() => {
const base = chipRowsFromLabels(m.create.communitySize.communitySizes);
const selected = new Set(state.selectedCommunitySizeIds ?? []);
return base.map((opt) => ({
...opt,
state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const),
}));
});
useEffect(() => {
const selected = new Set(state.selectedCommunitySizeIds ?? []);
setCommunitySizeOptions((prev) =>
prev.map((opt) =>
opt.state === "Custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
},
),
);
}, [state.selectedCommunitySizeIds]);
const communityCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setCommunitySizeOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const persistSelection = (next: ChipOption[]) => {
markCreateFlowInteraction();
setCommunitySizeOptions(next);
updateState({
selectedCommunitySizeIds: selectedIdsFromOptions(next),
});
};
const handleCommunitySizeClick = (chipId: string) => {
const next: ChipOption[] = communitySizeOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistSelection(next);
};
const multiLabel = t("multiSelect.label");
const addText = t("multiSelect.addButtonText");
const multiSelectBlock = (
<MultiSelect
label={multiLabel}
size="S"
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
{...communityCustomHandlers}
addButton={true}
addButtonText={addText}
/>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
{mdUp ? (
<div className="flex w-full max-w-[1280px] items-center justify-center gap-[var(--measures-spacing-1200,48px)]">
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start justify-center gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start gap-[var(--measures-spacing-800,32px)]">
{multiSelectBlock}
</div>
</div>
) : (
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-400,16px)]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
{multiSelectBlock}
</div>
)}
</CreateFlowStepShell>
);
}
@@ -0,0 +1,238 @@
"use client";
import {
useState,
useMemo,
useEffect,
type Dispatch,
type SetStateAction,
} from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
confirmState: "Unselected" | "Selected",
onInteraction?: () => void,
) {
const touch = () => onInteraction?.();
return {
onAddClick: () => {
touch();
setList((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
]);
},
onCustomChipConfirm: (chipId: string, value: string) => {
touch();
setList((prev) =>
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: confirmState }
: opt,
),
);
},
onCustomChipClose: (chipId: string) => {
touch();
setList((prev) => prev.filter((o) => o.id !== chipId));
},
};
}
function chipRowsFromLabels(
rows: readonly { label: string }[],
): ChipOption[] {
return rows.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "Unselected" as const,
}));
}
function applySavedSelection(
options: ChipOption[],
saved: string[] | undefined,
): ChipOption[] {
const selected = new Set(saved ?? []);
return options.map((opt) =>
opt.state === "Custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
},
);
}
/** Create Community — frame 5 (Figma 20094-41317). */
export function CommunityStructureSelectScreen() {
const m = useMessages();
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.communityStructure");
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
ChipOption[]
>(() =>
applySavedSelection(
chipRowsFromLabels(m.create.communityStructure.organizationTypes),
state.selectedOrganizationTypeIds,
),
);
const [governanceStyleOptions, setGovernanceStyleOptions] = useState<
ChipOption[]
>(() =>
applySavedSelection(
chipRowsFromLabels(m.create.communityStructure.governanceStyles),
state.selectedGovernanceStyleIds,
),
);
useEffect(() => {
setOrganizationTypeOptions((prev) =>
applySavedSelection(prev, state.selectedOrganizationTypeIds),
);
}, [state.selectedOrganizationTypeIds]);
useEffect(() => {
setGovernanceStyleOptions((prev) =>
applySavedSelection(prev, state.selectedGovernanceStyleIds),
);
}, [state.selectedGovernanceStyleIds]);
const organizationCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setOrganizationTypeOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const governanceCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setGovernanceStyleOptions,
"Unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const persistOrg = (next: ChipOption[]) => {
markCreateFlowInteraction();
setOrganizationTypeOptions(next);
updateState({
selectedOrganizationTypeIds: next
.filter((o) => o.state === "Selected")
.map((o) => o.id),
});
};
const persistGov = (next: ChipOption[]) => {
markCreateFlowInteraction();
setGovernanceStyleOptions(next);
updateState({
selectedGovernanceStyleIds: next
.filter((o) => o.state === "Selected")
.map((o) => o.id),
});
};
const handleOrganizationTypeClick = (chipId: string) => {
const next: ChipOption[] = organizationTypeOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistOrg(next);
};
const handleGovernanceStyleClick = (chipId: string) => {
const next: ChipOption[] = governanceStyleOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistGov(next);
};
const multiLabel = t("multiSelect.label");
const addText = t("multiSelect.addButtonText");
const multiSelectBlock = (
<>
<MultiSelect
label={multiLabel}
size="S"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
{...organizationCustomHandlers}
addButton={true}
addButtonText={addText}
/>
<MultiSelect
label={multiLabel}
size="S"
options={governanceStyleOptions}
onChipClick={handleGovernanceStyleClick}
{...governanceCustomHandlers}
addButton={true}
addButtonText={addText}
/>
</>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
{mdUp ? (
<div className="flex w-full max-w-[1280px] items-center justify-center gap-[var(--measures-spacing-1200,48px)]">
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start justify-center gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
</div>
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start gap-[var(--measures-spacing-800,32px)]">
{multiSelectBlock}
</div>
</div>
) : (
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-400,16px)]">
<CreateFlowHeaderLockup
title={t("header.title")}
description={t("header.description")}
justification="left"
/>
{multiSelectBlock}
</div>
)}
</CreateFlowStepShell>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import Alert from "../../../components/modals/Alert";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
export function ConfirmStakeholdersScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
const t = useTranslation("create.confirmStakeholders");
const [toastDismissed, setToastDismissed] = useState(false);
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
[],
);
const handleAddStakeholder = () => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
]);
};
const handleCustomChipConfirm = (chipId: string, value: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) =>
prev.map((opt) =>
opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt,
),
);
};
const handleCustomChipClose = (chipId: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
};
const handleChipClick = (chipId: string) => {
markCreateFlowInteraction();
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
};
return (
<>
<CreateFlowStepShell
variant="centeredNarrowBottomPad"
contentTopBelowMd="space-1400"
>
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-300,12px)]">
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
</div>
<MultiSelect
formHeader={false}
showHelpIcon={false}
size="S"
options={stakeholderOptions}
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
onCustomChipConfirm={handleCustomChipConfirm}
onCustomChipClose={handleCustomChipClose}
addButton
addButtonText={t("addStakeholder")}
/>
</div>
</CreateFlowStepShell>
{!toastDismissed && (
<div
className="fixed bottom-[5.25rem] left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 md:bottom-[5.5rem]"
role="status"
aria-live="polite"
>
<Alert
type="banner"
status="positive"
title={t("draftToastTitle")}
hasLeadingIcon={false}
hasBodyText={false}
onClose={() => setToastDismissed(true)}
className="w-full !px-[var(--space-600,24px)] !py-[var(--space-400,16px)] md:!py-4"
/>
</div>
)}
</>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useState, useEffect } from "react";
import TextInput from "../../../components/controls/TextInput";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
messageNamespace: string;
stateField: CreateFlowTextStateField;
maxLength: number;
};
/**
* Shared narrow-column + TextInput pattern for Create Community text frames.
*/
export function CreateFlowTextFieldScreen({
messageNamespace,
stateField,
maxLength,
}: Props) {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation(messageNamespace);
const readFromState = (): string => {
const raw = state[stateField];
return typeof raw === "string" ? raw : "";
};
const [value, setValue] = useState(() => readFromState());
useEffect(() => {
const incoming = readFromState();
if (incoming.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
setValue((prev) => (prev === "" ? incoming : prev));
}, [state, stateField]);
const characterCount = value.length;
const hint = t("characterCountTemplate")
.replace("{current}", String(characterCount))
.replace("{max}", String(maxLength));
return (
<CreateFlowStepShell variant="centeredNarrow">
<div className="flex w-full max-w-[640px] flex-col items-start gap-[18px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
<div className="w-full">
<TextInput
placeholder={t("placeholder")}
value={value}
onChange={(e) => {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ [stateField]: v } as Record<string, string>);
}}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
textHint={hint}
maxLength={maxLength}
/>
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,41 @@
"use client";
import Upload from "../../../components/controls/Upload";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
/** Create Community — frame 6 (Figma 20094-41524). */
export function CommunityUploadScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.communityUpload");
const handleUploadClick = () => {
markCreateFlowInteraction();
};
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div className="flex w-full max-w-[640px] flex-col items-center gap-[18px]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification={mdUp ? "center" : "left"}
/>
<div className="w-full max-w-[474px]">
<Upload
active={true}
showHelpIcon={true}
onClick={handleUploadClick}
/>
</div>
</div>
</CreateFlowStepShell>
);
}