Walk Create Community through the current screens and restore a chosen logo after reload #96
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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(".");
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user