Finish a Rule, invite members, and let invited people read or copy it #100
No files matched your search
@@ -199,9 +199,7 @@ function CreateFlowLayoutContent({
|
||||
const editingPublishedRuleIdTrimmed =
|
||||
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,20 +61,42 @@ 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;
|
||||
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,
|
||||
);
|
||||
setPublishBannerMessage(message);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const finalize = useCallback(
|
||||
async (opts?: FinalizeOptions) => {
|
||||
if (sessionUser === undefined) return;
|
||||
const skipInvites = opts?.skipInvites === true;
|
||||
|
||||
setPublishBannerTitle(null);
|
||||
setPublishBannerMessage(null);
|
||||
setIsPublishing(true);
|
||||
const prepared = await stateWithPendingCommunityAvatar(state);
|
||||
if (prepared.ok === false) {
|
||||
setIsPublishing(false);
|
||||
setPublishBannerMessage(messages.create.upload.errors.generic);
|
||||
showFailedBanner(messages.create.upload.errors.generic, false);
|
||||
return;
|
||||
}
|
||||
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
|
||||
@@ -77,10 +105,11 @@ export function useCreateFlowFinalize({
|
||||
const payloadResult = buildPublishPayload(prepared.state);
|
||||
if (payloadResult.ok === false) {
|
||||
setIsPublishing(false);
|
||||
setPublishBannerMessage(
|
||||
showFailedBanner(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.reviewAndComplete.publish.missingCommunityName
|
||||
: payloadResult.error,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -135,20 +164,24 @@ export function useCreateFlowFinalize({
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPublishBannerMessage(
|
||||
showFailedBanner(
|
||||
updateResult.error.trim() !== ""
|
||||
? updateResult.error
|
||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isGuest = sessionUser === null;
|
||||
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||
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,
|
||||
@@ -187,12 +220,14 @@ export function useCreateFlowFinalize({
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPublishBannerMessage(
|
||||
showFailedBanner(
|
||||
publishResult.error.trim() !== ""
|
||||
? publishResult.error
|
||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||
sendingInvites,
|
||||
);
|
||||
}, [
|
||||
},
|
||||
[
|
||||
state,
|
||||
router,
|
||||
openLogin,
|
||||
@@ -200,11 +235,19 @@ export function useCreateFlowFinalize({
|
||||
loginReturnPath,
|
||||
sessionUser,
|
||||
onGuestPublished,
|
||||
]);
|
||||
showFailedBanner,
|
||||
],
|
||||
);
|
||||
|
||||
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 handleAddStakeholder = () => {
|
||||
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;
|
||||
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 handleAdd = () => {
|
||||
markCreateFlowInteraction();
|
||||
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,
|
||||
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),
|
||||
),
|
||||
);
|
||||
updateState({ stakeholderEmails: [...current, trimmed] });
|
||||
};
|
||||
|
||||
const handleCustomChipClose = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
};
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
updateState({
|
||||
stakeholderEmails: (state.stakeholderEmails ?? []).filter(
|
||||
(e) => e !== chipId,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (managePublishedMode) {
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
header={
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("managePublished.lockupTitle")}
|
||||
description={t("managePublished.lockupDescription")}
|
||||
justification="left"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
setPasteResult(parts.length > 0 ? parts.join(" ") : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
header={
|
||||
const handleRemove = (email: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPasteResult(null);
|
||||
persistInvitees(invitees.filter((item) => item !== email));
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{chipError ? (
|
||||
<p
|
||||
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
|
||||
role="alert"
|
||||
>
|
||||
{chipError}
|
||||
</p>
|
||||
{preview ? (
|
||||
<MembershipInvitePreviewCard
|
||||
preview={preview}
|
||||
fromYourRuleLabel={t("fromYourRule")}
|
||||
whoBelongsLabel={t("whoBelongsLabel")}
|
||||
joiningLabel={t("joiningLabel")}
|
||||
/>
|
||||
) : 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (manageStakeholdersIntent) {
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
|
||||
{editingPublishedRuleId.length > 0 ? (
|
||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||
) : null}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
<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,18 +201,19 @@ export function PublishedStakeholdersManagePanel({
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 pt-[29px]">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
className="md:mb-[2px]"
|
||||
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,6 +280,7 @@ export default function LoginForm({
|
||||
{emailError}
|
||||
</p>
|
||||
) : null}
|
||||
<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="submit"
|
||||
size="large"
|
||||
@@ -306,6 +321,7 @@ export default function LoginForm({
|
||||
</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"}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
|
||||
import {
|
||||
membershipPresetFor,
|
||||
publishedMethodDisplayLabel,
|
||||
} from "./finalReviewChipPresets";
|
||||
|
||||
export type MembershipInvitePreview = {
|
||||
categoryName: string;
|
||||
title: string;
|
||||
eligibility: string;
|
||||
joiningProcess: string;
|
||||
};
|
||||
|
||||
/** First selected membership method, for the Invite your members card. */
|
||||
export function membershipInvitePreview(
|
||||
state: CreateFlowState,
|
||||
): MembershipInvitePreview | null {
|
||||
const ids = state.selectedMembershipMethodIds ?? [];
|
||||
const firstId = ids.find((id) => typeof id === "string" && id.trim() !== "");
|
||||
if (!firstId) return null;
|
||||
|
||||
const saved = state.membershipMethodDetailsById?.[firstId];
|
||||
const preset = membershipPresetFor(firstId);
|
||||
const eligibility = (saved?.eligibility ?? "").trim() || preset.eligibility;
|
||||
const joiningProcess =
|
||||
(saved?.joiningProcess ?? "").trim() || preset.joiningProcess;
|
||||
const title =
|
||||
publishedMethodDisplayLabel(
|
||||
"membership",
|
||||
firstId,
|
||||
state.customMethodCardMetaById,
|
||||
) || firstId;
|
||||
|
||||
if (!title && !eligibility && !joiningProcess) return null;
|
||||
|
||||
return {
|
||||
categoryName: RULE_SECTION_CATEGORY.membership,
|
||||
title,
|
||||
eligibility,
|
||||
joiningProcess,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Shared invite-field email check (create-flow Invite your members). */
|
||||
export const INVITE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const COUNT_WORDS = [
|
||||
"zero",
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"ten",
|
||||
] as const;
|
||||
|
||||
/** Lowercase count for paste-result copy (`five`, or digits past ten). */
|
||||
export function formatInviteCountWord(n: number): string {
|
||||
if (Number.isInteger(n) && n >= 0 && n <= 10) {
|
||||
return COUNT_WORDS[n] ?? String(n);
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function splitInvitePasteTokens(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,;]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0);
|
||||
}
|
||||
|
||||
export type ParseInvitePasteResult = {
|
||||
added: string[];
|
||||
invalidRemainder: string;
|
||||
skippedDuplicate: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a pasted list into valid new addresses and leftovers that stay in the
|
||||
* field. Tokens that are not emails, duplicates, or that would exceed `max`
|
||||
* remain in {@link ParseInvitePasteResult.invalidRemainder}.
|
||||
*/
|
||||
export function parseInvitePaste(
|
||||
raw: string,
|
||||
existing: readonly string[],
|
||||
max: number,
|
||||
): ParseInvitePasteResult {
|
||||
const tokens = splitInvitePasteTokens(raw);
|
||||
const added: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
const seen = new Set(
|
||||
existing
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter((email) => email.length > 0),
|
||||
);
|
||||
let skippedDuplicate = 0;
|
||||
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
if (!INVITE_EMAIL_PATTERN.test(lower)) {
|
||||
invalid.push(token);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(lower)) {
|
||||
skippedDuplicate += 1;
|
||||
continue;
|
||||
}
|
||||
if (existing.length + added.length >= max) {
|
||||
invalid.push(token);
|
||||
continue;
|
||||
}
|
||||
seen.add(lower);
|
||||
added.push(lower);
|
||||
}
|
||||
|
||||
return {
|
||||
added,
|
||||
invalidRemainder: invalid.join(", "),
|
||||
skippedDuplicate,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"toastTitle": "This is what folks see when you share your CommunityRule",
|
||||
"toastDescription": "Your group can use this document as an operating manual.",
|
||||
"toastTitle": "Your Rule is finished",
|
||||
"toastDescription": "This is what the people you invite will see.",
|
||||
"askOrganizerLead": "Has your Rule changed how your community works?",
|
||||
"askOrganizerLink": "Tell an organizer what changed.",
|
||||
"shareNoRuleTitle": "Nothing to share yet",
|
||||
"shareNoRuleDescription": "Open this page from your profile after you finish, or finish a rule first.",
|
||||
"shareLinkCopiedTitle": "Link copied",
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
{
|
||||
"title": "Do other stakeholders need to be involved in creating your community?",
|
||||
"description": "Add their email addresses. When you publish, we'll send each person a one-time link to join Community Rule and view this rule in their profile (they won't be able to manage it unless they created it).",
|
||||
"addStakeholder": "Add stakeholder",
|
||||
"draftToastTitle": "Congratulations! You've drafted your CommunityRule!",
|
||||
"invalidEmail": "Enter a valid email address.",
|
||||
"title": "Invite people to read your Rule",
|
||||
"description": "Everyone you add gets a private link to read the Rule.",
|
||||
"fromYourRule": "Who belongs, in your Rule’s words",
|
||||
"whoBelongsLabel": "Who belongs.",
|
||||
"joiningLabel": "Joining.",
|
||||
"emailLabel": "Add someone by email",
|
||||
"emailPlaceholder": "name@example.org",
|
||||
"add": "Add",
|
||||
"you": "You",
|
||||
"removeAria": "Remove {email}",
|
||||
"showMore": "Show {count} more",
|
||||
"countOne": "1 person gets a private link to read the Rule.",
|
||||
"countMany": "{count} people get a private link to read the Rule.",
|
||||
"privacy": "Every Rule starts private. Only the people you invite can read it until the person who holds the pen turns on a link.",
|
||||
"finishWithoutInviting": "Finish without inviting",
|
||||
"sendInvitations": "Send invitations",
|
||||
"invalidEmail": "Not an email address. Fix it and select Add, or clear it.",
|
||||
"duplicateEmail": "That email is already on the list.",
|
||||
"maxStakeholders": "You can add up to 30 stakeholder emails.",
|
||||
"maxStakeholders": "You can add up to 30 emails.",
|
||||
"pasteAdded": "Added {count} {people} from your list.",
|
||||
"pasteInvalidOne": "One is not an email address.",
|
||||
"pasteInvalidMany": "{count} are not email addresses.",
|
||||
"peopleNounOne": "person",
|
||||
"peopleNounMany": "people",
|
||||
"sendFailedTitle": "Some invitations didn’t send",
|
||||
"sendFailedDescription": "Check the addresses and try again",
|
||||
"managePublished": {
|
||||
"lockupTitle": "Stakeholders",
|
||||
"lockupDescription": "Invite people by email. They get a one-time link to view this rule in their profile.",
|
||||
"emailLabel": "Email address",
|
||||
"emailPlaceholder": "colleague@example.com",
|
||||
"addInvite": "Send invite",
|
||||
"lockupTitle": "Invite people to read your Rule",
|
||||
"lockupDescription": "Everyone you add gets a private link to read the Rule.",
|
||||
"emailLabel": "Add someone by email",
|
||||
"emailPlaceholder": "name@example.org",
|
||||
"addInvite": "Add",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Could not load stakeholders. Refresh and try again.",
|
||||
"loadFailed": "Could not load the list. Refresh and try again.",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"remove": "Remove",
|
||||
"resend": "Resend invite",
|
||||
"empty": "No stakeholders yet.",
|
||||
"invalidEmail": "Enter a valid email address.",
|
||||
"empty": "No one invited yet.",
|
||||
"invalidEmail": "Not an email address. Fix it and select Add, or clear it.",
|
||||
"actionFailed": "Something went wrong. Try again.",
|
||||
"rateLimited": "Too many requests. Try again in {seconds} seconds.",
|
||||
"removeAria": "Remove {email}",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to log in. If you don't save now you could lose your progress.",
|
||||
"keepRuleTitle": "Log in to keep this rule on your profile",
|
||||
"keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.",
|
||||
"inviteTitle": "Log in to invite your members",
|
||||
"inviteSubtitle": "Only a logged-in person can invite. Enter your email and we’ll send you a magic link to log in.",
|
||||
"backToYourRule": "← Back to your Rule",
|
||||
"continueWithoutSaving": "Continue without saving",
|
||||
"emailLabel": "Email address",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"pageTitle": "Your profile",
|
||||
"welcomeTitle": "Congrats {{name}}!",
|
||||
"welcomeBodyFirstRule": "You’ve made your first CommunityRule. Make sure to link it in your community resources and evolve it as your group grows and changes.",
|
||||
"welcomeBodyFirstRule": "Invite the people in your community to read it.",
|
||||
"welcomeBodyNoRules": "Create your first CommunityRule to capture how your group makes decisions—then link it from your community resources as you grow.",
|
||||
"yourOptionsHeading": "Your Options",
|
||||
"yourOptionsHeading": "Your options",
|
||||
"optionCreateCustom": "Create new custom Rule",
|
||||
"optionCreateTemplate": "Create new Rule from template",
|
||||
"optionLogout": "Log out of CommunityRule",
|
||||
@@ -12,10 +12,12 @@
|
||||
"signInPrompt": "Log in to view your profile and saved rules.",
|
||||
"signInCta": "Log in",
|
||||
"loading": "Loading…",
|
||||
"yourRulesHeading": "Your CommunityRules",
|
||||
"yourRulesHeading": "Your Rules",
|
||||
"yourRulesEmpty": "You have not finished a rule yet. Create one to see it here.",
|
||||
"draftHeading": "Saved in progress",
|
||||
"draftInProgressBadge": "In progress",
|
||||
"draftInProgressBadge": "Draft",
|
||||
"finishedBadge": "Finished",
|
||||
"inviteMembers": "Invite your members",
|
||||
"continueDraft": "Continue",
|
||||
"createHeading": "Create",
|
||||
"createCustomDescription": "Start a new custom rule from the beginning.",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
|
||||
|
||||
export default {
|
||||
title: "Pages/Create Flow/Confirm stakeholders",
|
||||
title: "Pages/Create Flow/Invite your members",
|
||||
component: ConfirmStakeholdersScreen,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Stacked lockup + MultiSelect; draft congratulations banner; before final review.",
|
||||
"Invite people to read the Rule after final review. Membership card beside the email list; Finish without inviting and Send invitations in the footer.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("CompletedScreen", () => {
|
||||
expect(screen.queryByText("Values")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not pin the title on small screens", () => {
|
||||
it("keeps the heading in place while the Rule column scrolls", () => {
|
||||
sessionStorage.setItem(
|
||||
CREATE_FLOW_LAST_PUBLISHED_KEY,
|
||||
JSON.stringify(storedRuleFixture),
|
||||
@@ -78,11 +78,25 @@ describe("CompletedScreen", () => {
|
||||
});
|
||||
const column = heading.closest("div[class*='md:justify-center']");
|
||||
expect(column).toBeTruthy();
|
||||
expect(column.className).not.toMatch(/\bsticky\b/);
|
||||
expect(column.className).toMatch(/\bshrink-0\b/);
|
||||
const ruleColumn = screen
|
||||
.getByText("Fixture value title")
|
||||
.closest("div[class*='md:overflow-y-auto']");
|
||||
expect(ruleColumn?.className).toMatch(/max-md:shrink-0/);
|
||||
.closest("div[class*='overflow-y-auto']");
|
||||
expect(ruleColumn?.className).toMatch(/overflow-y-auto/);
|
||||
});
|
||||
|
||||
it("offers Ask an organizer under the finished Rule heading", () => {
|
||||
sessionStorage.setItem(
|
||||
CREATE_FLOW_LAST_PUBLISHED_KEY,
|
||||
JSON.stringify(storedRuleFixture),
|
||||
);
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.getByText("Has your Rule changed how your community works?"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Tell an organizer what changed." }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders header and document from sessionStorage", () => {
|
||||
@@ -106,12 +120,12 @@ describe("CompletedScreen", () => {
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"This is what folks see when you share your CommunityRule",
|
||||
"Your Rule is finished",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Your group can use this document as an operating manual.",
|
||||
"This is what the people you invite will see.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -123,20 +137,16 @@ describe("CompletedScreen", () => {
|
||||
});
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This is what folks see when you share your CommunityRule",
|
||||
),
|
||||
screen.getByText("Your Rule is finished"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Your group can use this document as an operating manual.",
|
||||
),
|
||||
screen.getByText("This is what the people you invite will see."),
|
||||
).toBeInTheDocument();
|
||||
const statusRegions = screen.getAllByRole("status");
|
||||
expect(statusRegions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
statusRegions.some((el) =>
|
||||
el.textContent?.includes("This is what folks see when you share"),
|
||||
el.textContent?.includes("Your Rule is finished"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,42 +1,128 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useEffect } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
vi.mock("../../lib/create/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/create/api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAuthSession: vi.fn(async () => ({
|
||||
user: { id: "u1", email: "maya@example.org" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
function SeedCreateFlow({ patch }: { patch: Partial<CreateFlowState> }) {
|
||||
const { updateState } = useCreateFlow();
|
||||
useEffect(() => {
|
||||
updateState(patch);
|
||||
}, [patch, updateState]);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("ConfirmStakeholdersScreen", () => {
|
||||
it("renders title and description", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the invite heading and email field", () => {
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: /Do other stakeholders need to be involved/i,
|
||||
name: "Invite people to read your Rule",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Everyone you add gets a private link to read the Rule."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Add their email addresses\. When you publish, we'll send each person a one-time link/i,
|
||||
/Every Rule starts private\. Only the people you invite can read it/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Add stakeholder control", () => {
|
||||
it("does not show the old draft toast", () => {
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add stakeholder" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows draft toast and can dismiss it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(
|
||||
screen.getByText(/Congratulations! You've drafted your CommunityRule!/i),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/Congratulations! You've drafted your CommunityRule!/i,
|
||||
),
|
||||
screen.queryByText(/Congratulations! You've drafted your CommunityRule!/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an email and shows the owner as You", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
expect(await screen.findByText("maya@example.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("You")).toBeInTheDocument();
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Add someone by email/i }),
|
||||
"rosa@example.org",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(screen.getByText("rosa@example.org")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("2 people get a private link to read the Rule."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Remove maya@example.org/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Remove rosa@example.org/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a pasted invalid address in the field and reports what was added", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmStakeholdersScreen />);
|
||||
const field = await screen.findByRole("textbox", {
|
||||
name: /Add someone by email/i,
|
||||
});
|
||||
field.focus();
|
||||
await user.paste("rosa@example.org, jo@example, sam@example.org");
|
||||
expect(await screen.findByText("rosa@example.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("sam@example.org")).toBeInTheDocument();
|
||||
expect(field).toHaveValue("jo@example");
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Added two people from your list\. One is not an email address\./,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Not an email address. Fix it and select Add, or clear it."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the membership card from the selected method", async () => {
|
||||
render(
|
||||
<>
|
||||
<SeedCreateFlow
|
||||
patch={{
|
||||
selectedMembershipMethodIds: ["open-admission"],
|
||||
membershipMethodDetailsById: {
|
||||
"open-admission": {
|
||||
eligibility: "Anyone who shares the work belongs.",
|
||||
joiningProcess: "Work two Sunday shares, then ask to join.",
|
||||
expectations: "",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ConfirmStakeholdersScreen />
|
||||
</>,
|
||||
);
|
||||
expect(await screen.findByText("Open admission")).toBeInTheDocument();
|
||||
expect(screen.getByText("Who belongs, in your Rule’s words")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Anyone who shares the work belongs/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -212,6 +212,44 @@ describe("LoginForm", () => {
|
||||
window.localStorage.removeItem("create-flow-anonymous");
|
||||
});
|
||||
|
||||
it("invite variant attaches a create-flow draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.setItem(
|
||||
"create-flow-anonymous",
|
||||
JSON.stringify({ title: "Guest draft" }),
|
||||
);
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
variant="invite"
|
||||
magicLinkNextPath="/create/confirm-stakeholders?syncDraft=1"
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: /log in to invite your members/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"guest@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||
"guest@example.com",
|
||||
"/create/confirm-stakeholders?syncDraft=1",
|
||||
expect.objectContaining({ title: "Guest draft" }),
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
window.localStorage.removeItem("create-flow-anonymous");
|
||||
});
|
||||
|
||||
it("keepRule continue without saving calls onDismiss", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
@@ -87,6 +87,17 @@ function LoginTrigger() {
|
||||
>
|
||||
Open keep rule then leave
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openLogin({
|
||||
variant: "invite",
|
||||
nextPath: "/create/confirm-stakeholders?syncDraft=1",
|
||||
})
|
||||
}
|
||||
>
|
||||
Open invite login
|
||||
</button>
|
||||
<button type="button" onClick={() => closeLogin()}>
|
||||
Close from outside
|
||||
</button>
|
||||
@@ -253,4 +264,44 @@ describe("AuthModalProvider (header overlay)", () => {
|
||||
});
|
||||
expect(screen.getByText("Left completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invite openLogin shows back-to-rule and attaches a draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.setItem(
|
||||
"create-flow-anonymous",
|
||||
JSON.stringify({ title: "Guest draft" }),
|
||||
);
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /open invite login/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /log in to invite your members/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /back to your rule/i }),
|
||||
).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"guest@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||
"guest@example.com",
|
||||
"/create/confirm-stakeholders?syncDraft=1",
|
||||
expect.objectContaining({ title: "Guest draft" }),
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
window.localStorage.removeItem("create-flow-anonymous");
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ function viewProps(
|
||||
onCloseDeleteRule: vi.fn(),
|
||||
onConfirmDeleteRule: vi.fn(),
|
||||
onDuplicateRule: vi.fn(),
|
||||
onInviteMembers: vi.fn(),
|
||||
onContinueDraft: vi.fn(),
|
||||
onDeleteDraft: vi.fn(),
|
||||
onCloseDeleteDraft: vi.fn(),
|
||||
@@ -142,4 +143,45 @@ describe("ProfilePageView", () => {
|
||||
await user.click(confirm);
|
||||
expect(onConfirmDeleteAccount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("finished owner cards offer Invite your members beside Duplicate and Delete", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInviteMembers = vi.fn();
|
||||
render(
|
||||
<ProfilePageView
|
||||
{...viewProps({
|
||||
onInviteMembers,
|
||||
rules: [
|
||||
{
|
||||
id: "rule-1",
|
||||
title: "Garden coop",
|
||||
summary: "How we share the plots.",
|
||||
createdAt: "2026-11-01T00:00:00.000Z",
|
||||
updatedAt: "2026-11-01T00:00:00.000Z",
|
||||
role: "owner",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(copy.welcomeBodyFirstRule),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(copy.finishedBadge)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: copy.yourRulesHeading }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: copy.inviteMembers }),
|
||||
);
|
||||
expect(onInviteMembers).toHaveBeenCalledWith("rule-1");
|
||||
expect(
|
||||
screen.getByRole("button", { name: copy.duplicate }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: copy.deleteRule }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe("flowSteps", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parseReviewReturnSearchParam accepts final-review, edit-rule, and review", () => {
|
||||
it("parseReviewReturnSearchParam accepts final-review, edit-rule, review, and completed", () => {
|
||||
expect(
|
||||
parseReviewReturnSearchParam(
|
||||
new URLSearchParams("reviewReturn=final-review"),
|
||||
@@ -197,6 +197,11 @@ describe("flowSteps", () => {
|
||||
expect(
|
||||
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")),
|
||||
).toBe("review");
|
||||
expect(
|
||||
parseReviewReturnSearchParam(
|
||||
new URLSearchParams("reviewReturn=completed"),
|
||||
),
|
||||
).toBe("completed");
|
||||
expect(
|
||||
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")),
|
||||
).toBeNull();
|
||||
|
||||
@@ -306,6 +306,71 @@ describe("useCreateFlowFinalize", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("omits stakeholder emails when skipInvites is set", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize({ skipInvites: true });
|
||||
});
|
||||
|
||||
expect(publishRule).toHaveBeenCalledWith({
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the send-failed banner when invitations do not send", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: "mail failed",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize({ skipInvites: false });
|
||||
});
|
||||
|
||||
expect(result.current.publishBannerTitle).toBe(
|
||||
messages.create.reviewAndComplete.confirmStakeholders.sendFailedTitle,
|
||||
);
|
||||
expect(result.current.publishBannerMessage).toBe(
|
||||
messages.create.reviewAndComplete.confirmStakeholders.sendFailedDescription,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to /create/completed without celebrate after PATCH update", async () => {
|
||||
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { membershipInvitePreview } from "../../lib/create/membershipInvitePreview";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
describe("membershipInvitePreview", () => {
|
||||
it("returns null when no membership method is selected", () => {
|
||||
expect(membershipInvitePreview({} as CreateFlowState)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the first selected membership method and saved words", () => {
|
||||
const preview = membershipInvitePreview({
|
||||
selectedMembershipMethodIds: ["open-admission"],
|
||||
membershipMethodDetailsById: {
|
||||
"open-admission": {
|
||||
eligibility: "Anyone who shares the work belongs.",
|
||||
joiningProcess: "Work two Sunday shares, then ask to join.",
|
||||
expectations: "",
|
||||
},
|
||||
},
|
||||
} as CreateFlowState);
|
||||
expect(preview?.title).toBe("Open admission");
|
||||
expect(preview?.categoryName).toBe("Membership");
|
||||
expect(preview?.eligibility).toBe("Anyone who shares the work belongs.");
|
||||
expect(preview?.joiningProcess).toBe(
|
||||
"Work two Sunday shares, then ask to join.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatInviteCountWord,
|
||||
parseInvitePaste,
|
||||
splitInvitePasteTokens,
|
||||
} from "../../lib/create/parseInviteEmails";
|
||||
|
||||
describe("parseInviteEmails", () => {
|
||||
it("splits comma, semicolon, and whitespace lists", () => {
|
||||
expect(
|
||||
splitInvitePasteTokens("rosa@example.org, dev@example.org; sam@example.org\nlee@example.org"),
|
||||
).toEqual([
|
||||
"rosa@example.org",
|
||||
"dev@example.org",
|
||||
"sam@example.org",
|
||||
"lee@example.org",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds valid addresses and leaves invalid tokens in the remainder", () => {
|
||||
const result = parseInvitePaste(
|
||||
"rosa@example.org, jo@example, sam@example.org",
|
||||
["maya@example.org"],
|
||||
30,
|
||||
);
|
||||
expect(result.added).toEqual(["rosa@example.org", "sam@example.org"]);
|
||||
expect(result.invalidRemainder).toBe("jo@example");
|
||||
});
|
||||
|
||||
it("skips duplicates already on the list", () => {
|
||||
const result = parseInvitePaste(
|
||||
"maya@example.org, rosa@example.org",
|
||||
["maya@example.org"],
|
||||
30,
|
||||
);
|
||||
expect(result.added).toEqual(["rosa@example.org"]);
|
||||
expect(result.skippedDuplicate).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps overflow addresses in the remainder once the max is hit", () => {
|
||||
const result = parseInvitePaste(
|
||||
"a@example.org, b@example.org, c@example.org",
|
||||
[],
|
||||
2,
|
||||
);
|
||||
expect(result.added).toEqual(["a@example.org", "b@example.org"]);
|
||||
expect(result.invalidRemainder).toBe("c@example.org");
|
||||
});
|
||||
|
||||
it("uses words for counts through ten", () => {
|
||||
expect(formatInviteCountWord(5)).toBe("five");
|
||||
expect(formatInviteCountWord(11)).toBe("11");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user