From 2348a9e94fe425132b8b1535b9a72b63f6e09028 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:14:04 -0700 Subject: [PATCH 1/2] 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. --- app/(app)/create/CreateFlowLayoutClient.tsx | 207 ++++++++++++++---- .../CreateFlowContinueAttemptContext.tsx | 50 +++++ app/(app)/create/hooks/useCreateFlowExit.ts | 6 +- .../screens/createFlowScreenComponents.tsx | 4 + .../informational/InformationalScreen.tsx | 15 +- .../screens/review/CommunityReviewScreen.tsx | 113 +++++++++- .../select/CommunitySizeSelectScreen.tsx | 6 + .../select/CommunityStructureSelectScreen.tsx | 201 +++++++++++------ .../text/CreateFlowTextFieldScreen.tsx | 95 ++++++-- .../screens/upload/CommunityUploadScreen.tsx | 163 ++++++-------- app/(app)/create/types.ts | 10 + .../create/utils/createFlowFooterLabels.ts | 24 +- .../utils/createFlowProportionProgress.ts | 8 +- .../create/utils/createFlowScreenRegistry.ts | 6 +- app/(app)/create/utils/flowSteps.ts | 19 +- app/components/cards/Rule/Rule.view.tsx | 6 +- .../MultiSelect/MultiSelect.container.tsx | 4 + .../controls/MultiSelect/MultiSelect.types.ts | 11 + .../controls/MultiSelect/MultiSelect.view.tsx | 20 +- .../controls/TextInput/TextInput.types.ts | 1 + .../controls/TextInput/TextInput.view.tsx | 2 + .../controls/Upload/Upload.container.tsx | 12 +- .../controls/Upload/Upload.types.ts | 14 ++ .../controls/Upload/Upload.view.tsx | 109 +++++---- .../modals/Alert/Alert.container.tsx | 4 + app/components/modals/Alert/Alert.types.ts | 5 + app/components/modals/Alert/Alert.view.tsx | 11 + .../ProportionBar/ProportionBar.types.ts | 3 + .../ProportionBar/ProportionBar.view.tsx | 10 +- .../type/NumberedList/NumberedList.types.ts | 2 +- .../type/NumberedList/NumberedList.view.tsx | 21 +- lib/create/isValidCreateFlowSaveEmail.ts | 18 +- lib/server/mail.ts | 2 +- lib/server/validation/createFlowSchemas.ts | 5 + .../en/create/community/communityContext.json | 9 +- .../en/create/community/communityName.json | 5 +- .../en/create/community/communitySave.json | 12 +- .../en/create/community/communitySize.json | 1 + .../create/community/communityStructure.json | 20 +- .../en/create/community/communityUpload.json | 13 +- .../en/create/community/informational.json | 13 +- messages/en/create/community/review.json | 12 +- messages/en/create/footer.json | 6 +- messages/en/create/topNav.json | 13 +- stories/progress/ProportionBar.stories.js | 3 + tests/components/Alert.test.tsx | 14 ++ .../CommunityStructureSelectScreen.test.tsx | 12 +- tests/components/CreateFlowFooter.test.tsx | 4 +- tests/components/CreateFlowTopNav.test.tsx | 10 +- tests/components/InformationalPage.test.tsx | 6 +- tests/components/MultiSelect.test.tsx | 23 ++ tests/components/ProportionBar.test.tsx | 5 +- tests/components/ReviewPage.test.tsx | 32 ++- tests/components/TextPage.test.tsx | 15 +- tests/components/Upload.test.tsx | 16 ++ tests/components/UploadPage.test.tsx | 27 +-- tests/unit/communitySizeBands.test.ts | 40 +++- .../unit/createFlowProportionProgress.test.ts | 11 +- tests/unit/createFooterMessages.test.ts | 6 + tests/unit/flowSteps.test.ts | 12 +- tests/unit/isValidCreateFlowSaveEmail.test.ts | 4 + 61 files changed, 1110 insertions(+), 421 deletions(-) create mode 100644 app/(app)/create/context/CreateFlowContinueAttemptContext.tsx diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index f537c74..38ed396 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -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 ( + + ); @@ -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(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((resolve) => { + new Promise((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( `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]" > @@ -725,22 +810,23 @@ function CreateFlowLayoutContent({ /> 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={
} @@ -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} + ) : currentStep === "community-upload" && nextStep ? ( + ) : currentStep === "review" && nextStep ? (
-
+
+
+ {kindValue ? ( + goEdit("community-structure")} + /> + ) : null} + {sizeValue ? ( + goEdit("community-size")} + /> + ) : null} + {maturityValue ? ( + goEdit("community-structure")} + /> + ) : null} +
diff --git a/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx b/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx index 1c88fec..69df26a 100644 --- a/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx +++ b/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx @@ -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 = ( ); diff --git a/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx b/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx index a5b2591..39acbd1 100644 --- a/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx +++ b/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx @@ -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(() => { const fromSnap = snapshotRowsToChipOptions( state.communityStructureChipSnapshots?.organizationTypes, ); + return withSoleSelection( + fromSnap ?? chipRowsFromLabels(cs.organizationTypes), + savedMainId, + ); + }); + + const [otherKindOptions, setOtherKindOptions] = useState(() => { + 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 = ( <> + ); diff --git a/app/(app)/create/screens/text/CreateFlowTextFieldScreen.tsx b/app/(app)/create/screens/text/CreateFlowTextFieldScreen.tsx index c83c342..f76d9f4 100644 --- a/app/(app)/create/screens/text/CreateFlowTextFieldScreen.tsx +++ b/app/(app)/create/screens/text/CreateFlowTextFieldScreen.tsx @@ -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); }; + 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} /> -
+
{multiline ? (