Merge pull request 'Walk Create Community through the current screens and restore a chosen logo after reload' (#96) from adilallo/fix/CR-279-create-community into main

Reviewed-on: #96
This commit was merged in pull request #96.
This commit is contained in:
an.di committed 2026-11-06 03:04:47 +00:00
commit b4bfa157f8
76 files changed
+1890 -482

No files matched your search

+161 -46
View File
@@ -87,7 +87,9 @@ import {
} from "./utils/customRuleConfirmFooterSteps"; } from "./utils/customRuleConfirmFooterSteps";
import { import {
getDefaultFooterLabel, getDefaultFooterLabel,
isCommunityNameStepBlocked,
isCommunitySizeStepBlocked, isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked,
} from "./utils/createFlowFooterLabels"; } from "./utils/createFlowFooterLabels";
import { useAuthModal } from "../../contexts/AuthModalContext"; import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext";
@@ -102,6 +104,10 @@ import {
CreateFlowDraftSaveBannerProvider, CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner, useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext"; } from "./context/CreateFlowDraftSaveBannerContext";
import {
CreateFlowContinueAttemptProvider,
useCreateFlowContinueAttempt,
} from "./context/CreateFlowContinueAttemptContext";
function CreateFlowSessionShell({ children }: { children: ReactNode }) { function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState< const [sessionUser, setSessionUser] = useState<
@@ -128,6 +134,7 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
return ( return (
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}> <CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
<CreateFlowDraftSaveBannerProvider> <CreateFlowDraftSaveBannerProvider>
<CreateFlowContinueAttemptProvider>
<Suspense fallback={null}> <Suspense fallback={null}>
<CreateFlowLayoutContent <CreateFlowLayoutContent
sessionUser={sessionUser} sessionUser={sessionUser}
@@ -136,6 +143,7 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
{children} {children}
</CreateFlowLayoutContent> </CreateFlowLayoutContent>
</Suspense> </Suspense>
</CreateFlowContinueAttemptProvider>
</CreateFlowDraftSaveBannerProvider> </CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider> </CreateFlowProvider>
); );
@@ -191,13 +199,14 @@ function CreateFlowLayoutContent({
editingPublishedRuleIdTrimmed.length > 0; editingPublishedRuleIdTrimmed.length > 0;
const { draftSaveBannerMessage, setDraftSaveBannerMessage } = const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner(); useCreateFlowDraftSaveBanner();
const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt();
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
useState(false); useState(false);
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState< const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
string | null string | null
>(null); >(null);
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = const [saveSentEmail, setSaveSentEmail] = useState<string | null>(null);
useState(false); const [isOffline, setIsOffline] = useState(false);
const [completedFlowBanner, setCompletedFlowBanner] = useState<{ const [completedFlowBanner, setCompletedFlowBanner] = useState<{
key: string; key: string;
status: "positive" | "danger" | "warning"; status: "positive" | "danger" | "warning";
@@ -208,24 +217,29 @@ function CreateFlowLayoutContent({
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] = const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false); useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>( type LeaveConfirmChoice = "leave" | "save" | "cancel";
null, 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; leaveConfirmResolverRef.current = resolve;
setLeaveConfirmOpen(true); setLeaveConfirmOpen(true);
}), }),
[], [],
); );
const closeLeaveConfirm = useCallback((proceed: boolean) => { const confirmLeave = useCallback(async () => {
return (await requestLeaveChoice()) === "leave";
}, [requestLeaveChoice]);
const closeLeaveConfirm = useCallback((choice: LeaveConfirmChoice) => {
setLeaveConfirmOpen(false); setLeaveConfirmOpen(false);
const resolve = leaveConfirmResolverRef.current; const resolve = leaveConfirmResolverRef.current;
leaveConfirmResolverRef.current = null; leaveConfirmResolverRef.current = null;
resolve?.(proceed); resolve?.(choice);
}, []); }, []);
const { const {
@@ -359,6 +373,25 @@ function CreateFlowLayoutContent({
return; 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) { if (sessionUser === null) {
const returnToTemplateReview = const returnToTemplateReview =
templateReviewSlug != null templateReviewSlug != null
@@ -388,10 +421,25 @@ function CreateFlowLayoutContent({
} }
}, [sessionResolved, sessionUser, currentStep, router]); }, [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(() => { useEffect(() => {
if (currentStep !== "community-save") { if (currentStep !== "community-save") {
setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
setCommunitySaveMagicLinkSubmitting(false); setCommunitySaveMagicLinkSubmitting(false);
} }
}, [currentStep]); }, [currentStep]);
@@ -495,7 +543,6 @@ function CreateFlowLayoutContent({
formEl?: HTMLFormElement | null, formEl?: HTMLFormElement | null,
) => { ) => {
setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
const namedEmail = formEl?.elements.namedItem("email"); const namedEmail = formEl?.elements.namedItem("email");
const emailField = const emailField =
namedEmail instanceof HTMLInputElement namedEmail instanceof HTMLInputElement
@@ -505,10 +552,6 @@ function CreateFlowLayoutContent({
: document.querySelector<HTMLInputElement>( : document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`, `input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
); );
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw = const raw =
emailField != null ? emailField.value : state.communitySaveEmail; emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return; if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
@@ -544,13 +587,30 @@ function CreateFlowLayoutContent({
} }
setTransferPendingFlag(); setTransferPendingFlag();
updateState({ communitySaveEmail: trimmed }); updateState({ communitySaveEmail: trimmed });
setCommunitySaveMagicLinkSuccess(true); setSaveSentEmail(trimmed);
goToNextStep();
} catch { } catch {
setCommunitySaveMagicLinkError(tLogin("errors.network")); setCommunitySaveMagicLinkError(tLogin("errors.network"));
} finally { } finally {
setCommunitySaveMagicLinkSubmitting(false); 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 isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "decision-approaches"; const isRightRailStep = currentStep === "decision-approaches";
@@ -589,6 +649,11 @@ function CreateFlowLayoutContent({
const proportionBarProgress = getProportionBarProgressForCreateFlowStep( const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep, 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 * Custom Rule stage "confirm selection" steps: all five render the same
@@ -617,6 +682,9 @@ function CreateFlowLayoutContent({
title: string; title: string;
description?: string; description?: string;
onClose: () => void; onClose: () => void;
actionLabel?: string;
onAction?: () => void;
type?: "banner" | "toast";
}> = [ }> = [
draftSaveBannerMessage draftSaveBannerMessage
? { ? {
@@ -655,13 +723,28 @@ function CreateFlowLayoutContent({
onClose: () => setCommunitySaveMagicLinkError(null), onClose: () => setCommunitySaveMagicLinkError(null),
} }
: null, : null,
communitySaveMagicLinkSuccess saveSentEmail
? { ? {
key: "magicLinkSuccess", key: "magicLinkSuccess",
status: "positive" as const, status: "positive" as const,
title: communitySaveMessages.magicLinkSuccessTitle, type: "toast" as const,
description: communitySaveMessages.magicLinkSuccessDescription, title: communitySaveMessages.sentToastTitle,
onClose: () => setCommunitySaveMagicLinkSuccess(false), 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, : null,
completedFlowBanner completedFlowBanner
@@ -688,11 +771,13 @@ function CreateFlowLayoutContent({
className="pointer-events-auto mx-auto w-full max-w-[960px]" className="pointer-events-auto mx-auto w-full max-w-[960px]"
> >
<Alert <Alert
type="banner" type={b.type ?? "banner"}
status={b.status} status={b.status}
title={b.title} title={b.title}
description={b.description} description={b.description}
onClose={b.onClose} onClose={b.onClose}
actionLabel={b.actionLabel}
onAction={b.onAction}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -725,22 +810,23 @@ function CreateFlowLayoutContent({
/> />
<Create <Create
isOpen={leaveConfirmOpen} isOpen={leaveConfirmOpen}
onClose={() => closeLeaveConfirm(false)} onClose={() => closeLeaveConfirm("cancel")}
title={messages.create.topNav.leaveConfirmTitle} title={messages.create.topNav.leaveConfirmTitle}
description={messages.create.topNav.leaveConfirmDescription} description={messages.create.topNav.leaveConfirmDescription}
showBackButton={false} showBackButton={false}
showNextButton showNextButton
nextButtonText={messages.create.topNav.leaveConfirmProceed} nextButtonText={messages.create.topNav.leaveConfirmSaveProgress}
onNext={() => closeLeaveConfirm(true)} onNext={() => closeLeaveConfirm("save")}
footerContent={ footerContent={
<div className={MODAL_FOOTER_START_SLOT_CLASS}> <div className={MODAL_FOOTER_START_SLOT_CLASS}>
<Button <Button
buttonType="ghost" buttonType="outline"
danger
palette="default" palette="default"
size="xsmall" size="xsmall"
onClick={() => closeLeaveConfirm(false)} onClick={() => closeLeaveConfirm("leave")}
> >
{messages.create.topNav.leaveConfirmCancel} {messages.create.topNav.leaveConfirmProceed}
</Button> </Button>
</div> </div>
} }
@@ -801,6 +887,7 @@ function CreateFlowLayoutContent({
className="hidden" className="hidden"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
markAttempted();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget); void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}} }}
/> />
@@ -855,13 +942,13 @@ function CreateFlowLayoutContent({
buttonType="filled" buttonType="filled"
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={ disabled={isPublishing}
isPublishing ||
typeof state.title !== "string" ||
state.title.trim().length === 0
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
markAttempted();
if (isCommunityNameStepBlocked(currentStep, state.title)) {
return;
}
goToNextStep(); goToNextStep();
}} }}
> >
@@ -879,7 +966,7 @@ function CreateFlowLayoutContent({
goToNextStep(); goToNextStep();
}} }}
> >
{footer.saveLater} {footer.skipForNow}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -888,10 +975,7 @@ function CreateFlowLayoutContent({
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={ disabled={
isPublishing || isPublishing || communitySaveMagicLinkSubmitting
communitySaveMagicLinkSubmitting ||
communitySaveMagicLinkSuccess ||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
> >
@@ -900,6 +984,19 @@ function CreateFlowLayoutContent({
: footer.submitEmail} : footer.submitEmail}
</Button> </Button>
</div> </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 ? ( ) : currentStep === "review" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4"> <div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button <Button
@@ -988,20 +1085,38 @@ function CreateFlowLayoutContent({
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={ disabled={
isPublishing || isPublishing || (isFinalReviewLike && !sessionResolved)
(isFinalReviewLike && !sessionResolved) ||
isCommunitySizeStepBlocked(
currentStep,
state.selectedCommunitySizeIds,
)
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
if (isFinalReviewLike) { if (isFinalReviewLike) {
void handleFinalize(); void handleFinalize();
} else { return;
goToNextStep();
} }
markAttempted();
if (
isCommunityStructureStepBlocked(
currentStep,
state.selectedOrganizationTypeIds,
state.selectedMaturityIds,
) ||
isCommunitySizeStepBlocked(
currentStep,
state.selectedCommunitySizeIds,
)
) {
return;
}
if (reviewReturnTarget) {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
return;
}
goToNextStep();
}} }}
> >
{isFinalReviewLike {isFinalReviewLike
+26 -3
View File
@@ -16,6 +16,7 @@ import messages from "../../../messages/en/index";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
import { flushPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar";
function buildPayloadWithStep( function buildPayloadWithStep(
base: CreateFlowState, base: CreateFlowState,
@@ -111,8 +112,15 @@ export function PostLoginDraftTransfer({
return; return;
} }
const flushed = await flushPendingCommunityAvatar();
if (cancelled) return;
const transferred =
flushed.status === "uploaded"
? { ...payload, communityAvatarUrl: flushed.url }
: payload;
if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) { if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
const saveResult = await saveDraftToServer(payload); const saveResult = await saveDraftToServer(transferred);
if (cancelled) return; if (cancelled) return;
if (saveResult.ok === false) { if (saveResult.ok === false) {
@@ -127,8 +135,23 @@ export function PostLoginDraftTransfer({
} }
} }
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage({
replaceState(payload); preservePendingAvatar: flushed.status === "failed",
});
replaceState((prev) => {
const fromPayload =
typeof transferred.communityAvatarUrl === "string"
? transferred.communityAvatarUrl.trim()
: "";
const fromPrev =
typeof prev.communityAvatarUrl === "string"
? prev.communityAvatarUrl.trim()
: "";
const communityAvatarUrl = fromPayload || fromPrev;
return communityAvatarUrl
? { ...transferred, communityAvatarUrl }
: transferred;
});
if (pathname) { if (pathname) {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -1,19 +1,16 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { import { flushPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
CreateFlowUploadValidationError,
uploadCreateFlowFile, /** Background tries after a transient upload failure. The file stays staged. */
} from "../../../../lib/create/uploadToServer"; const MAX_FLUSH_ATTEMPTS = 4;
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
} from "../../../../lib/create/pendingCommunityAvatarUpload";
/** /**
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick) * After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
* and writes `communityAvatarUrl` on success. * and writes `communityAvatarUrl` on success. A transient failure retries with
* backoff while this layout stays mounted.
*/ */
export function CreateFlowPendingAvatarFlush({ export function CreateFlowPendingAvatarFlush({
sessionUser, sessionUser,
@@ -23,7 +20,8 @@ export function CreateFlowPendingAvatarFlush({
sessionResolved: boolean; sessionResolved: boolean;
}) { }) {
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */ const [attempt, setAttempt] = useState(0);
/** One successful flush per signed-in user id (survives the effect re-running). */
const lastFlushedUserIdRef = useRef<string | null>(null); const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar = const hasServerAvatar =
typeof state.communityAvatarUrl === "string" && typeof state.communityAvatarUrl === "string" &&
@@ -33,29 +31,31 @@ export function CreateFlowPendingAvatarFlush({
if (!sessionResolved || !sessionUser) return; if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return; if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return; if (hasServerAvatar) return;
if (attempt >= MAX_FLUSH_ATTEMPTS) return;
let cancelled = false; let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void (async () => { void (async () => {
const file = await readPendingCommunityAvatarFile(); const result = await flushPendingCommunityAvatar();
if (cancelled || !file) return; if (cancelled) return;
try { if (result.status === "uploaded") {
const { url } = await uploadCreateFlowFile(file, "communityAvatar"); updateState({ communityAvatarUrl: result.url });
if (cancelled) return;
await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id; lastFlushedUserIdRef.current = sessionUser.id;
} catch (err) { return;
if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
} }
if (result.status !== "failed") return;
if (attempt + 1 >= MAX_FLUSH_ATTEMPTS) return;
const delay = Math.min(20_000, 5_000 * 2 ** attempt);
timer = setTimeout(() => {
if (!cancelled) setAttempt((current) => current + 1);
}, delay);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (timer) clearTimeout(timer);
}; };
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]); }, [attempt, hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null; return null;
} }
@@ -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;
}
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { readPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
function trimmedAvatarUrl(url: string | null): string | null {
if (typeof url !== "string") return null;
const trimmed = url.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Logo src for review cards. Uses the uploaded URL when the draft has one,
* otherwise the guest file staged in IndexedDB.
*
* @param serverUrl `communityAvatarUrl` from create-flow state, if any.
* @returns A URL the Rule card can render, or `null` when there is no logo.
*
* @example
* const logoUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl ?? null);
*/
export function useCommunityAvatarDisplayUrl(
serverUrl: string | null | undefined,
): string | null {
const resolvedServerUrl = trimmedAvatarUrl(serverUrl ?? null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
useEffect(() => {
if (resolvedServerUrl) {
setObjectUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
return;
}
let cancelled = false;
let created: string | null = null;
void (async () => {
try {
const file = await readPendingCommunityAvatarFile();
if (cancelled || !file) return;
created = URL.createObjectURL(file);
if (cancelled) {
URL.revokeObjectURL(created);
created = null;
return;
}
setObjectUrl((prev) => {
if (prev && prev !== created) URL.revokeObjectURL(prev);
return created;
});
} catch {
// IndexedDB missing or blocked: the card keeps the placeholder.
}
})();
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [resolvedServerUrl]);
return resolvedServerUrl ?? objectUrl;
}
+14 -7
View File
@@ -3,6 +3,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { CreateFlowState, CreateFlowStep } from "../types"; import type { CreateFlowState, CreateFlowStep } from "../types";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api"; import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
@@ -33,26 +34,32 @@ export function useCreateFlowExit({
setDraftSaveBannerMessage?: (_message: string | null) => void; setDraftSaveBannerMessage?: (_message: string | null) => void;
/** When exit would discard unsaved work, return true to proceed. Defaults to denying leave. */ /** When exit would discard unsaved work, return true to proceed. Defaults to denying leave. */
confirmLeave?: () => Promise<boolean>; confirmLeave?: () => Promise<boolean>;
}): (_options?: { saveDraft?: boolean }) => Promise<void> { }): (_options?: { saveDraft?: boolean; skipConfirm?: boolean }) => Promise<void> {
return useCallback( return useCallback(
async (options?: { saveDraft?: boolean }) => { async (options?: { saveDraft?: boolean; skipConfirm?: boolean }) => {
if (!user) return; if (!user) return;
const saveDraft = options?.saveDraft ?? false; const saveDraft = options?.saveDraft ?? false;
if (!saveDraft) { if (!saveDraft && !options?.skipConfirm) {
const confirmFn = confirmLeave ?? (async () => false); const confirmFn = confirmLeave ?? (async () => false);
const confirmed = await confirmFn(); const confirmed = await confirmFn();
if (!confirmed) return; if (!confirmed) return;
} }
if (saveDraft && isBackendSyncEnabled()) { if (saveDraft && isBackendSyncEnabled()) {
const prepared = await stateWithPendingCommunityAvatar(state);
if (prepared.ok === false) {
setDraftSaveBannerMessage?.(messages.create.upload.errors.generic);
return;
}
const draftState = prepared.state;
const editingId = const editingId =
typeof state.editingPublishedRuleId === "string" typeof draftState.editingPublishedRuleId === "string"
? state.editingPublishedRuleId.trim() ? draftState.editingPublishedRuleId.trim()
: ""; : "";
if (editingId.length > 0) { if (editingId.length > 0) {
const payloadResult = buildPublishPayload(state); const payloadResult = buildPublishPayload(draftState);
if (payloadResult.ok === false) { if (payloadResult.ok === false) {
setDraftSaveBannerMessage?.( setDraftSaveBannerMessage?.(
payloadResult.error === "missingCommunityName" payloadResult.error === "missingCommunityName"
@@ -82,7 +89,7 @@ export function useCreateFlowExit({
} }
} else { } else {
const payload: CreateFlowState = { const payload: CreateFlowState = {
...state, ...draftState,
...(currentStep ? { currentStep } : {}), ...(currentStep ? { currentStep } : {}),
}; };
const result = await saveDraftToServer(payload); const result = await saveDraftToServer(payload);
@@ -2,6 +2,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api"; import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin"; import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
@@ -63,8 +64,19 @@ export function useCreateFlowFinalize({
if (sessionUser === undefined) return; if (sessionUser === undefined) return;
setPublishBannerMessage(null); setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state); setIsPublishing(true);
const prepared = await stateWithPendingCommunityAvatar(state);
if (prepared.ok === false) {
setIsPublishing(false);
setPublishBannerMessage(messages.create.upload.errors.generic);
return;
}
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
}
const payloadResult = buildPublishPayload(prepared.state);
if (payloadResult.ok === false) { if (payloadResult.ok === false) {
setIsPublishing(false);
setPublishBannerMessage( setPublishBannerMessage(
payloadResult.error === "missingCommunityName" payloadResult.error === "missingCommunityName"
? messages.create.reviewAndComplete.publish.missingCommunityName ? messages.create.reviewAndComplete.publish.missingCommunityName
@@ -73,7 +85,6 @@ export function useCreateFlowFinalize({
return; return;
} }
const { title, summary, document: ruleDocument } = payloadResult; const { title, summary, document: ruleDocument } = payloadResult;
setIsPublishing(true);
const editingId = const editingId =
typeof state.editingPublishedRuleId === "string" typeof state.editingPublishedRuleId === "string"
@@ -116,6 +116,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="title" stateField="title"
maxLength={48} maxLength={48}
required required
autoFocus
/> />
); );
case "community-structure": case "community-structure":
@@ -128,6 +129,8 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
maxLength={200} maxLength={200}
mainAlign="center" mainAlign="center"
multiline multiline
locationField
autoFocus
/> />
); );
case "community-size": case "community-size":
@@ -144,6 +147,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
inputType="email" inputType="email"
showCharacterCount={false} showCharacterCount={false}
headerJustification="center" headerJustification="center"
autoFocus
/> />
); );
case "review": case "review":
@@ -16,18 +16,9 @@ export function InformationalScreen() {
const copy = useMessages().create.community.informational; const copy = useMessages().create.community.informational;
const items = [ const items = [
{ { title: copy.steps["0"].title },
title: copy.steps["0"].title, { title: copy.steps["1"].title },
description: copy.steps["0"].description, { title: copy.steps["2"].title },
},
{
title: copy.steps["1"].title,
description: copy.steps["1"].description,
},
{
title: copy.steps["2"].title,
description: copy.steps["2"].description,
},
]; ];
return ( return (
@@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule"; import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button"; 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 { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp"; import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { import {
@@ -19,11 +21,67 @@ import {
} from "../../../../../lib/assetUtils"; } from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState"; import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths"; 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`). */ /** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() { export function CommunityReviewScreen() {
const router = useRouter(); const router = useRouter();
const lgUp = useCreateFlowLgUp(); const lgUp = useCreateFlowLgUp();
const messages = useMessages();
const t = useTranslation("create.community.review"); const t = useTranslation("create.community.review");
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** /**
@@ -91,11 +149,33 @@ export function CommunityReviewScreen() {
? state.communityContext.trim() ? state.communityContext.trim()
: undefined; : undefined;
const avatarUrl = const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0 const structure = messages.create.community.communityStructure;
? state.communityAvatarUrl.trim() const sizes = messages.create.community.communitySize.communitySizes;
: null; 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) { if (state.pendingTemplateAction || !reviewReady) {
return null; return null;
@@ -143,7 +223,7 @@ export function CommunityReviewScreen() {
description={t("header.description")} description={t("header.description")}
/> />
</div> </div>
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}> <div className={`flex flex-col ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}>
<Rule <Rule
title={cardTitle} title={cardTitle}
description={cardDescription} description={cardDescription}
@@ -156,6 +236,32 @@ export function CommunityReviewScreen() {
logoAlt={cardTitle} logoAlt={cardTitle}
className="rounded-[24px]" 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>
</div> </div>
</CreateFlowStepShell> </CreateFlowStepShell>
@@ -28,6 +28,7 @@ import {
} from "../../components/FinalReviewChipEditModal"; } from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal"; import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal"; import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor"; import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep"; import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
@@ -251,11 +252,7 @@ export function FinalReviewScreen({
const descriptionEmptyHint = const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined; variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
const avatarUrl = const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: null;
const storedFallback = const storedFallback =
typeof state.previewSurfaceColor === "string" typeof state.previewSurfaceColor === "string"
? state.previewSurfaceColor ? state.previewSurfaceColor
@@ -5,6 +5,7 @@ import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -48,6 +49,7 @@ export function CommunitySizeSelectScreen() {
const m = useMessages(); const m = useMessages();
const cs = m.create.community.communitySize; const cs = m.create.community.communitySize;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const { attempted } = useCreateFlowContinueAttempt();
const [communitySizeOptions, setCommunitySizeOptions] = useState< const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[] ChipOption[]
@@ -87,6 +89,9 @@ export function CommunitySizeSelectScreen() {
persistSelection(withSoleSelection(communitySizeOptions, nextId)); persistSelection(withSoleSelection(communitySizeOptions, nextId));
}; };
const showSizeError =
attempted && soleSizeId(state.selectedCommunitySizeIds) == null;
const multiSelectBlock = ( const multiSelectBlock = (
<MultiSelect <MultiSelect
formHeader={false} formHeader={false}
@@ -94,6 +99,7 @@ export function CommunitySizeSelectScreen() {
options={communitySizeOptions} options={communitySizeOptions}
onChipClick={handleCommunitySizeClick} onChipClick={handleCommunitySizeClick}
addButton={false} 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 type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
import type { CommunityStructureChipSnapshotRow } from "../../types"; import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; 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[] { function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options return options.filter((o) => o.state === "selected").map((o) => o.id);
.filter((o) => o.state === "selected") }
.map((o) => o.id);
function firstSelectedId(options: ChipOption[]): string | null {
return selectedIdsFromOptions(options)[0] ?? null;
} }
function chipOptionsToSnapshotRows( 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() { export function CommunityStructureSelectScreen() {
const m = useMessages(); const m = useMessages();
const cs = m.create.community.communityStructure; const cs = m.create.community.communityStructure;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const { attempted } = useCreateFlowContinueAttempt();
const [organizationTypeOptions, setOrganizationTypeOptions] = useState< const savedKindIds = state.selectedOrganizationTypeIds ?? [];
ChipOption[] 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( const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.organizationTypes, 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; if (fromSnap) return fromSnap;
return applySavedSelection( return applySavedSelection(
chipRowsFromLabels(cs.organizationTypes), chipRowsFromLabels(cs.organizationTypes),
state.selectedOrganizationTypeIds, savedOtherIds,
); );
}); });
@@ -137,29 +181,48 @@ export function CommunityStructureSelectScreen() {
const fromSnap = snapshotRowsToChipOptions( const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.maturity, state.communityStructureChipSnapshots?.maturity,
); );
if (fromSnap) return fromSnap; const base = fromSnap ?? chipRowsFromLabels(cs.maturityOptions);
return applySavedSelection( const maturityId =
chipRowsFromLabels(cs.maturityOptions), (state.selectedMaturityIds ?? []).find((id) => id.length > 0) ?? null;
state.selectedMaturityIds, return withSoleSelection(base, maturityId);
);
}); });
useEffect(() => { useEffect(() => {
const fromSnap = snapshotRowsToChipOptions( const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.organizationTypes, state.communityStructureChipSnapshots?.organizationTypes,
); );
if (fromSnap) { const mainId =
setOrganizationTypeOptions(fromSnap); (state.selectedOrganizationTypeIds ?? []).find((id) => id.length > 0) ??
return; null;
} setMainKindOptions(
setOrganizationTypeOptions((prev) => withSoleSelection(
applySavedSelection(prev, state.selectedOrganizationTypeIds), fromSnap ?? chipRowsFromLabels(cs.organizationTypes),
mainId,
),
); );
}, [ }, [
cs.organizationTypes,
state.communityStructureChipSnapshots?.organizationTypes, state.communityStructureChipSnapshots?.organizationTypes,
state.selectedOrganizationTypeIds, 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(() => { useEffect(() => {
const fromSnap = snapshotRowsToChipOptions( const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.scale, state.communityStructureChipSnapshots?.scale,
@@ -178,53 +241,50 @@ export function CommunityStructureSelectScreen() {
const fromSnap = snapshotRowsToChipOptions( const fromSnap = snapshotRowsToChipOptions(
state.communityStructureChipSnapshots?.maturity, state.communityStructureChipSnapshots?.maturity,
); );
const maturityId =
(state.selectedMaturityIds ?? []).find((id) => id.length > 0) ?? null;
if (fromSnap) { if (fromSnap) {
setMaturityOptions(fromSnap); setMaturityOptions(withSoleSelection(fromSnap, maturityId));
return; return;
} }
setMaturityOptions((prev) => setMaturityOptions((prev) => withSoleSelection(prev, maturityId));
applySavedSelection(prev, state.selectedMaturityIds),
);
}, [ }, [
state.communityStructureChipSnapshots?.maturity, state.communityStructureChipSnapshots?.maturity,
state.selectedMaturityIds, state.selectedMaturityIds,
]); ]);
const organizationCustomHandlers = useMemo( const mainKindCustomHandlers = useMemo(
() => () =>
createListCustomHandlers( createListCustomHandlers(
setOrganizationTypeOptions, setMainKindOptions,
"unselected", "unselected",
markCreateFlowInteraction, markCreateFlowInteraction,
), ),
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const scaleCustomHandlers = useMemo( const otherKindCustomHandlers = useMemo(
() => () =>
createListCustomHandlers( createListCustomHandlers(
setScaleOptions, setOtherKindOptions,
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
);
const maturityCustomHandlers = useMemo(
() =>
createListCustomHandlers(
setMaturityOptions,
"unselected", "unselected",
markCreateFlowInteraction, markCreateFlowInteraction,
), ),
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const persistOrg = (next: ChipOption[]) => { const persistKinds = (main: ChipOption[], others: ChipOption[]) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setOrganizationTypeOptions(next); setMainKindOptions(main);
setOtherKindOptions(others);
const mainId = firstSelectedId(main);
updateState({ updateState({
selectedOrganizationTypeIds: selectedIdsFromOptions(next), selectedOrganizationTypeIds: organizationIdsForPersist(
mainId,
selectedIdsFromOptions(others),
),
communityStructureChipSnapshots: { communityStructureChipSnapshots: {
organizationTypes: chipOptionsToSnapshotRows(next), organizationTypes: chipOptionsToSnapshotRows(main),
otherOrganizationTypes: chipOptionsToSnapshotRows(others),
}, },
}); });
}; };
@@ -251,9 +311,16 @@ export function CommunityStructureSelectScreen() {
}); });
}; };
const handleOrganizationTypeClick = (chipId: string) => { const handleMainKindClick = (chipId: string) => {
persistOrg( const current = firstSelectedId(mainKindOptions);
organizationTypeOptions.map((opt) => const nextId = current === chipId ? null : chipId;
persistKinds(withSoleSelection(mainKindOptions, nextId), otherKindOptions);
};
const handleOtherKindClick = (chipId: string) => {
persistKinds(
mainKindOptions,
otherKindOptions.map((opt) =>
opt.id === chipId opt.id === chipId
? { ? {
...opt, ...opt,
@@ -284,49 +351,49 @@ export function CommunityStructureSelectScreen() {
}; };
const handleMaturityClick = (chipId: string) => { const handleMaturityClick = (chipId: string) => {
persistMaturity( const current = firstSelectedId(maturityOptions);
maturityOptions.map((opt) => const nextId = current === chipId ? null : chipId;
opt.id === chipId persistMaturity(withSoleSelection(maturityOptions, nextId));
? {
...opt,
state:
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
);
}; };
const showMainError = attempted && firstSelectedId(mainKindOptions) == null;
const showMaturityError =
attempted && firstSelectedId(maturityOptions) == null;
const multiSelectBlock = ( const multiSelectBlock = (
<> <>
<MultiSelect <MultiSelect
label={cs.organizationMultiSelect.label} label={cs.mainKindMultiSelect.label}
helperText={cs.mainKindMultiSelect.helperText}
size="s" size="s"
options={organizationTypeOptions} options={mainKindOptions}
onChipClick={handleOrganizationTypeClick} onChipClick={handleMainKindClick}
{...organizationCustomHandlers} {...mainKindCustomHandlers}
addButton
errorMessage={showMainError ? cs.pickOneError : undefined}
/>
<MultiSelect
label={cs.otherKindMultiSelect.label}
size="s"
options={otherKindOptions}
onChipClick={handleOtherKindClick}
{...otherKindCustomHandlers}
addButton addButton
addButtonText={cs.organizationMultiSelect.addButtonText}
/> />
<MultiSelect <MultiSelect
label={cs.scaleMultiSelect.label} label={cs.scaleMultiSelect.label}
size="s" size="s"
options={scaleOptions} options={scaleOptions}
onChipClick={handleScaleClick} onChipClick={handleScaleClick}
{...scaleCustomHandlers} addButton={false}
addButton
addButtonText={cs.scaleMultiSelect.addButtonText}
/> />
<MultiSelect <MultiSelect
label={cs.maturityMultiSelect.label} label={cs.maturityMultiSelect.label}
size="s" size="s"
options={maturityOptions} options={maturityOptions}
onChipClick={handleMaturityClick} onChipClick={handleMaturityClick}
{...maturityCustomHandlers} addButton={false}
addButton errorMessage={showMaturityError ? cs.pickOneError : undefined}
addButtonText={cs.maturityMultiSelect.addButtonText}
/> />
</> </>
); );
@@ -6,6 +6,7 @@ import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types"; import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { import {
@@ -14,6 +15,7 @@ import {
} from "../../components/CreateFlowStepShell"; } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths"; import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import { isValidCreateFlowSaveEmail } from "../../../../../lib/create/isValidCreateFlowSaveEmail";
import type { CreateFlowTextStateField } from "../../types"; import type { CreateFlowTextStateField } from "../../types";
type Props = { type Props = {
@@ -31,6 +33,9 @@ type Props = {
headerJustification?: HeaderLockupJustificationValue; headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */ /** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
contentTopBelowMd?: CreateFlowContentTopBelowMd; contentTopBelowMd?: CreateFlowContentTopBelowMd;
autoFocus?: boolean;
/** Optional city/region field under the description. */
locationField?: boolean;
}; };
/** /**
@@ -47,35 +52,38 @@ export function CreateFlowTextFieldScreen({
showCharacterCount = true, showCharacterCount = true,
headerJustification = "left", headerJustification = "left",
contentTopBelowMd = "space-1400", contentTopBelowMd = "space-1400",
autoFocus = false,
locationField = false,
}: Props) { }: Props) {
const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const { attempted } = useCreateFlowContinueAttempt();
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const t = useTranslation(messageNamespace); const t = useTranslation(messageNamespace);
const readFromState = (): string => { const readFromState = (field: CreateFlowTextStateField): string => {
const raw = state[stateField]; const raw = state[field];
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
}; };
const [value, setValue] = useState(() => readFromState()); const [value, setValue] = useState(() => readFromState(stateField));
const [locationValue, setLocationValue] = useState(() =>
readFromState("communityLocation"),
);
useEffect(() => { useEffect(() => {
const incoming = readFromState(); const incoming = readFromState(stateField);
if (incoming.length === 0) return; if (incoming.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local // eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
setValue((prev) => (prev === "" ? incoming : prev)); setValue((prev) => (prev === "" ? incoming : prev));
}, [state, stateField]); }, [state, stateField]);
const characterCount = value.length; useEffect(() => {
const hint = if (!locationField) return;
showCharacterCount === false const incoming = readFromState("communityLocation");
? false if (incoming.length === 0) return;
: t("characterCountTemplate") // eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
.replace("{current}", String(characterCount)) setLocationValue((prev) => (prev === "" ? incoming : prev));
.replace("{max}", String(maxLength)); }, [locationField, state]);
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
const isEmail = inputType === "email"; const isEmail = inputType === "email";
const persistValue = (next: string) => { const persistValue = (next: string) => {
@@ -83,6 +91,42 @@ export function CreateFlowTextFieldScreen({
markCreateFlowInteraction(); markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>); 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"); const fieldLabel = t("inputLabel");
@@ -101,7 +145,7 @@ export function CreateFlowTextFieldScreen({
justification={headerJustification} justification={headerJustification}
/> />
</div> </div>
<div className="w-full"> <div className="flex w-full flex-col gap-[var(--space-800)]">
{multiline ? ( {multiline ? (
<TextArea <TextArea
className="!transition-none" className="!transition-none"
@@ -119,6 +163,7 @@ export function CreateFlowTextFieldScreen({
textHint={hint} textHint={hint}
maxLength={maxLength} maxLength={maxLength}
required={required} required={required}
autoFocus={autoFocus}
rows={4} rows={4}
/> />
) : ( ) : (
@@ -149,13 +194,33 @@ export function CreateFlowTextFieldScreen({
formHeader={false} formHeader={false}
showHelpIcon={false} showHelpIcon={false}
textHint={hint} textHint={hint}
error={showFieldError}
maxLength={maxLength} maxLength={maxLength}
required={required || isEmail} required={required || isEmail}
autoComplete={isEmail ? "email" : undefined} autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined} inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : 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>
</div> </div>
</CreateFlowStepShell> </CreateFlowStepShell>
@@ -14,7 +14,6 @@ import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { fetchAuthSession } from "../../../../../lib/create/api"; import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { import {
createFlowUploadFailureMessageKey, createFlowUploadFailureMessageKey,
uploadCreateFlowFile, uploadCreateFlowFile,
@@ -34,7 +33,7 @@ function hasCommunityAvatarUrl(url: string | undefined): boolean {
return typeof url === "string" && url.trim().length > 0; return typeof url === "string" && url.trim().length > 0;
} }
/** Create Community — Figma Flow — Upload `20094:41524`. */ /** Create Community — Figma Flow — Upload. */
export function CommunityUploadScreen() { export function CommunityUploadScreen() {
const m = useMessages(); const m = useMessages();
const u = m.create.community.communityUpload; const u = m.create.community.communityUpload;
@@ -44,6 +43,7 @@ export function CommunityUploadScreen() {
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [signedIn, setSignedIn] = useState<boolean | null>(null); const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [localFileName, setLocalFileName] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -104,6 +104,10 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return objectUrl; return objectUrl;
}); });
setLocalFileName(file.name);
if (!state.communityAvatarFileName) {
updateState({ communityAvatarFileName: file.name });
}
} catch { } catch {
// Missing IndexedDB / quota: leave the picker empty. // Missing IndexedDB / quota: leave the picker empty.
} }
@@ -111,11 +115,15 @@ export function CommunityUploadScreen() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [state.communityAvatarFileName, updateState]);
const resolveUploadError = useCallback( const resolveUploadError = useCallback(
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)), (err: unknown) => {
[tUpload], const key = createFlowUploadFailureMessageKey(err);
if (key === "errors.tooLarge") return u.fileTooLarge;
return tUpload(key);
},
[tUpload, u.fileTooLarge],
); );
const handleFileChange = useCallback( const handleFileChange = useCallback(
@@ -126,7 +134,7 @@ export function CommunityUploadScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
setErrorMessage(null); setErrorMessage(null);
if (signedIn) { if (signedIn === true) {
setBusy(true); setBusy(true);
try { try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar"); const { url } = await uploadCreateFlowFile(file, "communityAvatar");
@@ -134,7 +142,11 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
}); });
updateState({ communityAvatarUrl: url }); setLocalFileName(file.name);
updateState({
communityAvatarUrl: url,
communityAvatarFileName: file.name,
});
} catch (err) { } catch (err) {
setErrorMessage(resolveUploadError(err)); setErrorMessage(resolveUploadError(err));
} finally { } finally {
@@ -143,26 +155,28 @@ export function CommunityUploadScreen() {
return; return;
} }
if (signedIn === false) { const validated = await validateCreateFlowUploadFile(
const validated = await validateCreateFlowUploadFile( file,
file, "communityAvatar",
"communityAvatar", );
if (validated.ok === false) {
setErrorMessage(
validated.reason === "tooLarge"
? u.fileTooLarge
: tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
); );
if (validated.ok === false) { return;
setErrorMessage( }
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)), try {
); await storePendingCommunityAvatarFile(file);
return; setLocalPreviewUrl((prev) => {
} if (prev) URL.revokeObjectURL(prev);
try { return URL.createObjectURL(file);
await storePendingCommunityAvatarFile(file); });
setLocalPreviewUrl((prev) => { setLocalFileName(file.name);
if (prev) URL.revokeObjectURL(prev); updateState({ communityAvatarFileName: file.name });
return URL.createObjectURL(file); } catch {
}); setErrorMessage(tUpload("errors.generic"));
} catch {
setErrorMessage(tUpload("errors.generic"));
}
} }
}, },
[ [
@@ -170,28 +184,23 @@ export function CommunityUploadScreen() {
resolveUploadError, resolveUploadError,
signedIn, signedIn,
tUpload, tUpload,
u.fileTooLarge,
updateState, updateState,
], ],
); );
const handleClearPendingUpload = useCallback(() => { const handlePickFile = useCallback(() => {
markCreateFlowInteraction(); if (!busy) fileInputRef.current?.click();
setErrorMessage(null); }, [busy]);
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 displaySrc = serverAvatarUrl ?? localPreviewUrl; const displaySrc = serverAvatarUrl ?? localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; 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 ( return (
<CreateFlowStepShell <CreateFlowStepShell
@@ -214,60 +223,28 @@ export function CommunityUploadScreen() {
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept={COMMUNITY_AVATAR_ACCEPT} accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText} aria-label={hasPreview ? u.replaceButton : u.hintText}
onChange={handleFileChange} onChange={handleFileChange}
/> />
<div className="flex w-full flex-col items-center gap-3"> <Upload
{hasPreview ? ( hasLabel={false}
<div className="relative inline-block max-w-full"> active={!busy}
<button state={
type="button" errorMessage ? "error" : busy ? "disabled" : "default"
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)]" hintText={
aria-label={u.clearPendingUploadAriaLabel} errorMessage
title={u.clearPendingUploadTooltip} ? errorMessage
> : busy
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */} ? u.uploadingLabel
<img : u.hintText
src={getAssetPath(ASSETS.ICON_CLOSE)} }
alt="" buttonLabel={hasPreview ? u.replaceButton : undefined}
className="h-[16px] w-[16px]" previewSrc={hasPreview ? displaySrc ?? undefined : undefined}
style={{ previewAlt={u.previewAlt}
filter: "brightness(0) invert(1)", fileName={hasPreview ? fileName : undefined}
}} onClick={handlePickFile}
/> />
</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>
</div> </div>
</CreateFlowStepShell> </CreateFlowStepShell>
); );
+10
View File
@@ -37,6 +37,7 @@ export type CreateFlowTextStateField =
| "title" | "title"
| "summary" | "summary"
| "communityContext" | "communityContext"
| "communityLocation"
| "communitySaveEmail"; | "communitySaveEmail";
/** /**
@@ -111,6 +112,8 @@ export interface CreateFlowState {
summary?: string; summary?: string;
/** Additional copy fields for multi-step Create Community text frames (Figma). */ /** Additional copy fields for multi-step Create Community text frames (Figma). */
communityContext?: string; 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`). */ /** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
communitySaveEmail?: string; communitySaveEmail?: string;
/** /**
@@ -118,6 +121,12 @@ export interface CreateFlowState {
* Set after successful `POST /api/uploads` with purpose `communityAvatar`. * Set after successful `POST /api/uploads` with purpose `communityAvatar`.
*/ */
communityAvatarUrl?: string; 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. * 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. * Picked once and kept on the draft so reload does not change it.
@@ -147,6 +156,7 @@ export interface CreateFlowState {
*/ */
communityStructureChipSnapshots?: { communityStructureChipSnapshots?: {
organizationTypes?: CommunityStructureChipSnapshotRow[]; organizationTypes?: CommunityStructureChipSnapshotRow[];
otherOrganizationTypes?: CommunityStructureChipSnapshotRow[];
scale?: CommunityStructureChipSnapshotRow[]; scale?: CommunityStructureChipSnapshotRow[];
maturity?: CommunityStructureChipSnapshotRow[]; maturity?: CommunityStructureChipSnapshotRow[];
}; };
@@ -1,4 +1,5 @@
import type { CreateFlowState } from "../types"; import type { CreateFlowState } from "../types";
import { forgetFlushedCommunityAvatarUrl } from "../../../../lib/create/flushPendingCommunityAvatar";
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState"; import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload"; import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
@@ -47,7 +48,10 @@ export function writeAnonymousCreateFlowState(value: CreateFlowState): void {
} }
} }
export function clearAnonymousCreateFlowStorage(): void { export function clearAnonymousCreateFlowStorage(options?: {
/** Keep the staged logo so a failed upload can retry. */
preservePendingAvatar?: boolean;
}): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY); window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY);
@@ -55,6 +59,8 @@ export function clearAnonymousCreateFlowStorage(): void {
} catch { } catch {
// ignore // ignore
} }
if (options?.preservePendingAvatar) return;
forgetFlushedCommunityAvatarUrl();
void clearPendingCommunityAvatarFile(); void clearPendingCommunityAvatarFile();
} }
@@ -23,12 +23,34 @@ const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
["community-size", "confirmSize"], ["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( export function isCommunitySizeStepBlocked(
step: CreateFlowStep | null | undefined, step: CreateFlowStep | null | undefined,
selectedCommunitySizeIds: readonly string[] | undefined, selectedCommunitySizeIds: readonly string[] | undefined,
): boolean { ): boolean {
if (step !== "community-size") return false; 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-2", // community-structure
"1-3", // community-context "1-3", // community-context
"1-4", // community-size "1-4", // community-size
"1-5", // community-upload "1-6", // community-upload (1-5 is reserved for setup questions)
"2-0", // community-save "1-7", // community-save
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community) "1-8", // review
"2-0", // core-values (same segment as review / end of Create Community) "2-0", // core-values (same fill as 1-8)
"2-1", // communication-methods (Figma — Compact Card Stack) "2-1", // communication-methods (Figma — Compact Card Stack)
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`) "2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`) "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). */ /** Figma: Flow — Informational (node 20094-16005). */
informational: { informational: {
layoutKind: "informational", layoutKind: "informational",
figmaNodeId: "20094-16005", figmaNodeId: "25224-105191",
messageNamespace: "create.community.informational", messageNamespace: "create.community.informational",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
@@ -59,13 +59,13 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
}, },
"community-context": { "community-context": {
layoutKind: "text", layoutKind: "text",
figmaNodeId: "20094-41243", figmaNodeId: "25224-106030",
messageNamespace: "create.community.communityContext", messageNamespace: "create.community.communityContext",
centeredBodyBelowMd: true, centeredBodyBelowMd: true,
}, },
"community-structure": { "community-structure": {
layoutKind: "select", layoutKind: "select",
figmaNodeId: "20094-18244", figmaNodeId: "25224-105767",
messageNamespace: "create.community.communityStructure", messageNamespace: "create.community.communityStructure",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
+12 -7
View File
@@ -172,13 +172,13 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
return FLOW_STEP_ORDER.indexOf(step); return FLOW_STEP_ORDER.indexOf(step);
} }
/** First wizard step that offers Save & Exit (first Create Community select). */ /** First wizard step that offers Save and exit (after the community is ready to keep). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure"); const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-save");
/** /**
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from * Top-nav Save and exit (vs Exit). Guests and signed-in users share this from
* `community-structure` onward and on `edit-rule`. On completed, guests still * `community-save` onward and on `edit-rule`. On completed, guests still
* get Save & Exit (claim/login); signed-in users get Exit (already owned). * get Save and exit (claim/login); signed-in users get Exit (already owned).
* *
* @param sessionUser `null` is a guest. `undefined` (session in flight) is * @param sessionUser `null` is a guest. `undefined` (session in flight) is
* not a guest — completed stays Exit until the session resolves. * 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_QUERY = "manageStakeholders" as const;
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" 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( export function parseReviewReturnSearchParam(
searchParams: { get: (name: string) => string | null } | null | undefined, searchParams: { get: (name: string) => string | null } | null | undefined,
): CreateFlowReviewReturnTarget | null { ): CreateFlowReviewReturnTarget | null {
if (!searchParams) return null; if (!searchParams) return null;
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); 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; return null;
} }
+35 -11
View File
@@ -31,6 +31,17 @@ function isPurpose(x: string): x is CreateFlowUploadPurpose {
return x === "communityAvatar" || x === "customMethodAttachment"; return x === "communityAvatar" || x === "customMethodAttachment";
} }
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown"
);
}
/** Same spacing as a signed-in upload, keyed by IP so a guest cannot fill the disk. */
const ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS = 5_000;
function messageForValidationReason( function messageForValidationReason(
reason: CreateFlowUploadValidationReason, reason: CreateFlowUploadValidationReason,
): string { ): string {
@@ -54,7 +65,19 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
} }
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) {
let formData: FormData | null = null;
try {
formData = await request.formData();
} catch {
formData = null;
}
const purposeRaw = formData?.get("purpose");
// A guest publishes before they have a session. Only the community logo
// can be stored without one; attachments stay signed-in. A body that
// cannot be parsed is not a logo upload.
if (!user && purposeRaw !== "communityAvatar") {
return unauthorized(); return unauthorized();
} }
@@ -64,15 +87,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
const rl = rateLimitKey(`upload:${user.id}`, 5_000); if (!formData) {
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return errorJson( return errorJson(
"payload_too_large", "payload_too_large",
"Upload body is too large or malformed.", "Upload body is too large or malformed.",
@@ -80,9 +95,18 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
const purposeRaw = formData.get("purpose");
const file = asUploadedBlob(formData.get("file")); const file = asUploadedBlob(formData.get("file"));
const rl = user
? rateLimitKey(`upload:${user.id}`, 5_000)
: rateLimitKey(
`upload-anon:${clientIp(request)}`,
ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS,
);
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
return errorJson( return errorJson(
"validation_error", "validation_error",
+8 -6
View File
@@ -163,14 +163,16 @@ export function RuleView({
// Render logo/icon // Render logo/icon
const renderLogo = () => { const renderLogo = () => {
if (logoUrl) { if (logoUrl) {
// Check if it's a localhost URL or external URL that needs regular img tag // next/image cannot load blob previews or localhost optimizer targets.
const isLocalhost = const useNativeImage =
logoUrl.startsWith("blob:") ||
logoUrl.startsWith("data:") ||
logoUrl.startsWith("http://localhost") || logoUrl.startsWith("http://localhost") ||
logoUrl.startsWith("https://localhost"); logoUrl.startsWith("https://localhost");
const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity`; const containerClass = `${logoContainerClass} relative overflow-hidden rounded-full bg-[var(--color-surface-invert-brand-teal)]`;
if (isLocalhost) { if (useNativeImage) {
return ( return (
<div className={containerClass}> <div className={containerClass}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -179,7 +181,7 @@ export function RuleView({
alt={logoAlt || title} alt={logoAlt || title}
width={logoSize} width={logoSize}
height={logoSize} height={logoSize}
className="w-full h-full object-cover rounded-full" className="h-full w-full rounded-full object-cover opacity-70 mix-blend-color-burn"
/> />
</div> </div>
); );
@@ -192,7 +194,7 @@ export function RuleView({
alt={logoAlt || title} alt={logoAlt || title}
width={logoSize} width={logoSize}
height={logoSize} height={logoSize}
className="w-full h-full object-cover rounded-full" className="h-full w-full rounded-full object-cover opacity-70 mix-blend-color-burn"
/> />
</div> </div>
); );
@@ -21,6 +21,8 @@ const MultiSelectContainer = memo<MultiSelectProps>(
addButton: addButtonProp = true, addButton: addButtonProp = true,
addButtonText, addButtonText,
formHeader = true, formHeader = true,
helperText,
errorMessage,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
maxSelections, maxSelections,
@@ -49,6 +51,8 @@ const MultiSelectContainer = memo<MultiSelectProps>(
chipSelectAriaLabel={t("chipSelect")} chipSelectAriaLabel={t("chipSelect")}
chipDeselectAriaLabel={t("chipDeselect")} chipDeselectAriaLabel={t("chipDeselect")}
formHeader={formHeader} formHeader={formHeader}
helperText={helperText}
errorMessage={errorMessage}
onCustomChipConfirm={onCustomChipConfirm} onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose} onCustomChipClose={onCustomChipClose}
maxSelections={maxSelections} maxSelections={maxSelections}
@@ -54,6 +54,15 @@ export interface MultiSelectProps {
* @default true * @default true
*/ */
formHeader?: boolean; formHeader?: boolean;
/**
* Helper line beside the label (Figma “Pick one”). Wins over
* `selectionCountText` in the header.
*/
helperText?: string;
/**
* Danger hint under the chips for a required question left unanswered.
*/
errorMessage?: string;
/** /**
* Callback when a custom chip is confirmed (check button clicked) * Callback when a custom chip is confirmed (check button clicked)
*/ */
@@ -88,6 +97,8 @@ export interface MultiSelectViewProps {
chipSelectAriaLabel: string; chipSelectAriaLabel: string;
chipDeselectAriaLabel: string; chipDeselectAriaLabel: string;
formHeader: boolean; formHeader: boolean;
helperText?: string;
errorMessage?: string;
onCustomChipConfirm?: (chipId: string, value: string) => void; onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void; onCustomChipClose?: (chipId: string) => void;
maxSelections?: number; maxSelections?: number;
@@ -19,6 +19,8 @@ function MultiSelectView({
chipSelectAriaLabel, chipSelectAriaLabel,
chipDeselectAriaLabel, chipDeselectAriaLabel,
formHeader = true, formHeader = true,
helperText: helperTextProp,
errorMessage,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
maxSelections, maxSelections,
@@ -39,7 +41,9 @@ function MultiSelectView({
const atLimit = const atLimit =
maxSelections != null && selectedCount >= maxSelections; maxSelections != null && selectedCount >= maxSelections;
const countCaption = selectionCountText?.trim() ?? ""; const countCaption = selectionCountText?.trim() ?? "";
const helperText = countCaption.length > 0 ? countCaption : false; const headerHelper =
helperTextProp?.trim() ||
(countCaption.length > 0 ? countCaption : false);
return ( return (
<div <div
@@ -51,16 +55,16 @@ function MultiSelectView({
label={label} label={label}
helpIcon={showHelpIcon} helpIcon={showHelpIcon}
asterisk={false} asterisk={false}
helperText={helperText} helperText={headerHelper}
size={size} size={size}
palette={palette} palette={palette}
/> />
)} )}
{!formHeader || !label ? ( {!formHeader || !label ? (
countCaption.length > 0 ? ( headerHelper && typeof headerHelper === "string" ? (
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary)]"> <p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary)]">
{countCaption} {headerHelper}
</p> </p>
) : null ) : null
) : null} ) : null}
@@ -177,6 +181,14 @@ function MultiSelectView({
</button> </button>
)} )}
</div> </div>
{errorMessage ? (
<p
className="w-full text-x-small-paragraph text-[color:var(--color-content-default-negative-primary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div> </div>
); );
} }
@@ -71,5 +71,6 @@ export interface TextInputViewProps {
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"]; inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
required?: boolean; required?: boolean;
form?: string; form?: string;
autoFocus?: boolean;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>; onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
} }
@@ -35,6 +35,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
inputMode, inputMode,
required = false, required = false,
form, form,
autoFocus,
onKeyDown, onKeyDown,
state: _state, state: _state,
isFilled: _isFilled, isFilled: _isFilled,
@@ -94,6 +95,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
inputMode={inputMode} inputMode={inputMode}
required={required} required={required}
form={form} form={form}
autoFocus={autoFocus}
aria-invalid={error || undefined} aria-invalid={error || undefined}
aria-required={required || undefined} aria-required={required || undefined}
className={inputClasses} className={inputClasses}
@@ -18,12 +18,17 @@ const UploadContainer = memo<UploadProps>(
label, label,
showHelpIcon = true, showHelpIcon = true,
hintText, hintText,
buttonLabel,
previewSrc,
previewAlt,
fileName,
onClick, onClick,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
const resolvedState: UploadStateValue = const resolvedState: UploadStateValue =
state ?? (active ? "default" : "disabled"); state ?? (active ? "default" : "disabled");
const uploadButtonLabel = buttonLabel ?? t("uploadButton");
return ( return (
<UploadView <UploadView
@@ -32,8 +37,11 @@ const UploadContainer = memo<UploadProps>(
label={label} label={label}
showHelpIcon={showHelpIcon} showHelpIcon={showHelpIcon}
hintText={hintText ?? t("uploadHintDefault")} hintText={hintText ?? t("uploadHintDefault")}
uploadButtonLabel={t("uploadButton")} uploadButtonLabel={uploadButtonLabel}
uploadAriaLabel={t("uploadAriaLabel")} uploadAriaLabel={uploadButtonLabel}
previewSrc={previewSrc}
previewAlt={previewAlt ?? t("uploadAriaLabel")}
fileName={fileName}
onClick={onClick} onClick={onClick}
className={className} className={className}
/> />
@@ -32,6 +32,17 @@ export interface UploadProps {
* @default "Add image from your device" * @default "Add image from your device"
*/ */
hintText?: string; hintText?: string;
/**
* Label on the upload control. Defaults to the chrome “Upload” string.
* Pass “Replace” for the uploaded state.
*/
buttonLabel?: string;
/**
* Round preview of the selected file (Figma Has Preview).
*/
previewSrc?: string;
previewAlt?: string;
fileName?: string;
/** /**
* Callback when upload button is clicked * Callback when upload button is clicked
*/ */
@@ -50,6 +61,9 @@ export interface UploadViewProps {
hintText: string; hintText: string;
uploadButtonLabel: string; uploadButtonLabel: string;
uploadAriaLabel: string; uploadAriaLabel: string;
previewSrc?: string;
previewAlt: string;
fileName?: string;
onClick?: () => void; onClick?: () => void;
className: string; className: string;
} }
+62 -47
View File
@@ -12,13 +12,17 @@ function UploadView({
hintText, hintText,
uploadButtonLabel, uploadButtonLabel,
uploadAriaLabel, uploadAriaLabel,
previewSrc,
previewAlt,
fileName,
onClick, onClick,
className = "", className = "",
}: UploadViewProps) { }: UploadViewProps) {
const isActive = state !== "disabled"; const isActive = state !== "disabled";
const isDisabled = state === "disabled"; const isDisabled = state === "disabled";
const hasPreview =
typeof previewSrc === "string" && previewSrc.length > 0;
// Button styles based on active state
const buttonBgClass = isActive const buttonBgClass = isActive
? "bg-[var(--color-surface-invert-primary)]" ? "bg-[var(--color-surface-invert-primary)]"
: "bg-[var(--color-surface-default-secondary)]"; : "bg-[var(--color-surface-default-secondary)]";
@@ -27,12 +31,10 @@ function UploadView({
? "text-[color:var(--color-content-invert-primary)]" ? "text-[color:var(--color-content-invert-primary)]"
: "text-[color:var(--color-content-invert-tertiary)]"; : "text-[color:var(--color-content-invert-tertiary)]";
// Description text color based on active state
const descriptionTextColor = isActive const descriptionTextColor = isActive
? "text-[color:var(--color-content-default-primary)]" ? "text-[color:var(--color-content-default-primary)]"
: "text-[color:var(--color-content-default-tertiary)]"; : "text-[color:var(--color-content-default-tertiary)]";
// Icon color based on active state
const iconColor = isActive const iconColor = isActive
? "text-[color:var(--color-content-invert-primary)]" ? "text-[color:var(--color-content-invert-primary)]"
: "text-[color:var(--color-content-invert-tertiary)]"; : "text-[color:var(--color-content-invert-tertiary)]";
@@ -41,7 +43,6 @@ function UploadView({
<div <div
className={`flex flex-col gap-[var(--space-300)] items-start relative w-full ${className}`} className={`flex flex-col gap-[var(--space-300)] items-start relative w-full ${className}`}
> >
{/* Label using InputLabel component */}
{hasLabel && label ? ( {hasLabel && label ? (
<InputLabel <InputLabel
label={label} label={label}
@@ -53,15 +54,27 @@ function UploadView({
/> />
) : null} ) : null}
{/* Upload container — max width per create-flow spec */}
<div <div
className={`bg-[var(--color-surface-default-secondary)] mx-auto flex w-full max-w-[474px] shrink-0 items-center justify-center gap-[24px] rounded-[var(--radius-200)] px-[var(--space-600)] py-[var(--space-1200)] ${ className={`bg-[var(--color-surface-default-secondary)] mx-auto flex w-full max-w-[474px] shrink-0 items-center justify-center gap-[24px] rounded-[var(--radius-200)] px-[var(--space-600)] py-[var(--space-1200)] ${
state === "error" state === "error"
? "border border-[var(--color-border-default-utility-negative)]" ? "border-2 border-[var(--color-border-default-negative-primary)]"
: "" : ""
} ${state === "focus" ? "shadow-[var(--shadow-focus-default)]" : ""}`} } ${state === "focus" ? "shadow-[var(--shadow-focus-default)]" : ""}`}
> >
{/* Upload button */} {hasPreview ? (
<div
className="relative size-[80px] shrink-0 overflow-hidden rounded-full bg-[var(--color-surface-invert-brand-teal)]"
aria-hidden={false}
>
{/* eslint-disable-next-line @next/next/no-img-element -- user/device file or same-origin upload URL */}
<img
src={previewSrc}
alt={previewAlt}
className="h-full w-full object-cover opacity-70 mix-blend-color-burn"
/>
</div>
) : null}
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
@@ -71,43 +84,43 @@ function UploadView({
} ${state === "active" ? "scale-[0.98]" : ""}`} } ${state === "active" ? "scale-[0.98]" : ""}`}
aria-label={uploadAriaLabel} aria-label={uploadAriaLabel}
> >
{/* Upload icon */} {hasPreview ? null : (
<div className={`relative shrink-0 size-[20px] ${iconColor}`}> <div className={`relative shrink-0 size-[20px] ${iconColor}`}>
<svg <svg
width="20" width="20"
height="20" height="20"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="size-full" className="size-full"
> >
<path <path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<polyline <polyline
points="17 8 12 3 7 8" points="17 8 12 3 7 8"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<line <line
x1="12" x1="12"
y1="3" y1="3"
x2="12" x2="12"
y2="15" y2="15"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</div> </div>
{/* Button text */} )}
<div <div
className={`flex flex-col font-medium justify-center leading-[0] relative shrink-0 text-[length:var(--sizing-400)] whitespace-nowrap ${buttonTextColor}`} className={`flex flex-col font-medium justify-center leading-[0] relative shrink-0 text-[length:var(--sizing-400)] whitespace-nowrap ${buttonTextColor}`}
> >
@@ -115,11 +128,13 @@ function UploadView({
</div> </div>
</button> </button>
{/* Description text */}
<div <div
className={`flex flex-[1_0_0] flex-col font-normal h-[32px] justify-center leading-[0] min-h-px min-w-px relative text-[length:var(--sizing-350)] ${descriptionTextColor}`} className={`flex flex-[1_0_0] flex-col font-normal min-h-[32px] justify-center leading-[0] min-w-px relative text-[length:var(--sizing-350)] ${descriptionTextColor}`}
role={state === "error" ? "alert" : undefined}
> >
<p className="leading-[20px] whitespace-pre-wrap">{hintText}</p> <p className="leading-[20px] whitespace-pre-wrap">
{hasPreview && fileName ? fileName : hintText}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -73,6 +73,8 @@ const AlertContainer = memo<AlertProps>(
hasBodyText = true, hasBodyText = true,
hasTrailingIcon: hasTrailingIconProp, hasTrailingIcon: hasTrailingIconProp,
onClose, onClose,
actionLabel,
onAction,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
@@ -178,6 +180,8 @@ const AlertContainer = memo<AlertProps>(
closeButtonIconColor={statusStyles.closeButtonIconColor} closeButtonIconColor={statusStyles.closeButtonIconColor}
onClose={onClose} onClose={onClose}
closeAlertAriaLabel={t("closeAlert")} closeAlertAriaLabel={t("closeAlert")}
actionLabel={actionLabel}
onAction={onAction}
/> />
); );
}, },
@@ -38,6 +38,9 @@ export interface AlertProps {
*/ */
hasTrailingIcon?: boolean; hasTrailingIcon?: boolean;
onClose?: () => void; onClose?: () => void;
/** Optional trailing action (Figma “Send it again”). */
actionLabel?: string;
onAction?: () => void;
className?: string; className?: string;
} }
@@ -58,4 +61,6 @@ export interface AlertViewProps {
closeButtonIconColor: string; closeButtonIconColor: string;
onClose?: () => void; onClose?: () => void;
closeAlertAriaLabel: string; closeAlertAriaLabel: string;
actionLabel?: string;
onAction?: () => void;
} }
@@ -18,6 +18,8 @@ export function AlertView({
closeButtonIconColor, closeButtonIconColor,
onClose, onClose,
closeAlertAriaLabel, closeAlertAriaLabel,
actionLabel,
onAction,
}: AlertViewProps) { }: AlertViewProps) {
const getIcon = () => { const getIcon = () => {
// Use the Icon_Alert.svg with dynamic fill color // Use the Icon_Alert.svg with dynamic fill color
@@ -55,6 +57,15 @@ export function AlertView({
{hasBodyText && description && ( {hasBodyText && description && (
<p className={descriptionClasses}>{description}</p> <p className={descriptionClasses}>{description}</p>
)} )}
{actionLabel && onAction ? (
<button
type="button"
onClick={onAction}
className="mt-[var(--space-100)] cursor-pointer text-small-label text-[color:var(--color-content-invert-primary)] underline underline-offset-[3px]"
>
{actionLabel}
</button>
) : null}
</div> </div>
{hasTrailingIcon && onClose ? ( {hasTrailingIcon && onClose ? (
<Button <Button
@@ -7,6 +7,9 @@ export type ProportionBarState =
| "1-3" | "1-3"
| "1-4" | "1-4"
| "1-5" | "1-5"
| "1-6"
| "1-7"
| "1-8"
| "2-0" | "2-0"
| "2-1" | "2-1"
| "2-2" | "2-2"
@@ -27,13 +27,13 @@ export function ProportionBarView({
// Proportion bar type // Proportion bar type
const [fullSegments, partialSegment] = progress.split("-").map(Number); const [fullSegments, partialSegment] = progress.split("-").map(Number);
// Calculate total progress: // Calculate total progress:
// - For 1-X: first section is (X+1)/6 filled // - For 1-X: first section is (X+1)/9 filled (intro 1-0 through review 1-8)
// - For 2-X: first section full, second section filled per Figma ratios (see `SECOND_SEGMENT_FILL_RATIO`) // - For 2-X: first section full, second section filled per Figma ratios (see `SECOND_SEGMENT_FILL_RATIO`)
// - For 3-X: first two sections full, third section X/3 filled // - For 3-X: first two sections full, third section X/3 filled
// Max is 3 full segments = 9 units // Max is 3 full segments = 9 units
let totalProgress = 0; let totalProgress = 0;
if (fullSegments === 1) { if (fullSegments === 1) {
totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section totalProgress = (partialSegment + 1) / 9; // 1/9 to 9/9 of first section
} else if (fullSegments === 2) { } else if (fullSegments === 2) {
totalProgress = 1 + getSecondSegmentFillRatio(partialSegment); totalProgress = 1 + getSecondSegmentFillRatio(partialSegment);
} else if (fullSegments === 3) { } else if (fullSegments === 3) {
@@ -53,7 +53,7 @@ export function ProportionBarView({
? `Progress: ${fullSegments} segments, ${partialSegment} of 3 parts filled (${progressPercentage}%)` ? `Progress: ${fullSegments} segments, ${partialSegment} of 3 parts filled (${progressPercentage}%)`
: fullSegments === 2 : fullSegments === 2
? `Progress: ${fullSegments} segments, ${partialSegment} of 3 parts filled (${progressPercentage}%)` ? `Progress: ${fullSegments} segments, ${partialSegment} of 3 parts filled (${progressPercentage}%)`
: `Progress: ${fullSegments} segment, ${partialSegment + 1} of 6 parts filled (${progressPercentage}%)`; : `Progress: ${fullSegments} segment, ${partialSegment + 1} of 9 parts filled (${progressPercentage}%)`;
return ( return (
<div <div
@@ -78,12 +78,12 @@ export function ProportionBarView({
background capsule. background capsule.
*/} */}
<div className="absolute inset-0 flex gap-[var(--space-200)] px-[4px] overflow-hidden"> <div className="absolute inset-0 flex gap-[var(--space-200)] px-[4px] overflow-hidden">
{/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */} {/* First section - for 1-X: (X+1)/9 filled, for 2-X and 3-X: fully filled */}
<div className="flex-1 h-full relative"> <div className="flex-1 h-full relative">
{fullSegments === 1 ? ( {fullSegments === 1 ? (
<div <div
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }} style={{ width: `${((partialSegment + 1) / 9) * 100}%` }}
/> />
) : fullSegments >= 2 ? ( ) : fullSegments >= 2 ? (
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" /> <div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" />
@@ -2,7 +2,7 @@ export type NumberedListSizeValue = "M" | "S";
export interface NumberedListItem { export interface NumberedListItem {
title: string; title: string;
description: string; description?: string;
} }
export interface NumberedListProps { export interface NumberedListProps {
@@ -45,16 +45,17 @@ function NumberedListView({ items, size }: NumberedListViewProps) {
</h3> </h3>
</div> </div>
{/* Description */} {item.description ? (
<p <p
className={`max-w-[640px] overflow-hidden relative shrink-0 text-[var(--color-content-default-tertiary,#b4b4b4)] text-ellipsis w-full whitespace-pre-wrap ${ className={`max-w-[640px] overflow-hidden relative shrink-0 text-[var(--color-content-default-tertiary,#b4b4b4)] text-ellipsis w-full whitespace-pre-wrap ${
isM isM
? "text-small-paragraph" ? "text-small-paragraph"
: "text-x-small-paragraph" : "text-x-small-paragraph"
}`} }`}
> >
{item.description} {item.description}
</p> </p>
) : null}
</div> </div>
</li> </li>
))} ))}
+97
View File
@@ -0,0 +1,97 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import { CreateFlowUploadValidationError } from "./createFlowUploadValidation";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
} from "./pendingCommunityAvatarUpload";
import { uploadCreateFlowFile } from "./uploadToServer";
export type FlushPendingCommunityAvatarResult =
| { status: "none" }
| { status: "uploaded"; url: string }
| { status: "invalid" }
| { status: "failed" };
/**
* URL from the last successful flush. The IndexedDB file is removed on
* success, so a second caller (publish, sign-in transfer) can still apply
* the same URL if React state has not caught up yet.
* Cleared when anonymous draft storage is wiped.
*/
let lastUploadedUrl: string | null = null;
let inFlight: Promise<FlushPendingCommunityAvatarResult> | null = null;
export function forgetFlushedCommunityAvatarUrl(): void {
lastUploadedUrl = null;
}
async function runFlush(): Promise<FlushPendingCommunityAvatarResult> {
let file: File | null;
try {
file = await readPendingCommunityAvatarFile();
} catch {
return lastUploadedUrl
? { status: "uploaded", url: lastUploadedUrl }
: { status: "none" };
}
if (!file) {
return lastUploadedUrl
? { status: "uploaded", url: lastUploadedUrl }
: { status: "none" };
}
try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
lastUploadedUrl = url;
await clearPendingCommunityAvatarFile();
return { status: "uploaded", url };
} catch (err) {
if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
return { status: "invalid" };
}
return { status: "failed" };
}
}
/**
* Upload the staged community logo, once, even if review and sign-in both ask.
* A validation failure drops the file. A transient failure leaves it in place.
*/
export function flushPendingCommunityAvatar(): Promise<FlushPendingCommunityAvatarResult> {
if (!inFlight) {
inFlight = runFlush().finally(() => {
inFlight = null;
});
}
return inFlight;
}
/**
* Copy a staged logo onto `state` as `communityAvatarUrl` when upload works.
* `upload_failed` means the file is still staged and must not be published over.
*/
export async function stateWithPendingCommunityAvatar(
state: CreateFlowState,
): Promise<
| { ok: true; state: CreateFlowState }
| { ok: false; reason: "upload_failed" }
> {
const existing =
typeof state.communityAvatarUrl === "string"
? state.communityAvatarUrl.trim()
: "";
if (existing.length > 0) return { ok: true, state };
const flushed = await flushPendingCommunityAvatar();
if (flushed.status === "uploaded") {
return {
ok: true,
state: { ...state, communityAvatarUrl: flushed.url },
};
}
if (flushed.status === "failed") {
return { ok: false, reason: "upload_failed" };
}
return { ok: true, state };
}
+12 -6
View File
@@ -21,11 +21,17 @@ export function isValidCreateFlowSaveEmail(value: unknown): boolean {
// Native type=email rejects whitespace; jsdom’s checkValidity does not. // Native type=email rejects whitespace; jsdom’s checkValidity does not.
if (/\s/.test(value)) return false; if (/\s/.test(value)) return false;
if (typeof document === "undefined") { if (typeof document === "undefined") {
return EMAIL_FALLBACK_PATTERN.test(value); if (!EMAIL_FALLBACK_PATTERN.test(value)) return false;
} else {
const input = document.createElement("input");
input.type = "email";
input.required = true;
input.value = value;
if (!input.checkValidity()) return false;
} }
const input = document.createElement("input"); // Native type=email allows a single domain label (`maya@example`). The
input.type = "email"; // save-progress field needs a dotted domain so we can actually send mail.
input.required = true; const at = value.lastIndexOf("@");
input.value = value; const domain = at >= 0 ? value.slice(at + 1) : "";
return input.checkValidity(); return domain.includes(".");
} }
+2 -2
View File
@@ -28,8 +28,8 @@ function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | n
} }
/** /**
* Authenticated multipart upload to `POST /api/uploads`. * Multipart upload to `POST /api/uploads`.
* Caller must have a session cookie (same-origin fetch). * `communityAvatar` is accepted for a guest; other purposes need a session.
*/ */
export async function uploadCreateFlowFile( export async function uploadCreateFlowFile(
file: File, file: File,
+1 -1
View File
@@ -81,7 +81,7 @@ export async function sendMagicLinkEmail(
const { text, html } = buildVerifyLinkParts( const { text, html } = buildVerifyLinkParts(
verifyUrl, verifyUrl,
"Open this link to sign in (it expires in 60 minutes):", "Open this link to sign in (it expires in 60 minutes):",
"If you did not request this, you can ignore this email.", "If you did not request this, you can ignore this email.\n\nSent because you saved a draft at communityrule.info",
"Sign in", "Sign in",
); );
await sendHtmlMail({ await sendHtmlMail({
@@ -22,6 +22,9 @@ const communityStructureChipSnapshotRowSchema = z.object({
const communityStructureChipSnapshotsSchema = z const communityStructureChipSnapshotsSchema = z
.object({ .object({
organizationTypes: z.array(communityStructureChipSnapshotRowSchema).optional(), organizationTypes: z.array(communityStructureChipSnapshotRowSchema).optional(),
otherOrganizationTypes: z
.array(communityStructureChipSnapshotRowSchema)
.optional(),
scale: z.array(communityStructureChipSnapshotRowSchema).optional(), scale: z.array(communityStructureChipSnapshotRowSchema).optional(),
maturity: z.array(communityStructureChipSnapshotRowSchema).optional(), maturity: z.array(communityStructureChipSnapshotRowSchema).optional(),
}) })
@@ -104,8 +107,10 @@ export const createFlowStateSchema = z
title: z.string().max(500).optional(), title: z.string().max(500).optional(),
summary: z.string().max(8000).optional(), summary: z.string().max(8000).optional(),
communityContext: z.string().max(200).optional(), communityContext: z.string().max(200).optional(),
communityLocation: z.string().max(200).optional(),
communitySaveEmail: z.string().max(320).optional(), communitySaveEmail: z.string().max(320).optional(),
communityAvatarUrl: z.string().max(512).optional(), communityAvatarUrl: z.string().max(512).optional(),
communityAvatarFileName: z.string().max(255).optional(),
previewSurfaceColor: z previewSurfaceColor: z
.string() .string()
.max(32) .max(32)
@@ -1,7 +1,10 @@
{ {
"title": "Why does your community exist?", "title": "What does your community do?",
"description": "Optional. Write a short paragraph about your mission, goals, and scope. You can skip this and add it later.", "description": "For example, we cook and share free meals with neighbors every Sunday.",
"inputLabel": "Community description", "inputLabel": "Community description",
"placeholder": "Describe your community", "placeholder": "Describe your community",
"characterCountTemplate": "{current}/{max}" "characterCountTemplate": "{current}/{max}",
"locationLabel": "Where is your community based?",
"locationPlaceholder": "City or region",
"locationHint": "Optional. A city or region is enough."
} }
@@ -1,7 +1,8 @@
{ {
"title": "What is your community called?", "title": "What is your community called?",
"description": "This will be the name of your community.", "description": "Use the name your members know. You can change this later.",
"inputLabel": "Community name", "inputLabel": "Community name",
"placeholder": "Enter community name", "placeholder": "Enter community name",
"characterCountTemplate": "{current}/{max}" "characterCountTemplate": "{current}/{max}",
"errorMessage": "Add your community’s name to continue."
} }
@@ -1,10 +1,12 @@
{ {
"title": "Save your progress", "title": "Save your progress",
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.", "description": "We will email a link so you can come back to your Rule. Only the person with this email can edit it.",
"inputLabel": "Email address", "inputLabel": "Email address",
"placeholder": "email@domain.com", "placeholder": "email@domain.com",
"characterCountTemplate": "{current}/{max}", "skipHint": "If you skip, your progress stays in this browser only.",
"magicLinkSuccessTitle": "Check your email to log in", "errorMessage": "Check your email address. We need it to send your link.",
"magicLinkSuccessDescription": "We emailed a sign-in link. Open it on this device to continue — check spam or promotions if you don't see it.", "magicLinkErrorTitle": "Could not send link",
"magicLinkErrorTitle": "Could not send link" "sentToastTitle": "Check your email",
"sentToastDescription": "A link back to your Rule is on its way to {email}.",
"sentToastAction": "Send it again"
} }
@@ -3,6 +3,7 @@
"title": "How many people will be in your community in the near term?", "title": "How many people will be in your community in the near term?",
"description": "Count the people who have a say in your big decisions. If others take part without a vote, you can say so later in setup." "description": "Count the people who have a say in your big decisions. If others take part without a vote, you can say so later in setup."
}, },
"pickOneError": "Pick one to continue.",
"communitySizes": [ "communitySizes": [
{ "id": "oneMember", "label": "One member" }, { "id": "oneMember", "label": "One member" },
{ "id": "twoToFive", "label": "2 to 5" }, { "id": "twoToFive", "label": "2 to 5" },
@@ -1,20 +1,22 @@
{ {
"header": { "header": {
"title": "What kind of community would you like to improve?", "title": "What kind of community is this?",
"description": "Choose tags that describe your community. You can also combine or add new values to the list." "description": "Your answers shape which cards we suggest for your Rule."
}, },
"organizationMultiSelect": { "mainKindMultiSelect": {
"label": "Organization Type", "label": "What kind of community are you, mainly?",
"addButtonText": "Add organization type" "helperText": "Pick one"
},
"otherKindMultiSelect": {
"label": "Anything else that fits?"
}, },
"scaleMultiSelect": { "scaleMultiSelect": {
"label": "Scale", "label": "Scale"
"addButtonText": "Add scale"
}, },
"maturityMultiSelect": { "maturityMultiSelect": {
"label": "Maturity", "label": "Maturity"
"addButtonText": "Add maturity"
}, },
"pickOneError": "Pick one to continue.",
"organizationTypes": [ "organizationTypes": [
{ "id": "workersCoop", "label": "Worker co-op" }, { "id": "workersCoop", "label": "Worker co-op" },
{ "id": "mutualAid", "label": "Mutual aid" }, { "id": "mutualAid", "label": "Mutual aid" },
@@ -1,10 +1,9 @@
{ {
"title": "Add a photo to identify your group", "title": "Add a logo",
"description": "This photo will be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.", "description": "Optional. A simple logo or graphic works best. Only people who can read your Rule can see this image. Skip this if your community needs to stay hard to identify.",
"hintText": "Add image from your device", "hintText": "Add image from your device",
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).", "replaceButton": "Replace",
"uploadingLabel": "Uploading your photo…", "uploadingLabel": "Uploading your logo",
"previewAlt": "Selected community photo preview", "previewAlt": "Selected community logo preview",
"clearPendingUploadAriaLabel": "Remove uploaded photo", "fileTooLarge": "That file is too large. Try a smaller one"
"clearPendingUploadTooltip": "Remove photo"
} }
@@ -1,18 +1,15 @@
{ {
"title": "How CommunityRule helps groups like yours", "title": "Write your community’s Rule",
"description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider.", "description": "Work through it together or on your own. Nothing is shared until you invite people, and you don’t need an account to start.",
"steps": { "steps": {
"0": { "0": {
"title": "Tell us about your organization", "title": "Tell us about your community"
"description": "Start by providing your group's name, description, and profile image."
}, },
"1": { "1": {
"title": "Define your group's CommunityRule.", "title": "Write your Rule"
"description": "Outline decision-making processes, conflict resolution methods, and membership practices. Get recommendations."
}, },
"2": { "2": {
"title": "Share and evolve over time", "title": "Review and finish"
"description": "Review and refine your community framework before putting it into action and adapting it over time."
} }
} }
} }
+10 -2
View File
@@ -1,11 +1,19 @@
{ {
"header": { "header": {
"title": "Your community is added - congrats!", "title": "Here is your community",
"description": "In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared." "description": "Next you will write your Rule — the values, ways of talking, membership, decisions, and conflict practices your group wants to try."
}, },
"empty": { "empty": {
"title": "No community in progress yet", "title": "No community in progress yet",
"description": "This step is for a community you have already named. Start from the beginning to add one.", "description": "This step is for a community you have already named. Start from the beginning to add one.",
"startLabel": "Start creating" "startLabel": "Start creating"
},
"rows": {
"kind": "Kind",
"size": "Size",
"maturity": "Maturity",
"setup": "A few more things",
"edit": "Edit",
"sizePeople": "{label} people"
} }
} }
+4 -2
View File
@@ -1,7 +1,9 @@
{ {
"next": "Next", "next": "Next",
"saveLater": "Save Later", "skip": "Skip",
"submitEmail": "Submit Email", "skipForNow": "Skip for now",
"saveLater": "Skip for now",
"submitEmail": "Submit email",
"submitEmailSending": "Sending link…", "submitEmailSending": "Sending link…",
"createCustom": "Create custom", "createCustom": "Create custom",
"createFromTemplate": "Create from template", "createFromTemplate": "Create from template",
+8 -5
View File
@@ -1,7 +1,7 @@
{ {
"bannerAriaLabel": "Create rule flow navigation", "bannerAriaLabel": "Create rule flow navigation",
"navAriaLabel": "Create flow navigation", "navAriaLabel": "Create flow navigation",
"saveAndExit": "Save & Exit", "saveAndExit": "Save and exit",
"exit": "Exit", "exit": "Exit",
"share": "Share", "share": "Share",
"export": "Export", "export": "Export",
@@ -14,10 +14,13 @@
"moreOptionsAriaLabel": "More options", "moreOptionsAriaLabel": "More options",
"actionsMenuAriaLabel": "Create flow actions", "actionsMenuAriaLabel": "Create flow actions",
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.", "leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
"leaveConfirmTitle": "Leave create flow?", "leaveConfirmTitle": "Leave without saving?",
"leaveConfirmDescription": "Your progress on this device will be lost unless you use Save & Exit first.", "leaveConfirmDescription": "Your answers are not saved yet. Add your email and you can come back to them.",
"leaveConfirmProceed": "Leave", "leaveConfirmProceed": "Leave anyway",
"leaveConfirmSaveProgress": "Save my progress",
"leaveConfirmCancel": "Stay", "leaveConfirmCancel": "Stay",
"draftSaveBannerTitle": "Couldn't save draft", "draftSaveBannerTitle": "Couldn't save draft",
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}" "postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}",
"offlineTitle": "You're offline",
"offlineDescription": "Your progress stays in this browser until you reconnect."
} }
@@ -28,6 +28,9 @@ export default {
"1-3", "1-3",
"1-4", "1-4",
"1-5", "1-5",
"1-6",
"1-7",
"1-8",
"2-0", "2-0",
"2-1", "2-1",
"2-2", "2-2",
+14
View File
@@ -57,4 +57,18 @@ describe("Alert dismiss control", () => {
screen.queryByRole("button", { name: "Close" }), screen.queryByRole("button", { name: "Close" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("renders an action control when actionLabel and onAction are set", async () => {
const user = userEvent.setup();
const onAction = vi.fn();
render(
<Alert
title="Check your email"
actionLabel="Send it again"
onAction={onAction}
/>,
);
await user.click(screen.getByRole("button", { name: "Send it again" }));
expect(onAction).toHaveBeenCalledTimes(1);
});
}); });
@@ -8,21 +8,25 @@ describe("CommunityStructureSelectScreen", () => {
it("does not render dummy help icons", () => { it("does not render dummy help icons", () => {
renderWithProviders(<CommunityStructureSelectScreen />); renderWithProviders(<CommunityStructureSelectScreen />);
expect(screen.getByText("Organization Type")).toBeInTheDocument(); expect(
screen.getByText("What kind of community are you, mainly?"),
).toBeInTheDocument();
expect(screen.getByText("Anything else that fits?")).toBeInTheDocument();
expect(screen.getByText("Scale")).toBeInTheDocument(); expect(screen.getByText("Scale")).toBeInTheDocument();
expect(screen.getByText("Maturity")).toBeInTheDocument(); expect(screen.getByText("Maturity")).toBeInTheDocument();
expect( expect(
screen.getByText(/Choose tags that describe your community/i), screen.getByText(/Your answers shape which cards we suggest/i),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByAltText("Help")).not.toBeInTheDocument(); expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
expect(screen.getByText("Pick one")).toBeInTheDocument();
}); });
it("gives structure chips Select/Deselect accessible names", () => { it("gives structure chips Select/Deselect accessible names", () => {
renderWithProviders(<CommunityStructureSelectScreen />); renderWithProviders(<CommunityStructureSelectScreen />);
expect( expect(
screen.getByRole("button", { name: "Select Worker co-op" }), screen.getAllByRole("button", { name: "Select Worker co-op" }).length,
).toBeInTheDocument(); ).toBeGreaterThanOrEqual(2);
expect( expect(
screen.getByRole("button", { name: "Select Local" }), screen.getByRole("button", { name: "Select Local" }),
).toBeInTheDocument(); ).toBeInTheDocument();
+2 -2
View File
@@ -50,7 +50,7 @@ describe("CreateFlowFooter (behavioral tests)", () => {
}); });
expect(footer).toBeInTheDocument(); expect(footer).toBeInTheDocument();
const bar = screen.getByRole("progressbar"); const bar = screen.getByRole("progressbar");
expect(bar).toHaveAttribute("aria-valuenow", String(1 / 6)); expect(bar).toHaveAttribute("aria-valuenow", String(1 / 9));
}); });
it("passes proportionBarProgress to the progress bar", () => { it("passes proportionBarProgress to the progress bar", () => {
@@ -63,7 +63,7 @@ describe("CreateFlowFooter (behavioral tests)", () => {
); );
expect(screen.getByRole("progressbar")).toHaveAttribute( expect(screen.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow", "aria-valuenow",
String(2 / 6), String(2 / 9),
); );
}); });
@@ -0,0 +1,61 @@
import { act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CreateFlowPendingAvatarFlush } from "../../app/(app)/create/components/CreateFlowPendingAvatarFlush";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { flushPendingCommunityAvatar } from "../../lib/create/flushPendingCommunityAvatar";
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({
flushPendingCommunityAvatar: vi.fn(),
}));
function AvatarProbe() {
const { state } = useCreateFlow();
return <div data-testid="avatar">{state.communityAvatarUrl ?? ""}</div>;
}
describe("CreateFlowPendingAvatarFlush", () => {
afterEach(() => {
vi.useRealTimers();
vi.mocked(flushPendingCommunityAvatar).mockReset();
});
it("retries a transient upload and then stores the url", async () => {
vi.useFakeTimers();
vi.mocked(flushPendingCommunityAvatar)
.mockResolvedValueOnce({ status: "failed" })
.mockResolvedValueOnce({
status: "uploaded",
url: "/api/uploads/retry",
});
render(
<>
<CreateFlowPendingAvatarFlush
sessionUser={{ id: "user-1", email: "maya@example.com" }}
sessionResolved
/>
<AvatarProbe />
</>,
);
await act(async () => {
await Promise.resolve();
});
expect(flushPendingCommunityAvatar).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("avatar")).toHaveTextContent("");
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(flushPendingCommunityAvatar).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("avatar")).toHaveTextContent(
"/api/uploads/retry",
);
});
});
+5 -5
View File
@@ -66,9 +66,9 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
expect(exitButton).toBeInTheDocument(); expect(exitButton).toBeInTheDocument();
}); });
it("shows Save & Exit when saveDraftOnExit is true", () => { it("shows Save and exit when saveDraftOnExit is true", () => {
render(<CreateFlowTopNav saveDraftOnExit={true} />); render(<CreateFlowTopNav saveDraftOnExit={true} />);
const exitButton = screen.getByRole("button", { name: "Save & Exit" }); const exitButton = screen.getByRole("button", { name: "Save and exit" });
expect(exitButton).toBeInTheDocument(); expect(exitButton).toBeInTheDocument();
}); });
@@ -232,7 +232,7 @@ describe("CreateFlowTopNav (viewport < sm2 / 440px)", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: "Save & Exit" }), screen.queryByRole("button", { name: "Save and exit" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -255,7 +255,7 @@ describe("CreateFlowTopNav (viewport < sm2 / 440px)", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument();
expect( expect(
screen.getByRole("menuitem", { name: "Save & Exit" }), screen.getByRole("menuitem", { name: "Save and exit" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -284,7 +284,7 @@ describe("CreateFlowTopNav (viewport < sm2 / 440px)", () => {
expect(handleEdit).toHaveBeenCalledTimes(1); expect(handleEdit).toHaveBeenCalledTimes(1);
await user.click(kebab); await user.click(kebab);
await user.click(screen.getByRole("menuitem", { name: "Save & Exit" })); await user.click(screen.getByRole("menuitem", { name: "Save and exit" }));
expect(handleExit).toHaveBeenCalledTimes(1); expect(handleExit).toHaveBeenCalledTimes(1);
}); });
}); });
+3 -3
View File
@@ -8,7 +8,7 @@ describe("InformationalScreen", () => {
render(<InformationalScreen />); render(<InformationalScreen />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "How CommunityRule helps groups like yours", name: "Write your community’s Rule",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -17,7 +17,7 @@ describe("InformationalScreen", () => {
render(<InformationalScreen />); render(<InformationalScreen />);
expect( expect(
screen.getByText( screen.getByText(
/This flow will give you recommendations to improve your community/i, /Work through it together or on your own/i,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -30,7 +30,7 @@ describe("InformationalScreen", () => {
it("renders first numbered list item title", () => { it("renders first numbered list item title", () => {
render(<InformationalScreen />); render(<InformationalScreen />);
expect( expect(
screen.getByText("Tell us about your organization"), screen.getByText("Tell us about your community"),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
+23
View File
@@ -252,4 +252,27 @@ describe("MultiSelect – behaviour specifics", () => {
expect(handleClose).toHaveBeenCalled(); expect(handleClose).toHaveBeenCalled();
}); });
it("shows a danger hint under the chips when errorMessage is set", () => {
render(
<MultiSelect
options={defaultChipOptions}
errorMessage="Pick one to continue."
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Pick one to continue.",
);
});
it("shows helper text beside the label", () => {
render(
<MultiSelect
label="Main kind"
helperText="Pick one"
options={defaultChipOptions}
/>,
);
expect(screen.getByText("Pick one")).toBeInTheDocument();
});
}); });
@@ -0,0 +1,120 @@
import { useSearchParams } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { PostLoginDraftTransfer } from "../../app/(app)/create/PostLoginDraftTransfer";
import {
CREATE_FLOW_ANONYMOUS_KEY,
CREATE_FLOW_TRANSFER_PENDING_KEY,
} from "../../app/(app)/create/utils/anonymousDraftStorage";
import { saveDraftToServer } from "../../lib/create/api";
import { flushPendingCommunityAvatar } from "../../lib/create/flushPendingCommunityAvatar";
import { clearPendingCommunityAvatarFile } from "../../lib/create/pendingCommunityAvatarUpload";
import {
renderWithProviders as render,
screen,
waitFor,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
vi.mock("../../lib/create/api", () => ({
fetchDraftFromServer: vi.fn().mockResolvedValue(null),
saveDraftToServer: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({
flushPendingCommunityAvatar: vi.fn(),
forgetFlushedCommunityAvatarUrl: vi.fn(),
}));
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
storePendingCommunityAvatarFile: vi.fn(),
}));
function mockSearchParams(value: string) {
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams(value) as NonNullable<
ReturnType<typeof useSearchParams>
>,
);
}
function StateProbe() {
const { state } = useCreateFlow();
return (
<div>
<span data-testid="title">{state.title ?? ""}</span>
<span data-testid="avatar">{state.communityAvatarUrl ?? ""}</span>
</div>
);
}
describe("PostLoginDraftTransfer", () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({ title: "Garden Club" }),
);
localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1");
mockSearchParams("syncDraft=1");
vi.mocked(flushPendingCommunityAvatar).mockReset();
vi.mocked(saveDraftToServer).mockClear();
vi.mocked(clearPendingCommunityAvatarFile).mockClear();
});
it("uploads the staged logo before clearing the anonymous draft", async () => {
let draftStillPresent = false;
vi.mocked(flushPendingCommunityAvatar).mockImplementation(async () => {
draftStillPresent =
localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY) != null;
return { status: "uploaded", url: "/api/uploads/from-guest" };
});
render(
<>
<PostLoginDraftTransfer
sessionUser={{ id: "user-1", email: "maya@example.com" }}
/>
<StateProbe />
</>,
);
await waitFor(() => {
expect(screen.getByTestId("avatar")).toHaveTextContent(
"/api/uploads/from-guest",
);
});
expect(draftStillPresent).toBe(true);
expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(saveDraftToServer).toHaveBeenCalledWith(
expect.objectContaining({
title: "Garden Club",
communityAvatarUrl: "/api/uploads/from-guest",
}),
);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalled();
});
it("keeps the staged file when the upload fails", async () => {
vi.mocked(flushPendingCommunityAvatar).mockResolvedValue({
status: "failed",
});
render(
<>
<PostLoginDraftTransfer
sessionUser={{ id: "user-1", email: "maya@example.com" }}
/>
<StateProbe />
</>,
);
await waitFor(() => {
expect(screen.getByTestId("title")).toHaveTextContent("Garden Club");
});
expect(clearPendingCommunityAvatarFile).not.toHaveBeenCalled();
expect(screen.getByTestId("avatar")).toHaveTextContent("");
});
});
+3 -2
View File
@@ -61,8 +61,9 @@ describe("ProportionBar (behavioral tests)", () => {
it("handles all proportion states correctly", () => { it("handles all proportion states correctly", () => {
const testCases = [ const testCases = [
{ progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled { progress: "1-0" as const, expected: 1 / 9 },
{ progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled) { progress: "1-5" as const, expected: 6 / 9 },
{ progress: "1-8" as const, expected: 1 },
{ progress: "2-0" as const, expected: 1 }, // First section full, second empty { progress: "2-0" as const, expected: 1 }, // First section full, second empty
{ progress: "2-2" as const, expected: 1 + 1 / 2 }, // 1 + 1/2 per Figma `18861:15250` { progress: "2-2" as const, expected: 1 + 1 / 2 }, // 1 + 1/2 per Figma `18861:15250`
{ progress: "2-3" as const, expected: 1 + 3 / 4 }, // 1 + 3/4 per Figma `21434:17632` { progress: "2-3" as const, expected: 1 + 3 / 4 }, // 1 + 3/4 per Figma `21434:17632`
+67 -3
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, it, expect } from "vitest"; import { beforeEach, describe, it, expect, vi } from "vitest";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { import {
renderWithProviders as render, renderWithProviders as render,
@@ -9,6 +9,13 @@ import "@testing-library/jest-dom/vitest";
import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen"; import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { testRouter } from "../mocks/navigation"; import { testRouter } from "../mocks/navigation";
import { readPendingCommunityAvatarFile } from "../../lib/create/pendingCommunityAvatarUpload";
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
storePendingCommunityAvatarFile: vi.fn(),
clearPendingCommunityAvatarFile: vi.fn(),
}));
function ReviewWithTitle({ title }: { title: string }) { function ReviewWithTitle({ title }: { title: string }) {
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
@@ -22,10 +29,29 @@ function ReviewWithTitle({ title }: { title: string }) {
return <CommunityReviewScreen />; return <CommunityReviewScreen />;
} }
function ReviewWithAnswers() {
const { state, updateState } = useCreateFlow();
const seededRef = React.useRef(false);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
updateState({
title: "Garden Club",
selectedOrganizationTypeIds: ["mutualAid"],
selectedCommunitySizeIds: ["thirteenToOneHundred"],
selectedMaturityIds: ["established"],
});
}, [updateState]);
if (state.title !== "Garden Club") return null;
return <CommunityReviewScreen />;
}
describe("CommunityReviewScreen", () => { describe("CommunityReviewScreen", () => {
beforeEach(() => { beforeEach(() => {
testRouter.replace.mockReset(); testRouter.replace.mockReset();
testRouter.push.mockReset(); testRouter.push.mockReset();
vi.mocked(readPendingCommunityAvatarFile).mockReset();
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null);
}); });
it("renders without crashing", () => { it("renders without crashing", () => {
@@ -53,7 +79,7 @@ describe("CommunityReviewScreen", () => {
render(<ReviewWithTitle title="Garden Club" />); render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "Your community is added - congrats!", name: "Here is your community",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -62,7 +88,7 @@ describe("CommunityReviewScreen", () => {
render(<ReviewWithTitle title="Garden Club" />); render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
screen.getByText( screen.getByText(
/In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared./i, /Next you will write your Rule/i,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -72,6 +98,33 @@ describe("CommunityReviewScreen", () => {
expect(screen.getByText("Garden Club")).toBeInTheDocument(); expect(screen.getByText("Garden Club")).toBeInTheDocument();
}); });
it("shows a guest logo staged in the browser on the rule card", async () => {
const pending = new File([new Uint8Array([1, 2, 3])], "logo.png", {
type: "image/png",
});
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending);
Object.defineProperty(URL, "createObjectURL", {
value: vi.fn(() => "blob:pending-avatar"),
writable: true,
configurable: true,
});
Object.defineProperty(URL, "revokeObjectURL", {
value: vi.fn(),
writable: true,
configurable: true,
});
render(<ReviewWithTitle title="Garden Club" />);
await waitFor(() => {
expect(screen.getByRole("img", { name: "Garden Club" })).toHaveAttribute(
"src",
"blob:pending-avatar",
);
});
expect(readPendingCommunityAvatarFile).toHaveBeenCalled();
});
it("omits the Rule description when the user has not entered community context", () => { it("omits the Rule description when the user has not entered community context", () => {
render(<ReviewWithTitle title="Garden Club" />); render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
@@ -81,6 +134,17 @@ describe("CommunityReviewScreen", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("lists kind, size, and maturity with Edit controls", () => {
render(<ReviewWithAnswers />);
expect(screen.getByText("Kind")).toBeInTheDocument();
expect(screen.getByText("Mutual aid")).toBeInTheDocument();
expect(screen.getByText("Size")).toBeInTheDocument();
expect(screen.getByText("13 to 100 people")).toBeInTheDocument();
expect(screen.getByText("Maturity")).toBeInTheDocument();
expect(screen.getByText("Established")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Edit" })).toHaveLength(3);
});
it("renders Rule as a button when a community name exists", () => { it("renders Rule as a button when a community name exists", () => {
render(<ReviewWithTitle title="Garden Club" />); render(<ReviewWithTitle title="Garden Club" />);
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
+13 -2
View File
@@ -31,7 +31,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
/>, />,
); );
expect( expect(
screen.getByText("This will be the name of your community."), screen.getByText("Use the name your members know. You can change this later."),
).toBeInTheDocument(); ).toBeInTheDocument();
const input = screen.getByRole("textbox", { name: "Community name" }); const input = screen.getByRole("textbox", { name: "Community name" });
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
@@ -46,18 +46,29 @@ describe("CreateFlowTextFieldScreen (community description)", () => {
<CreateFlowTextFieldScreen <CreateFlowTextFieldScreen
messageNamespace="create.community.communityContext" messageNamespace="create.community.communityContext"
stateField="communityContext" stateField="communityContext"
locationField="communityLocation"
maxLength={200} maxLength={200}
multiline multiline
/>, />,
); );
expect( expect(
screen.getByText(/Optional\. Write a short paragraph/i), screen.getByText(
"For example, we cook and share free meals with neighbors every Sunday.",
),
).toBeInTheDocument(); ).toBeInTheDocument();
const field = screen.getByRole("textbox", { const field = screen.getByRole("textbox", {
name: "Community description", name: "Community description",
}); });
expect(field.tagName).toBe("TEXTAREA"); expect(field.tagName).toBe("TEXTAREA");
expect(field).not.toBeRequired(); expect(field).not.toBeRequired();
const location = screen.getByRole("textbox", {
name: "Where is your community based?",
});
expect(location).toHaveAttribute("placeholder", "City or region");
expect(location).not.toBeRequired();
expect(
screen.getByText("Optional. A city or region is enough."),
).toBeInTheDocument();
}); });
}); });
+16
View File
@@ -109,4 +109,20 @@ describe("Upload (behavioral tests)", () => {
"text-[color:var(--color-content-default-tertiary)]", "text-[color:var(--color-content-default-tertiary)]",
); );
}); });
it("shows a round preview, Replace, and filename when previewSrc is set", () => {
render(
<Upload
previewSrc="blob:logo"
previewAlt="Selected community logo preview"
fileName="logo.png"
buttonLabel="Replace"
/>,
);
expect(
screen.getByRole("img", { name: "Selected community logo preview" }),
).toHaveAttribute("src", "blob:logo");
expect(screen.getByRole("button", { name: "Replace" })).toBeInTheDocument();
expect(screen.getByText("logo.png")).toBeInTheDocument();
});
}); });
+9 -18
View File
@@ -5,7 +5,6 @@ import {
fireEvent, fireEvent,
cleanup, cleanup,
} from "../utils/test-utils"; } from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen"; import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen";
import { import {
@@ -70,7 +69,7 @@ describe("CommunityUploadScreen", () => {
render(<CommunityUploadScreen />); render(<CommunityUploadScreen />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "Add a photo to identify your group", name: "Add a logo",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -80,13 +79,12 @@ describe("CommunityUploadScreen", () => {
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
/This photo will be used as a profile picture for your group/i, /Only people who can read your Rule can see this image/i,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("restores a pending IndexedDB photo and lets the user remove it", async () => { it("restores a pending IndexedDB photo into the uploaded preview", async () => {
const user = userEvent.setup();
const pending = new File([pngBytes], "avatar.png", { type: "image/png" }); const pending = new File([pngBytes], "avatar.png", { type: "image/png" });
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending); vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending);
@@ -94,22 +92,15 @@ describe("CommunityUploadScreen", () => {
const preview = await screen.findByRole("img", { name: copy.previewAlt }); const preview = await screen.findByRole("img", { name: copy.previewAlt });
expect(preview).toHaveAttribute("src", "blob:pending-avatar"); expect(preview).toHaveAttribute("src", "blob:pending-avatar");
const remove = screen.getByRole("button", {
name: copy.clearPendingUploadAriaLabel,
});
await user.click(remove);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
expect( expect(
screen.queryByRole("img", { name: copy.previewAlt }), screen.getByRole("button", { name: copy.replaceButton }),
).not.toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("avatar.png")).toBeInTheDocument();
}); });
it("rejects an empty file with a clear error and does not stage it", async () => { it("rejects an empty file with a clear error and does not stage it", async () => {
render(<CommunityUploadScreen />); render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote); await screen.findByRole("heading", { name: copy.title });
fireEvent.change(fileInput(), { fireEvent.change(fileInput(), {
target: { files: [new File([], "empty.png", { type: "image/png" })] }, target: { files: [new File([], "empty.png", { type: "image/png" })] },
@@ -123,7 +114,7 @@ describe("CommunityUploadScreen", () => {
it("rejects a text file renamed as PNG", async () => { it("rejects a text file renamed as PNG", async () => {
render(<CommunityUploadScreen />); render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote); await screen.findByRole("heading", { name: copy.title });
fireEvent.change(fileInput(), { fireEvent.change(fileInput(), {
target: { target: {
@@ -139,7 +130,7 @@ describe("CommunityUploadScreen", () => {
it("rejects SVG uploads", async () => { it("rejects SVG uploads", async () => {
render(<CommunityUploadScreen />); render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote); await screen.findByRole("heading", { name: copy.title });
fireEvent.change(fileInput(), { fireEvent.change(fileInput(), {
target: { target: {
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const clearPendingCommunityAvatarFile = vi.fn();
const forgetFlushedCommunityAvatarUrl = vi.fn();
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
clearPendingCommunityAvatarFile: () => clearPendingCommunityAvatarFile(),
}));
vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({
forgetFlushedCommunityAvatarUrl: () => forgetFlushedCommunityAvatarUrl(),
}));
import {
CREATE_FLOW_ANONYMOUS_KEY,
CREATE_FLOW_TRANSFER_PENDING_KEY,
clearAnonymousCreateFlowStorage,
} from "../../app/(app)/create/utils/anonymousDraftStorage";
describe("clearAnonymousCreateFlowStorage", () => {
beforeEach(() => {
localStorage.clear();
clearPendingCommunityAvatarFile.mockReset();
forgetFlushedCommunityAvatarUrl.mockReset();
localStorage.setItem(CREATE_FLOW_ANONYMOUS_KEY, "{\"title\":\"Garden\"}");
localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1");
});
it("removes the draft and the staged logo", () => {
clearAnonymousCreateFlowStorage();
expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(localStorage.getItem(CREATE_FLOW_TRANSFER_PENDING_KEY)).toBeNull();
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
expect(forgetFlushedCommunityAvatarUrl).toHaveBeenCalledTimes(1);
});
it("keeps the staged logo when a transfer upload still needs to retry", () => {
clearAnonymousCreateFlowStorage({ preservePendingAvatar: true });
expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(clearPendingCommunityAvatarFile).not.toHaveBeenCalled();
expect(forgetFlushedCommunityAvatarUrl).not.toHaveBeenCalled();
});
});
+39 -1
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import communitySize from "../../messages/en/create/community/communitySize.json"; import communitySize from "../../messages/en/create/community/communitySize.json";
import { SIZE_BANDS } from "../../lib/create/deckTypes"; import { SIZE_BANDS } from "../../lib/create/deckTypes";
import { isCommunitySizeStepBlocked } from "../../app/(app)/create/utils/createFlowFooterLabels"; import {
isCommunityNameStepBlocked,
isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked,
} from "../../app/(app)/create/utils/createFlowFooterLabels";
/** /**
* Inclusive bounds. 100 stays in 13 to 100. More than 100 starts at 101. * Inclusive bounds. 100 stays in 13 to 100. More than 100 starts at 101.
@@ -64,3 +68,37 @@ describe("community size bands", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("community name and type continue gates", () => {
it("blocks the name step until a title is entered", () => {
expect(isCommunityNameStepBlocked("community-name", undefined)).toBe(true);
expect(isCommunityNameStepBlocked("community-name", " ")).toBe(true);
expect(isCommunityNameStepBlocked("community-name", "Garden Club")).toBe(
false,
);
expect(isCommunityNameStepBlocked("community-size", "")).toBe(false);
});
it("blocks the type step until a main kind and a maturity are chosen", () => {
expect(
isCommunityStructureStepBlocked("community-structure", [], ["earlyStage"]),
).toBe(true);
expect(
isCommunityStructureStepBlocked(
"community-structure",
["mutualAid"],
[],
),
).toBe(true);
expect(
isCommunityStructureStepBlocked(
"community-structure",
["mutualAid"],
["established"],
),
).toBe(false);
expect(
isCommunityStructureStepBlocked("community-size", [], []),
).toBe(false);
});
});
@@ -17,11 +17,14 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
); );
}); });
it("uses 2-0 on community-save, review, and core-values (Create Community segment / same fill)", () => { it("maps the rest of Create Community onto part 1, skipping setup at 1-5", () => {
expect(getProportionBarProgressForCreateFlowStep("community-save")).toBe( expect(getProportionBarProgressForCreateFlowStep("community-upload")).toBe(
"2-0", "1-6",
); );
expect(getProportionBarProgressForCreateFlowStep("review")).toBe("2-0"); expect(getProportionBarProgressForCreateFlowStep("community-save")).toBe(
"1-7",
);
expect(getProportionBarProgressForCreateFlowStep("review")).toBe("1-8");
expect(getProportionBarProgressForCreateFlowStep("core-values")).toBe( expect(getProportionBarProgressForCreateFlowStep("core-values")).toBe(
"2-0", "2-0",
); );
+6
View File
@@ -25,4 +25,10 @@ describe("create footer messages", () => {
"Publish CommunityRule", "Publish CommunityRule",
); );
}); });
it("exposes Skip and Skip for now for optional community steps", () => {
expect(messages.create.footer.skip).toBe("Skip");
expect(messages.create.footer.skipForNow).toBe("Skip for now");
expect(messages.create.footer.submitEmail).toBe("Submit email");
});
}); });
+9 -3
View File
@@ -175,7 +175,7 @@ describe("flowSteps", () => {
).toBeNull(); ).toBeNull();
}); });
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => { it("parseReviewReturnSearchParam accepts final-review, edit-rule, and review", () => {
expect( expect(
parseReviewReturnSearchParam( parseReviewReturnSearchParam(
new URLSearchParams("reviewReturn=final-review"), new URLSearchParams("reviewReturn=final-review"),
@@ -186,16 +186,22 @@ describe("flowSteps", () => {
new URLSearchParams("reviewReturn=edit-rule"), new URLSearchParams("reviewReturn=edit-rule"),
), ),
).toBe("edit-rule"); ).toBe("edit-rule");
expect(
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")),
).toBe("review");
expect( expect(
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")), parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")),
).toBeNull(); ).toBeNull();
expect(parseReviewReturnSearchParam(null)).toBeNull(); expect(parseReviewReturnSearchParam(null)).toBeNull();
}); });
it("offers Save & Exit from community-structure through final-review and on edit-rule", () => { it("offers Save and exit from community-save through final-review and on edit-rule", () => {
expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false); expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false); expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(true); expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-upload")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-save")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("review")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("final-review")).toBe(true); expect(shouldOfferCreateFlowSaveAndExit("final-review")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("edit-rule")).toBe(true); expect(shouldOfferCreateFlowSaveAndExit("edit-rule")).toBe(true);
}); });
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CreateFlowUploadValidationError } from "../../lib/create/createFlowUploadValidation";
const readPendingCommunityAvatarFile = vi.fn();
const clearPendingCommunityAvatarFile = vi.fn();
const uploadCreateFlowFile = vi.fn();
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
readPendingCommunityAvatarFile: (...args: unknown[]) =>
readPendingCommunityAvatarFile(...args),
clearPendingCommunityAvatarFile: (...args: unknown[]) =>
clearPendingCommunityAvatarFile(...args),
}));
vi.mock("../../lib/create/uploadToServer", () => ({
uploadCreateFlowFile: (...args: unknown[]) => uploadCreateFlowFile(...args),
}));
import {
flushPendingCommunityAvatar,
forgetFlushedCommunityAvatarUrl,
stateWithPendingCommunityAvatar,
} from "../../lib/create/flushPendingCommunityAvatar";
const pending = new File([new Uint8Array([1, 2, 3])], "logo.png", {
type: "image/png",
});
describe("flushPendingCommunityAvatar", () => {
beforeEach(() => {
readPendingCommunityAvatarFile.mockReset();
clearPendingCommunityAvatarFile.mockReset();
uploadCreateFlowFile.mockReset();
clearPendingCommunityAvatarFile.mockResolvedValue(undefined);
forgetFlushedCommunityAvatarUrl();
});
it("does nothing when no file is staged", async () => {
readPendingCommunityAvatarFile.mockResolvedValue(null);
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "none",
});
expect(uploadCreateFlowFile).not.toHaveBeenCalled();
});
it("uploads once and remembers the url after the file is cleared", async () => {
readPendingCommunityAvatarFile
.mockResolvedValueOnce(pending)
.mockResolvedValue(null);
uploadCreateFlowFile.mockResolvedValue({ url: "/api/uploads/logo" });
const [first, second] = await Promise.all([
flushPendingCommunityAvatar(),
flushPendingCommunityAvatar(),
]);
expect(first).toEqual({ status: "uploaded", url: "/api/uploads/logo" });
expect(second).toEqual(first);
expect(uploadCreateFlowFile).toHaveBeenCalledTimes(1);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "uploaded",
url: "/api/uploads/logo",
});
expect(uploadCreateFlowFile).toHaveBeenCalledTimes(1);
});
it("drops an invalid file and leaves a transient failure staged", async () => {
readPendingCommunityAvatarFile.mockResolvedValue(pending);
uploadCreateFlowFile.mockRejectedValueOnce(
new CreateFlowUploadValidationError("tooLarge"),
);
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "invalid",
});
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
uploadCreateFlowFile.mockRejectedValueOnce(new Error("network"));
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "failed",
});
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
});
it("copies a successful upload onto publish state and blocks on failure", async () => {
readPendingCommunityAvatarFile.mockResolvedValue(pending);
uploadCreateFlowFile.mockResolvedValue({ url: "/api/uploads/logo" });
await expect(
stateWithPendingCommunityAvatar({ title: "Garden" }),
).resolves.toEqual({
ok: true,
state: { title: "Garden", communityAvatarUrl: "/api/uploads/logo" },
});
forgetFlushedCommunityAvatarUrl();
uploadCreateFlowFile.mockRejectedValueOnce(new Error("network"));
await expect(
stateWithPendingCommunityAvatar({
title: "Garden",
communityAvatarFileName: "logo.png",
}),
).resolves.toEqual({ ok: false, reason: "upload_failed" });
});
});
@@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CreateFlowState } from "../../../app/(app)/create/types"; import type { CreateFlowState } from "../../../app/(app)/create/types";
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize"; import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
import { publishRule, updatePublishedRule } from "../../../lib/create/api"; import { publishRule, updatePublishedRule } from "../../../lib/create/api";
import { buildPublishPayload } from "../../../lib/create/buildPublishPayload";
import { stateWithPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar";
import messages from "../../../messages/en/index";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin"; import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin";
import { import {
@@ -24,6 +27,13 @@ vi.mock("../../../lib/create/api", () => ({
updatePublishedRule: vi.fn(), updatePublishedRule: vi.fn(),
})); }));
vi.mock("../../../lib/create/flushPendingCommunityAvatar", () => ({
stateWithPendingCommunityAvatar: vi.fn(async (state: CreateFlowState) => ({
ok: true as const,
state,
})),
}));
vi.mock("../../../lib/create/lastPublishedRule", () => ({ vi.mock("../../../lib/create/lastPublishedRule", () => ({
writeLastPublishedRule: vi.fn(), writeLastPublishedRule: vi.fn(),
})); }));
@@ -41,6 +51,10 @@ describe("useCreateFlowFinalize", () => {
vi.mocked(publishRule).mockReset(); vi.mocked(publishRule).mockReset();
vi.mocked(updatePublishedRule).mockReset(); vi.mocked(updatePublishedRule).mockReset();
vi.mocked(writeLastPublishedRule).mockReset(); vi.mocked(writeLastPublishedRule).mockReset();
vi.mocked(stateWithPendingCommunityAvatar).mockReset();
vi.mocked(stateWithPendingCommunityAvatar).mockImplementation(
async (state) => ({ ok: true as const, state }),
);
router.push.mockReset(); router.push.mockReset();
updateState.mockReset(); updateState.mockReset();
openLogin.mockReset(); openLogin.mockReset();
@@ -148,6 +162,82 @@ describe("useCreateFlowFinalize", () => {
); );
}); });
it("publishes a guest logo that was only staged in the browser", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
id: "guest-rule-id",
title: "Published title",
});
vi.mocked(stateWithPendingCommunityAvatar).mockResolvedValue({
ok: true,
state: {
communityAvatarFileName: "logo.png",
communityAvatarUrl: "/api/uploads/guest-logo",
},
});
vi.mocked(buildPublishPayload).mockImplementationOnce((draft) => ({
ok: true as const,
title: "Published title",
summary: "Published summary",
document: {
communityAvatarUrl: draft.communityAvatarUrl,
},
}));
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: { communityAvatarFileName: "logo.png" },
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: null,
onGuestPublished,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).toHaveBeenCalledWith({
title: "Published title",
summary: "Published summary",
document: { communityAvatarUrl: "/api/uploads/guest-logo" },
});
expect(updateState).toHaveBeenCalledWith({
communityAvatarUrl: "/api/uploads/guest-logo",
});
});
it("does not publish when the staged logo fails to upload", async () => {
vi.mocked(stateWithPendingCommunityAvatar).mockResolvedValue({
ok: false,
reason: "upload_failed",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: { communityAvatarFileName: "logo.png" },
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: null,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).not.toHaveBeenCalled();
expect(result.current.publishBannerMessage).toBe(
messages.create.upload.errors.generic,
);
expect(result.current.isPublishing).toBe(false);
});
it("hints to claim later when a guest publishes without stakeholder emails", async () => { it("hints to claim later when a guest publishes without stakeholder emails", async () => {
vi.mocked(publishRule).mockResolvedValue({ vi.mocked(publishRule).mockResolvedValue({
ok: true, ok: true,
@@ -18,4 +18,8 @@ describe("isValidCreateFlowSaveEmail", () => {
it("accepts a required type=email value", () => { it("accepts a required type=email value", () => {
expect(isValidCreateFlowSaveEmail("user@example.com")).toBe(true); expect(isValidCreateFlowSaveEmail("user@example.com")).toBe(true);
}); });
it("rejects a domain with no dot", () => {
expect(isValidCreateFlowSaveEmail("maya@example")).toBe(false);
});
}); });
+33
View File
@@ -80,6 +80,39 @@ describe("POST /api/uploads", () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("returns 401 when a guest uploads an attachment", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await POST(
multipartRequest({
purpose: "customMethodAttachment",
fileName: "notes.pdf",
fileContent: "hello",
contentType: "application/pdf",
}),
undefined,
);
expect(res.status).toBe(401);
});
it("lets a guest submit a community logo and then validates it", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await POST(
multipartRequest({
purpose: "communityAvatar",
fileName: "empty.png",
fileContent: "",
}),
undefined,
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: { code: string };
details?: { reason?: string };
};
expect(body.error.code).toBe("validation_error");
expect(body.details?.reason).toBe("empty");
});
it("returns 500 when UPLOAD_ROOT is unset", async () => { it("returns 500 when UPLOAD_ROOT is unset", async () => {
getUploadRootFromEnvMock.mockReturnValueOnce(null); getUploadRootFromEnvMock.mockReturnValueOnce(null);
const res = await POST( const res = await POST(