Invite people after review, then finish the Rule, with login before inviting and Invite your members on Profile.

This commit is contained in:
adilallo committed 2026-11-06 08:56:13 -07:00
1 parent 88bfccd1e7
commit 70ceb73104
31 files changed
+1388 -401

No files matched your search

+86 -8
View File
@@ -199,9 +199,7 @@ function CreateFlowLayoutContent({
const editingPublishedRuleIdTrimmed = const editingPublishedRuleIdTrimmed =
state.editingPublishedRuleId?.trim() ?? ""; state.editingPublishedRuleId?.trim() ?? "";
const isConfirmStakeholdersManagePublished = const isConfirmStakeholdersManagePublished =
currentStep === "confirm-stakeholders" && currentStep === "confirm-stakeholders" && manageStakeholdersIntent;
manageStakeholdersIntent &&
editingPublishedRuleIdTrimmed.length > 0;
const { draftSaveBannerMessage, setDraftSaveBannerMessage } = const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner(); useCreateFlowDraftSaveBanner();
const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt(); const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt();
@@ -277,6 +275,7 @@ function CreateFlowLayoutContent({
: createFlowStepPathWithSyncDraft("final-review"); : createFlowStepPathWithSyncDraft("final-review");
const { const {
publishBannerTitle,
publishBannerMessage, publishBannerMessage,
setPublishBannerMessage, setPublishBannerMessage,
isPublishing, isPublishing,
@@ -450,7 +449,10 @@ function CreateFlowLayoutContent({
}, [currentStep]); }, [currentStep]);
useEffect(() => { useEffect(() => {
if (currentStep !== "edit-rule") return; const editingFromCompleted =
currentStep === "final-review" &&
(state.editingPublishedRuleId?.trim() ?? "").length > 0;
if (currentStep !== "edit-rule" && !editingFromCompleted) return;
const last = readLastPublishedRule(); const last = readLastPublishedRule();
if (!last) { if (!last) {
router.replace(CREATE_ROUTES.completed); router.replace(CREATE_ROUTES.completed);
@@ -515,6 +517,33 @@ function CreateFlowLayoutContent({
state.customMethodCardMetaById, state.customMethodCardMetaById,
]); ]);
useEffect(() => {
if (currentStep !== "confirm-stakeholders") return;
if (!manageStakeholdersIntent) return;
if (editingPublishedRuleIdTrimmed.length > 0) return;
const last = readLastPublishedRule();
if (!last) {
router.replace(CREATE_ROUTES.completed);
return;
}
const patch = createFlowStateFromPublishedRule(last);
const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch);
updateState({
...patch,
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
});
}, [
currentStep,
manageStakeholdersIntent,
editingPublishedRuleIdTrimmed.length,
router,
updateState,
state.methodSectionsPinCommitted,
]);
useEffect(() => { useEffect(() => {
if (currentStep !== "completed") { if (currentStep !== "completed") {
setCompletedFlowBanner(null); setCompletedFlowBanner(null);
@@ -705,6 +734,7 @@ function CreateFlowLayoutContent({
key: "publish", key: "publish",
status: "danger" as const, status: "danger" as const,
title: title:
publishBannerTitle ??
messages.create.reviewAndComplete.publish.publishBannerTitle, messages.create.reviewAndComplete.publish.publishBannerTitle,
description: publishBannerMessage, description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null), onClose: () => setPublishBannerMessage(null),
@@ -862,7 +892,7 @@ function CreateFlowLayoutContent({
editingPublishedRuleId: last.id, editingPublishedRuleId: last.id,
sections: [], sections: [],
}); });
router.push(createFlowStepPath("edit-rule")); router.push(createFlowStepPath("final-review"));
} }
: undefined : undefined
} }
@@ -1125,12 +1155,14 @@ function CreateFlowLayoutContent({
buttonType="filled" buttonType="filled"
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={isPublishing} disabled={
isPublishing || editingPublishedRuleIdTrimmed.length === 0
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
router.push( router.push(
createFlowStepPathAfterStrippingReviewReturn( createFlowStepPathAfterStrippingReviewReturn(
"edit-rule", reviewReturnTarget ?? "completed",
searchParams, searchParams,
), ),
); );
@@ -1141,6 +1173,39 @@ function CreateFlowLayoutContent({
.footerDone .footerDone
} }
</Button> </Button>
) : currentStep === "confirm-stakeholders" ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="ghost"
palette="default"
size="xsmall"
disabled={isPublishing || !sessionResolved}
className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
onClick={() => {
void handleFinalize({ skipInvites: true });
}}
>
{
create.reviewAndComplete.confirmStakeholders
.finishWithoutInviting
}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing || !sessionResolved}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleFinalize({ skipInvites: false });
}}
>
{
create.reviewAndComplete.confirmStakeholders
.sendInvitations
}
</Button>
</div>
) : nextStep || isFinalReviewLike ? ( ) : nextStep || isFinalReviewLike ? (
<Button <Button
buttonType="filled" buttonType="filled"
@@ -1150,7 +1215,8 @@ function CreateFlowLayoutContent({
isPublishing || isPublishing ||
(isEditRuleStep && !sessionResolved) || (isEditRuleStep && !sessionResolved) ||
(currentStep === "final-review" && (currentStep === "final-review" &&
isFinalReviewContinueBlocked(state)) (!sessionResolved ||
isFinalReviewContinueBlocked(state)))
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
@@ -1160,6 +1226,18 @@ function CreateFlowLayoutContent({
} }
if (currentStep === "final-review") { if (currentStep === "final-review") {
if (isFinalReviewContinueBlocked(state)) return; if (isFinalReviewContinueBlocked(state)) return;
if (!sessionResolved) return;
if (sessionUser === null) {
openLogin({
variant: "invite",
nextPath:
createFlowStepPathWithSyncDraft(
"confirm-stakeholders",
),
backdropVariant: "blurredYellow",
});
return;
}
goToNextStep(); goToNextStep();
return; return;
} }
@@ -0,0 +1,43 @@
"use client";
import type { MembershipInvitePreview } from "../../../../lib/create/membershipInvitePreview";
export function MembershipInvitePreviewCard({
preview,
fromYourRuleLabel,
whoBelongsLabel,
joiningLabel,
}: {
preview: MembershipInvitePreview;
fromYourRuleLabel: string;
whoBelongsLabel: string;
joiningLabel: string;
}) {
return (
<div className="flex w-full min-w-0 flex-col gap-[var(--space-300)]">
<p className="text-small-label text-[var(--color-content-default-tertiary)]">
{fromYourRuleLabel}
</p>
<div className="flex w-full min-w-0 flex-col gap-[var(--space-200)] rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-teal)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
<p className="text-small-label text-[var(--color-content-invert-secondary)]">
{preview.categoryName}
</p>
<p className="font-bricolage text-x-small-heading">
{preview.title}
</p>
{preview.eligibility ? (
<p className="text-small-paragraph">
<span className="font-bold">{whoBelongsLabel}</span>
{` ${preview.eligibility}`}
</p>
) : null}
{preview.joiningProcess ? (
<p className="text-small-paragraph">
<span className="font-bold">{joiningLabel}</span>
{` ${preview.joiningProcess}`}
</p>
) : null}
</div>
</div>
);
}
+161 -118
View File
@@ -17,21 +17,27 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: { type OpenLogin = (args: {
variant: "default" | "saveProgress" | "keepRule"; variant: "default" | "saveProgress" | "keepRule" | "invite";
nextPath: string; nextPath: string;
backdropVariant: "blurredYellow"; backdropVariant: "blurredYellow";
}) => void; }) => void;
type SessionUser = { id: string; email: string }; type SessionUser = { id: string; email: string };
export type FinalizeOptions = {
/** When true, finish without sending the current email list. */
skipInvites?: boolean;
};
export type UseCreateFlowFinalizeResult = { export type UseCreateFlowFinalizeResult = {
publishBannerTitle: string | null;
publishBannerMessage: string | null; publishBannerMessage: string | null;
setPublishBannerMessage: (_message: string | null) => void; setPublishBannerMessage: (_message: string | null) => void;
isPublishing: boolean; isPublishing: boolean;
finalize: () => Promise<void>; finalize: (_opts?: FinalizeOptions) => Promise<void>;
}; };
/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */ /** Invite / Finish → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */
export function useCreateFlowFinalize({ export function useCreateFlowFinalize({
state, state,
router, router,
@@ -55,65 +61,67 @@ export function useCreateFlowFinalize({
/** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */ /** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */
onGuestPublished?: (_info: { skippedInvites: boolean }) => void; onGuestPublished?: (_info: { skippedInvites: boolean }) => void;
}): UseCreateFlowFinalizeResult { }): UseCreateFlowFinalizeResult {
const [publishBannerTitle, setPublishBannerTitle] = useState<string | null>(
null,
);
const [publishBannerMessage, setPublishBannerMessage] = useState< const [publishBannerMessage, setPublishBannerMessage] = useState<
string | null string | null
>(null); >(null);
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const finalize = useCallback(async () => { const showFailedBanner = useCallback(
if (sessionUser === undefined) return; (message: string, sendingInvites: boolean) => {
const inviteCopy = messages.create.reviewAndComplete.confirmStakeholders;
setPublishBannerMessage(null); if (sendingInvites) {
setIsPublishing(true); setPublishBannerTitle(inviteCopy.sendFailedTitle);
const prepared = await stateWithPendingCommunityAvatar(state); setPublishBannerMessage(inviteCopy.sendFailedDescription);
if (prepared.ok === false) { return;
setIsPublishing(false); }
setPublishBannerMessage(messages.create.upload.errors.generic); setPublishBannerTitle(
return; messages.create.reviewAndComplete.publish.publishBannerTitle,
}
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
}
const payloadResult = buildPublishPayload(prepared.state);
if (payloadResult.ok === false) {
setIsPublishing(false);
setPublishBannerMessage(
payloadResult.error === "missingCommunityName"
? messages.create.reviewAndComplete.publish.missingCommunityName
: payloadResult.error,
); );
return; setPublishBannerMessage(message);
} },
const { title, summary, document: ruleDocument } = payloadResult; [],
);
const editingId = const finalize = useCallback(
typeof state.editingPublishedRuleId === "string" async (opts?: FinalizeOptions) => {
? state.editingPublishedRuleId.trim() if (sessionUser === undefined) return;
: ""; const skipInvites = opts?.skipInvites === true;
if (editingId.length > 0 && sessionUser === null) { setPublishBannerTitle(null);
setIsPublishing(false); setPublishBannerMessage(null);
writeLastPublishedRule({ setIsPublishing(true);
id: editingId, const prepared = await stateWithPendingCommunityAvatar(state);
title, if (prepared.ok === false) {
summary: summary ?? null, setIsPublishing(false);
document: ruleDocument, showFailedBanner(messages.create.upload.errors.generic, false);
}); return;
updateState({ editingPublishedRuleId: undefined }); }
writePendingKeepRuleLogin(); if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
onGuestPublished?.({ skippedInvites: false }); updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
router.push(createFlowStepPath("completed")); }
return; const payloadResult = buildPublishPayload(prepared.state);
} if (payloadResult.ok === false) {
setIsPublishing(false);
showFailedBanner(
payloadResult.error === "missingCommunityName"
? messages.create.reviewAndComplete.publish.missingCommunityName
: payloadResult.error,
false,
);
return;
}
const { title, summary, document: ruleDocument } = payloadResult;
if (editingId.length > 0) { const editingId =
const updateResult = await updatePublishedRule(editingId, { typeof state.editingPublishedRuleId === "string"
title, ? state.editingPublishedRuleId.trim()
summary: summary ?? null, : "";
document: ruleDocument,
}); if (editingId.length > 0 && sessionUser === null) {
setIsPublishing(false); setIsPublishing(false);
if (updateResult.ok === true) {
writeLastPublishedRule({ writeLastPublishedRule({
id: editingId, id: editingId,
title, title,
@@ -121,10 +129,87 @@ export function useCreateFlowFinalize({
document: ruleDocument, document: ruleDocument,
}); });
updateState({ editingPublishedRuleId: undefined }); updateState({ editingPublishedRuleId: undefined });
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: false });
router.push(createFlowStepPath("completed")); router.push(createFlowStepPath("completed"));
return; return;
} }
if (updateResult.status === 401) {
if (editingId.length > 0) {
const updateResult = await updatePublishedRule(editingId, {
title,
summary: summary ?? null,
document: ruleDocument,
});
setIsPublishing(false);
if (updateResult.ok === true) {
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document: ruleDocument,
});
updateState({ editingPublishedRuleId: undefined });
router.push(createFlowStepPath("completed"));
return;
}
if (updateResult.status === 401) {
openLogin({
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
}
showFailedBanner(
updateResult.error.trim() !== ""
? updateResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed,
false,
);
return;
}
const isGuest = sessionUser === null;
const rawStakeholderEmails = skipInvites
? []
: (state.stakeholderEmails ?? []).filter(
(e) => typeof e === "string" && e.trim() !== "",
);
const stakeholderEmails = isGuest ? [] : rawStakeholderEmails;
const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0;
const sendingInvites = stakeholderEmails.length > 0;
const publishResult = await publishRule({
title,
summary,
document: ruleDocument,
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
});
setIsPublishing(false);
if (publishResult.ok === true) {
writeLastPublishedRule({
id: publishResult.id,
title,
summary: summary ?? null,
document: ruleDocument,
});
if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
}
router.push(
createFlowStepPath("completed", {
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
}),
);
return;
}
if (publishResult.status === 401) {
openLogin({ openLogin({
variant: sessionUser === null ? "keepRule" : "default", variant: sessionUser === null ? "keepRule" : "default",
nextPath: nextPath:
@@ -135,76 +220,34 @@ export function useCreateFlowFinalize({
}); });
return; return;
} }
setPublishBannerMessage( showFailedBanner(
updateResult.error.trim() !== "" publishResult.error.trim() !== ""
? updateResult.error ? publishResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed, : messages.create.reviewAndComplete.publish.genericPublishFailed,
sendingInvites,
); );
return; },
} [
state,
router,
openLogin,
updateState,
loginReturnPath,
sessionUser,
onGuestPublished,
showFailedBanner,
],
);
const isGuest = sessionUser === null; const clearPublishBanner = useCallback((message: string | null) => {
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter( if (message === null) setPublishBannerTitle(null);
(e) => typeof e === "string" && e.trim() !== "", setPublishBannerMessage(message);
); }, []);
const stakeholderEmails = isGuest ? [] : rawStakeholderEmails;
const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0;
const publishResult = await publishRule({
title,
summary,
document: ruleDocument,
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
});
setIsPublishing(false);
if (publishResult.ok === true) {
writeLastPublishedRule({
id: publishResult.id,
title,
summary: summary ?? null,
document: ruleDocument,
});
if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
}
router.push(
createFlowStepPath("completed", {
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
}),
);
return;
}
if (publishResult.status === 401) {
openLogin({
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
}
setPublishBannerMessage(
publishResult.error.trim() !== ""
? publishResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed,
);
}, [
state,
router,
openLogin,
updateState,
loginReturnPath,
sessionUser,
onGuestPublished,
]);
return { return {
publishBannerTitle,
publishBannerMessage, publishBannerMessage,
setPublishBannerMessage, setPublishBannerMessage: clearPublishBanner,
isPublishing, isPublishing,
finalize, finalize,
}; };
@@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from "next/navigation";
import CommunityRule from "../../../../components/type/CommunityRule"; import CommunityRule from "../../../../components/type/CommunityRule";
import type { CommunityRuleSection } from "../../../../components/type/CommunityRule/CommunityRule.types"; import type { CommunityRuleSection } from "../../../../components/type/CommunityRule/CommunityRule.types";
import Alert from "../../../../components/modals/Alert"; import Alert from "../../../../components/modals/Alert";
import AskOrganizerInquiryModal from "../../../../components/modals/AskOrganizerInquiry";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { fetchPublishedRuleDetail } from "../../../../../lib/create/api"; import { fetchPublishedRuleDetail } from "../../../../../lib/create/api";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections"; import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections";
@@ -86,6 +88,7 @@ export function CompletedScreen() {
const [toastDismissed, setToastDismissed] = useState(false); const [toastDismissed, setToastDismissed] = useState(false);
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */ /** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
const [showCelebrateToast] = useState(celebrateInUrl); const [showCelebrateToast] = useState(celebrateInUrl);
const [askOrganizerOpen, setAskOrganizerOpen] = useState(false);
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle); const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
const [headerDescription, setHeaderDescription] = useState< const [headerDescription, setHeaderDescription] = useState<
string | undefined string | undefined
@@ -165,10 +168,10 @@ export function CompletedScreen() {
<> <>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full"> <div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div <div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
> >
<div <div
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`flex shrink-0 flex-col justify-start min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
> >
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={headerTitle} title={headerTitle}
@@ -177,9 +180,20 @@ export function CompletedScreen() {
size="L" size="L"
palette="inverse" palette="inverse"
/> />
{headerTitle ? (
<p className="mt-[var(--space-400)] max-w-[640px] text-small-paragraph text-[var(--color-content-invert-secondary)]">
{completed.askOrganizerLead}{" "}
<InlineTextButton
className="text-small-paragraph text-[var(--color-content-invert-primary)]"
onClick={() => setAskOrganizerOpen(true)}
>
{completed.askOrganizerLink}
</InlineTextButton>
</p>
) : null}
</div> </div>
<div <div
className={`relative flex max-md:min-h-min max-md:shrink-0 flex-col overflow-x-hidden md:min-h-0 md:max-h-full md:self-stretch md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
> >
<div <div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block" className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
@@ -196,6 +210,10 @@ export function CompletedScreen() {
</div> </div>
</div> </div>
{toast} {toast}
<AskOrganizerInquiryModal
isOpen={askOrganizerOpen}
onClose={() => setAskOrganizerOpen(false)}
/>
</> </>
); );
} }
@@ -1,34 +1,84 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import {
import { useRouter, useSearchParams } from "next/navigation"; useCallback,
import MultiSelect from "../../../../components/controls/MultiSelect"; useEffect,
import Alert from "../../../../components/modals/Alert"; useMemo,
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; useState,
type ClipboardEvent,
} from "react";
import { useSearchParams } from "next/navigation";
import Button from "../../../../components/buttons/Button";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import TextInput from "../../../../components/controls/TextInput";
import Icon from "../../../../components/asset/icon";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { fetchAuthSession } from "../../../../../lib/create/api";
import { membershipInvitePreview } from "../../../../../lib/create/membershipInvitePreview";
import {
INVITE_EMAIL_PATTERN,
formatInviteCountWord,
parseInvitePaste,
} from "../../../../../lib/create/parseInviteEmails";
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits"; import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { MembershipInvitePreviewCard } from "../../components/MembershipInvitePreviewCard";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
} from "../../utils/flowSteps"; } from "../../utils/flowSteps";
import { createFlowStepPath } from "../../utils/createFlowPaths";
import { PublishedStakeholdersManagePanel } from "./PublishedStakeholdersManagePanel"; import { PublishedStakeholdersManagePanel } from "./PublishedStakeholdersManagePanel";
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const VISIBLE_WHEN_COLLAPSED = 6;
function emailsToChipOptions(emails: string[]): ChipOption[] { function InvitePersonRow({
return emails.map((email) => ({ email,
id: email, youLabel,
label: email, removeAria,
state: "selected" as const, onRemove,
})); }: {
email: string;
youLabel?: string;
removeAria: string;
onRemove?: () => void;
}) {
return (
<div className="flex w-full items-center gap-[var(--space-300)] border-b border-[var(--color-border-default-secondary)] py-[var(--space-200)]">
<div className="flex min-w-0 flex-1 items-center gap-[var(--space-200)]">
<p className="truncate text-medium-paragraph text-[var(--color-content-default-primary)]">
{email}
</p>
{youLabel ? (
<p className="shrink-0 text-small-label text-[var(--color-content-default-tertiary)]">
{youLabel}
</p>
) : null}
</div>
{onRemove ? (
<button
type="button"
onClick={onRemove}
aria-label={removeAria}
className="flex size-[30px] shrink-0 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)]"
>
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_CLOSE)}
alt=""
width={14}
height={14}
className="size-[14px]"
/>
</button>
) : null}
</div>
);
} }
export function ConfirmStakeholdersScreen() { export function ConfirmStakeholdersScreen() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const t = useTranslation("create.reviewAndComplete.confirmStakeholders"); const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
@@ -37,163 +87,271 @@ export function ConfirmStakeholdersScreen() {
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
const editingPublishedRuleId = state.editingPublishedRuleId?.trim() ?? ""; const editingPublishedRuleId = state.editingPublishedRuleId?.trim() ?? "";
const managePublishedMode =
manageStakeholdersIntent && editingPublishedRuleId.length > 0; const [ownerEmail, setOwnerEmail] = useState<string | null>(null);
const [draftEmail, setDraftEmail] = useState("");
const [fieldError, setFieldError] = useState<string | null>(null);
const [pasteResult, setPasteResult] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => { useEffect(() => {
if (!manageStakeholdersIntent) return; let cancelled = false;
if (editingPublishedRuleId.length > 0) return; void fetchAuthSession().then(({ user }) => {
router.replace(createFlowStepPath("edit-rule")); if (cancelled) return;
}, [ const email = user?.email?.trim().toLowerCase() ?? "";
manageStakeholdersIntent, setOwnerEmail(email.length > 0 ? email : null);
editingPublishedRuleId.length, });
router, return () => {
]); cancelled = true;
};
}, []);
const persistedKey = (state.stakeholderEmails ?? []).join("\0"); const invitees = useMemo(() => {
const [toastDismissed, setToastDismissed] = useState(false); const raw = state.stakeholderEmails ?? [];
const [chipError, setChipError] = useState<string | null>(null); const owner = ownerEmail;
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>( return raw
() => emailsToChipOptions(state.stakeholderEmails ?? []), .map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0 && email !== owner);
}, [state.stakeholderEmails, ownerEmail]);
const rows = useMemo(() => {
const list: Array<{ email: string; isYou: boolean }> = [];
if (ownerEmail) {
list.push({ email: ownerEmail, isYou: true });
}
for (const email of invitees) {
list.push({ email, isYou: false });
}
return list;
}, [ownerEmail, invitees]);
const visibleRows = expanded
? rows
: rows.slice(0, VISIBLE_WHEN_COLLAPSED);
const hiddenCount = Math.max(0, rows.length - visibleRows.length);
const preview = membershipInvitePreview(state);
const countLine =
rows.length === 1
? t("countOne")
: t("countMany").replace("{count}", String(rows.length));
const persistInvitees = useCallback(
(next: string[]) => {
updateState({ stakeholderEmails: next });
},
[updateState],
); );
useEffect(() => { const addOne = useCallback(
setStakeholderOptions((prev) => { (raw: string): boolean => {
const inFlight = prev.filter((c) => c.state === "custom"); const trimmed = raw.trim().toLowerCase();
const nextPersisted = emailsToChipOptions(state.stakeholderEmails ?? []); if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
return [...nextPersisted, ...inFlight]; setFieldError(t("invalidEmail"));
}); return false;
}, [persistedKey]); }
if (ownerEmail && trimmed === ownerEmail) {
setFieldError(t("duplicateEmail"));
return false;
}
if (invitees.includes(trimmed)) {
setFieldError(t("duplicateEmail"));
return false;
}
if (invitees.length >= MAX_STAKEHOLDER_EMAILS) {
setFieldError(t("maxStakeholders"));
return false;
}
persistInvitees([...invitees, trimmed]);
return true;
},
[invitees, ownerEmail, persistInvitees, t],
);
const handleAddStakeholder = () => { const handleAdd = () => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setChipError(null); setPasteResult(null);
const confirmed = state.stakeholderEmails ?? []; if (addOne(draftEmail)) {
const customCount = stakeholderOptions.filter( setDraftEmail("");
(o) => o.state === "custom", setFieldError(null);
).length;
if (confirmed.length + customCount >= MAX_STAKEHOLDER_EMAILS) {
setChipError(t("maxStakeholders"));
return;
} }
setStakeholderOptions((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "custom" },
]);
}; };
const handleCustomChipConfirm = (chipId: string, value: string) => { const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const text = event.clipboardData.getData("text");
const tokens = text
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter((token) => token.length > 0);
if (tokens.length <= 1) return;
event.preventDefault();
markCreateFlowInteraction(); markCreateFlowInteraction();
setChipError(null); setFieldError(null);
const trimmed = value.trim().toLowerCase();
if (!EMAIL_PATTERN.test(trimmed)) { const existing = ownerEmail ? [ownerEmail, ...invitees] : [...invitees];
setChipError(t("invalidEmail")); const parsed = parseInvitePaste(text, existing, MAX_STAKEHOLDER_EMAILS);
return; if (parsed.added.length > 0) {
persistInvitees([...invitees, ...parsed.added]);
} }
const current = state.stakeholderEmails ?? []; setDraftEmail(parsed.invalidRemainder);
if (current.includes(trimmed)) { if (parsed.invalidRemainder.length > 0) {
setChipError(t("duplicateEmail")); setFieldError(t("invalidEmail"));
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
return;
} }
if (current.length >= MAX_STAKEHOLDER_EMAILS) {
setChipError(t("maxStakeholders")); const parts: string[] = [];
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); if (parsed.added.length > 0) {
return; const people =
parsed.added.length === 1 ? t("peopleNounOne") : t("peopleNounMany");
parts.push(
t("pasteAdded")
.replace("{count}", formatInviteCountWord(parsed.added.length))
.replace("{people}", people),
);
} }
setStakeholderOptions((prev) => const invalidCount = parsed.invalidRemainder
prev.map((opt) => .split(",")
opt.id === chipId .map((item) => item.trim())
? { id: trimmed, label: trimmed, state: "selected" as const } .filter((item) => item.length > 0).length;
: opt, if (invalidCount === 1) {
), parts.push(t("pasteInvalidOne"));
); } else if (invalidCount > 1) {
updateState({ stakeholderEmails: [...current, trimmed] }); parts.push(
t("pasteInvalidMany").replace(
"{count}",
formatInviteCountWord(invalidCount),
),
);
}
setPasteResult(parts.length > 0 ? parts.join(" ") : null);
}; };
const handleCustomChipClose = (chipId: string) => { const handleRemove = (email: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setChipError(null); setPasteResult(null);
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); persistInvitees(invitees.filter((item) => item !== email));
}; };
const handleChipClick = (chipId: string) => { const header = (
markCreateFlowInteraction(); <div className="flex w-full min-w-0 flex-col gap-[var(--space-800)]">
setChipError(null); <CreateFlowHeaderLockup
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); title={t("title")}
updateState({ description={t("description")}
stakeholderEmails: (state.stakeholderEmails ?? []).filter( justification="left"
(e) => e !== chipId, />
), {preview ? (
}); <MembershipInvitePreviewCard
}; preview={preview}
fromYourRuleLabel={t("fromYourRule")}
whoBelongsLabel={t("whoBelongsLabel")}
joiningLabel={t("joiningLabel")}
/>
) : null}
</div>
);
if (managePublishedMode) { if (manageStakeholdersIntent) {
return ( return (
<CreateFlowTwoColumnSelectShell <CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
header={ {editingPublishedRuleId.length > 0 ? (
<CreateFlowHeaderLockup <PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
title={t("managePublished.lockupTitle")} ) : null}
description={t("managePublished.lockupDescription")}
justification="left"
/>
}
>
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
); );
} }
return ( return (
<> <CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
<CreateFlowTwoColumnSelectShell <div className="flex w-full min-w-0 flex-col gap-[var(--space-600)]">
header={ <div
<CreateFlowHeaderLockup className="flex w-full items-start gap-[var(--space-200)]"
title={t("title")} onPasteCapture={handlePaste}
description={t("description")} >
justification="left" <div className="min-w-0 flex-1">
/> <TextInput
} id="invite-member-email"
> type="email"
{chipError ? ( inputSize="small"
<p showHelpIcon={false}
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]" label={t("emailLabel")}
role="alert" placeholder={t("emailPlaceholder")}
> value={draftEmail}
{chipError} autoComplete="email"
autoFocus
error={Boolean(fieldError)}
textHint={fieldError || false}
onChange={(event) => {
setDraftEmail(event.target.value);
setFieldError(null);
setPasteResult(null);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
handleAdd();
}}
/>
</div>
<div className="shrink-0 pt-[29px]">
<Button
type="button"
size="small"
buttonType="outline"
palette="default"
onClick={handleAdd}
>
{t("add")}
</Button>
</div>
</div>
{pasteResult ? (
<div className="flex items-center gap-2" role="status">
<Icon name="warning" size={24} />
<p className="text-medium-label text-[var(--color-content-default-primary)]">
{pasteResult}
</p>
</div>
) : null}
{rows.length > 0 ? (
<div className="flex w-full flex-col border-t border-[var(--color-border-default-secondary)]">
{visibleRows.map((row) => (
<InvitePersonRow
key={row.isYou ? `you:${row.email}` : row.email}
email={row.email}
youLabel={row.isYou ? t("you") : undefined}
removeAria={t("removeAria").replace("{email}", row.email)}
onRemove={
row.isYou ? undefined : () => handleRemove(row.email)
}
/>
))}
{hiddenCount > 0 ? (
<div className="pt-[var(--space-300)] pb-[var(--space-100)]">
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{t("showMore").replace("{count}", String(hiddenCount))}
</InlineTextButton>
</div>
) : null}
</div>
) : null}
{rows.length > 0 ? (
<p className="text-medium-label text-[var(--color-content-default-primary)]">
{countLine}
</p> </p>
) : null} ) : null}
<MultiSelect <p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
formHeader={false} {t("privacy")}
showHelpIcon={false} </p>
size="s" </div>
options={stakeholderOptions} </CreateFlowTwoColumnSelectShell>
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
onCustomChipConfirm={handleCustomChipConfirm}
onCustomChipClose={handleCustomChipClose}
addButton
addButtonText={t("addStakeholder")}
/>
</CreateFlowTwoColumnSelectShell>
{!toastDismissed && (
<div
className="fixed bottom-[5.25rem] left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 md:bottom-[5.5rem]"
role="status"
aria-live="polite"
>
<Alert
type="banner"
status="positive"
title={t("draftToastTitle")}
hasLeadingIcon={false}
hasBodyText={false}
onClose={() => setToastDismissed(true)}
className="w-full !px-[var(--space-600)] !py-[var(--space-400)] md:!py-4"
/>
</div>
)}
</>
); );
} }
@@ -129,28 +129,28 @@ export function PublishedStakeholdersManagePanel({
{t("managePublished.empty")} {t("managePublished.empty")}
</p> </p>
) : ( ) : (
<ul className="flex flex-col gap-3"> <ul className="flex w-full flex-col border-t border-[var(--color-border-default-secondary)]">
{items.map((row) => ( {items.map((row) => (
<li <li
key={row.id} key={row.id}
className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between" className="flex items-center gap-[var(--space-300)] border-b border-[var(--color-border-default-secondary)] py-[var(--space-200)]"
> >
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-sm font-medium text-[var(--color-content-default-primary)] md:text-base"> <span className="truncate text-medium-paragraph text-[var(--color-content-default-primary)]">
{row.email} {row.email}
</span> </span>
<span className="text-x-small-paragraph text-[var(--color-content-default-tertiary)] md:text-sm"> <span className="text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
{row.status === "pending" {row.status === "pending"
? t("managePublished.pending") ? t("managePublished.pending")
: t("managePublished.accepted")} : t("managePublished.accepted")}
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex shrink-0 flex-wrap justify-end gap-2">
{row.status === "pending" ? ( {row.status === "pending" ? (
<Button <Button
type="button" type="button"
size="small" size="small"
buttonType="outline" buttonType="ghost"
palette="default" palette="default"
disabled={busyId === row.id} disabled={busyId === row.id}
onClick={() => void handleResend(row.id)} onClick={() => void handleResend(row.id)}
@@ -165,7 +165,7 @@ export function PublishedStakeholdersManagePanel({
<Button <Button
type="button" type="button"
size="small" size="small"
buttonType="outline" buttonType="ghost"
palette="default" palette="default"
disabled={busyId === row.id} disabled={busyId === row.id}
onClick={() => void handleRemove(row.id)} onClick={() => void handleRemove(row.id)}
@@ -182,7 +182,7 @@ export function PublishedStakeholdersManagePanel({
</ul> </ul>
)} )}
<div className="flex flex-col gap-3 md:flex-row md:items-end md:gap-3"> <div className="flex items-start gap-[var(--space-200)]">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<TextInput <TextInput
id="published-stakeholder-email" id="published-stakeholder-email"
@@ -201,17 +201,18 @@ export function PublishedStakeholdersManagePanel({
autoComplete="email" autoComplete="email"
/> />
</div> </div>
<Button <div className="shrink-0 pt-[29px]">
type="button" <Button
size="small" type="button"
buttonType="filled" size="small"
palette="default" buttonType="outline"
className="md:mb-[2px]" palette="default"
disabled={addBusy || items === null} disabled={addBusy || items === null}
onClick={() => void handleAdd()} onClick={() => void handleAdd()}
> >
{t("managePublished.addInvite")} {t("managePublished.addInvite")}
</Button> </Button>
</div>
</div> </div>
</section> </section>
); );
@@ -131,7 +131,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
}, },
"confirm-stakeholders": { "confirm-stakeholders": {
layoutKind: "select", layoutKind: "select",
figmaNodeId: "21104-46594", figmaNodeId: "25336-62720",
messageNamespace: "create.reviewAndComplete.confirmStakeholders", messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
+11 -4
View File
@@ -305,8 +305,9 @@ export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const; export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
/** /**
* `/create/confirm-stakeholders?manageStakeholders=1` — edit published rule invites (requires `state.editingPublishedRuleId`). * `/create/confirm-stakeholders?manageStakeholders=1` — published-rule invites.
* Typically paired with `reviewReturn=edit-rule`. * Layout hydrates `editingPublishedRuleId` from the last published rule when needed.
* Typically paired with `reviewReturn=edit-rule` or `reviewReturn=completed`.
*/ */
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;
@@ -314,14 +315,20 @@ export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const;
export type CreateFlowReviewReturnTarget = export type CreateFlowReviewReturnTarget =
| "final-review" | "final-review"
| "edit-rule" | "edit-rule"
| "review"; | "review"
| "completed";
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" || raw === "review") { if (
raw === "final-review" ||
raw === "edit-rule" ||
raw === "review" ||
raw === "completed"
) {
return raw; return raw;
} }
return null; return null;
+40
View File
@@ -11,12 +11,16 @@ import {
duplicatePublishedRule, duplicatePublishedRule,
fetchAuthSession, fetchAuthSession,
fetchMyPublishedRules, fetchMyPublishedRules,
fetchPublishedRuleDetail,
fetchServerDraftForProfile, fetchServerDraftForProfile,
logout, logout,
requestEmailChange, requestEmailChange,
type MyPublishedRule, type MyPublishedRule,
} from "../../../lib/create/api"; } from "../../../lib/create/api";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
FIRST_STEP, FIRST_STEP,
isValidStep, isValidStep,
} from "../create/utils/flowSteps"; } from "../create/utils/flowSteps";
@@ -24,6 +28,8 @@ import type { CreateFlowStep } from "../create/types";
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage"; import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage"; import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry"; import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
import { createFlowStepPath } from "../create/utils/createFlowPaths";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import { import {
ProfilePageSignedOutView, ProfilePageSignedOutView,
@@ -225,6 +231,37 @@ export default function ProfilePageClient() {
} }
}, [load, ruleDeleteBusy, ruleDeleteTargetId, t]); }, [load, ruleDeleteBusy, ruleDeleteTargetId, t]);
const handleInviteMembers = useCallback(
async (id: string) => {
setActionError(null);
const detail = await fetchPublishedRuleDetail(id);
if (
!detail ||
!detail.viewerIsOwner ||
detail.rule.document === null ||
typeof detail.rule.document !== "object" ||
Array.isArray(detail.rule.document)
) {
setActionError(t("actionError"));
return;
}
writeLastPublishedRule({
id: detail.rule.id,
title: detail.rule.title,
summary: detail.rule.summary,
document: detail.rule.document as Record<string, unknown>,
});
router.push(
createFlowStepPath("confirm-stakeholders", {
[CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]:
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
[CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "completed",
}),
);
},
[router, t],
);
const handleDuplicateRule = useCallback( const handleDuplicateRule = useCallback(
async (id: string) => { async (id: string) => {
setActionError(null); setActionError(null);
@@ -359,6 +396,9 @@ export default function ProfilePageClient() {
onCloseDeleteRule={handleCloseDeleteRuleDialog} onCloseDeleteRule={handleCloseDeleteRuleDialog}
onConfirmDeleteRule={handleConfirmDeleteRule} onConfirmDeleteRule={handleConfirmDeleteRule}
onDuplicateRule={handleDuplicateRule} onDuplicateRule={handleDuplicateRule}
onInviteMembers={(id) => {
void handleInviteMembers(id);
}}
onContinueDraft={handleContinueDraft} onContinueDraft={handleContinueDraft}
onDeleteDraft={handleRequestDeleteDraft} onDeleteDraft={handleRequestDeleteDraft}
onCloseDeleteDraft={handleCloseDeleteDraftDialog} onCloseDeleteDraft={handleCloseDeleteDraftDialog}
@@ -63,6 +63,7 @@ export type ProfilePageViewProps = {
onCloseDeleteRule: () => void; onCloseDeleteRule: () => void;
onConfirmDeleteRule: () => void; onConfirmDeleteRule: () => void;
onDuplicateRule: (id: string) => void; onDuplicateRule: (id: string) => void;
onInviteMembers: (id: string) => void;
onContinueDraft: () => void; onContinueDraft: () => void;
onDeleteDraft: () => void; onDeleteDraft: () => void;
onCloseDeleteDraft: () => void; onCloseDeleteDraft: () => void;
@@ -185,6 +186,7 @@ export function ProfilePageView({
onCloseDeleteRule, onCloseDeleteRule,
onConfirmDeleteRule, onConfirmDeleteRule,
onDuplicateRule, onDuplicateRule,
onInviteMembers,
onContinueDraft, onContinueDraft,
onDeleteDraft, onDeleteDraft,
onCloseDeleteDraft, onCloseDeleteDraft,
@@ -332,6 +334,11 @@ export function ProfilePageView({
expanded expanded
size={ruleCardSize} size={ruleCardSize}
hasBottomLinks hasBottomLinks
bottomStatusLabel={
rule.role === "stakeholder"
? undefined
: t("finishedBadge")
}
bottomLinks={ bottomLinks={
rule.role === "stakeholder" rule.role === "stakeholder"
? [ ? [
@@ -343,14 +350,9 @@ export function ProfilePageView({
] ]
: [ : [
{ {
id: "view", id: "invite",
label: t("viewPublic"), label: t("inviteMembers"),
href: `/rules/${encodeURIComponent(rule.id)}`, onClick: () => onInviteMembers(rule.id),
},
{
id: "manage",
label: t("manageRule"),
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
}, },
{ {
id: "dup", id: "dup",
@@ -24,6 +24,9 @@ const LoginContainer = memo<LoginProps>(
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null); const previousActiveElementRef = useRef<HTMLElement | null>(null);
const [portalReady, setPortalReady] = useState(() => !usePortal); const [portalReady, setPortalReady] = useState(() => !usePortal);
const [viewportHeight, setViewportHeight] = useState<number | undefined>(
undefined,
);
// Defer enabling the portal until after the layout commit so we don’t sync-setState // Defer enabling the portal until after the layout commit so we don’t sync-setState
// inside the effect (eslint react-hooks/set-state-in-effect) while still mounting // inside the effect (eslint react-hooks/set-state-in-effect) while still mounting
@@ -36,6 +39,24 @@ const LoginContainer = memo<LoginProps>(
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [usePortal]); }, [usePortal]);
useEffect(() => {
if (!isOpen) return;
const vp = window.visualViewport;
const sync = () => {
const next = vp?.height ?? window.innerHeight;
setViewportHeight(next);
};
sync();
vp?.addEventListener("resize", sync);
vp?.addEventListener("scroll", sync);
window.addEventListener("resize", sync);
return () => {
vp?.removeEventListener("resize", sync);
vp?.removeEventListener("scroll", sync);
window.removeEventListener("resize", sync);
};
}, [isOpen]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
if (usePortal && !portalReady) return; if (usePortal && !portalReady) return;
@@ -132,6 +153,7 @@ const LoginContainer = memo<LoginProps>(
portalReady={portalReady} portalReady={portalReady}
usePortal={usePortal} usePortal={usePortal}
backdropVariant={backdropVariant} backdropVariant={backdropVariant}
viewportHeight={viewportHeight}
> >
{children} {children}
</LoginView> </LoginView>
@@ -33,4 +33,6 @@ export interface LoginViewProps {
portalReady: boolean; portalReady: boolean;
usePortal: boolean; usePortal: boolean;
backdropVariant: LoginBackdropVariant; backdropVariant: LoginBackdropVariant;
/** Visible viewport height on phones so the footer rides above the keyboard. */
viewportHeight?: number;
} }
+13 -3
View File
@@ -23,14 +23,21 @@ export function LoginView({
portalReady, portalReady,
usePortal, usePortal,
backdropVariant, backdropVariant,
viewportHeight,
}: LoginViewProps) { }: LoginViewProps) {
if (!isOpen) return null; if (!isOpen) return null;
if (usePortal && !portalReady) return null; if (usePortal && !portalReady) return null;
const phoneSheetStyle =
viewportHeight != null
? { height: viewportHeight, maxHeight: viewportHeight }
: undefined;
const content = ( const content = (
<div <div
ref={backdropRef} ref={backdropRef}
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`} className={`fixed inset-0 z-[9998] flex flex-col items-center gap-6 overflow-y-auto px-4 py-8 max-md:justify-end max-md:overflow-hidden max-md:px-0 max-md:py-0 md:justify-center ${backdropClasses[backdropVariant]}`}
style={phoneSheetStyle}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
onClose(); onClose();
@@ -43,7 +50,7 @@ export function LoginView({
aria-modal="true" aria-modal="true"
aria-label={ariaLabel} aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy} aria-labelledby={ariaLabelledBy}
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`} className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] max-md:max-h-full max-md:rounded-b-none ${className}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ModalHeader onClose={onClose} showMoreOptionsButton={false} /> <ModalHeader onClose={onClose} showMoreOptionsButton={false} />
@@ -52,7 +59,10 @@ export function LoginView({
</div> </div>
</div> </div>
{belowCard ? ( {belowCard ? (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}> <div
className="shrink-0 max-md:w-full max-md:bg-[var(--color-surface-default-primary)] max-md:px-6 max-md:pb-6"
onClick={(e) => e.stopPropagation()}
>
{belowCard} {belowCard}
</div> </div>
) : null} ) : null}
+51 -35
View File
@@ -44,7 +44,11 @@ function MailIconInline() {
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export type LoginFormVariant = "default" | "saveProgress" | "keepRule"; export type LoginFormVariant =
| "default"
| "saveProgress"
| "keepRule"
| "invite";
function loginFormHeading( function loginFormHeading(
variant: LoginFormVariant, variant: LoginFormVariant,
@@ -66,6 +70,12 @@ function loginFormHeading(
description: t("keepRuleSubtitle"), description: t("keepRuleSubtitle"),
}; };
} }
if (variant === "invite") {
return {
title: t("inviteTitle"),
description: t("inviteSubtitle"),
};
}
return { title: t("title"), description: t("subtitle") }; return { title: t("title"), description: t("subtitle") };
} }
@@ -74,7 +84,11 @@ function shouldAttachCreateFlowDraft(
nextPath: string, nextPath: string,
): boolean { ): boolean {
if (variant === "keepRule") return false; if (variant === "keepRule") return false;
return variant === "saveProgress" || nextPath.includes("syncDraft=1"); return (
variant === "saveProgress" ||
variant === "invite" ||
nextPath.includes("syncDraft=1")
);
} }
export type LoginFormProps = { export type LoginFormProps = {
@@ -266,46 +280,48 @@ export default function LoginForm({
{emailError} {emailError}
</p> </p>
) : null} ) : null}
<Button <div className="flex flex-col gap-4 max-md:sticky max-md:bottom-0 max-md:bg-[var(--color-surface-default-primary)] max-md:pt-2">
type="submit"
size="large"
buttonType="filled"
palette="default"
disabled={submitting}
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
>
{t("sendMagicLink")}
</Button>
{onDismiss ? (
<Button <Button
type="button" type="submit"
size="large" size="large"
buttonType="ghost" buttonType="filled"
palette="default" palette="default"
disabled={submitting} disabled={submitting}
onClick={onDismiss}
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]" className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
> >
{t("continueWithoutSaving")} {t("sendMagicLink")}
</Button> </Button>
) : null} {onDismiss ? (
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]"> <Button
{t("legalPrefix")} type="button"
<Link size="large"
href={tFooter("legal.termsOfServiceHref")} buttonType="ghost"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2" palette="default"
> disabled={submitting}
{tFooter("legal.termsOfService")} onClick={onDismiss}
</Link> className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
{t("legalAnd")} >
<Link {t("continueWithoutSaving")}
href={tFooter("legal.privacyPolicyHref")} </Button>
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2" ) : null}
> <p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
{tFooter("legal.privacyPolicy")} {t("legalPrefix")}
</Link> <Link
{t("legalSuffix")} href={tFooter("legal.termsOfServiceHref")}
</p> className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.termsOfService")}
</Link>
{t("legalAnd")}
<Link
href={tFooter("legal.privacyPolicyHref")}
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.privacyPolicy")}
</Link>
{t("legalSuffix")}
</p>
</div>
</form> </form>
) : null} ) : null}
</div> </div>
+18 -1
View File
@@ -8,10 +8,15 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "./MessagesContext";
import Login from "../components/modals/Login"; import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm"; import LoginForm from "../components/modals/Login/LoginForm";
export type AuthModalLoginVariant = "default" | "saveProgress" | "keepRule"; export type AuthModalLoginVariant =
| "default"
| "saveProgress"
| "keepRule"
| "invite";
export type AuthModalBackdropVariant = "solid" | "blurredYellow"; export type AuthModalBackdropVariant = "solid" | "blurredYellow";
@@ -35,6 +40,7 @@ type AuthModalContextValue = {
const AuthModalContext = createContext<AuthModalContextValue | null>(null); const AuthModalContext = createContext<AuthModalContextValue | null>(null);
export function AuthModalProvider({ children }: { children: ReactNode }) { export function AuthModalProvider({ children }: { children: ReactNode }) {
const tLogin = useTranslation("pages.login");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [opts, setOpts] = useState<OpenLoginOptions>({}); const [opts, setOpts] = useState<OpenLoginOptions>({});
@@ -72,6 +78,17 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
backdropVariant={backdropVariant} backdropVariant={backdropVariant}
usePortal usePortal
ariaLabelledBy="login-modal-heading" ariaLabelledBy="login-modal-heading"
belowCard={
opts.variant === "invite" ? (
<button
type="button"
onClick={closeLogin}
className="w-full text-center text-small-paragraph text-[var(--color-content-default-tertiary)]"
>
{tLogin("backToYourRule")}
</button>
) : undefined
}
> >
<LoginForm <LoginForm
variant={opts.variant ?? "default"} variant={opts.variant ?? "default"}
+43
View File
@@ -0,0 +1,43 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
import {
membershipPresetFor,
publishedMethodDisplayLabel,
} from "./finalReviewChipPresets";
export type MembershipInvitePreview = {
categoryName: string;
title: string;
eligibility: string;
joiningProcess: string;
};
/** First selected membership method, for the Invite your members card. */
export function membershipInvitePreview(
state: CreateFlowState,
): MembershipInvitePreview | null {
const ids = state.selectedMembershipMethodIds ?? [];
const firstId = ids.find((id) => typeof id === "string" && id.trim() !== "");
if (!firstId) return null;
const saved = state.membershipMethodDetailsById?.[firstId];
const preset = membershipPresetFor(firstId);
const eligibility = (saved?.eligibility ?? "").trim() || preset.eligibility;
const joiningProcess =
(saved?.joiningProcess ?? "").trim() || preset.joiningProcess;
const title =
publishedMethodDisplayLabel(
"membership",
firstId,
state.customMethodCardMetaById,
) || firstId;
if (!title && !eligibility && !joiningProcess) return null;
return {
categoryName: RULE_SECTION_CATEGORY.membership,
title,
eligibility,
joiningProcess,
};
}
+82
View File
@@ -0,0 +1,82 @@
/** Shared invite-field email check (create-flow Invite your members). */
export const INVITE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const COUNT_WORDS = [
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
] as const;
/** Lowercase count for paste-result copy (`five`, or digits past ten). */
export function formatInviteCountWord(n: number): string {
if (Number.isInteger(n) && n >= 0 && n <= 10) {
return COUNT_WORDS[n] ?? String(n);
}
return String(n);
}
export function splitInvitePasteTokens(raw: string): string[] {
return raw
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter((token) => token.length > 0);
}
export type ParseInvitePasteResult = {
added: string[];
invalidRemainder: string;
skippedDuplicate: number;
};
/**
* Split a pasted list into valid new addresses and leftovers that stay in the
* field. Tokens that are not emails, duplicates, or that would exceed `max`
* remain in {@link ParseInvitePasteResult.invalidRemainder}.
*/
export function parseInvitePaste(
raw: string,
existing: readonly string[],
max: number,
): ParseInvitePasteResult {
const tokens = splitInvitePasteTokens(raw);
const added: string[] = [];
const invalid: string[] = [];
const seen = new Set(
existing
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0),
);
let skippedDuplicate = 0;
for (const token of tokens) {
const lower = token.toLowerCase();
if (!INVITE_EMAIL_PATTERN.test(lower)) {
invalid.push(token);
continue;
}
if (seen.has(lower)) {
skippedDuplicate += 1;
continue;
}
if (existing.length + added.length >= max) {
invalid.push(token);
continue;
}
seen.add(lower);
added.push(lower);
}
return {
added,
invalidRemainder: invalid.join(", "),
skippedDuplicate,
};
}
@@ -1,6 +1,8 @@
{ {
"toastTitle": "This is what folks see when you share your CommunityRule", "toastTitle": "Your Rule is finished",
"toastDescription": "Your group can use this document as an operating manual.", "toastDescription": "This is what the people you invite will see.",
"askOrganizerLead": "Has your Rule changed how your community works?",
"askOrganizerLink": "Tell an organizer what changed.",
"shareNoRuleTitle": "Nothing to share yet", "shareNoRuleTitle": "Nothing to share yet",
"shareNoRuleDescription": "Open this page from your profile after you finish, or finish a rule first.", "shareNoRuleDescription": "Open this page from your profile after you finish, or finish a rule first.",
"shareLinkCopiedTitle": "Link copied", "shareLinkCopiedTitle": "Link copied",
@@ -1,25 +1,44 @@
{ {
"title": "Do other stakeholders need to be involved in creating your community?", "title": "Invite people to read your Rule",
"description": "Add their email addresses. When you publish, we'll send each person a one-time link to join Community Rule and view this rule in their profile (they won't be able to manage it unless they created it).", "description": "Everyone you add gets a private link to read the Rule.",
"addStakeholder": "Add stakeholder", "fromYourRule": "Who belongs, in your Rule’s words",
"draftToastTitle": "Congratulations! You've drafted your CommunityRule!", "whoBelongsLabel": "Who belongs.",
"invalidEmail": "Enter a valid email address.", "joiningLabel": "Joining.",
"emailLabel": "Add someone by email",
"emailPlaceholder": "name@example.org",
"add": "Add",
"you": "You",
"removeAria": "Remove {email}",
"showMore": "Show {count} more",
"countOne": "1 person gets a private link to read the Rule.",
"countMany": "{count} people get a private link to read the Rule.",
"privacy": "Every Rule starts private. Only the people you invite can read it until the person who holds the pen turns on a link.",
"finishWithoutInviting": "Finish without inviting",
"sendInvitations": "Send invitations",
"invalidEmail": "Not an email address. Fix it and select Add, or clear it.",
"duplicateEmail": "That email is already on the list.", "duplicateEmail": "That email is already on the list.",
"maxStakeholders": "You can add up to 30 stakeholder emails.", "maxStakeholders": "You can add up to 30 emails.",
"pasteAdded": "Added {count} {people} from your list.",
"pasteInvalidOne": "One is not an email address.",
"pasteInvalidMany": "{count} are not email addresses.",
"peopleNounOne": "person",
"peopleNounMany": "people",
"sendFailedTitle": "Some invitations didn’t send",
"sendFailedDescription": "Check the addresses and try again",
"managePublished": { "managePublished": {
"lockupTitle": "Stakeholders", "lockupTitle": "Invite people to read your Rule",
"lockupDescription": "Invite people by email. They get a one-time link to view this rule in their profile.", "lockupDescription": "Everyone you add gets a private link to read the Rule.",
"emailLabel": "Email address", "emailLabel": "Add someone by email",
"emailPlaceholder": "colleague@example.com", "emailPlaceholder": "name@example.org",
"addInvite": "Send invite", "addInvite": "Add",
"loading": "Loading…", "loading": "Loading…",
"loadFailed": "Could not load stakeholders. Refresh and try again.", "loadFailed": "Could not load the list. Refresh and try again.",
"pending": "Pending", "pending": "Pending",
"accepted": "Accepted", "accepted": "Accepted",
"remove": "Remove", "remove": "Remove",
"resend": "Resend invite", "resend": "Resend invite",
"empty": "No stakeholders yet.", "empty": "No one invited yet.",
"invalidEmail": "Enter a valid email address.", "invalidEmail": "Not an email address. Fix it and select Add, or clear it.",
"actionFailed": "Something went wrong. Try again.", "actionFailed": "Something went wrong. Try again.",
"rateLimited": "Too many requests. Try again in {seconds} seconds.", "rateLimited": "Too many requests. Try again in {seconds} seconds.",
"removeAria": "Remove {email}", "removeAria": "Remove {email}",
+3
View File
@@ -5,6 +5,9 @@
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to log in. If you don't save now you could lose your progress.", "saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to log in. If you don't save now you could lose your progress.",
"keepRuleTitle": "Log in to keep this rule on your profile", "keepRuleTitle": "Log in to keep this rule on your profile",
"keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.", "keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.",
"inviteTitle": "Log in to invite your members",
"inviteSubtitle": "Only a logged-in person can invite. Enter your email and we’ll send you a magic link to log in.",
"backToYourRule": "← Back to your Rule",
"continueWithoutSaving": "Continue without saving", "continueWithoutSaving": "Continue without saving",
"emailLabel": "Email address", "emailLabel": "Email address",
"emailPlaceholder": "you@example.com", "emailPlaceholder": "you@example.com",
+6 -4
View File
@@ -1,9 +1,9 @@
{ {
"pageTitle": "Your profile", "pageTitle": "Your profile",
"welcomeTitle": "Congrats {{name}}!", "welcomeTitle": "Congrats {{name}}!",
"welcomeBodyFirstRule": "You’ve made your first CommunityRule. Make sure to link it in your community resources and evolve it as your group grows and changes.", "welcomeBodyFirstRule": "Invite the people in your community to read it.",
"welcomeBodyNoRules": "Create your first CommunityRule to capture how your group makes decisions—then link it from your community resources as you grow.", "welcomeBodyNoRules": "Create your first CommunityRule to capture how your group makes decisions—then link it from your community resources as you grow.",
"yourOptionsHeading": "Your Options", "yourOptionsHeading": "Your options",
"optionCreateCustom": "Create new custom Rule", "optionCreateCustom": "Create new custom Rule",
"optionCreateTemplate": "Create new Rule from template", "optionCreateTemplate": "Create new Rule from template",
"optionLogout": "Log out of CommunityRule", "optionLogout": "Log out of CommunityRule",
@@ -12,10 +12,12 @@
"signInPrompt": "Log in to view your profile and saved rules.", "signInPrompt": "Log in to view your profile and saved rules.",
"signInCta": "Log in", "signInCta": "Log in",
"loading": "Loading…", "loading": "Loading…",
"yourRulesHeading": "Your CommunityRules", "yourRulesHeading": "Your Rules",
"yourRulesEmpty": "You have not finished a rule yet. Create one to see it here.", "yourRulesEmpty": "You have not finished a rule yet. Create one to see it here.",
"draftHeading": "Saved in progress", "draftHeading": "Saved in progress",
"draftInProgressBadge": "In progress", "draftInProgressBadge": "Draft",
"finishedBadge": "Finished",
"inviteMembers": "Invite your members",
"continueDraft": "Continue", "continueDraft": "Continue",
"createHeading": "Create", "createHeading": "Create",
"createCustomDescription": "Start a new custom rule from the beginning.", "createCustomDescription": "Start a new custom rule from the beginning.",
@@ -1,14 +1,14 @@
import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen"; import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
export default { export default {
title: "Pages/Create Flow/Confirm stakeholders", title: "Pages/Create Flow/Invite your members",
component: ConfirmStakeholdersScreen, component: ConfirmStakeholdersScreen,
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
docs: { docs: {
description: { description: {
component: component:
"Stacked lockup + MultiSelect; draft congratulations banner; before final review.", "Invite people to read the Rule after final review. Membership card beside the email list; Finish without inviting and Send invitations in the footer.",
}, },
}, },
}, },
+23 -13
View File
@@ -67,7 +67,7 @@ describe("CompletedScreen", () => {
expect(screen.queryByText("Values")).not.toBeInTheDocument(); expect(screen.queryByText("Values")).not.toBeInTheDocument();
}); });
it("does not pin the title on small screens", () => { it("keeps the heading in place while the Rule column scrolls", () => {
sessionStorage.setItem( sessionStorage.setItem(
CREATE_FLOW_LAST_PUBLISHED_KEY, CREATE_FLOW_LAST_PUBLISHED_KEY,
JSON.stringify(storedRuleFixture), JSON.stringify(storedRuleFixture),
@@ -78,11 +78,25 @@ describe("CompletedScreen", () => {
}); });
const column = heading.closest("div[class*='md:justify-center']"); const column = heading.closest("div[class*='md:justify-center']");
expect(column).toBeTruthy(); expect(column).toBeTruthy();
expect(column.className).not.toMatch(/\bsticky\b/); expect(column.className).toMatch(/\bshrink-0\b/);
const ruleColumn = screen const ruleColumn = screen
.getByText("Fixture value title") .getByText("Fixture value title")
.closest("div[class*='md:overflow-y-auto']"); .closest("div[class*='overflow-y-auto']");
expect(ruleColumn?.className).toMatch(/max-md:shrink-0/); expect(ruleColumn?.className).toMatch(/overflow-y-auto/);
});
it("offers Ask an organizer under the finished Rule heading", () => {
sessionStorage.setItem(
CREATE_FLOW_LAST_PUBLISHED_KEY,
JSON.stringify(storedRuleFixture),
);
render(<CompletedScreen />);
expect(
screen.getByText("Has your Rule changed how your community works?"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Tell an organizer what changed." }),
).toBeInTheDocument();
}); });
it("renders header and document from sessionStorage", () => { it("renders header and document from sessionStorage", () => {
@@ -106,12 +120,12 @@ describe("CompletedScreen", () => {
render(<CompletedScreen />); render(<CompletedScreen />);
expect( expect(
screen.queryByText( screen.queryByText(
"This is what folks see when you share your CommunityRule", "Your Rule is finished",
), ),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
screen.queryByText( screen.queryByText(
"Your group can use this document as an operating manual.", "This is what the people you invite will see.",
), ),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -123,20 +137,16 @@ describe("CompletedScreen", () => {
}); });
render(<CompletedScreen />); render(<CompletedScreen />);
expect( expect(
screen.getByText( screen.getByText("Your Rule is finished"),
"This is what folks see when you share your CommunityRule",
),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText("This is what the people you invite will see."),
"Your group can use this document as an operating manual.",
),
).toBeInTheDocument(); ).toBeInTheDocument();
const statusRegions = screen.getAllByRole("status"); const statusRegions = screen.getAllByRole("status");
expect(statusRegions.length).toBeGreaterThanOrEqual(1); expect(statusRegions.length).toBeGreaterThanOrEqual(1);
expect( expect(
statusRegions.some((el) => statusRegions.some((el) =>
el.textContent?.includes("This is what folks see when you share"), el.textContent?.includes("Your Rule is finished"),
), ),
).toBe(true); ).toBe(true);
}); });
+106 -20
View File
@@ -1,42 +1,128 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { useEffect } from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen"; import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import type { CreateFlowState } from "../../app/(app)/create/types";
vi.mock("../../lib/create/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/create/api")>();
return {
...actual,
fetchAuthSession: vi.fn(async () => ({
user: { id: "u1", email: "maya@example.org" },
})),
};
});
function SeedCreateFlow({ patch }: { patch: Partial<CreateFlowState> }) {
const { updateState } = useCreateFlow();
useEffect(() => {
updateState(patch);
}, [patch, updateState]);
return null;
}
describe("ConfirmStakeholdersScreen", () => { describe("ConfirmStakeholdersScreen", () => {
it("renders title and description", () => { beforeEach(() => {
vi.clearAllMocks();
});
it("renders the invite heading and email field", () => {
render(<ConfirmStakeholdersScreen />); render(<ConfirmStakeholdersScreen />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: /Do other stakeholders need to be involved/i, name: "Invite people to read your Rule",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.getByText("Everyone you add gets a private link to read the Rule."),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add" }),
).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
/Add their email addresses\. When you publish, we'll send each person a one-time link/i, /Every Rule starts private\. Only the people you invite can read it/,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders Add stakeholder control", () => { it("does not show the old draft toast", () => {
render(<ConfirmStakeholdersScreen />); render(<ConfirmStakeholdersScreen />);
expect( expect(
screen.getByRole("button", { name: "Add stakeholder" }), screen.queryByText(/Congratulations! You've drafted your CommunityRule!/i),
).toBeInTheDocument();
});
it("shows draft toast and can dismiss it", async () => {
const user = userEvent.setup();
render(<ConfirmStakeholdersScreen />);
expect(
screen.getByText(/Congratulations! You've drafted your CommunityRule!/i),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(
screen.queryByText(
/Congratulations! You've drafted your CommunityRule!/i,
),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("adds an email and shows the owner as You", async () => {
const user = userEvent.setup();
render(<ConfirmStakeholdersScreen />);
expect(await screen.findByText("maya@example.org")).toBeInTheDocument();
expect(screen.getByText("You")).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /Add someone by email/i }),
"rosa@example.org",
);
await user.click(screen.getByRole("button", { name: "Add" }));
expect(screen.getByText("rosa@example.org")).toBeInTheDocument();
expect(
screen.getByText("2 people get a private link to read the Rule."),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Remove maya@example.org/i }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Remove rosa@example.org/i }),
).toBeInTheDocument();
});
it("keeps a pasted invalid address in the field and reports what was added", async () => {
const user = userEvent.setup();
render(<ConfirmStakeholdersScreen />);
const field = await screen.findByRole("textbox", {
name: /Add someone by email/i,
});
field.focus();
await user.paste("rosa@example.org, jo@example, sam@example.org");
expect(await screen.findByText("rosa@example.org")).toBeInTheDocument();
expect(screen.getByText("sam@example.org")).toBeInTheDocument();
expect(field).toHaveValue("jo@example");
expect(
screen.getByText(
/Added two people from your list\. One is not an email address\./,
),
).toBeInTheDocument();
expect(
screen.getByText("Not an email address. Fix it and select Add, or clear it."),
).toBeInTheDocument();
});
it("shows the membership card from the selected method", async () => {
render(
<>
<SeedCreateFlow
patch={{
selectedMembershipMethodIds: ["open-admission"],
membershipMethodDetailsById: {
"open-admission": {
eligibility: "Anyone who shares the work belongs.",
joiningProcess: "Work two Sunday shares, then ask to join.",
expectations: "",
},
},
}}
/>
<ConfirmStakeholdersScreen />
</>,
);
expect(await screen.findByText("Open admission")).toBeInTheDocument();
expect(screen.getByText("Who belongs, in your Rule’s words")).toBeInTheDocument();
expect(
screen.getByText(/Anyone who shares the work belongs/),
).toBeInTheDocument();
});
}); });
+38
View File
@@ -212,6 +212,44 @@ describe("LoginForm", () => {
window.localStorage.removeItem("create-flow-anonymous"); window.localStorage.removeItem("create-flow-anonymous");
}); });
it("invite variant attaches a create-flow draft", async () => {
const user = userEvent.setup();
window.localStorage.setItem(
"create-flow-anonymous",
JSON.stringify({ title: "Guest draft" }),
);
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="invite"
magicLinkNextPath="/create/confirm-stakeholders?syncDraft=1"
/>
</Suspense>,
);
expect(
screen.getByRole("heading", {
name: /log in to invite your members/i,
}),
).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"guest@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"guest@example.com",
"/create/confirm-stakeholders?syncDraft=1",
expect.objectContaining({ title: "Guest draft" }),
);
});
expect(setTransferPendingFlag).toHaveBeenCalled();
window.localStorage.removeItem("create-flow-anonymous");
});
it("keepRule continue without saving calls onDismiss", async () => { it("keepRule continue without saving calls onDismiss", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onDismiss = vi.fn(); const onDismiss = vi.fn();
+51
View File
@@ -87,6 +87,17 @@ function LoginTrigger() {
> >
Open keep rule then leave Open keep rule then leave
</button> </button>
<button
type="button"
onClick={() =>
openLogin({
variant: "invite",
nextPath: "/create/confirm-stakeholders?syncDraft=1",
})
}
>
Open invite login
</button>
<button type="button" onClick={() => closeLogin()}> <button type="button" onClick={() => closeLogin()}>
Close from outside Close from outside
</button> </button>
@@ -253,4 +264,44 @@ describe("AuthModalProvider (header overlay)", () => {
}); });
expect(screen.getByText("Left completed")).toBeInTheDocument(); expect(screen.getByText("Left completed")).toBeInTheDocument();
}); });
it("invite openLogin shows back-to-rule and attaches a draft", async () => {
const user = userEvent.setup();
window.localStorage.setItem(
"create-flow-anonymous",
JSON.stringify({ title: "Guest draft" }),
);
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginTrigger />
</Suspense>,
);
await user.click(screen.getByRole("button", { name: /open invite login/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: /log in to invite your members/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /back to your rule/i }),
).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"guest@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"guest@example.com",
"/create/confirm-stakeholders?syncDraft=1",
expect.objectContaining({ title: "Guest draft" }),
);
});
expect(setTransferPendingFlag).toHaveBeenCalled();
window.localStorage.removeItem("create-flow-anonymous");
});
}); });
+42
View File
@@ -53,6 +53,7 @@ function viewProps(
onCloseDeleteRule: vi.fn(), onCloseDeleteRule: vi.fn(),
onConfirmDeleteRule: vi.fn(), onConfirmDeleteRule: vi.fn(),
onDuplicateRule: vi.fn(), onDuplicateRule: vi.fn(),
onInviteMembers: vi.fn(),
onContinueDraft: vi.fn(), onContinueDraft: vi.fn(),
onDeleteDraft: vi.fn(), onDeleteDraft: vi.fn(),
onCloseDeleteDraft: vi.fn(), onCloseDeleteDraft: vi.fn(),
@@ -142,4 +143,45 @@ describe("ProfilePageView", () => {
await user.click(confirm); await user.click(confirm);
expect(onConfirmDeleteAccount).toHaveBeenCalledTimes(1); expect(onConfirmDeleteAccount).toHaveBeenCalledTimes(1);
}); });
test("finished owner cards offer Invite your members beside Duplicate and Delete", async () => {
const user = userEvent.setup();
const onInviteMembers = vi.fn();
render(
<ProfilePageView
{...viewProps({
onInviteMembers,
rules: [
{
id: "rule-1",
title: "Garden coop",
summary: "How we share the plots.",
createdAt: "2026-11-01T00:00:00.000Z",
updatedAt: "2026-11-01T00:00:00.000Z",
role: "owner",
},
],
})}
/>,
);
expect(
screen.getByText(copy.welcomeBodyFirstRule),
).toBeInTheDocument();
expect(screen.getByText(copy.finishedBadge)).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: copy.yourRulesHeading }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: copy.inviteMembers }),
);
expect(onInviteMembers).toHaveBeenCalledWith("rule-1");
expect(
screen.getByRole("button", { name: copy.duplicate }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: copy.deleteRule }),
).toBeInTheDocument();
});
}); });
+6 -1
View File
@@ -183,7 +183,7 @@ describe("flowSteps", () => {
).toBeNull(); ).toBeNull();
}); });
it("parseReviewReturnSearchParam accepts final-review, edit-rule, and review", () => { it("parseReviewReturnSearchParam accepts final-review, edit-rule, review, and completed", () => {
expect( expect(
parseReviewReturnSearchParam( parseReviewReturnSearchParam(
new URLSearchParams("reviewReturn=final-review"), new URLSearchParams("reviewReturn=final-review"),
@@ -197,6 +197,11 @@ describe("flowSteps", () => {
expect( expect(
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")), parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")),
).toBe("review"); ).toBe("review");
expect(
parseReviewReturnSearchParam(
new URLSearchParams("reviewReturn=completed"),
),
).toBe("completed");
expect( expect(
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")), parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")),
).toBeNull(); ).toBeNull();
@@ -306,6 +306,71 @@ describe("useCreateFlowFinalize", () => {
); );
}); });
it("omits stakeholder emails when skipInvites is set", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
id: "new-rule-id",
title: "Published title",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: {
...emptyState,
stakeholderEmails: ["invitee@example.com"],
},
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: signedIn,
}),
);
await act(async () => {
await result.current.finalize({ skipInvites: true });
});
expect(publishRule).toHaveBeenCalledWith({
title: "Published title",
summary: "Published summary",
document: {},
});
});
it("uses the send-failed banner when invitations do not send", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: false,
status: 500,
error: "mail failed",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: {
...emptyState,
stakeholderEmails: ["invitee@example.com"],
},
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: signedIn,
}),
);
await act(async () => {
await result.current.finalize({ skipInvites: false });
});
expect(result.current.publishBannerTitle).toBe(
messages.create.reviewAndComplete.confirmStakeholders.sendFailedTitle,
);
expect(result.current.publishBannerMessage).toBe(
messages.create.reviewAndComplete.confirmStakeholders.sendFailedDescription,
);
});
it("routes to /create/completed without celebrate after PATCH update", async () => { it("routes to /create/completed without celebrate after PATCH update", async () => {
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true }); vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { membershipInvitePreview } from "../../lib/create/membershipInvitePreview";
import type { CreateFlowState } from "../../app/(app)/create/types";
describe("membershipInvitePreview", () => {
it("returns null when no membership method is selected", () => {
expect(membershipInvitePreview({} as CreateFlowState)).toBeNull();
});
it("uses the first selected membership method and saved words", () => {
const preview = membershipInvitePreview({
selectedMembershipMethodIds: ["open-admission"],
membershipMethodDetailsById: {
"open-admission": {
eligibility: "Anyone who shares the work belongs.",
joiningProcess: "Work two Sunday shares, then ask to join.",
expectations: "",
},
},
} as CreateFlowState);
expect(preview?.title).toBe("Open admission");
expect(preview?.categoryName).toBe("Membership");
expect(preview?.eligibility).toBe("Anyone who shares the work belongs.");
expect(preview?.joiningProcess).toBe(
"Work two Sunday shares, then ask to join.",
);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
formatInviteCountWord,
parseInvitePaste,
splitInvitePasteTokens,
} from "../../lib/create/parseInviteEmails";
describe("parseInviteEmails", () => {
it("splits comma, semicolon, and whitespace lists", () => {
expect(
splitInvitePasteTokens("rosa@example.org, dev@example.org; sam@example.org\nlee@example.org"),
).toEqual([
"rosa@example.org",
"dev@example.org",
"sam@example.org",
"lee@example.org",
]);
});
it("adds valid addresses and leaves invalid tokens in the remainder", () => {
const result = parseInvitePaste(
"rosa@example.org, jo@example, sam@example.org",
["maya@example.org"],
30,
);
expect(result.added).toEqual(["rosa@example.org", "sam@example.org"]);
expect(result.invalidRemainder).toBe("jo@example");
});
it("skips duplicates already on the list", () => {
const result = parseInvitePaste(
"maya@example.org, rosa@example.org",
["maya@example.org"],
30,
);
expect(result.added).toEqual(["rosa@example.org"]);
expect(result.skippedDuplicate).toBe(1);
});
it("keeps overflow addresses in the remainder once the max is hit", () => {
const result = parseInvitePaste(
"a@example.org, b@example.org, c@example.org",
[],
2,
);
expect(result.added).toEqual(["a@example.org", "b@example.org"]);
expect(result.invalidRemainder).toBe("c@example.org");
});
it("uses words for counts through ten", () => {
expect(formatInviteCountWord(5)).toBe("five");
expect(formatInviteCountWord(11)).toBe("11");
});
});