Finish a Rule, invite members, and let invited people read or copy it #100
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user