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
+1246 -259

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>
);
}
+56 -13
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,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,
},
+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",
@@ -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;
}
+13 -3
View File
@@ -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}
+18 -2
View File
@@ -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>
+18 -1
View File
@@ -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"}
+43
View File
@@ -0,0 +1,43 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
import {
membershipPresetFor,
publishedMethodDisplayLabel,
} from "./finalReviewChipPresets";
export type MembershipInvitePreview = {
categoryName: string;
title: string;
eligibility: string;
joiningProcess: string;
};
/** First selected membership method, for the Invite your members card. */
export function membershipInvitePreview(
state: CreateFlowState,
): MembershipInvitePreview | null {
const ids = state.selectedMembershipMethodIds ?? [];
const firstId = ids.find((id) => typeof id === "string" && id.trim() !== "");
if (!firstId) return null;
const saved = state.membershipMethodDetailsById?.[firstId];
const preset = membershipPresetFor(firstId);
const eligibility = (saved?.eligibility ?? "").trim() || preset.eligibility;
const joiningProcess =
(saved?.joiningProcess ?? "").trim() || preset.joiningProcess;
const title =
publishedMethodDisplayLabel(
"membership",
firstId,
state.customMethodCardMetaById,
) || firstId;
if (!title && !eligibility && !joiningProcess) return null;
return {
categoryName: RULE_SECTION_CATEGORY.membership,
title,
eligibility,
joiningProcess,
};
}
+82
View File
@@ -0,0 +1,82 @@
/** Shared invite-field email check (create-flow Invite your members). */
export const INVITE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const COUNT_WORDS = [
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
] as const;
/** Lowercase count for paste-result copy (`five`, or digits past ten). */
export function formatInviteCountWord(n: number): string {
if (Number.isInteger(n) && n >= 0 && n <= 10) {
return COUNT_WORDS[n] ?? String(n);
}
return String(n);
}
export function splitInvitePasteTokens(raw: string): string[] {
return raw
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter((token) => token.length > 0);
}
export type ParseInvitePasteResult = {
added: string[];
invalidRemainder: string;
skippedDuplicate: number;
};
/**
* Split a pasted list into valid new addresses and leftovers that stay in the
* field. Tokens that are not emails, duplicates, or that would exceed `max`
* remain in {@link ParseInvitePasteResult.invalidRemainder}.
*/
export function parseInvitePaste(
raw: string,
existing: readonly string[],
max: number,
): ParseInvitePasteResult {
const tokens = splitInvitePasteTokens(raw);
const added: string[] = [];
const invalid: string[] = [];
const seen = new Set(
existing
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0),
);
let skippedDuplicate = 0;
for (const token of tokens) {
const lower = token.toLowerCase();
if (!INVITE_EMAIL_PATTERN.test(lower)) {
invalid.push(token);
continue;
}
if (seen.has(lower)) {
skippedDuplicate += 1;
continue;
}
if (existing.length + added.length >= max) {
invalid.push(token);
continue;
}
seen.add(lower);
added.push(lower);
}
return {
added,
invalidRemainder: invalid.join(", "),
skippedDuplicate,
};
}
@@ -1,6 +1,8 @@
{
"toastTitle": "This is what folks see when you share your CommunityRule",
"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}",
+3
View File
@@ -5,6 +5,9 @@
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to log in. If you don't save now you could lose your progress.",
"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",
+6 -4
View File
@@ -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.",
},
},
},
+23 -13
View File
@@ -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);
});
+106 -20
View File
@@ -1,42 +1,128 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useEffect } from "react";
import userEvent from "@testing-library/user-event";
import { 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();
});
});
+38
View File
@@ -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();
+51
View File
@@ -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");
});
});
+42
View File
@@ -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();
});
});
+6 -1
View File
@@ -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.",
);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
formatInviteCountWord,
parseInvitePaste,
splitInvitePasteTokens,
} from "../../lib/create/parseInviteEmails";
describe("parseInviteEmails", () => {
it("splits comma, semicolon, and whitespace lists", () => {
expect(
splitInvitePasteTokens("rosa@example.org, dev@example.org; sam@example.org\nlee@example.org"),
).toEqual([
"rosa@example.org",
"dev@example.org",
"sam@example.org",
"lee@example.org",
]);
});
it("adds valid addresses and leaves invalid tokens in the remainder", () => {
const result = parseInvitePaste(
"rosa@example.org, jo@example, sam@example.org",
["maya@example.org"],
30,
);
expect(result.added).toEqual(["rosa@example.org", "sam@example.org"]);
expect(result.invalidRemainder).toBe("jo@example");
});
it("skips duplicates already on the list", () => {
const result = parseInvitePaste(
"maya@example.org, rosa@example.org",
["maya@example.org"],
30,
);
expect(result.added).toEqual(["rosa@example.org"]);
expect(result.skippedDuplicate).toBe(1);
});
it("keeps overflow addresses in the remainder once the max is hit", () => {
const result = parseInvitePaste(
"a@example.org, b@example.org, c@example.org",
[],
2,
);
expect(result.added).toEqual(["a@example.org", "b@example.org"]);
expect(result.invalidRemainder).toBe("c@example.org");
});
it("uses words for counts through ten", () => {
expect(formatInviteCountWord(5)).toBe("five");
expect(formatInviteCountWord(11)).toBe("11");
});
});