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

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

No files matched your search

+86 -8
View File
@@ -199,9 +199,7 @@ function CreateFlowLayoutContent({
const editingPublishedRuleIdTrimmed =
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>
);
}
+161 -118
View File
@@ -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,
},
+11 -4
View File
@@ -305,8 +305,9 @@ export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
/**
* `/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;
+40
View File
@@ -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",