Walk Create Community through the Figma screens and restore a chosen logo after reload, so a guest can leave without saving until they reach the email step.

This commit is contained in:
adilallo committed 2026-11-05 16:14:04 -07:00
1 parent ca1f58a50c
commit 2348a9e94f
61 files changed
+1110 -421

No files matched your search

+161 -46
View File
@@ -87,7 +87,9 @@ import {
} from "./utils/customRuleConfirmFooterSteps";
import {
getDefaultFooterLabel,
isCommunityNameStepBlocked,
isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked,
} from "./utils/createFlowFooterLabels";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
@@ -102,6 +104,10 @@ import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
import {
CreateFlowContinueAttemptProvider,
useCreateFlowContinueAttempt,
} from "./context/CreateFlowContinueAttemptContext";
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
@@ -128,6 +134,7 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
return (
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowContinueAttemptProvider>
<Suspense fallback={null}>
<CreateFlowLayoutContent
sessionUser={sessionUser}
@@ -136,6 +143,7 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
{children}
</CreateFlowLayoutContent>
</Suspense>
</CreateFlowContinueAttemptProvider>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider>
);
@@ -191,13 +199,14 @@ function CreateFlowLayoutContent({
editingPublishedRuleIdTrimmed.length > 0;
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt();
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
useState(false);
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
string | null
>(null);
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
useState(false);
const [saveSentEmail, setSaveSentEmail] = useState<string | null>(null);
const [isOffline, setIsOffline] = useState(false);
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
key: string;
status: "positive" | "danger" | "warning";
@@ -208,24 +217,29 @@ function CreateFlowLayoutContent({
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null,
);
type LeaveConfirmChoice = "leave" | "save" | "cancel";
const leaveConfirmResolverRef = useRef<
((choice: LeaveConfirmChoice) => void) | null
>(null);
const confirmLeave = useCallback(
const requestLeaveChoice = useCallback(
() =>
new Promise<boolean>((resolve) => {
new Promise<LeaveConfirmChoice>((resolve) => {
leaveConfirmResolverRef.current = resolve;
setLeaveConfirmOpen(true);
}),
[],
);
const closeLeaveConfirm = useCallback((proceed: boolean) => {
const confirmLeave = useCallback(async () => {
return (await requestLeaveChoice()) === "leave";
}, [requestLeaveChoice]);
const closeLeaveConfirm = useCallback((choice: LeaveConfirmChoice) => {
setLeaveConfirmOpen(false);
const resolve = leaveConfirmResolverRef.current;
leaveConfirmResolverRef.current = null;
resolve?.(proceed);
resolve?.(choice);
}, []);
const {
@@ -359,6 +373,25 @@ function CreateFlowLayoutContent({
return;
}
if (!opts?.saveDraft) {
const choice = await requestLeaveChoice();
if (choice === "cancel") return;
if (choice === "save") {
if (sessionUser) {
await runAuthenticatedExit({ saveDraft: true });
return;
}
router.push(createFlowStepPath("community-save"));
return;
}
if (sessionUser) {
await runAuthenticatedExit({ saveDraft: false, skipConfirm: true });
return;
}
router.push(CREATE_ROUTES.root);
return;
}
if (sessionUser === null) {
const returnToTemplateReview =
templateReviewSlug != null
@@ -388,10 +421,25 @@ function CreateFlowLayoutContent({
}
}, [sessionResolved, sessionUser, currentStep, router]);
useEffect(() => {
clearAttempted();
}, [currentStep, clearAttempted]);
useEffect(() => {
const onOffline = () => setIsOffline(true);
const onOnline = () => setIsOffline(false);
setIsOffline(typeof navigator !== "undefined" && !navigator.onLine);
window.addEventListener("offline", onOffline);
window.addEventListener("online", onOnline);
return () => {
window.removeEventListener("offline", onOffline);
window.removeEventListener("online", onOnline);
};
}, []);
useEffect(() => {
if (currentStep !== "community-save") {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
setCommunitySaveMagicLinkSubmitting(false);
}
}, [currentStep]);
@@ -495,7 +543,6 @@ function CreateFlowLayoutContent({
formEl?: HTMLFormElement | null,
) => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
const namedEmail = formEl?.elements.namedItem("email");
const emailField =
namedEmail instanceof HTMLInputElement
@@ -505,10 +552,6 @@ function CreateFlowLayoutContent({
: document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
);
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw =
emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
@@ -544,13 +587,30 @@ function CreateFlowLayoutContent({
}
setTransferPendingFlag();
updateState({ communitySaveEmail: trimmed });
setCommunitySaveMagicLinkSuccess(true);
setSaveSentEmail(trimmed);
goToNextStep();
} catch {
setCommunitySaveMagicLinkError(tLogin("errors.network"));
} finally {
setCommunitySaveMagicLinkSubmitting(false);
}
}, [state, currentStep, tLogin, updateState]);
}, [state, currentStep, tLogin, updateState, goToNextStep]);
const handleResendSaveLink = useCallback(async () => {
if (!saveSentEmail) return;
setCommunitySaveMagicLinkError(null);
const nextPath = safeInternalPath("/create/review?syncDraft=1");
const draftPayload = buildCreateFlowDraftPayload(state, currentStep);
const result = await requestMagicLink(saveSentEmail, nextPath, {
...draftPayload,
communitySaveEmail: saveSentEmail,
});
if (result.ok === false) {
setCommunitySaveMagicLinkError(
result.error || tLogin("errors.generic"),
);
}
}, [saveSentEmail, state, currentStep, tLogin]);
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "decision-approaches";
@@ -589,6 +649,11 @@ function CreateFlowLayoutContent({
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
);
const hasCommunityLogo =
(typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0) ||
(typeof state.communityAvatarFileName === "string" &&
state.communityAvatarFileName.trim().length > 0);
/**
* Custom Rule stage "confirm selection" steps: all five render the same
@@ -617,6 +682,9 @@ function CreateFlowLayoutContent({
title: string;
description?: string;
onClose: () => void;
actionLabel?: string;
onAction?: () => void;
type?: "banner" | "toast";
}> = [
draftSaveBannerMessage
? {
@@ -655,13 +723,28 @@ function CreateFlowLayoutContent({
onClose: () => setCommunitySaveMagicLinkError(null),
}
: null,
communitySaveMagicLinkSuccess
saveSentEmail
? {
key: "magicLinkSuccess",
status: "positive" as const,
title: communitySaveMessages.magicLinkSuccessTitle,
description: communitySaveMessages.magicLinkSuccessDescription,
onClose: () => setCommunitySaveMagicLinkSuccess(false),
type: "toast" as const,
title: communitySaveMessages.sentToastTitle,
description: communitySaveMessages.sentToastDescription.replace(
"{email}",
saveSentEmail,
),
actionLabel: communitySaveMessages.sentToastAction,
onAction: () => void handleResendSaveLink(),
onClose: () => setSaveSentEmail(null),
}
: null,
isOffline
? {
key: "offline",
status: "warning" as const,
title: messages.create.topNav.offlineTitle,
description: messages.create.topNav.offlineDescription,
onClose: () => setIsOffline(false),
}
: null,
completedFlowBanner
@@ -688,11 +771,13 @@ function CreateFlowLayoutContent({
className="pointer-events-auto mx-auto w-full max-w-[960px]"
>
<Alert
type="banner"
type={b.type ?? "banner"}
status={b.status}
title={b.title}
description={b.description}
onClose={b.onClose}
actionLabel={b.actionLabel}
onAction={b.onAction}
className="w-full"
/>
</div>
@@ -725,22 +810,23 @@ function CreateFlowLayoutContent({
/>
<Create
isOpen={leaveConfirmOpen}
onClose={() => closeLeaveConfirm(false)}
onClose={() => closeLeaveConfirm("cancel")}
title={messages.create.topNav.leaveConfirmTitle}
description={messages.create.topNav.leaveConfirmDescription}
showBackButton={false}
showNextButton
nextButtonText={messages.create.topNav.leaveConfirmProceed}
onNext={() => closeLeaveConfirm(true)}
nextButtonText={messages.create.topNav.leaveConfirmSaveProgress}
onNext={() => closeLeaveConfirm("save")}
footerContent={
<div className={MODAL_FOOTER_START_SLOT_CLASS}>
<Button
buttonType="ghost"
buttonType="outline"
danger
palette="default"
size="xsmall"
onClick={() => closeLeaveConfirm(false)}
onClick={() => closeLeaveConfirm("leave")}
>
{messages.create.topNav.leaveConfirmCancel}
{messages.create.topNav.leaveConfirmProceed}
</Button>
</div>
}
@@ -801,6 +887,7 @@ function CreateFlowLayoutContent({
className="hidden"
onSubmit={(e) => {
e.preventDefault();
markAttempted();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
@@ -855,13 +942,13 @@ function CreateFlowLayoutContent({
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
typeof state.title !== "string" ||
state.title.trim().length === 0
}
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
markAttempted();
if (isCommunityNameStepBlocked(currentStep, state.title)) {
return;
}
goToNextStep();
}}
>
@@ -879,7 +966,7 @@ function CreateFlowLayoutContent({
goToNextStep();
}}
>
{footer.saveLater}
{footer.skipForNow}
</Button>
<Button
type="submit"
@@ -888,10 +975,7 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={
isPublishing ||
communitySaveMagicLinkSubmitting ||
communitySaveMagicLinkSuccess ||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
isPublishing || communitySaveMagicLinkSubmitting
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
>
@@ -900,6 +984,19 @@ function CreateFlowLayoutContent({
: footer.submitEmail}
</Button>
</div>
) : currentStep === "community-upload" && nextStep ? (
<Button
buttonType={hasCommunityLogo ? "filled" : "outline"}
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
goToNextStep();
}}
>
{hasCommunityLogo ? footer.next : footer.skip}
</Button>
) : currentStep === "review" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
@@ -988,20 +1085,38 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={
isPublishing ||
(isFinalReviewLike && !sessionResolved) ||
isCommunitySizeStepBlocked(
currentStep,
state.selectedCommunitySizeIds,
)
isPublishing || (isFinalReviewLike && !sessionResolved)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
if (isFinalReviewLike) {
void handleFinalize();
} else {
goToNextStep();
return;
}
markAttempted();
if (
isCommunityStructureStepBlocked(
currentStep,
state.selectedOrganizationTypeIds,
state.selectedMaturityIds,
) ||
isCommunitySizeStepBlocked(
currentStep,
state.selectedCommunitySizeIds,
)
) {
return;
}
if (reviewReturnTarget) {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
return;
}
goToNextStep();
}}
>
{isFinalReviewLike
@@ -0,0 +1,50 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
type CreateFlowContinueAttemptContextValue = {
attempted: boolean;
markAttempted: () => void;
clearAttempted: () => void;
};
const CreateFlowContinueAttemptContext =
createContext<CreateFlowContinueAttemptContextValue | null>(null);
export function CreateFlowContinueAttemptProvider({
children,
}: {
children: ReactNode;
}) {
const [attempted, setAttempted] = useState(false);
const markAttempted = useCallback(() => setAttempted(true), []);
const clearAttempted = useCallback(() => setAttempted(false), []);
const value = useMemo(
() => ({ attempted, markAttempted, clearAttempted }),
[attempted, markAttempted, clearAttempted],
);
return (
<CreateFlowContinueAttemptContext.Provider value={value}>
{children}
</CreateFlowContinueAttemptContext.Provider>
);
}
export function useCreateFlowContinueAttempt(): CreateFlowContinueAttemptContextValue {
const value = useContext(CreateFlowContinueAttemptContext);
if (!value) {
return {
attempted: false,
markAttempted: () => {},
clearAttempted: () => {},
};
}
return value;
}
+3 -3
View File
@@ -33,14 +33,14 @@ export function useCreateFlowExit({
setDraftSaveBannerMessage?: (_message: string | null) => void;
/** When exit would discard unsaved work, return true to proceed. Defaults to denying leave. */
confirmLeave?: () => Promise<boolean>;
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
}): (_options?: { saveDraft?: boolean; skipConfirm?: boolean }) => Promise<void> {
return useCallback(
async (options?: { saveDraft?: boolean }) => {
async (options?: { saveDraft?: boolean; skipConfirm?: boolean }) => {
if (!user) return;
const saveDraft = options?.saveDraft ?? false;
if (!saveDraft) {
if (!saveDraft && !options?.skipConfirm) {
const confirmFn = confirmLeave ?? (async () => false);
const confirmed = await confirmFn();
if (!confirmed) return;
@@ -116,6 +116,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="title"
maxLength={48}
required
autoFocus
/>
);
case "community-structure":
@@ -128,6 +129,8 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
maxLength={200}
mainAlign="center"
multiline
locationField
autoFocus
/>
);
case "community-size":
@@ -144,6 +147,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
inputType="email"
showCharacterCount={false}
headerJustification="center"
autoFocus
/>
);
case "review":
@@ -16,18 +16,9 @@ export function InformationalScreen() {
const copy = useMessages().create.community.informational;
const items = [
{
title: copy.steps["0"].title,
description: copy.steps["0"].description,
},
{
title: copy.steps["1"].title,
description: copy.steps["1"].description,
},
{
title: copy.steps["2"].title,
description: copy.steps["2"].description,
},
{ title: copy.steps["0"].title },
{ title: copy.steps["1"].title },
{ title: copy.steps["2"].title },
];
return (
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button";
import { useTranslation } from "../../../../contexts/MessagesContext";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
@@ -19,11 +20,67 @@ import {
} from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../utils/flowSteps";
import type { CreateFlowStep } from "../../types";
function firstId(ids: readonly string[] | undefined): string | undefined {
return ids?.find((id) => id.length > 0);
}
function chipLabel(
id: string | undefined,
rows: readonly { id: string; label: string }[] | undefined,
snapshots: readonly { id: string; label: string }[] | undefined,
): string | undefined {
if (!id) return undefined;
const fromSnap = snapshots?.find((row) => row.id === id)?.label.trim();
if (fromSnap) return fromSnap;
return rows?.find((row) => row.id === id)?.label;
}
function reviewSizeValue(
id: string | undefined,
sizes: readonly { id: string; label: string }[],
peopleTemplate: string,
): string | undefined {
if (!id) return undefined;
const row = sizes.find((item) => item.id === id);
if (!row) return undefined;
if (id === "oneMember") return row.label;
return peopleTemplate.replace("{label}", row.label);
}
function CommunityReviewAnswerRow({
label,
value,
editLabel,
onEdit,
}: {
label: string;
value: string;
editLabel: string;
onEdit: () => void;
}) {
return (
<div className="flex w-full items-start justify-between gap-[var(--space-400)] py-[var(--space-300)]">
<div className="flex min-w-0 flex-1 flex-col gap-[var(--space-100)]">
<p className="text-small-label text-[color:var(--color-content-default-secondary)]">
{label}
</p>
<p className="text-medium-paragraph text-[color:var(--color-content-default-primary)]">
{value}
</p>
</div>
<InlineTextButton onClick={onEdit}>{editLabel}</InlineTextButton>
</div>
);
}
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() {
const router = useRouter();
const lgUp = useCreateFlowLgUp();
const messages = useMessages();
const t = useTranslation("create.community.review");
const { state, updateState } = useCreateFlow();
/**
@@ -97,6 +154,32 @@ export function CommunityReviewScreen() {
? state.communityAvatarUrl.trim()
: null;
const structure = messages.create.community.communityStructure;
const sizes = messages.create.community.communitySize.communitySizes;
const kindValue = chipLabel(
firstId(state.selectedOrganizationTypeIds),
structure.organizationTypes,
state.communityStructureChipSnapshots?.organizationTypes,
);
const sizeValue = reviewSizeValue(
firstId(state.selectedCommunitySizeIds),
sizes,
t("rows.sizePeople"),
);
const maturityValue = chipLabel(
firstId(state.selectedMaturityIds),
structure.maturityOptions,
state.communityStructureChipSnapshots?.maturity,
);
const goEdit = (step: CreateFlowStep) => {
router.push(
createFlowStepPath(step, {
[CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "review",
}),
);
};
if (state.pendingTemplateAction || !reviewReady) {
return null;
}
@@ -143,7 +226,7 @@ export function CommunityReviewScreen() {
description={t("header.description")}
/>
</div>
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}>
<div className={`flex flex-col ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}>
<Rule
title={cardTitle}
description={cardDescription}
@@ -156,6 +239,32 @@ export function CommunityReviewScreen() {
logoAlt={cardTitle}
className="rounded-[24px]"
/>
<div className="mt-[var(--space-600)] flex w-full flex-col divide-y divide-[var(--color-border-default-secondary)]">
{kindValue ? (
<CommunityReviewAnswerRow
label={t("rows.kind")}
value={kindValue}
editLabel={t("rows.edit")}
onEdit={() => goEdit("community-structure")}
/>
) : null}
{sizeValue ? (
<CommunityReviewAnswerRow
label={t("rows.size")}
value={sizeValue}
editLabel={t("rows.edit")}
onEdit={() => goEdit("community-size")}
/>
) : null}
{maturityValue ? (
<CommunityReviewAnswerRow
label={t("rows.maturity")}
value={maturityValue}
editLabel={t("rows.edit")}
onEdit={() => goEdit("community-structure")}
/>
) : null}
</div>
</div>
</div>
</CreateFlowStepShell>
@@ -5,6 +5,7 @@ import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -48,6 +49,7 @@ export function CommunitySizeSelectScreen() {
const m = useMessages();
const cs = m.create.community.communitySize;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const { attempted } = useCreateFlowContinueAttempt();
const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[]
@@ -87,6 +89,9 @@ export function CommunitySizeSelectScreen() {
persistSelection(withSoleSelection(communitySizeOptions, nextId));
};
const showSizeError =
attempted && soleSizeId(state.selectedCommunitySizeIds) == null;
const multiSelectBlock = (
<MultiSelect
formHeader={false}
@@ -94,6 +99,7 @@ export function CommunitySizeSelectScreen() {
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
addButton={false}
errorMessage={showSizeError ? cs.pickOneError : undefined}
/>
);
@@ -11,6 +11,7 @@ import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -73,10 +74,29 @@ function applySavedSelection(
);
}
function withSoleSelection(
options: ChipOption[],
selectedId: string | null,
): ChipOption[] {
return options.map((opt) =>
opt.state === "custom"
? opt
: {
...opt,
state:
opt.id === selectedId
? ("selected" as const)
: ("unselected" as const),
},
);
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "selected")
.map((o) => o.id);
return options.filter((o) => o.state === "selected").map((o) => o.id);
}
function firstSelectedId(options: ChipOption[]): string | null {
return selectedIdsFromOptions(options)[0] ?? null;
}
function chipOptionsToSnapshotRows(
@@ -103,22 +123,46 @@ function snapshotRowsToChipOptions(
}));
}
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
function organizationIdsForPersist(
mainId: string | null,
otherIds: readonly string[],
): string[] {
if (!mainId) return [];
return [mainId, ...otherIds.filter((id) => id !== mainId)];
}
/** Create Community type step — Figma `25224:105767`. */
export function CommunityStructureSelectScreen() {
const m = useMessages();
const cs = m.create.community.communityStructure;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const { attempted } = useCreateFlowContinueAttempt();
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
ChipOption[]
>(() => {
const savedKindIds = state.selectedOrganizationTypeIds ?? [];
const savedMainIndex = savedKindIds.findIndex((id) => id.length > 0);
const savedMainId =
savedMainIndex >= 0 ? savedKindIds[savedMainIndex]! : null;
const savedOtherIds =
savedMainIndex >= 0 ? savedKindIds.slice(savedMainIndex + 1) : [];
const [mainKindOptions, setMainKindOptions] = useState<ChipOption[]>(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.organizationTypes,
);
return withSoleSelection(
fromSnap ?? chipRowsFromLabels(cs.organizationTypes),
savedMainId,
);
});
const [otherKindOptions, setOtherKindOptions] = useState<ChipOption[]>(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.otherOrganizationTypes,
);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(cs.organizationTypes),
state.selectedOrganizationTypeIds,
savedOtherIds,
);
});
@@ -137,29 +181,48 @@ export function CommunityStructureSelectScreen() {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.maturity,
);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(cs.maturityOptions),
state.selectedMaturityIds,
);
const base = fromSnap ?? chipRowsFromLabels(cs.maturityOptions);
const maturityId =
(state.selectedMaturityIds ?? []).find((id) => id.length > 0) ?? null;
return withSoleSelection(base, maturityId);
});
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.organizationTypes,
);
if (fromSnap) {
setOrganizationTypeOptions(fromSnap);
return;
}
setOrganizationTypeOptions((prev) =>
applySavedSelection(prev, state.selectedOrganizationTypeIds),
const mainId =
(state.selectedOrganizationTypeIds ?? []).find((id) => id.length > 0) ??
null;
setMainKindOptions(
withSoleSelection(
fromSnap ?? chipRowsFromLabels(cs.organizationTypes),
mainId,
),
);
}, [
cs.organizationTypes,
state.communityStructureChipSnapshots?.organizationTypes,
state.selectedOrganizationTypeIds,
]);
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.otherOrganizationTypes,
);
const ids = state.selectedOrganizationTypeIds ?? [];
const mainId = ids.find((id) => id.length > 0);
const otherIds = mainId ? ids.slice(ids.indexOf(mainId) + 1) : [];
if (fromSnap) {
setOtherKindOptions(fromSnap);
return;
}
setOtherKindOptions((prev) => applySavedSelection(prev, otherIds));
}, [
state.communityStructureChipSnapshots?.otherOrganizationTypes,
state.selectedOrganizationTypeIds,
]);
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.scale,
@@ -178,53 +241,50 @@ export function CommunityStructureSelectScreen() {
const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.maturity,
);
const maturityId =
(state.selectedMaturityIds ?? []).find((id) => id.length > 0) ?? null;
if (fromSnap) {
setMaturityOptions(fromSnap);
setMaturityOptions(withSoleSelection(fromSnap, maturityId));
return;
}
setMaturityOptions((prev) =>
applySavedSelection(prev, state.selectedMaturityIds),
);
setMaturityOptions((prev) => withSoleSelection(prev, maturityId));
}, [
state.communityStructureChipSnapshots?.maturity,
state.selectedMaturityIds,
]);
const organizationCustomHandlers = useMemo(
const mainKindCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setOrganizationTypeOptions,
setMainKindOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const scaleCustomHandlers = useMemo(
const otherKindCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setScaleOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const maturityCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setMaturityOptions,
setOtherKindOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const persistOrg = (next: ChipOption[]) => {
const persistKinds = (main: ChipOption[], others: ChipOption[]) => {
markCreateFlowInteraction();
setOrganizationTypeOptions(next);
setMainKindOptions(main);
setOtherKindOptions(others);
const mainId = firstSelectedId(main);
updateState({
selectedOrganizationTypeIds: selectedIdsFromOptions(next),
selectedOrganizationTypeIds: organizationIdsForPersist(
mainId,
selectedIdsFromOptions(others),
),
communityStructureChipSnapshots: {
organizationTypes: chipOptionsToSnapshotRows(next),
organizationTypes: chipOptionsToSnapshotRows(main),
otherOrganizationTypes: chipOptionsToSnapshotRows(others),
},
});
};
@@ -251,9 +311,16 @@ export function CommunityStructureSelectScreen() {
});
};
const handleOrganizationTypeClick = (chipId: string) => {
persistOrg(
organizationTypeOptions.map((opt) =>
const handleMainKindClick = (chipId: string) => {
const current = firstSelectedId(mainKindOptions);
const nextId = current === chipId ? null : chipId;
persistKinds(withSoleSelection(mainKindOptions, nextId), otherKindOptions);
};
const handleOtherKindClick = (chipId: string) => {
persistKinds(
mainKindOptions,
otherKindOptions.map((opt) =>
opt.id === chipId
? {
...opt,
@@ -284,49 +351,49 @@ export function CommunityStructureSelectScreen() {
};
const handleMaturityClick = (chipId: string) => {
persistMaturity(
maturityOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
);
const current = firstSelectedId(maturityOptions);
const nextId = current === chipId ? null : chipId;
persistMaturity(withSoleSelection(maturityOptions, nextId));
};
const showMainError = attempted && firstSelectedId(mainKindOptions) == null;
const showMaturityError =
attempted && firstSelectedId(maturityOptions) == null;
const multiSelectBlock = (
<>
<MultiSelect
label={cs.organizationMultiSelect.label}
label={cs.mainKindMultiSelect.label}
helperText={cs.mainKindMultiSelect.helperText}
size="s"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
{...organizationCustomHandlers}
options={mainKindOptions}
onChipClick={handleMainKindClick}
{...mainKindCustomHandlers}
addButton
errorMessage={showMainError ? cs.pickOneError : undefined}
/>
<MultiSelect
label={cs.otherKindMultiSelect.label}
size="s"
options={otherKindOptions}
onChipClick={handleOtherKindClick}
{...otherKindCustomHandlers}
addButton
addButtonText={cs.organizationMultiSelect.addButtonText}
/>
<MultiSelect
label={cs.scaleMultiSelect.label}
size="s"
options={scaleOptions}
onChipClick={handleScaleClick}
{...scaleCustomHandlers}
addButton
addButtonText={cs.scaleMultiSelect.addButtonText}
addButton={false}
/>
<MultiSelect
label={cs.maturityMultiSelect.label}
size="s"
options={maturityOptions}
onChipClick={handleMaturityClick}
{...maturityCustomHandlers}
addButton
addButtonText={cs.maturityMultiSelect.addButtonText}
addButton={false}
errorMessage={showMaturityError ? cs.pickOneError : undefined}
/>
</>
);
@@ -6,6 +6,7 @@ import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import {
@@ -14,6 +15,7 @@ import {
} from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import { isValidCreateFlowSaveEmail } from "../../../../../lib/create/isValidCreateFlowSaveEmail";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
@@ -31,6 +33,9 @@ type Props = {
headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
contentTopBelowMd?: CreateFlowContentTopBelowMd;
autoFocus?: boolean;
/** Optional city/region field under the description. */
locationField?: boolean;
};
/**
@@ -47,35 +52,38 @@ export function CreateFlowTextFieldScreen({
showCharacterCount = true,
headerJustification = "left",
contentTopBelowMd = "space-1400",
autoFocus = false,
locationField = false,
}: Props) {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const { attempted } = useCreateFlowContinueAttempt();
const mdUp = useCreateFlowMdUp();
const t = useTranslation(messageNamespace);
const readFromState = (): string => {
const raw = state[stateField];
const readFromState = (field: CreateFlowTextStateField): string => {
const raw = state[field];
return typeof raw === "string" ? raw : "";
};
const [value, setValue] = useState(() => readFromState());
const [value, setValue] = useState(() => readFromState(stateField));
const [locationValue, setLocationValue] = useState(() =>
readFromState("communityLocation"),
);
useEffect(() => {
const incoming = readFromState();
const incoming = readFromState(stateField);
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 =
showCharacterCount === false
? false
: t("characterCountTemplate")
.replace("{current}", String(characterCount))
.replace("{max}", String(maxLength));
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
useEffect(() => {
if (!locationField) return;
const incoming = readFromState("communityLocation");
if (incoming.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
setLocationValue((prev) => (prev === "" ? incoming : prev));
}, [locationField, state]);
const isEmail = inputType === "email";
const persistValue = (next: string) => {
@@ -83,6 +91,42 @@ export function CreateFlowTextFieldScreen({
markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>);
};
const persistLocation = (next: string) => {
setLocationValue(next);
markCreateFlowInteraction();
updateState({ communityLocation: next });
};
const nameError =
required &&
!isEmail &&
!multiline &&
attempted &&
value.trim().length === 0;
const emailError =
isEmail &&
attempted &&
!isValidCreateFlowSaveEmail(value);
const showFieldError = nameError || emailError;
const errorCopy = nameError
? t("errorMessage")
: emailError
? t("errorMessage")
: "";
const characterCount = value.length;
const hint = showFieldError
? errorCopy
: isEmail
? t("skipHint")
: showCharacterCount === false
? false
: t("characterCountTemplate")
.replace("{current}", String(characterCount))
.replace("{max}", String(maxLength));
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
const fieldLabel = t("inputLabel");
@@ -101,7 +145,7 @@ export function CreateFlowTextFieldScreen({
justification={headerJustification}
/>
</div>
<div className="w-full">
<div className="flex w-full flex-col gap-[var(--space-800)]">
{multiline ? (
<TextArea
className="!transition-none"
@@ -119,6 +163,7 @@ export function CreateFlowTextFieldScreen({
textHint={hint}
maxLength={maxLength}
required={required}
autoFocus={autoFocus}
rows={4}
/>
) : (
@@ -149,13 +194,33 @@ export function CreateFlowTextFieldScreen({
formHeader={false}
showHelpIcon={false}
textHint={hint}
error={showFieldError}
maxLength={maxLength}
required={required || isEmail}
autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
autoFocus={autoFocus}
/>
)}
{locationField ? (
<TextInput
className="!transition-none"
type="text"
label={t("locationLabel")}
placeholder={t("locationPlaceholder")}
value={locationValue}
onChange={(e) => persistLocation(e.target.value)}
onBlur={() => {
const trimmed = locationValue.trim();
if (trimmed !== locationValue) persistLocation(trimmed);
}}
inputSize={mdUp ? "medium" : "small"}
formHeader
showHelpIcon={false}
textHint={t("locationHint")}
/>
) : null}
</div>
</div>
</CreateFlowStepShell>
@@ -14,7 +14,6 @@ import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import {
createFlowUploadFailureMessageKey,
uploadCreateFlowFile,
@@ -34,7 +33,7 @@ function hasCommunityAvatarUrl(url: string | undefined): boolean {
return typeof url === "string" && url.trim().length > 0;
}
/** Create Community — Figma Flow — Upload `20094:41524`. */
/** Create Community — Figma Flow — Upload. */
export function CommunityUploadScreen() {
const m = useMessages();
const u = m.create.community.communityUpload;
@@ -44,6 +43,7 @@ export function CommunityUploadScreen() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [localFileName, setLocalFileName] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -104,6 +104,10 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev);
return objectUrl;
});
setLocalFileName(file.name);
if (!state.communityAvatarFileName) {
updateState({ communityAvatarFileName: file.name });
}
} catch {
// Missing IndexedDB / quota: leave the picker empty.
}
@@ -111,11 +115,15 @@ export function CommunityUploadScreen() {
return () => {
cancelled = true;
};
}, []);
}, [state.communityAvatarFileName, updateState]);
const resolveUploadError = useCallback(
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
[tUpload],
(err: unknown) => {
const key = createFlowUploadFailureMessageKey(err);
if (key === "errors.tooLarge") return u.fileTooLarge;
return tUpload(key);
},
[tUpload, u.fileTooLarge],
);
const handleFileChange = useCallback(
@@ -126,7 +134,7 @@ export function CommunityUploadScreen() {
markCreateFlowInteraction();
setErrorMessage(null);
if (signedIn) {
if (signedIn === true) {
setBusy(true);
try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
@@ -134,7 +142,11 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev);
return null;
});
updateState({ communityAvatarUrl: url });
setLocalFileName(file.name);
updateState({
communityAvatarUrl: url,
communityAvatarFileName: file.name,
});
} catch (err) {
setErrorMessage(resolveUploadError(err));
} finally {
@@ -143,26 +155,28 @@ export function CommunityUploadScreen() {
return;
}
if (signedIn === false) {
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
setErrorMessage(
validated.reason === "tooLarge"
? u.fileTooLarge
: tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
if (validated.ok === false) {
setErrorMessage(
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
return;
}
try {
await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
} catch {
setErrorMessage(tUpload("errors.generic"));
}
return;
}
try {
await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
setLocalFileName(file.name);
updateState({ communityAvatarFileName: file.name });
} catch {
setErrorMessage(tUpload("errors.generic"));
}
},
[
@@ -170,28 +184,23 @@ export function CommunityUploadScreen() {
resolveUploadError,
signedIn,
tUpload,
u.fileTooLarge,
updateState,
],
);
const handleClearPendingUpload = useCallback(() => {
markCreateFlowInteraction();
setErrorMessage(null);
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
updateState({ communityAvatarUrl: undefined });
}
void clearPendingCommunityAvatarFile();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const handlePickFile = useCallback(() => {
if (!busy) fileInputRef.current?.click();
}, [busy]);
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
const fileName =
localFileName ??
(typeof state.communityAvatarFileName === "string" &&
state.communityAvatarFileName.trim().length > 0
? state.communityAvatarFileName.trim()
: undefined);
return (
<CreateFlowStepShell
@@ -214,60 +223,28 @@ export function CommunityUploadScreen() {
className="sr-only"
tabIndex={-1}
accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText}
aria-label={hasPreview ? u.replaceButton : u.hintText}
onChange={handleFileChange}
/>
<div className="flex w-full flex-col items-center gap-3">
{hasPreview ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={handleClearPendingUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={u.clearPendingUploadAriaLabel}
title={u.clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath(ASSETS.ICON_CLOSE)}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- user/device file or same-origin upload URL */}
<img
src={displaySrc ?? ""}
alt={u.previewAlt}
className="max-h-[200px] max-w-full rounded-[var(--radius-200)] object-contain"
/>
</div>
) : (
<Upload
active={!busy}
showHelpIcon={false}
hintText={busy ? u.uploadingLabel : u.hintText}
onClick={() => {
if (!busy) fileInputRef.current?.click();
}}
/>
)}
{signedIn === false ? (
<p className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-tertiary)]">
{u.signInToUploadNote}
</p>
) : null}
{errorMessage ? (
<p
className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
<Upload
hasLabel={false}
active={!busy}
state={
errorMessage ? "error" : busy ? "disabled" : "default"
}
hintText={
errorMessage
? errorMessage
: busy
? u.uploadingLabel
: u.hintText
}
buttonLabel={hasPreview ? u.replaceButton : undefined}
previewSrc={hasPreview ? displaySrc ?? undefined : undefined}
previewAlt={u.previewAlt}
fileName={hasPreview ? fileName : undefined}
onClick={handlePickFile}
/>
</div>
</CreateFlowStepShell>
);
+10
View File
@@ -37,6 +37,7 @@ export type CreateFlowTextStateField =
| "title"
| "summary"
| "communityContext"
| "communityLocation"
| "communitySaveEmail";
/**
@@ -111,6 +112,8 @@ export interface CreateFlowState {
summary?: string;
/** Additional copy fields for multi-step Create Community text frames (Figma). */
communityContext?: string;
/** Optional city or region from the description step. */
communityLocation?: string;
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
communitySaveEmail?: string;
/**
@@ -118,6 +121,12 @@ export interface CreateFlowState {
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
*/
communityAvatarUrl?: string;
/**
* Original filename for the staged or uploaded community logo.
* Lets the upload control show Replace + filename after reload, including
* guest files that still live in IndexedDB.
*/
communityAvatarFileName?: string;
/**
* Light `hsl(...)` fill for the review card when no logo is uploaded.
* Picked once and kept on the draft so reload does not change it.
@@ -147,6 +156,7 @@ export interface CreateFlowState {
*/
communityStructureChipSnapshots?: {
organizationTypes?: CommunityStructureChipSnapshotRow[];
otherOrganizationTypes?: CommunityStructureChipSnapshotRow[];
scale?: CommunityStructureChipSnapshotRow[];
maturity?: CommunityStructureChipSnapshotRow[];
};
@@ -23,12 +23,34 @@ const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
["community-size", "confirmSize"],
]);
function hasNonEmptyId(ids: readonly string[] | undefined): boolean {
return (ids ?? []).some((id) => id.length > 0);
}
export function isCommunityNameStepBlocked(
step: CreateFlowStep | null | undefined,
title: string | undefined,
): boolean {
if (step !== "community-name") return false;
return typeof title !== "string" || title.trim().length === 0;
}
export function isCommunityStructureStepBlocked(
step: CreateFlowStep | null | undefined,
selectedOrganizationTypeIds: readonly string[] | undefined,
selectedMaturityIds: readonly string[] | undefined,
): boolean {
if (step !== "community-structure") return false;
const mainKind = (selectedOrganizationTypeIds ?? []).find((id) => id.length > 0);
return !mainKind || !hasNonEmptyId(selectedMaturityIds);
}
export function isCommunitySizeStepBlocked(
step: CreateFlowStep | null | undefined,
selectedCommunitySizeIds: readonly string[] | undefined,
): boolean {
if (step !== "community-size") return false;
return !(selectedCommunitySizeIds ?? []).some((id) => id.length > 0);
return !hasNonEmptyId(selectedCommunitySizeIds);
}
/**
@@ -12,10 +12,10 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"1-2", // community-structure
"1-3", // community-context
"1-4", // community-size
"1-5", // community-upload
"2-0", // community-save
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
"2-0", // core-values (same segment as review / end of Create Community)
"1-6", // community-upload (1-5 is reserved for setup questions)
"1-7", // community-save
"1-8", // review
"2-0", // core-values (same fill as 1-8)
"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`)
@@ -41,7 +41,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
/** Figma: Flow — Informational (node 20094-16005). */
informational: {
layoutKind: "informational",
figmaNodeId: "20094-16005",
figmaNodeId: "25224-105191",
messageNamespace: "create.community.informational",
centeredBodyBelowMd: false,
},
@@ -59,13 +59,13 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
},
"community-context": {
layoutKind: "text",
figmaNodeId: "20094-41243",
figmaNodeId: "25224-106030",
messageNamespace: "create.community.communityContext",
centeredBodyBelowMd: true,
},
"community-structure": {
layoutKind: "select",
figmaNodeId: "20094-18244",
figmaNodeId: "25224-105767",
messageNamespace: "create.community.communityStructure",
centeredBodyBelowMd: false,
},
+12 -7
View File
@@ -172,13 +172,13 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
return FLOW_STEP_ORDER.indexOf(step);
}
/** First wizard step that offers Save & Exit (first Create Community select). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
/** First wizard step that offers Save and exit (after the community is ready to keep). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-save");
/**
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
* `community-structure` onward and on `edit-rule`. On completed, guests still
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
* Top-nav Save and exit (vs Exit). Guests and signed-in users share this from
* `community-save` onward and on `edit-rule`. On completed, guests still
* get Save and exit (claim/login); signed-in users get Exit (already owned).
*
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
* not a guest — completed stays Exit until the session resolves.
@@ -307,14 +307,19 @@ export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY = "manageStakeholders" as const;
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const;
export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
export type CreateFlowReviewReturnTarget =
| "final-review"
| "edit-rule"
| "review";
export function parseReviewReturnSearchParam(
searchParams: { get: (name: string) => string | null } | null | undefined,
): CreateFlowReviewReturnTarget | null {
if (!searchParams) return null;
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
if (raw === "final-review" || raw === "edit-rule") return raw;
if (raw === "final-review" || raw === "edit-rule" || raw === "review") {
return raw;
}
return null;
}