Invite people after review, then finish the Rule, with login before inviting and Invite your members on Profile.
This commit is contained in:
1 parent
88bfccd1e7
commit
70ceb73104
31 files changed
+1388
-401
No files matched your search
@@ -199,9 +199,7 @@ function CreateFlowLayoutContent({
|
||||
const editingPublishedRuleIdTrimmed =
|
||||
state.editingPublishedRuleId?.trim() ?? "";
|
||||
const isConfirmStakeholdersManagePublished =
|
||||
currentStep === "confirm-stakeholders" &&
|
||||
manageStakeholdersIntent &&
|
||||
editingPublishedRuleIdTrimmed.length > 0;
|
||||
currentStep === "confirm-stakeholders" && manageStakeholdersIntent;
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt();
|
||||
@@ -277,6 +275,7 @@ function CreateFlowLayoutContent({
|
||||
: createFlowStepPathWithSyncDraft("final-review");
|
||||
|
||||
const {
|
||||
publishBannerTitle,
|
||||
publishBannerMessage,
|
||||
setPublishBannerMessage,
|
||||
isPublishing,
|
||||
@@ -450,7 +449,10 @@ function CreateFlowLayoutContent({
|
||||
}, [currentStep]);
|
||||
|
||||
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();
|
||||
if (!last) {
|
||||
router.replace(CREATE_ROUTES.completed);
|
||||
@@ -515,6 +517,33 @@ function CreateFlowLayoutContent({
|
||||
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(() => {
|
||||
if (currentStep !== "completed") {
|
||||
setCompletedFlowBanner(null);
|
||||
@@ -705,6 +734,7 @@ function CreateFlowLayoutContent({
|
||||
key: "publish",
|
||||
status: "danger" as const,
|
||||
title:
|
||||
publishBannerTitle ??
|
||||
messages.create.reviewAndComplete.publish.publishBannerTitle,
|
||||
description: publishBannerMessage,
|
||||
onClose: () => setPublishBannerMessage(null),
|
||||
@@ -862,7 +892,7 @@ function CreateFlowLayoutContent({
|
||||
editingPublishedRuleId: last.id,
|
||||
sections: [],
|
||||
});
|
||||
router.push(createFlowStepPath("edit-rule"));
|
||||
router.push(createFlowStepPath("final-review"));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -1125,12 +1155,14 @@ function CreateFlowLayoutContent({
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
disabled={
|
||||
isPublishing || editingPublishedRuleIdTrimmed.length === 0
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
"edit-rule",
|
||||
reviewReturnTarget ?? "completed",
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
@@ -1141,6 +1173,39 @@ function CreateFlowLayoutContent({
|
||||
.footerDone
|
||||
}
|
||||
</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 ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
@@ -1150,7 +1215,8 @@ function CreateFlowLayoutContent({
|
||||
isPublishing ||
|
||||
(isEditRuleStep && !sessionResolved) ||
|
||||
(currentStep === "final-review" &&
|
||||
isFinalReviewContinueBlocked(state))
|
||||
(!sessionResolved ||
|
||||
isFinalReviewContinueBlocked(state)))
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
@@ -1160,6 +1226,18 @@ function CreateFlowLayoutContent({
|
||||
}
|
||||
if (currentStep === "final-review") {
|
||||
if (isFinalReviewContinueBlocked(state)) return;
|
||||
if (!sessionResolved) return;
|
||||
if (sessionUser === null) {
|
||||
openLogin({
|
||||
variant: "invite",
|
||||
nextPath:
|
||||
createFlowStepPathWithSyncDraft(
|
||||
"confirm-stakeholders",
|
||||
),
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
goToNextStep();
|
||||
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 OpenLogin = (args: {
|
||||
variant: "default" | "saveProgress" | "keepRule";
|
||||
variant: "default" | "saveProgress" | "keepRule" | "invite";
|
||||
nextPath: string;
|
||||
backdropVariant: "blurredYellow";
|
||||
}) => void;
|
||||
|
||||
type SessionUser = { id: string; email: string };
|
||||
|
||||
export type FinalizeOptions = {
|
||||
/** When true, finish without sending the current email list. */
|
||||
skipInvites?: boolean;
|
||||
};
|
||||
|
||||
export type UseCreateFlowFinalizeResult = {
|
||||
publishBannerTitle: string | null;
|
||||
publishBannerMessage: string | null;
|
||||
setPublishBannerMessage: (_message: string | null) => void;
|
||||
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({
|
||||
state,
|
||||
router,
|
||||
@@ -55,65 +61,67 @@ export function useCreateFlowFinalize({
|
||||
/** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */
|
||||
onGuestPublished?: (_info: { skippedInvites: boolean }) => void;
|
||||
}): UseCreateFlowFinalizeResult {
|
||||
const [publishBannerTitle, setPublishBannerTitle] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
const finalize = useCallback(async () => {
|
||||
if (sessionUser === undefined) return;
|
||||
|
||||
setPublishBannerMessage(null);
|
||||
setIsPublishing(true);
|
||||
const prepared = await stateWithPendingCommunityAvatar(state);
|
||||
if (prepared.ok === false) {
|
||||
setIsPublishing(false);
|
||||
setPublishBannerMessage(messages.create.upload.errors.generic);
|
||||
return;
|
||||
}
|
||||
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
|
||||
updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
|
||||
}
|
||||
const payloadResult = buildPublishPayload(prepared.state);
|
||||
if (payloadResult.ok === false) {
|
||||
setIsPublishing(false);
|
||||
setPublishBannerMessage(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.reviewAndComplete.publish.missingCommunityName
|
||||
: payloadResult.error,
|
||||
const showFailedBanner = useCallback(
|
||||
(message: string, sendingInvites: boolean) => {
|
||||
const inviteCopy = messages.create.reviewAndComplete.confirmStakeholders;
|
||||
if (sendingInvites) {
|
||||
setPublishBannerTitle(inviteCopy.sendFailedTitle);
|
||||
setPublishBannerMessage(inviteCopy.sendFailedDescription);
|
||||
return;
|
||||
}
|
||||
setPublishBannerTitle(
|
||||
messages.create.reviewAndComplete.publish.publishBannerTitle,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { title, summary, document: ruleDocument } = payloadResult;
|
||||
setPublishBannerMessage(message);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
? state.editingPublishedRuleId.trim()
|
||||
: "";
|
||||
const finalize = useCallback(
|
||||
async (opts?: FinalizeOptions) => {
|
||||
if (sessionUser === undefined) return;
|
||||
const skipInvites = opts?.skipInvites === true;
|
||||
|
||||
if (editingId.length > 0 && sessionUser === null) {
|
||||
setIsPublishing(false);
|
||||
writeLastPublishedRule({
|
||||
id: editingId,
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
updateState({ editingPublishedRuleId: undefined });
|
||||
writePendingKeepRuleLogin();
|
||||
onGuestPublished?.({ skippedInvites: false });
|
||||
router.push(createFlowStepPath("completed"));
|
||||
return;
|
||||
}
|
||||
setPublishBannerTitle(null);
|
||||
setPublishBannerMessage(null);
|
||||
setIsPublishing(true);
|
||||
const prepared = await stateWithPendingCommunityAvatar(state);
|
||||
if (prepared.ok === false) {
|
||||
setIsPublishing(false);
|
||||
showFailedBanner(messages.create.upload.errors.generic, false);
|
||||
return;
|
||||
}
|
||||
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
|
||||
updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
|
||||
}
|
||||
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 updateResult = await updatePublishedRule(editingId, {
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
setIsPublishing(false);
|
||||
if (updateResult.ok === true) {
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
? state.editingPublishedRuleId.trim()
|
||||
: "";
|
||||
|
||||
if (editingId.length > 0 && sessionUser === null) {
|
||||
setIsPublishing(false);
|
||||
writeLastPublishedRule({
|
||||
id: editingId,
|
||||
title,
|
||||
@@ -121,10 +129,87 @@ export function useCreateFlowFinalize({
|
||||
document: ruleDocument,
|
||||
});
|
||||
updateState({ editingPublishedRuleId: undefined });
|
||||
writePendingKeepRuleLogin();
|
||||
onGuestPublished?.({ skippedInvites: false });
|
||||
router.push(createFlowStepPath("completed"));
|
||||
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({
|
||||
variant: sessionUser === null ? "keepRule" : "default",
|
||||
nextPath:
|
||||
@@ -135,76 +220,34 @@ export function useCreateFlowFinalize({
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPublishBannerMessage(
|
||||
updateResult.error.trim() !== ""
|
||||
? updateResult.error
|
||||
showFailedBanner(
|
||||
publishResult.error.trim() !== ""
|
||||
? publishResult.error
|
||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||
sendingInvites,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
state,
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath,
|
||||
sessionUser,
|
||||
onGuestPublished,
|
||||
showFailedBanner,
|
||||
],
|
||||
);
|
||||
|
||||
const isGuest = sessionUser === null;
|
||||
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||
(e) => typeof e === "string" && e.trim() !== "",
|
||||
);
|
||||
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,
|
||||
]);
|
||||
const clearPublishBanner = useCallback((message: string | null) => {
|
||||
if (message === null) setPublishBannerTitle(null);
|
||||
setPublishBannerMessage(message);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
publishBannerTitle,
|
||||
publishBannerMessage,
|
||||
setPublishBannerMessage,
|
||||
setPublishBannerMessage: clearPublishBanner,
|
||||
isPublishing,
|
||||
finalize,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import CommunityRule from "../../../../components/type/CommunityRule";
|
||||
import type { CommunityRuleSection } from "../../../../components/type/CommunityRule/CommunityRule.types";
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import AskOrganizerInquiryModal from "../../../../components/modals/AskOrganizerInquiry";
|
||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { fetchPublishedRuleDetail } from "../../../../../lib/create/api";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections";
|
||||
@@ -86,6 +88,7 @@ export function CompletedScreen() {
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
|
||||
const [showCelebrateToast] = useState(celebrateInUrl);
|
||||
const [askOrganizerOpen, setAskOrganizerOpen] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
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={`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
|
||||
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
|
||||
title={headerTitle}
|
||||
@@ -177,9 +180,20 @@ export function CompletedScreen() {
|
||||
size="L"
|
||||
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
|
||||
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
|
||||
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>
|
||||
{toast}
|
||||
<AskOrganizerInquiryModal
|
||||
isOpen={askOrganizerOpen}
|
||||
onClose={() => setAskOrganizerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
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 { 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 { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { MembershipInvitePreviewCard } from "../../components/MembershipInvitePreviewCard";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||
} from "../../utils/flowSteps";
|
||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
import { PublishedStakeholdersManagePanel } from "./PublishedStakeholdersManagePanel";
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const VISIBLE_WHEN_COLLAPSED = 6;
|
||||
|
||||
function emailsToChipOptions(emails: string[]): ChipOption[] {
|
||||
return emails.map((email) => ({
|
||||
id: email,
|
||||
label: email,
|
||||
state: "selected" as const,
|
||||
}));
|
||||
function InvitePersonRow({
|
||||
email,
|
||||
youLabel,
|
||||
removeAria,
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
||||
@@ -37,163 +87,271 @@ export function ConfirmStakeholdersScreen() {
|
||||
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
|
||||
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(() => {
|
||||
if (!manageStakeholdersIntent) return;
|
||||
if (editingPublishedRuleId.length > 0) return;
|
||||
router.replace(createFlowStepPath("edit-rule"));
|
||||
}, [
|
||||
manageStakeholdersIntent,
|
||||
editingPublishedRuleId.length,
|
||||
router,
|
||||
]);
|
||||
let cancelled = false;
|
||||
void fetchAuthSession().then(({ user }) => {
|
||||
if (cancelled) return;
|
||||
const email = user?.email?.trim().toLowerCase() ?? "";
|
||||
setOwnerEmail(email.length > 0 ? email : null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistedKey = (state.stakeholderEmails ?? []).join("\0");
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [chipError, setChipError] = useState<string | null>(null);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
() => emailsToChipOptions(state.stakeholderEmails ?? []),
|
||||
const invitees = useMemo(() => {
|
||||
const raw = state.stakeholderEmails ?? [];
|
||||
const owner = ownerEmail;
|
||||
return raw
|
||||
.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(() => {
|
||||
setStakeholderOptions((prev) => {
|
||||
const inFlight = prev.filter((c) => c.state === "custom");
|
||||
const nextPersisted = emailsToChipOptions(state.stakeholderEmails ?? []);
|
||||
return [...nextPersisted, ...inFlight];
|
||||
});
|
||||
}, [persistedKey]);
|
||||
const addOne = useCallback(
|
||||
(raw: string): boolean => {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
|
||||
setFieldError(t("invalidEmail"));
|
||||
return false;
|
||||
}
|
||||
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();
|
||||
setChipError(null);
|
||||
const confirmed = state.stakeholderEmails ?? [];
|
||||
const customCount = stakeholderOptions.filter(
|
||||
(o) => o.state === "custom",
|
||||
).length;
|
||||
if (confirmed.length + customCount >= MAX_STAKEHOLDER_EMAILS) {
|
||||
setChipError(t("maxStakeholders"));
|
||||
return;
|
||||
setPasteResult(null);
|
||||
if (addOne(draftEmail)) {
|
||||
setDraftEmail("");
|
||||
setFieldError(null);
|
||||
}
|
||||
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();
|
||||
setChipError(null);
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(trimmed)) {
|
||||
setChipError(t("invalidEmail"));
|
||||
return;
|
||||
setFieldError(null);
|
||||
|
||||
const existing = ownerEmail ? [ownerEmail, ...invitees] : [...invitees];
|
||||
const parsed = parseInvitePaste(text, existing, MAX_STAKEHOLDER_EMAILS);
|
||||
if (parsed.added.length > 0) {
|
||||
persistInvitees([...invitees, ...parsed.added]);
|
||||
}
|
||||
const current = state.stakeholderEmails ?? [];
|
||||
if (current.includes(trimmed)) {
|
||||
setChipError(t("duplicateEmail"));
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
return;
|
||||
setDraftEmail(parsed.invalidRemainder);
|
||||
if (parsed.invalidRemainder.length > 0) {
|
||||
setFieldError(t("invalidEmail"));
|
||||
}
|
||||
if (current.length >= MAX_STAKEHOLDER_EMAILS) {
|
||||
setChipError(t("maxStakeholders"));
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
return;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (parsed.added.length > 0) {
|
||||
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) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { id: trimmed, label: trimmed, state: "selected" as const }
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
updateState({ stakeholderEmails: [...current, trimmed] });
|
||||
const invalidCount = parsed.invalidRemainder
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0).length;
|
||||
if (invalidCount === 1) {
|
||||
parts.push(t("pasteInvalidOne"));
|
||||
} else if (invalidCount > 1) {
|
||||
parts.push(
|
||||
t("pasteInvalidMany").replace(
|
||||
"{count}",
|
||||
formatInviteCountWord(invalidCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
setPasteResult(parts.length > 0 ? parts.join(" ") : null);
|
||||
};
|
||||
|
||||
const handleCustomChipClose = (chipId: string) => {
|
||||
const handleRemove = (email: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
setPasteResult(null);
|
||||
persistInvitees(invitees.filter((item) => item !== email));
|
||||
};
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
updateState({
|
||||
stakeholderEmails: (state.stakeholderEmails ?? []).filter(
|
||||
(e) => e !== chipId,
|
||||
),
|
||||
});
|
||||
};
|
||||
const header = (
|
||||
<div className="flex w-full min-w-0 flex-col gap-[var(--space-800)]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification="left"
|
||||
/>
|
||||
{preview ? (
|
||||
<MembershipInvitePreviewCard
|
||||
preview={preview}
|
||||
fromYourRuleLabel={t("fromYourRule")}
|
||||
whoBelongsLabel={t("whoBelongsLabel")}
|
||||
joiningLabel={t("joiningLabel")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (managePublishedMode) {
|
||||
if (manageStakeholdersIntent) {
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
header={
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("managePublished.lockupTitle")}
|
||||
description={t("managePublished.lockupDescription")}
|
||||
justification="left"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||
<CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
|
||||
{editingPublishedRuleId.length > 0 ? (
|
||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||
) : null}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
header={
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification="left"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{chipError ? (
|
||||
<p
|
||||
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
|
||||
role="alert"
|
||||
>
|
||||
{chipError}
|
||||
<CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
|
||||
<div className="flex w-full min-w-0 flex-col gap-[var(--space-600)]">
|
||||
<div
|
||||
className="flex w-full items-start gap-[var(--space-200)]"
|
||||
onPasteCapture={handlePaste}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<TextInput
|
||||
id="invite-member-email"
|
||||
type="email"
|
||||
inputSize="small"
|
||||
showHelpIcon={false}
|
||||
label={t("emailLabel")}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={draftEmail}
|
||||
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>
|
||||
) : null}
|
||||
<MultiSelect
|
||||
formHeader={false}
|
||||
showHelpIcon={false}
|
||||
size="s"
|
||||
options={stakeholderOptions}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
<p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
|
||||
{t("privacy")}
|
||||
</p>
|
||||
</div>
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
@@ -129,28 +129,28 @@ export function PublishedStakeholdersManagePanel({
|
||||
{t("managePublished.empty")}
|
||||
</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) => (
|
||||
<li
|
||||
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">
|
||||
<span className="truncate text-sm font-medium text-[var(--color-content-default-primary)] md:text-base">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-medium-paragraph text-[var(--color-content-default-primary)]">
|
||||
{row.email}
|
||||
</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"
|
||||
? t("managePublished.pending")
|
||||
: t("managePublished.accepted")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{row.status === "pending" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => void handleResend(row.id)}
|
||||
@@ -165,7 +165,7 @@ export function PublishedStakeholdersManagePanel({
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => void handleRemove(row.id)}
|
||||
@@ -182,7 +182,7 @@ export function PublishedStakeholdersManagePanel({
|
||||
</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">
|
||||
<TextInput
|
||||
id="published-stakeholder-email"
|
||||
@@ -201,17 +201,18 @@ export function PublishedStakeholdersManagePanel({
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
className="md:mb-[2px]"
|
||||
disabled={addBusy || items === null}
|
||||
onClick={() => void handleAdd()}
|
||||
>
|
||||
{t("managePublished.addInvite")}
|
||||
</Button>
|
||||
<div className="shrink-0 pt-[29px]">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
disabled={addBusy || items === null}
|
||||
onClick={() => void handleAdd()}
|
||||
>
|
||||
{t("managePublished.addInvite")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -131,7 +131,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
},
|
||||
"confirm-stakeholders": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "21104-46594",
|
||||
figmaNodeId: "25336-62720",
|
||||
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
|
||||
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;
|
||||
|
||||
/**
|
||||
* `/create/confirm-stakeholders?manageStakeholders=1` — edit published rule invites (requires `state.editingPublishedRuleId`).
|
||||
* Typically paired with `reviewReturn=edit-rule`.
|
||||
* `/create/confirm-stakeholders?manageStakeholders=1` — published-rule invites.
|
||||
* 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_VALUE = "1" as const;
|
||||
@@ -314,14 +315,20 @@ export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const;
|
||||
export type CreateFlowReviewReturnTarget =
|
||||
| "final-review"
|
||||
| "edit-rule"
|
||||
| "review";
|
||||
| "review"
|
||||
| "completed";
|
||||
|
||||
export function parseReviewReturnSearchParam(
|
||||
searchParams: { get: (name: string) => string | null } | null | undefined,
|
||||
): CreateFlowReviewReturnTarget | null {
|
||||
if (!searchParams) return null;
|
||||
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 null;
|
||||
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
duplicatePublishedRule,
|
||||
fetchAuthSession,
|
||||
fetchMyPublishedRules,
|
||||
fetchPublishedRuleDetail,
|
||||
fetchServerDraftForProfile,
|
||||
logout,
|
||||
requestEmailChange,
|
||||
type MyPublishedRule,
|
||||
} from "../../../lib/create/api";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
FIRST_STEP,
|
||||
isValidStep,
|
||||
} from "../create/utils/flowSteps";
|
||||
@@ -24,6 +28,8 @@ import type { CreateFlowStep } from "../create/types";
|
||||
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
|
||||
import { createFlowStepPath } from "../create/utils/createFlowPaths";
|
||||
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import {
|
||||
ProfilePageSignedOutView,
|
||||
@@ -225,6 +231,37 @@ export default function ProfilePageClient() {
|
||||
}
|
||||
}, [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(
|
||||
async (id: string) => {
|
||||
setActionError(null);
|
||||
@@ -359,6 +396,9 @@ export default function ProfilePageClient() {
|
||||
onCloseDeleteRule={handleCloseDeleteRuleDialog}
|
||||
onConfirmDeleteRule={handleConfirmDeleteRule}
|
||||
onDuplicateRule={handleDuplicateRule}
|
||||
onInviteMembers={(id) => {
|
||||
void handleInviteMembers(id);
|
||||
}}
|
||||
onContinueDraft={handleContinueDraft}
|
||||
onDeleteDraft={handleRequestDeleteDraft}
|
||||
onCloseDeleteDraft={handleCloseDeleteDraftDialog}
|
||||
|
||||
@@ -63,6 +63,7 @@ export type ProfilePageViewProps = {
|
||||
onCloseDeleteRule: () => void;
|
||||
onConfirmDeleteRule: () => void;
|
||||
onDuplicateRule: (id: string) => void;
|
||||
onInviteMembers: (id: string) => void;
|
||||
onContinueDraft: () => void;
|
||||
onDeleteDraft: () => void;
|
||||
onCloseDeleteDraft: () => void;
|
||||
@@ -185,6 +186,7 @@ export function ProfilePageView({
|
||||
onCloseDeleteRule,
|
||||
onConfirmDeleteRule,
|
||||
onDuplicateRule,
|
||||
onInviteMembers,
|
||||
onContinueDraft,
|
||||
onDeleteDraft,
|
||||
onCloseDeleteDraft,
|
||||
@@ -332,6 +334,11 @@ export function ProfilePageView({
|
||||
expanded
|
||||
size={ruleCardSize}
|
||||
hasBottomLinks
|
||||
bottomStatusLabel={
|
||||
rule.role === "stakeholder"
|
||||
? undefined
|
||||
: t("finishedBadge")
|
||||
}
|
||||
bottomLinks={
|
||||
rule.role === "stakeholder"
|
||||
? [
|
||||
@@ -343,14 +350,9 @@ export function ProfilePageView({
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "manage",
|
||||
label: t("manageRule"),
|
||||
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||
id: "invite",
|
||||
label: t("inviteMembers"),
|
||||
onClick: () => onInviteMembers(rule.id),
|
||||
},
|
||||
{
|
||||
id: "dup",
|
||||
|
||||
@@ -24,6 +24,9 @@ const LoginContainer = memo<LoginProps>(
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
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
|
||||
// inside the effect (eslint react-hooks/set-state-in-effect) while still mounting
|
||||
@@ -36,6 +39,24 @@ const LoginContainer = memo<LoginProps>(
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [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(() => {
|
||||
if (!isOpen) return;
|
||||
if (usePortal && !portalReady) return;
|
||||
@@ -132,6 +153,7 @@ const LoginContainer = memo<LoginProps>(
|
||||
portalReady={portalReady}
|
||||
usePortal={usePortal}
|
||||
backdropVariant={backdropVariant}
|
||||
viewportHeight={viewportHeight}
|
||||
>
|
||||
{children}
|
||||
</LoginView>
|
||||
|
||||
@@ -33,4 +33,6 @@ export interface LoginViewProps {
|
||||
portalReady: boolean;
|
||||
usePortal: boolean;
|
||||
backdropVariant: LoginBackdropVariant;
|
||||
/** Visible viewport height on phones so the footer rides above the keyboard. */
|
||||
viewportHeight?: number;
|
||||
}
|
||||
@@ -23,14 +23,21 @@ export function LoginView({
|
||||
portalReady,
|
||||
usePortal,
|
||||
backdropVariant,
|
||||
viewportHeight,
|
||||
}: LoginViewProps) {
|
||||
if (!isOpen) return null;
|
||||
if (usePortal && !portalReady) return null;
|
||||
|
||||
const phoneSheetStyle =
|
||||
viewportHeight != null
|
||||
? { height: viewportHeight, maxHeight: viewportHeight }
|
||||
: undefined;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
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) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
onClose();
|
||||
@@ -43,7 +50,7 @@ export function LoginView({
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
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()}
|
||||
>
|
||||
<ModalHeader onClose={onClose} showMoreOptionsButton={false} />
|
||||
@@ -52,7 +59,10 @@ export function LoginView({
|
||||
</div>
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -44,7 +44,11 @@ function MailIconInline() {
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export type LoginFormVariant = "default" | "saveProgress" | "keepRule";
|
||||
export type LoginFormVariant =
|
||||
| "default"
|
||||
| "saveProgress"
|
||||
| "keepRule"
|
||||
| "invite";
|
||||
|
||||
function loginFormHeading(
|
||||
variant: LoginFormVariant,
|
||||
@@ -66,6 +70,12 @@ function loginFormHeading(
|
||||
description: t("keepRuleSubtitle"),
|
||||
};
|
||||
}
|
||||
if (variant === "invite") {
|
||||
return {
|
||||
title: t("inviteTitle"),
|
||||
description: t("inviteSubtitle"),
|
||||
};
|
||||
}
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
@@ -74,7 +84,11 @@ function shouldAttachCreateFlowDraft(
|
||||
nextPath: string,
|
||||
): boolean {
|
||||
if (variant === "keepRule") return false;
|
||||
return variant === "saveProgress" || nextPath.includes("syncDraft=1");
|
||||
return (
|
||||
variant === "saveProgress" ||
|
||||
variant === "invite" ||
|
||||
nextPath.includes("syncDraft=1")
|
||||
);
|
||||
}
|
||||
|
||||
export type LoginFormProps = {
|
||||
@@ -266,46 +280,48 @@ export default function LoginForm({
|
||||
{emailError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
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 ? (
|
||||
<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">
|
||||
<Button
|
||||
type="button"
|
||||
type="submit"
|
||||
size="large"
|
||||
buttonType="ghost"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
disabled={submitting}
|
||||
onClick={onDismiss}
|
||||
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
|
||||
>
|
||||
{t("continueWithoutSaving")}
|
||||
{t("sendMagicLink")}
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{t("legalPrefix")}
|
||||
<Link
|
||||
href={tFooter("legal.termsOfServiceHref")}
|
||||
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>
|
||||
{onDismiss ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="large"
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
disabled={submitting}
|
||||
onClick={onDismiss}
|
||||
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
|
||||
>
|
||||
{t("continueWithoutSaving")}
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{t("legalPrefix")}
|
||||
<Link
|
||||
href={tFooter("legal.termsOfServiceHref")}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "./MessagesContext";
|
||||
import Login from "../components/modals/Login";
|
||||
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";
|
||||
|
||||
@@ -35,6 +40,7 @@ type AuthModalContextValue = {
|
||||
const AuthModalContext = createContext<AuthModalContextValue | null>(null);
|
||||
|
||||
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||
const tLogin = useTranslation("pages.login");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
||||
|
||||
@@ -72,6 +78,17 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||
backdropVariant={backdropVariant}
|
||||
usePortal
|
||||
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
|
||||
variant={opts.variant ?? "default"}
|
||||
|
||||
Reference in new issue
Block a user