Finish a Rule, invite members, and let invited people read or copy it #100

Merged
an.di merged 6 commits from adilallo/fix/CR-282-review-finish-invite into main 2026-11-06 16:54:23 +00:00
106 changed files with 4469 additions and 529 deletions

No files matched your search

+3
View File
@@ -44,6 +44,9 @@ npm-cache/
!public/favicon-32x32.png
!public/apple-touch-icon.png
# Invitation email mark (2x PNG; Gmail and Outlook drop SVG)
!public/assets/logos/community-rule-mark.png
# Per-article body ornaments (Figma Content page Template image fills)
!public/content/blog/*-ornament-*.png
+116 -13
View File
@@ -62,6 +62,10 @@ import {
} from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
CREATE_FLOW_COPIED_QUERY,
CREATE_FLOW_COPIED_VALUE,
} from "../../../lib/create/invitePaths";
import {
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
@@ -90,6 +94,7 @@ import {
isCommunityNameStepBlocked,
isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked,
isFinalReviewContinueBlocked,
} from "./utils/createFlowFooterLabels";
import {
EMPTY_SETUP_TRAITS,
@@ -198,9 +203,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();
@@ -221,6 +224,7 @@ function CreateFlowLayoutContent({
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const [completedCopiedReady, setCompletedCopiedReady] = useState(false);
type LeaveConfirmChoice = "leave" | "save" | "cancel";
const leaveConfirmResolverRef = useRef<
((choice: LeaveConfirmChoice) => void) | null
@@ -276,6 +280,7 @@ function CreateFlowLayoutContent({
: createFlowStepPathWithSyncDraft("final-review");
const {
publishBannerTitle,
publishBannerMessage,
setPublishBannerMessage,
isPublishing,
@@ -449,7 +454,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);
@@ -514,13 +522,43 @@ 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);
setCompletedHasPublishedRule(false);
setCompletedCopiedReady(false);
return;
}
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
const last = readLastPublishedRule();
setCompletedHasPublishedRule(Boolean(last));
setCompletedCopiedReady(Boolean(last?.copiedFromTitle));
}, [currentStep]);
useEffect(() => {
@@ -704,6 +742,7 @@ function CreateFlowLayoutContent({
key: "publish",
status: "danger" as const,
title:
publishBannerTitle ??
messages.create.reviewAndComplete.publish.publishBannerTitle,
description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null),
@@ -861,7 +900,7 @@ function CreateFlowLayoutContent({
editingPublishedRuleId: last.id,
sections: [],
});
router.push(createFlowStepPath("edit-rule"));
router.push(createFlowStepPath("final-review"));
}
: undefined
}
@@ -882,7 +921,13 @@ function CreateFlowLayoutContent({
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={`relative z-[210] shrink-0 ${
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
isCompletedStep
? completedCopiedReady ||
searchParams.get(CREATE_FLOW_COPIED_QUERY) ===
CREATE_FLOW_COPIED_VALUE
? "!bg-[var(--color-lavender-lavender150,#d1bdf3)]"
: "!bg-[var(--color-teal-teal50,#c9fef9)]"
: ""
}`.trim()}
/>
{currentStep === "community-save" ? (
@@ -912,7 +957,7 @@ function CreateFlowLayoutContent({
progressBar={
!isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike &&
currentStep !== "edit-rule" &&
reviewReturnTarget !== "edit-rule"
}
proportionBarProgress={proportionBarProgress}
@@ -1124,12 +1169,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,
),
);
@@ -1140,20 +1187,74 @@ 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"
palette="default"
size="xsmall"
disabled={
isPublishing || (isFinalReviewLike && !sessionResolved)
isPublishing ||
(isEditRuleStep && !sessionResolved) ||
(currentStep === "final-review" &&
(!sessionResolved ||
isFinalReviewContinueBlocked(state)))
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
if (isFinalReviewLike) {
if (isEditRuleStep) {
void handleFinalize();
return;
}
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;
}
markAttempted();
if (
isCommunityStructureStepBlocked(
@@ -1180,7 +1281,9 @@ function CreateFlowLayoutContent({
goToNextStep();
}}
>
{isFinalReviewLike
{currentStep === "final-review"
? footer.continue
: isFinalReviewLike
? isPublishing
? messages.create.reviewAndComplete.publish
.publishButtonPublishing
@@ -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,
};
@@ -38,7 +38,7 @@ const blurActiveElement = (): void => {
* still needs identity (direct from a template) walks name / description /
* photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* identity and land on `final-review`; Back from that step returns
* to the template instead of `conflict-management`.
*
* Template review footer Back uses {@link buildTemplateReviewHref}’s
@@ -43,7 +43,7 @@ export type UseTemplateReviewActionsResult = {
* `/create/community-name` with a pin so Next/Back collect only name →
* description → photo, and drops leftover setup answers along with the
* other identity fields. In-flow (`?fromFlow=1`, community steps already
* done) routes to `/create/confirm-stakeholders` and keeps those answers.
* done) routes to `/create/final-review` and keeps those answers.
*/
handleUseWithoutChanges: () => Promise<void>;
};
@@ -268,7 +268,7 @@ export function useTemplateReviewActions({
});
router.push(
fromCreateWizard
? createFlowStepPath("confirm-stakeholders")
? createFlowStepPath("final-review")
: createFlowStepPath("community-name"),
);
}, [
@@ -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";
@@ -24,6 +26,11 @@ import {
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
} from "../../utils/flowSteps";
import {
CREATE_FLOW_COPIED_QUERY,
CREATE_FLOW_COPIED_VALUE,
} from "../../../../../lib/create/invitePaths";
import { communityPossessive } from "../../../../../lib/create/ruleInviteCardMeta";
function emptyCompletedDocumentState(): {
headerTitle: string;
@@ -78,6 +85,11 @@ export function CompletedScreen() {
const celebrateInUrl =
searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) ===
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE;
const copiedInUrl =
searchParams.get(CREATE_FLOW_COPIED_QUERY) === CREATE_FLOW_COPIED_VALUE;
const storedForCopy = ruleIdParam ? null : readLastPublishedRule();
const copiedFromTitle = storedForCopy?.copiedFromTitle;
const copiedNameHit = storedForCopy?.copiedNameHit ?? null;
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.reviewAndComplete.completed;
@@ -86,6 +98,8 @@ 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 [showCopiedReady] = useState(copiedInUrl);
const [askOrganizerOpen, setAskOrganizerOpen] = useState(false);
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
@@ -138,11 +152,17 @@ export function CompletedScreen() {
}, [ruleIdParam, router]);
useEffect(() => {
if (!celebrateInUrl) return;
if (!celebrateInUrl && !copiedInUrl) return;
router.replace("/create/completed");
}, [celebrateInUrl, router]);
}, [celebrateInUrl, copiedInUrl, router]);
const toast = showCelebrateToast && !toastDismissed ? (
const surfaceCopied = showCopiedReady || Boolean(copiedFromTitle);
const surfaceClass = surfaceCopied
? "bg-[var(--color-lavender-lavender150,#d1bdf3)]"
: "bg-[var(--color-teal-teal50,#c9fef9)]";
const toast =
!toastDismissed && (showCopiedReady || showCelebrateToast) ? (
<div
className="fixed bottom-0 left-0 right-0 z-10 w-full"
role="status"
@@ -151,8 +171,19 @@ export function CompletedScreen() {
<Alert
type="toast"
status="default"
title={completed.toastTitle}
description={completed.toastDescription}
title={
showCopiedReady
? completed.copyReadyToastTitle
: completed.toastTitle
}
description={
showCopiedReady
? completed.copyReadyToastDescription.replace(
"{possessive}",
communityPossessive(copiedFromTitle ?? ""),
)
: completed.toastDescription
}
hasLeadingIcon
hasBodyText
onClose={() => setToastDismissed(true)}
@@ -163,12 +194,12 @@ export function CompletedScreen() {
return (
<>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div className={`flex min-h-0 w-full flex-1 flex-col overflow-hidden ${surfaceClass} 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,15 +208,49 @@ export function CompletedScreen() {
size="L"
palette="inverse"
/>
{headerTitle && !surfaceCopied ? (
<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"
className={`pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-0% via-50% to-transparent md:block ${
surfaceCopied
? "from-[var(--color-lavender-lavender150,#d1bdf3)]/55 via-[var(--color-lavender-lavender150,#d1bdf3)]/20"
: "from-[var(--color-teal-teal50,#c9fef9)]/55 via-[var(--color-teal-teal50,#c9fef9)]/20"
}`}
aria-hidden
/>
<div className="w-full min-w-0 py-0 md:pb-8">
{copiedNameHit && copiedFromTitle ? (
<div className="mb-[var(--space-400)]">
<Alert
type="banner"
status="warning"
title={completed.copyNameHitTitle.replace(
"{name}",
copiedFromTitle,
)}
description={completed.copyNameHitDescription
.replace("{entry}", copiedNameHit.entryTitle)
.replace("{category}", copiedNameHit.categoryName)}
hasLeadingIcon
hasBodyText
hasTrailingIcon={false}
className="w-full"
/>
</div>
) : null}
<CommunityRule
sections={documentSections}
useCardStyle={!mdUp}
@@ -196,6 +261,10 @@ export function CompletedScreen() {
</div>
</div>
{toast}
<AskOrganizerInquiryModal
isOpen={askOrganizerOpen}
onClose={() => setAskOrganizerOpen(false)}
/>
</>
);
}
@@ -14,8 +14,11 @@ import {
} from "../../components/CreateFlowLockupCardStepShell";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories";
import { communityInitialsFromTitle } from "../../../../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../../../../lib/create/finalReviewChipWarnings";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type {
TemplateChipDetail,
@@ -32,10 +35,6 @@ import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisp
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
import {
persistDraftPreviewSurfaceColor,
@@ -51,53 +50,17 @@ function bindingArbitrationNeedsSeparateYes(state: CreateFlowState): boolean {
return joinsWithoutAYes && conflict.includes("binding-arbitration");
}
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* `finalReview.json.categories` ships a demo ordering + localized names
* (Values / Communication / Membership / Decision-making / Conflict
* management). We reuse that ordering for the state-derived rows so the
* Rule layout stays stable across customize / use-without-changes /
* plain-custom flows, and fall back to the demo chips when state resolves
* to nothing selected.
*/
function readFallbackCategoryRows(
categories: readonly { name: string; chips: readonly string[] }[],
): {
names: {
values: string;
communication: string;
membership: string;
decisions: string;
conflict: string;
};
rows: FinalReviewCategoryRowDetailed[];
} {
function categoryNamesFromMessages(
categories: readonly { name: string }[],
): FinalReviewCategoryNames {
const get = (i: number): string =>
typeof categories[i]?.name === "string" ? categories[i].name : "";
return {
names: {
values: get(0),
communication: get(1),
membership: get(2),
decisions: get(3),
conflict: get(4),
},
rows: categories.map((c) => ({
name: c.name,
groupKey: null,
entries: [...c.chips].map((label) => ({
label,
groupKey: null,
overrideKey: null,
})),
})),
};
}
@@ -145,28 +108,32 @@ export function FinalReviewScreen({
);
const { categories: finalReviewCategories } = useMemo(() => {
const { names, rows: fallbackRows } = readFallbackCategoryRows(
m.create.reviewAndComplete.finalReview.categories,
);
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
const copy = m.create.reviewAndComplete.finalReview;
const names = categoryNamesFromMessages(copy.categories);
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
derived.length > 0 ? derived : fallbackRows;
const usingFallbackRows = derived.length === 0;
buildFinalReviewCategoryRowsDetailed(state, names);
const lookup = new Map<
string,
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
>();
const cats: Category[] = rowsToRender.map((row, rowIndex) => {
const effectiveGroupKey: TemplateFacetGroupKey | null =
row.groupKey ??
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
? FACET_FALLBACK_ORDER[rowIndex]
: null);
const cats: Category[] = rowsToRender.map((row) => {
const effectiveGroupKey: TemplateFacetGroupKey | null = row.groupKey;
const reviewReturn =
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
const isEmpty = row.entries.length === 0;
const flag =
isEmpty && effectiveGroupKey === "coreValues"
? copy.flags.noValues
: isEmpty && effectiveGroupKey != null
? copy.flags.noCard
: undefined;
const addButtonText = isEmpty
? effectiveGroupKey === "coreValues"
? copy.addValue
: copy.addCard
: "";
const chipOptions = row.entries.map((entry, idx) => {
const chipId = `${row.name}-${idx}`;
@@ -190,12 +157,19 @@ export function FinalReviewScreen({
id: chipId,
label: entry.label,
state: "unselected" as const,
hasWarning: finalReviewChipHasWarning(
state,
entry.groupKey,
entry.overrideKey,
),
};
});
return {
name: row.name,
chipOptions,
addButton: effectiveGroupKey != null,
addButtonText,
flag,
onChipClick: (_categoryName: string, chipId: string) => {
const hit = lookup.get(chipId);
if (!hit) return;
@@ -219,7 +193,7 @@ export function FinalReviewScreen({
});
return { categories: cats };
}, [
m.create.reviewAndComplete.finalReview.categories,
m.create.reviewAndComplete.finalReview,
state,
markCreateFlowInteraction,
goToStep,
@@ -249,8 +223,7 @@ export function FinalReviewScreen({
const rawTitleForModal =
typeof state.title === "string" ? state.title : "";
const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
const descriptionEmptyHint = t("communityContextEditModal.emptyHint");
const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
const storedFallback =
@@ -305,34 +278,21 @@ export function FinalReviewScreen({
<Rule
title={ruleCardTitle}
description={ruleCardDescription}
onTitleClick={
variant === "editPublished"
? () => setTitleModalOpen(true)
: undefined
}
titleEditAriaLabel={
variant === "editPublished"
? t("titleEditModal.ariaEditTitle")
: undefined
}
onDescriptionClick={
variant === "editPublished"
? () => setCommunityContextModalOpen(true)
: undefined
}
onTitleClick={() => setTitleModalOpen(true)}
titleEditAriaLabel={t("titleEditModal.ariaEditTitle")}
onDescriptionClick={() => setCommunityContextModalOpen(true)}
descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={
variant === "editPublished"
? t("communityContextEditModal.ariaEditDescription")
: undefined
}
descriptionEditAriaLabel={t("communityContextEditModal.ariaEditDescription")}
size={mdUp ? "L" : "M"}
expanded={true}
descriptionAtBottom
backgroundColor="bg-transparent"
surfaceColor={surfaceColor}
logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))}
logoUrl={avatarUrl}
logoAlt={ruleCardTitle}
communityInitials={
avatarUrl ? undefined : communityInitialsFromTitle(ruleCardTitle)
}
categories={finalReviewCategories}
className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`}
onClick={() => {}}
@@ -352,8 +312,6 @@ export function FinalReviewScreen({
onClose={() => setActiveReadOnlyDetail(null)}
detail={activeReadOnlyDetail}
/>
{variant === "editPublished" ? (
<>
<FinalReviewTitleEditModal
isOpen={titleModalOpen}
onClose={() => setTitleModalOpen(false)}
@@ -372,8 +330,6 @@ export function FinalReviewScreen({
updateState({ communityContext: value, summary: value });
}}
/>
</>
) : null}
</CreateFlowLockupCardStepShell>
);
}
@@ -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>
) : null}
<MultiSelect
formHeader={false}
showHelpIcon={false}
size="s"
options={stakeholderOptions}
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
onCustomChipConfirm={handleCustomChipConfirm}
onCustomChipClose={handleCustomChipClose}
addButton
addButtonText={t("addStakeholder")}
{preview ? (
<MembershipInvitePreviewCard
preview={preview}
fromYourRuleLabel={t("fromYourRule")}
whoBelongsLabel={t("whoBelongsLabel")}
joiningLabel={t("joiningLabel")}
/>
</CreateFlowTwoColumnSelectShell>
) : null}
</div>
);
{!toastDismissed && (
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="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"
className="flex w-full items-start gap-[var(--space-200)]"
onPasteCapture={handlePaste}
>
<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 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>
);
}
@@ -1,5 +1,10 @@
import type footerMessages from "../../../../messages/en/create/footer.json";
import type { CreateFlowStep } from "../types";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
} from "../../../../lib/create/buildFinalReviewCategories";
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
import type { CreateFlowState, CreateFlowStep } from "../types";
type FooterMessages = typeof footerMessages;
@@ -9,9 +14,10 @@ type FooterMessages = typeof footerMessages;
* fires `goToNextStep` for steps without a bespoke footer). Steps absent
* from this map fall back to `footer.next`.
*
* `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `publishCommunityRule`).
* `final-review` uses Continue (gated by {@link isFinalReviewContinueBlocked}).
* `edit-rule` is handled separately by the caller because its label also
* depends on the in-flight publish flag (`publishButtonPublishing` vs
* `publishCommunityRule`).
*/
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep,
@@ -53,10 +59,42 @@ export function isCommunitySizeStepBlocked(
return !hasNonEmptyId(selectedCommunitySizeIds);
}
const FINAL_REVIEW_BLOCK_NAMES: FinalReviewCategoryNames = {
values: "values",
communication: "communication",
membership: "membership",
decisions: "decisions",
conflict: "conflict",
};
const FINAL_REVIEW_REQUIRED_GROUPS: readonly TemplateFacetGroupKey[] = [
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* Continue on final review waits for a community name and at least one card
* on each of the four questions. Skipped values do not block.
*/
export function isFinalReviewContinueBlocked(state: CreateFlowState): boolean {
const title = typeof state.title === "string" ? state.title.trim() : "";
if (title.length === 0) return true;
const rows = buildFinalReviewCategoryRowsDetailed(
state,
FINAL_REVIEW_BLOCK_NAMES,
);
return FINAL_REVIEW_REQUIRED_GROUPS.some((groupKey) => {
const row = rows.find((candidate) => candidate.groupKey === groupKey);
return !row || row.entries.length === 0;
});
}
/**
* Resolve the localized label for the default "next-step" footer button.
* Returns the per-step override when one is registered, otherwise
* `footer.next`. Caller still owns the `final-review` special case.
* `footer.next`. Caller still owns the `final-review` / `edit-rule` special case.
*/
export function getDefaultFooterLabel(
step: CreateFlowStep | null | undefined,
@@ -21,8 +21,8 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // final-review
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // completed
] as const;
@@ -123,18 +123,18 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.customRule.conflictManagement",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-212767",
figmaNodeId: "20907-213155",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "25336-62720",
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false,
},
"edit-rule": {
layoutKind: "review",
figmaNodeId: "20907-212767",
+16 -9
View File
@@ -27,8 +27,8 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"membership-methods",
"decision-approaches",
"conflict-management",
"confirm-stakeholders",
"final-review",
"confirm-stakeholders",
"completed",
] as const;
@@ -61,7 +61,7 @@ export type CreateFlowNavigationOptions = {
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` →
* `community-upload`, then `confirm-stakeholders`.
* `community-upload`, then `final-review`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
@@ -131,9 +131,9 @@ export function getPreviousStep(
* Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* `final-review` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* from `final-review` returns to template review instead of
* `conflict-management`.
*/
export type CreateFlowBackTarget =
@@ -157,7 +157,7 @@ export function resolveCreateFlowBackTarget(
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
currentStep === "final-review" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
@@ -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;
+50
View File
@@ -0,0 +1,50 @@
import { notFound, redirect } from "next/navigation";
import { getSessionUser } from "../../../../lib/server/session";
import { getAcceptedStakeholderInviteView } from "../../../../lib/server/publishedRules";
import { inviteRuleCountsSpoken } from "../../../../lib/create/ruleInviteCardMeta";
import {
INVITE_COPY_VALUE,
invitedRulePath,
} from "../../../../lib/create/invitePaths";
import { InvitedRuleView } from "../_components/InvitedRuleView";
type PageProps = {
params: Promise<{ ruleId: string }>;
searchParams: Promise<{ copy?: string }>;
};
export default async function InviteRulePage({
params,
searchParams,
}: PageProps) {
const { ruleId } = await params;
const query = await searchParams;
const copy = query.copy === INVITE_COPY_VALUE;
const user = await getSessionUser();
if (!user) {
const next = invitedRulePath(ruleId, { copy });
redirect(`/login?next=${encodeURIComponent(next)}`);
}
const view = await getAcceptedStakeholderInviteView({
userId: user.id,
ruleId,
});
if (!view) {
notFound();
}
return (
<InvitedRuleView
ruleId={view.ruleId}
title={view.title}
summary={view.summary}
document={view.document}
updatedAt={view.updatedAt}
inviterEmail={view.inviterEmail}
viewerEmail={user.email}
countsSpoken={inviteRuleCountsSpoken(view.document)}
openCopyDialog={copy}
/>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useId, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import Login from "../../../components/modals/Login";
import Button from "../../../components/buttons/Button";
import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup";
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
import { INVITE_EMAIL_PATTERN } from "../../../../lib/create/parseInviteEmails";
import { resendExpiredStakeholderInvite } from "../../../../lib/create/api";
const expiredPageBgClass =
"min-h-[100dvh] bg-[var(--color-surface-invert-brand-primary)]";
export function ExpiredInviteScreen() {
const t = useTranslation("pages.invite");
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const titleId = useId();
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false);
const heading = sent
? { title: t("sentTitle"), description: t("sentBody") }
: { title: t("expiredTitle"), description: t("expiredDescription") };
async function sendLink() {
const trimmed = email.trim().toLowerCase();
if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("emailInvalid"));
return;
}
if (token.length < 10) {
setFormError(t("missingToken"));
return;
}
setEmailError("");
setFormError("");
setSubmitting(true);
const result = await resendExpiredStakeholderInvite(token, trimmed);
setSubmitting(false);
if (result.ok === false) {
if (result.retryAfterMs) {
const seconds = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
setFormError(t("rateLimited").replace("{seconds}", String(seconds)));
return;
}
setFormError(result.error || t("genericError"));
return;
}
setSent(true);
}
return (
<div className={expiredPageBgClass}>
<Login
isOpen
usePortal={false}
backdropVariant="solid"
onClose={() => {
router.push("/");
}}
ariaLabelledBy={titleId}
belowCard={
<Link
href="/"
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
{t("backToHome")}
</Link>
}
>
<div className="flex flex-col gap-6">
<ContentLockup
titleId={titleId}
title={heading.title}
description={heading.description}
variant="login"
alignment="left"
/>
{formError ? (
<p
role="alert"
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{formError}
</p>
) : null}
{!sent ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void sendLink();
}}
noValidate
>
<TextInput
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
type="email"
name="email"
autoComplete="email"
inputMode="email"
value={email}
onChange={(event) => {
setEmail(event.target.value);
setEmailError("");
}}
disabled={submitting}
error={Boolean(emailError)}
showHelpIcon
maxLength={EMAIL_MAX_LEN}
/>
{emailError ? (
<p
role="alert"
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{emailError}
</p>
) : null}
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("notInvitedAddress")}
</p>
<Button
type="submit"
size="large"
buttonType="filled"
palette="default"
disabled={submitting}
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
>
{t("sendNewLink")}
</Button>
</form>
) : null}
</div>
</Login>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Logo from "../../../components/asset/Logo";
import Button from "../../../components/buttons/Button";
import Alert from "../../../components/modals/Alert";
import CommunityRule from "../../../components/type/CommunityRule";
import { useTranslation } from "../../../contexts/MessagesContext";
import { makeCopyPublishedRule } from "../../../../lib/create/api";
import { communityPossessive } from "../../../../lib/create/ruleInviteCardMeta";
import { CREATE_FLOW_COPIED_QUERY, CREATE_FLOW_COPIED_VALUE } from "../../../../lib/create/invitePaths";
import { formatInviteLastChanged } from "../../../../lib/create/inviteLastChanged";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { downloadStoredRuleAsPdf } from "../../../../lib/create/ruleExport";
import { createCompletedPath } from "../../create/utils/createFlowPaths";
import { CreateFlowHeaderLockup } from "../../create/components/CreateFlowHeaderLockup";
import { useCreateFlowMdUp } from "../../create/hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
} from "../../create/components/createFlowLayoutTokens";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { MakeACopyDialog } from "./MakeACopyDialog";
import type { CommunityRuleSection } from "../../../components/type/CommunityRule/CommunityRule.types";
const outlineButtonClass =
"md:!text-x-small-label !text-xx-small-label !px-[var(--space-150)] md:!px-[var(--space-200)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
const SURFACE = "bg-[var(--color-teal-teal50,#c9fef9)]";
export type InvitedRuleViewProps = {
ruleId: string;
title: string;
summary: string | null;
document: Record<string, unknown>;
updatedAt: string;
inviterEmail: string | null;
viewerEmail: string;
countsSpoken: string;
openCopyDialog?: boolean;
};
export function InvitedRuleView({
ruleId,
title,
summary,
document,
updatedAt,
inviterEmail,
viewerEmail,
countsSpoken,
openCopyDialog = false,
}: InvitedRuleViewProps) {
const t = useTranslation("pages.invite");
const router = useRouter();
const mdUp = useCreateFlowMdUp();
const [copyOpen, setCopyOpen] = useState(openCopyDialog);
const [copyBusy, setCopyBusy] = useState(false);
const [copyError, setCopyError] = useState<string | null>(null);
const [exportError, setExportError] = useState(false);
const sections: CommunityRuleSection[] =
parsePublishedDocumentForCommunityRuleDisplay(document);
const possessive = communityPossessive(title);
const lastChanged = formatInviteLastChanged(updatedAt);
const bannerTitle = inviterEmail
? t("bannerTitle").replace("{email}", inviterEmail)
: t("bannerTitleNoEmail");
const whoCanEdit = inviterEmail ?? t("whoCanEditUnknown");
useEffect(() => {
if (!openCopyDialog) return;
router.replace(`/invite/${encodeURIComponent(ruleId)}`);
}, [openCopyDialog, ruleId, router]);
const handlePdf = useCallback(() => {
try {
downloadStoredRuleAsPdf({
id: ruleId,
title,
summary,
document,
});
setExportError(false);
} catch {
setExportError(true);
}
}, [document, ruleId, summary, title]);
const handleCopy = useCallback(
async (communityName: string) => {
setCopyBusy(true);
setCopyError(null);
const result = await makeCopyPublishedRule(ruleId, communityName);
setCopyBusy(false);
if (result.ok === false) {
setCopyError(t("copyFailed"));
return;
}
writeLastPublishedRule({
id: result.id,
title: result.title,
summary: result.summary,
document: result.document,
copiedFromTitle: result.copiedFromTitle,
copiedNameHit: result.copiedNameHit,
});
router.push(
createCompletedPath({
[CREATE_FLOW_COPIED_QUERY]: CREATE_FLOW_COPIED_VALUE,
}),
);
},
[ruleId, router, t],
);
return (
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} ${SURFACE}`}>
<header
className={`${SURFACE} w-full print:hidden`}
role="banner"
>
<nav
className={`mx-auto flex w-full max-w-[1920px] items-center justify-between ${CREATE_FLOW_PAGE_GUTTER_CLASS} py-[var(--space-300)] md:py-[var(--space-400)]`}
aria-label={t("navAria")}
>
<Logo size="createFlow" wordmark palette="inverse" />
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
type="button"
ariaLabel={t("makeACopyAria")}
className={outlineButtonClass}
onClick={() => {
setCopyError(null);
setCopyOpen(true);
}}
>
{t("makeACopy")}
</Button>
</nav>
</header>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
<div
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 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={title}
description={summary ?? undefined}
justification="left"
size="L"
palette="inverse"
/>
</div>
<div
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="flex w-full min-w-0 flex-col gap-[var(--space-600)] py-0 md:pb-8">
<div className="print:hidden">
<Alert
type="banner"
status="default"
title={bannerTitle}
description={t("bannerDescription")}
hasLeadingIcon
hasBodyText
hasTrailingIcon={false}
className="w-full"
/>
</div>
<div className="flex flex-wrap gap-[var(--space-300)] print:hidden">
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
type="button"
className={outlineButtonClass}
onClick={handlePdf}
>
{t("downloadPdf")}
</Button>
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
type="button"
className={outlineButtonClass}
onClick={() => window.print()}
>
{t("print")}
</Button>
</div>
{exportError ? (
<p role="alert" className="text-small-paragraph print:hidden">
{t("exportFailedDescription")}
</p>
) : null}
<CommunityRule
sections={sections}
useCardStyle={!mdUp}
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
/>
<div className="flex w-full flex-col border-t border-[var(--color-content-invert-secondary)] print:hidden">
<h2 className="sr-only">{t("factsHeading")}</h2>
<FactRow label={t("whoCanEdit")} value={whoCanEdit} />
<FactRow label={t("whoCanSee")} value={t("whoCanSeeValue")} />
<FactRow label={t("lastChanged")} value={lastChanged} />
</div>
<div className="flex w-full min-w-0 flex-col gap-[var(--space-400)] rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-600)] text-[var(--color-content-invert-primary)] print:hidden">
<p className="font-bricolage text-x-small-heading">
{t("copyCardTitle")}
</p>
<p className="text-small-paragraph">
{t("copyCardBody").replace("{counts}", countsSpoken)}
</p>
<Button
buttonType="outline"
palette="inverse"
size="small"
type="button"
className="self-start"
onClick={() => {
setCopyError(null);
setCopyOpen(true);
}}
>
{t("makeACopy")}
</Button>
</div>
<p className="text-small-paragraph text-[var(--color-content-invert-secondary)] print:hidden">
{t("privacy")}
</p>
</div>
</div>
</div>
</div>
<MakeACopyDialog
isOpen={copyOpen}
onClose={() => {
if (!copyBusy) setCopyOpen(false);
}}
possessive={possessive}
countsSpoken={countsSpoken}
viewerEmail={viewerEmail}
busy={copyBusy}
error={copyError}
onConfirm={(communityName) => {
void handleCopy(communityName);
}}
/>
</div>
);
}
function FactRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-[var(--space-400)] border-b border-[var(--color-content-invert-secondary)] py-[var(--space-300)]">
<p className="text-small-label text-[var(--color-content-invert-secondary)]">
{label}
</p>
<p className="text-right text-small-paragraph text-[var(--color-content-invert-primary)]">
{value}
</p>
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import Button from "../../../components/buttons/Button";
import TextInput from "../../../components/controls/TextInput";
import Dialog from "../../../components/modals/Dialog";
import { useTranslation } from "../../../contexts/MessagesContext";
const COMMUNITY_NAME_MAX = 48;
export function MakeACopyDialog({
isOpen,
onClose,
possessive,
countsSpoken,
viewerEmail,
busy,
error,
onConfirm,
}: {
isOpen: boolean;
onClose: () => void;
possessive: string;
countsSpoken: string;
viewerEmail: string;
busy: boolean;
error: string | null;
onConfirm: (_title: string) => void;
}) {
const t = useTranslation("pages.invite");
const [title, setTitle] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const trimmed = title.trim();
return (
<Dialog
isOpen={isOpen}
onClose={() => {
if (!busy) onClose();
}}
title={t("dialogTitle")}
description={t("dialogDescription")
.replace("{possessive}", possessive)
.replace("{counts}", countsSpoken)}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onClose}
disabled={busy}
>
{t("cancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
disabled={busy}
onClick={() => {
if (trimmed.length === 0) {
setNameError(t("nameRequired"));
return;
}
onConfirm(trimmed);
}}
>
{t("confirm")}
</Button>
</>
}
>
<div className="flex w-full min-w-0 flex-col gap-[var(--space-500)]">
<TextInput
id="make-copy-community-name"
inputSize="small"
showHelpIcon={false}
label={t("communityNameLabel")}
placeholder={t("communityNamePlaceholder")}
value={title}
maxLength={COMMUNITY_NAME_MAX}
error={Boolean(nameError)}
textHint={
nameError ||
t("characterCountTemplate")
.replace("{current}", String(title.length))
.replace("{max}", String(COMMUNITY_NAME_MAX))
}
onChange={(event) => {
setTitle(event.target.value);
setNameError(null);
}}
/>
<div className="grid w-full grid-cols-1 gap-[var(--space-300)] sm:grid-cols-2">
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
<p className="text-small-label">{t("comesWithTitle")}</p>
<p className="mt-[var(--space-200)] text-small-paragraph">
{t("comesWithBody")}
</p>
</div>
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
<p className="text-small-label">{t("staysWithTitle")}</p>
<p className="mt-[var(--space-200)] text-small-paragraph">
{t("staysWithBody")}
</p>
</div>
</div>
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("saveTo").replace("{email}", viewerEmail)}
</p>
{error ? (
<p role="alert" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]">
{error}
</p>
) : null}
</div>
</Dialog>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import messages from "../../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../../lib/siteMetadata";
export const metadata: Metadata = routeMetadata("/invite/expired", {
title: messages.metadata.inviteExpired.title,
description: messages.metadata.inviteExpired.description,
robots: NO_INDEX_ROBOTS,
});
export default function ExpiredInviteLayout({
children,
}: {
children: ReactNode;
}) {
return children;
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { ExpiredInviteScreen } from "../_components/ExpiredInviteScreen";
function ExpiredInviteFallback() {
const t = useTranslation("pages.invite");
return (
<div className="flex min-h-[100dvh] items-center justify-center bg-[var(--color-surface-invert-brand-primary)]">
<p className="text-[14px] text-[var(--color-content-default-primary)]">
{t("loadingFallback")}
</p>
</div>
);
}
export default function ExpiredInvitePage() {
return (
<Suspense fallback={<ExpiredInviteFallback />}>
<ExpiredInviteScreen />
</Suspense>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = routeMetadata("/invite", {
title: messages.metadata.invite.title,
description: messages.metadata.invite.description,
robots: NO_INDEX_ROBOTS,
});
export default function InviteLayout({ children }: { children: ReactNode }) {
return children;
}
+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,25 +334,25 @@ export function ProfilePageView({
expanded
size={ruleCardSize}
hasBottomLinks
bottomStatusLabel={
rule.role === "stakeholder"
? undefined
: t("finishedBadge")
}
bottomLinks={
rule.role === "stakeholder"
? [
{
id: "view",
label: t("viewPublic"),
href: `/rules/${encodeURIComponent(rule.id)}`,
href: `/invite/${encodeURIComponent(rule.id)}`,
},
]
: [
{
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",
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { hashSessionToken, newSessionToken } from "../../../../../lib/server/hash";
import { sendRuleStakeholderInviteEmail } from "../../../../../lib/server/mail";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { logRouteError } from "../../../../../lib/server/requestId";
import { stakeholderInviteVerifyUrl } from "../../../../../lib/server/ruleStakeholderInviteOps";
import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../../../../lib/server/ruleStakeholderInviteMail";
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../lib/server/ruleStakeholders";
import {
dbUnavailable,
errorJson,
rateLimited,
serverMisconfigured,
} from "../../../../../lib/server/responses";
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
import { resendExpiredStakeholderInviteBodySchema } from "../../../../../lib/server/validation/createFlowSchemas";
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown"
);
}
export const POST = apiRoute(
"invites.ruleStakeholder.resendExpired",
async (request: NextRequest, _ctx, { requestId }) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const body = await readLimitedJson(request);
if (body.ok === false) return body.response;
const parsed = resendExpiredStakeholderInviteBodySchema.safeParse(body.value);
if (!parsed.success) return jsonFromZodError(parsed.error);
const { token, email } = parsed.data;
const rl = rateLimitKey(
`invite-resend-expired:${clientIp(request)}:${email}`,
60_000,
);
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch (err) {
logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, {
phase: "getSessionPepper",
});
return serverMisconfigured();
}
const tokenHash = hashSessionToken(token, pepper);
const row = await prisma.ruleStakeholder.findUnique({
where: { inviteTokenHash: tokenHash },
select: {
id: true,
email: true,
inviteTokenHash: true,
inviteExpiresAt: true,
invitedBy: { select: { email: true } },
rule: {
select: {
title: true,
summary: true,
document: true,
user: { select: { email: true } },
},
},
},
});
if (!row || row.email !== email) {
return NextResponse.json({ ok: true });
}
const prevHash = row.inviteTokenHash;
const prevExp = row.inviteExpiresAt;
const nextToken = newSessionToken();
const newHash = hashSessionToken(nextToken, pepper);
const newExp = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
await prisma.ruleStakeholder.update({
where: { id: row.id },
data: {
inviteTokenHash: newHash,
inviteExpiresAt: newExp,
},
});
const origin = getPublicOrigin(request);
const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken);
const inviterEmail =
row.invitedBy?.email ??
row.rule.user?.email ??
RULE_STAKEHOLDER_INVITER_FALLBACK;
try {
await sendRuleStakeholderInviteEmail({
to: row.email,
verifyUrl,
origin,
communityName: row.rule.title,
inviterEmail,
summary: row.rule.summary,
document: row.rule.document,
});
} catch (err) {
logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, {
phase: "sendRuleStakeholderInviteEmail",
});
await prisma.ruleStakeholder
.update({
where: { id: row.id },
data: {
inviteTokenHash: prevHash,
inviteExpiresAt: prevExp,
},
})
.catch(() => {});
return errorJson(
"mail_failed",
"Could not resend stakeholder invite",
502,
);
}
return NextResponse.json({ ok: true });
},
);
@@ -17,6 +17,12 @@ import {
setSessionCookie,
} from "../../../../../lib/server/session";
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
import {
INVITE_COPY_QUERY,
INVITE_COPY_VALUE,
inviteExpiredPath,
invitedRulePath,
} from "../../../../../lib/create/invitePaths";
const SCOPE = "invites.ruleStakeholder.verify";
@@ -66,7 +72,7 @@ export async function GET(request: NextRequest) {
) {
return redirectWithRequestId(
request,
"/login?error=expired_link",
inviteExpiredPath(token),
requestId,
);
}
@@ -104,7 +110,9 @@ export async function GET(request: NextRequest) {
);
await setSessionCookie(sessionToken, expiresAt);
const dest = `/rules/${encodeURIComponent(row.ruleId)}`;
const copy =
request.nextUrl.searchParams.get(INVITE_COPY_QUERY) === INVITE_COPY_VALUE;
const dest = invitedRulePath(row.ruleId, { copy });
return redirectWithRequestId(request, dest, requestId);
} catch (err) {
logRouteError(SCOPE, requestId, err);
+105
View File
@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "../../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../../lib/server/env";
import {
dbUnavailable,
forbidden,
notFound,
rateLimited,
unauthorized,
} from "../../../../../lib/server/responses";
import { getSessionUser } from "../../../../../lib/server/session";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
import {
makeCopyRuleBodySchema,
publishedRuleDocumentSchema,
} from "../../../../../lib/server/validation/createFlowSchemas";
import {
findCopiedNameHit,
stripCopiedRuleDocument,
} from "../../../../../lib/create/copiedRuleDocument";
type RouteContext = { params: Promise<{ id: string }> };
export const POST = apiRoute<RouteContext>(
"rules.byId.makeCopy",
async (request, context) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { id } = await context.params;
const body = await readLimitedJson(request);
if (body.ok === false) return body.response;
const parsed = makeCopyRuleBodySchema.safeParse(body.value);
if (!parsed.success) return jsonFromZodError(parsed.error);
const rl = rateLimitKey(`rule-make-copy:${user.id}:${id}`, 60_000);
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
const stakeholder = await prisma.ruleStakeholder.findFirst({
where: { ruleId: id, userId: user.id },
select: {
id: true,
rule: {
select: {
id: true,
title: true,
document: true,
userId: true,
},
},
},
});
if (!stakeholder) {
return notFound();
}
if (stakeholder.rule.userId === user.id) {
return forbidden();
}
const stripped = stripCopiedRuleDocument(stakeholder.rule.document);
const documentParsed = publishedRuleDocumentSchema.safeParse(stripped);
if (!documentParsed.success) {
return jsonFromZodError(documentParsed.error);
}
const copiedNameHit = findCopiedNameHit(
documentParsed.data,
stakeholder.rule.title,
);
const newRule = await prisma.publishedRule.create({
data: {
userId: user.id,
title: parsed.data.title,
summary: null,
document: documentParsed.data as Prisma.InputJsonValue,
},
});
return NextResponse.json({
rule: {
id: newRule.id,
title: newRule.title,
summary: newRule.summary,
document: documentParsed.data,
copiedFromTitle: stakeholder.rule.title,
copiedNameHit,
createdAt: newRule.createdAt,
updatedAt: newRule.updatedAt,
},
});
},
);
@@ -46,7 +46,7 @@ export const POST = apiRoute<RouteContext>(
email: true,
inviteTokenHash: true,
inviteExpiresAt: true,
rule: { select: { userId: true, title: true } },
rule: { select: { userId: true, title: true, summary: true, document: true } },
},
});
@@ -93,9 +93,18 @@ export const POST = apiRoute<RouteContext>(
},
});
const verifyUrl = stakeholderInviteVerifyUrl(getPublicOrigin(request), token);
const origin = getPublicOrigin(request);
const verifyUrl = stakeholderInviteVerifyUrl(origin, token);
try {
await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title);
await sendRuleStakeholderInviteEmail({
to: row.email,
verifyUrl,
origin,
communityName: row.rule.title,
inviterEmail: user.email,
summary: row.rule.summary,
document: row.rule.document,
});
} catch (err) {
logRouteError("rules.stakeholders.resend", requestId, err, {
phase: "sendRuleStakeholderInviteEmail",
+4 -1
View File
@@ -31,7 +31,7 @@ type RouteContext = { params: Promise<{ id: string }> };
async function ownedRuleMeta(ruleId: string, userId: string) {
return prisma.publishedRule.findFirst({
where: { id: ruleId, userId },
select: { id: true, title: true },
select: { id: true, title: true, summary: true, document: true },
});
}
@@ -159,8 +159,11 @@ export const POST = apiRoute<RouteContext>(
origin,
ruleId: rule.id,
ruleTitle: rule.title,
summary: rule.summary,
document: rule.document,
email,
invitedByUserId: user.id,
inviterEmail: user.email,
pepper,
});
+16 -3
View File
@@ -205,15 +205,28 @@ export const POST = apiRoute(
});
const origin = getPublicOrigin(request);
try {
let sent = 0;
for (const inv of invites) {
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title);
}
try {
await sendRuleStakeholderInviteEmail({
to: inv.email,
verifyUrl,
origin,
communityName: title,
inviterEmail: user.email,
summary,
document,
});
sent += 1;
} catch (err) {
logRouteError("rules.publish", requestId, err, {
phase: "sendRuleStakeholderInviteEmail",
email: inv.email,
});
}
}
if (sent === 0) {
try {
await prisma.publishedRule.delete({ where: { id: rule.id } });
} catch (delErr) {
+4
View File
@@ -6,6 +6,10 @@ export interface Category {
chipOptions: ChipOption[];
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
addButton?: boolean;
/** Label on the add control. Empty string keeps the icon-only circle. */
addButtonText?: string;
/** Figma Has Flag: short line after the add chip in the same row. */
flag?: string;
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (
+2 -1
View File
@@ -472,7 +472,8 @@ export function RuleView({
addButton={
!hideCategoryAddButton && category.addButton !== false
}
addButtonText="" // Empty text for icon-only circular button
addButtonText={category.addButtonText ?? ""}
flag={category.flag}
className="w-full"
/>
))}
@@ -21,6 +21,7 @@ const ChipContainer = memo<ChipProps>(
onRemove,
onCheck,
onClose,
hasWarning = false,
ariaLabel,
}) => {
const t = useTranslation("controlsChrome");
@@ -89,11 +90,19 @@ const ChipContainer = memo<ChipProps>(
onRemove={onRemove}
onCheck={handleCheck}
onClose={handleClose}
hasWarning={hasWarning}
inputValue={isCustom ? inputValue : undefined}
onInputChange={isCustom ? setInputValue : undefined}
onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel ?? (isCustom ? undefined : label)}
ariaLabel={
ariaLabel ??
(isCustom
? undefined
: hasWarning
? t("chipMissingWords").replace("{label}", label)
: label)
}
confirmAriaLabel={t("chipConfirm")}
typeToAddPlaceholder={t("chipTypeToAdd")}
closeAriaLabel={t("chipClose")}
@@ -48,6 +48,11 @@ export interface ChipProps {
* Optional callback when custom chip is closed/removed.
*/
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Figma Has Warning: show a warning icon in place of the selected check.
* Used on final review when a card or value is missing its words.
*/
hasWarning?: boolean;
ariaLabel?: string;
}
@@ -62,6 +67,7 @@ export interface ChipViewProps {
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
hasWarning?: boolean;
inputValue?: string;
onInputChange?: (value: string) => void;
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
+9 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, type CSSProperties } from "react";
import Icon from "../../asset/icon/Icon";
import type { ChipViewProps } from "./Chip.types";
function chipSurfaceStyle(
@@ -87,6 +88,7 @@ function ChipView({
onRemove,
onCheck,
onClose,
hasWarning = false,
inputValue,
onInputChange,
onInputKeyDown,
@@ -280,7 +282,13 @@ function ChipView({
onClick={handleClick}
{...sharedA11y}
>
{isSelected ? (
{hasWarning ? (
<Icon
name="warning"
size={isSmall ? 12 : 16}
className="shrink-0 text-current"
/>
) : isSelected ? (
<svg
aria-hidden
viewBox="0 0 12 12"
@@ -20,6 +20,7 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick,
addButton: addButtonProp = true,
addButtonText,
flag,
formHeader = true,
helperText,
errorMessage,
@@ -45,11 +46,13 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick={onAddClick}
addButton={addButtonProp}
addButtonText={addButtonText}
flag={flag}
addButtonAriaLabel={
addButtonText || t("multiSelectAddFallback")
}
chipSelectAriaLabel={t("chipSelect")}
chipDeselectAriaLabel={t("chipDeselect")}
chipMissingWordsAriaLabel={t("chipMissingWords")}
formHeader={formHeader}
helperText={helperText}
errorMessage={errorMessage}
@@ -7,6 +7,8 @@ export interface ChipOption {
id: string;
label: string;
state?: ChipStateValue;
/** Figma Has Warning on Control / Chip. */
hasWarning?: boolean;
}
export type MultiSelectSizeValue = "s" | "m";
@@ -49,6 +51,11 @@ export interface MultiSelectProps {
* Text for the add button
*/
addButtonText?: string;
/**
* Figma Has Flag: short line after the add chip in the same row, with no
* fill and no extra height.
*/
flag?: string;
/**
* Whether to show form header (label and help icon) above multi-select (Figma prop).
* @default true
@@ -93,7 +100,9 @@ export interface MultiSelectViewProps {
onAddClick?: () => void;
addButton: boolean;
addButtonText?: string;
flag?: string;
addButtonAriaLabel: string;
chipMissingWordsAriaLabel: string;
chipSelectAriaLabel: string;
chipDeselectAriaLabel: string;
formHeader: boolean;
@@ -2,6 +2,7 @@
import { memo } from "react";
import Chip from "../Chip";
import Icon from "../../asset/icon/Icon";
import InputLabel from "../../type/InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
@@ -15,9 +16,11 @@ function MultiSelectView({
onAddClick,
addButton,
addButtonText,
flag,
addButtonAriaLabel,
chipSelectAriaLabel,
chipDeselectAriaLabel,
chipMissingWordsAriaLabel,
formHeader = true,
helperText: helperTextProp,
errorMessage,
@@ -94,9 +97,15 @@ function MultiSelectView({
palette={palette}
size={chipSize}
disabled={chipDisabled}
hasWarning={option.hasWarning}
ariaLabel={
isCustom || !option.label
? undefined
: option.hasWarning
? chipMissingWordsAriaLabel.replace(
"{label}",
option.label,
)
: (isSelected
? chipDeselectAriaLabel
: chipSelectAriaLabel
@@ -180,6 +189,22 @@ function MultiSelectView({
)}
</button>
)}
{flag ? (
<span
className={`inline-flex items-center gap-[var(--space-100)] text-x-small-paragraph ${
isInverse
? "text-[color:var(--color-content-invert-primary)]"
: "text-[color:var(--color-content-default-primary)]"
}`}
>
<Icon
name="warning"
size={12}
className="shrink-0 text-current"
/>
{flag}
</span>
) : null}
</div>
{errorMessage ? (
<p
@@ -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"}
+6 -6
View File
@@ -12,7 +12,7 @@ The Figma **Create Community** sequence is the **source of truth** for the first
| --- | --- | --- |
| **Create Community** | Intro, naming, structure, context, size, optional setup questions, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `setup-questions` → `community-upload` → `community-save` → `review` |
| **Create Custom CommunityRule** | Author the CommunityRule content and structure (core values + four card-stack categories). | `core-values` → `communication-methods` → `membership-methods` → `decision-approaches` → `conflict-management` |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
| **Review and complete** | Final card, invite members, finish, success. | `final-review` → `confirm-stakeholders` → `completed` |
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/(app)/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma still names eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed**. Question-and-options steps (**select**, **card**, **right-rail**) share [`CreateFlowTwoColumnSelectShell`](../app/(app)/create/components/CreateFlowTwoColumnSelectShell.tsx) and [`Selection`](../app/components/cards/Selection/) size variants (`s` chip, `m` tile, `l` row). `app/(app)/create/screens/` still mirrors the Figma kind names.
@@ -40,8 +40,8 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSt
| 12 | Create Custom CommunityRule | `membership-methods` | `/create/membership-methods` |
| 13 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` |
| 14 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` |
| 15 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 16 | Review and complete | `final-review` | `/create/final-review` |
| 15 | Review and complete | `final-review` | `/create/final-review` |
| 16 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 17 | Review and complete | `completed` | `/create/completed` |
**Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)).
@@ -83,13 +83,13 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
**Exit on template preview.** Direct catalog / marketing entry (`/create/review-template/[slug]` without `?fromFlow=1`) has nothing to save — top-nav **Exit** clears client state and goes home, with no save-progress modal and no leave confirm. In-flow (`?fromFlow=1`) still has community progress, so Exit keeps the usual save / leave-confirm path.
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and skips custom-rule authoring. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and skips custom-rule authoring. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. Continue on final review opens **Invite your members**. Finishing there runs **`handleFinalize → publishRule`**. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**Entering a template before community stage is done.** Both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` when the community questionnaire still needs to run.
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, setup questions, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
- **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/final-review`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, setup questions, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
- **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/final-review`. Back returns to template review.
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
+2
View File
@@ -226,6 +226,8 @@ export function contentCatalogSlugForFallback(
export const ASSETS = {
// Brand logo
LOGO: "assets/logos/community-rule.svg",
/** 2x cream mark for invitation email (Gmail/Outlook drop SVG). */
LOGO_MARK: "assets/logos/community-rule-mark.png",
// Avatars
AVATAR_1: "assets/marketing/avatar-1.png",
+113
View File
@@ -561,6 +561,119 @@ export type DuplicateRuleResult =
| { ok: true; id: string; title: string }
| { ok: false; error: string; status: number };
export type MakeCopyRuleResult =
| {
ok: true;
id: string;
title: string;
summary: string | null;
document: Record<string, unknown>;
copiedFromTitle: string;
copiedNameHit: {
categoryName: string;
entryTitle: string;
} | null;
}
| { ok: false; error: string; status: number };
export async function makeCopyPublishedRule(
id: string,
title: string,
): Promise<MakeCopyRuleResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(id)}/make-copy`,
{
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ title }),
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: {
id: string;
title: string;
summary?: string | null;
document?: Record<string, unknown>;
copiedFromTitle?: string;
copiedNameHit?: {
categoryName: string;
entryTitle: string;
} | null;
};
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
const document = rule?.document;
if (
!res.ok ||
!rule ||
typeof rule.id !== "string" ||
typeof rule.title !== "string" ||
!document ||
typeof document !== "object" ||
Array.isArray(document)
) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return {
ok: true,
id: rule.id,
title: rule.title,
summary: typeof rule.summary === "string" ? rule.summary : null,
document,
copiedFromTitle:
typeof rule.copiedFromTitle === "string" ? rule.copiedFromTitle : "",
copiedNameHit: rule.copiedNameHit ?? null,
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export async function resendExpiredStakeholderInvite(
token: string,
email: string,
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
try {
const res = await fetch("/api/invites/rule-stakeholder/resend", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ token, email }),
});
const data = await safeParseJsonResponse(res);
if (!res.ok) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
return {
ok: false as const,
error: fromBody && fromBody !== "Request failed"
? fromBody
: DRAFT_SAVE_NETWORK_ERROR,
retryAfterMs: retryAfterFromResponse(res, data),
};
}
return { ok: true };
} catch {
return { ok: false as const, error: DRAFT_SAVE_NETWORK_ERROR };
}
}
export async function duplicatePublishedRule(
id: string,
): Promise<DuplicateRuleResult> {
+54 -5
View File
@@ -50,6 +50,56 @@ export type FinalReviewCategoryNames = {
conflict: string;
};
const CANONICAL_FACET_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
function nameForGroup(
groupKey: TemplateFacetGroupKey,
names: FinalReviewCategoryNames,
): string {
switch (groupKey) {
case "coreValues":
return names.values;
case "communication":
return names.communication;
case "membership":
return names.membership;
case "decisionApproaches":
return names.decisions;
case "conflictManagement":
return names.conflict;
}
}
/** Always five section rows, in values-first order, including empty ones. */
export function ensureCanonicalFinalReviewRows(
rows: readonly FinalReviewCategoryRowDetailed[],
names: FinalReviewCategoryNames,
): FinalReviewCategoryRowDetailed[] {
const byGroup = new Map<TemplateFacetGroupKey, FinalReviewCategoryRowDetailed>();
for (const row of rows) {
if (row.groupKey == null) continue;
byGroup.set(row.groupKey, {
...row,
name: nameForGroup(row.groupKey, names),
});
}
return CANONICAL_FACET_ORDER.map((groupKey) => {
const existing = byGroup.get(groupKey);
if (existing) return existing;
return {
name: nameForGroup(groupKey, names),
groupKey,
entries: [],
};
});
}
type MethodPreset = { id: string; label: string };
/**
@@ -230,7 +280,7 @@ export function buildFinalReviewCategoryRowsDetailed(
if (entries.length === 0) continue;
rows.push({ name: s.categoryName, groupKey, entries });
}
return rows;
return ensureCanonicalFinalReviewRows(rows, names);
}
const rows: FinalReviewCategoryRowDetailed[] = [
@@ -276,7 +326,7 @@ export function buildFinalReviewCategoryRowsDetailed(
),
},
];
return rows.filter((r) => r.entries.length > 0);
return ensureCanonicalFinalReviewRows(rows, names);
}
/**
@@ -293,9 +343,8 @@ export function buildFinalReviewCategoryRowsDetailed(
* against the curated message `methods[]` list to get the display labels,
* matching what the user saw as chips in-flow.
*
* Empty categories are filtered out so the review card doesn't render headings
* with no chips. If nothing in state resolves to any chip, the caller should
* fall back to the demo categories shipped in `finalReview.json`.
* Empty categories stay on the card so add chips and flags can show. The five
* named rows always render, in values-first order.
*/
export function buildFinalReviewCategoriesFromState(
state: CreateFlowState,
+7
View File
@@ -0,0 +1,7 @@
/** One or two letters from a community name for the Rule card mark. */
export function communityInitialsFromTitle(title: string): string {
const parts = title.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
}
+59
View File
@@ -0,0 +1,59 @@
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
const IDENTITY_KEYS = [
"communityAvatarUrl",
"communityAvatarFileName",
"communityContext",
"communityLocation",
"communitySaveEmail",
"title",
] as const;
export type CopiedNameHit = {
categoryName: string;
entryTitle: string;
};
/** Copy cards and text; drop name, description, photo, and save-email. */
export function stripCopiedRuleDocument(
document: unknown,
): Record<string, unknown> {
if (!document || typeof document !== "object" || Array.isArray(document)) {
return {};
}
const copy = structuredClone(document) as Record<string, unknown>;
for (const key of IDENTITY_KEYS) {
delete copy[key];
}
return copy;
}
function entryHaystack(entry: CommunityRuleSection["entries"][number]): string {
const blockText = (entry.blocks ?? [])
.map((block) => `${block.label} ${block.body}`)
.join("\n");
return `${entry.title}\n${entry.body}\n${blockText}`;
}
/** First line that still names the original community after a copy. */
export function findCopiedNameHit(
document: unknown,
originalName: string,
): CopiedNameHit | null {
const name = originalName.trim();
if (name.length < 2) return null;
const needle = name.toLowerCase();
const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
for (const section of sections) {
for (const entry of section.entries) {
if (entryHaystack(entry).toLowerCase().includes(needle)) {
return {
categoryName: section.categoryName,
entryTitle: entry.title,
};
}
}
}
return null;
}
+90
View File
@@ -0,0 +1,90 @@
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
function missingText(...values: readonly (string | undefined)[]): boolean {
return values.some((value) => (value ?? "").trim().length === 0);
}
function communicationMissingWords(
saved: CommunicationMethodDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.corePrinciple,
saved.logisticsAdmin,
saved.codeOfConduct,
);
}
function membershipMissingWords(
saved: MembershipMethodDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.eligibility,
saved.joiningProcess,
saved.expectations,
);
}
function decisionMissingWords(
saved: DecisionApproachDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.corePrinciple,
saved.stepByStepInstructions,
saved.objectionsDeadlocks,
);
}
function conflictMissingWords(
saved: ConflictManagementDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.corePrinciple,
saved.processProtocol,
saved.restorationFallbacks,
);
}
/**
* A review chip shows a warning when its value is missing meaning or signals,
* or a card is missing its words.
*/
export function finalReviewChipHasWarning(
state: CreateFlowState,
groupKey: TemplateFacetGroupKey | null,
overrideKey: string | null,
): boolean {
if (!groupKey || !overrideKey) return true;
switch (groupKey) {
case "coreValues": {
const saved = state.coreValueDetailsByChipId?.[overrideKey];
return missingText(saved?.meaning, saved?.signals);
}
case "communication":
return communicationMissingWords(
state.communicationMethodDetailsById?.[overrideKey],
);
case "membership":
return membershipMissingWords(
state.membershipMethodDetailsById?.[overrideKey],
);
case "decisionApproaches":
return decisionMissingWords(
state.decisionApproachDetailsById?.[overrideKey],
);
case "conflictManagement":
return conflictMissingWords(
state.conflictManagementDetailsById?.[overrideKey],
);
}
}
+10
View File
@@ -0,0 +1,10 @@
/** Short date for the invited-view facts table (`6 Nov 2026`). */
export function formatInviteLastChanged(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
}
+20
View File
@@ -0,0 +1,20 @@
export const INVITE_COPY_QUERY = "copy";
export const INVITE_COPY_VALUE = "1";
export const CREATE_FLOW_COPIED_QUERY = "copied";
export const CREATE_FLOW_COPIED_VALUE = "1";
export function invitedRulePath(
ruleId: string,
opts?: { copy?: boolean },
): string {
const base = `/invite/${encodeURIComponent(ruleId)}`;
if (opts?.copy) {
return `${base}?${INVITE_COPY_QUERY}=${INVITE_COPY_VALUE}`;
}
return base;
}
export function inviteExpiredPath(token?: string | null): string {
if (!token) return "/invite/expired";
return `/invite/expired?token=${encodeURIComponent(token)}`;
}
+28 -1
View File
@@ -1,7 +1,9 @@
import type { CopiedNameHit } from "./copiedRuleDocument";
/**
* Bridges final-review → completed without query strings, and re-opens a rule
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id]. Profile
* "Manage" links here; "View" uses `/rules/[id]`.
* "Manage" links here; stakeholder "View" uses `/invite/[id]`.
*/
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
@@ -10,6 +12,9 @@ export type StoredLastPublishedRule = {
title: string;
summary?: string | null;
document: Record<string, unknown>;
/** Present after Make a copy; drives the copy-ready banner and lavender surface. */
copiedFromTitle?: string;
copiedNameHit?: CopiedNameHit | null;
};
export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
@@ -39,11 +44,33 @@ export function readLastPublishedRule(): StoredLastPublishedRule | null {
} else {
summary = undefined;
}
let copiedFromTitle: string | undefined;
if (
typeof o.copiedFromTitle === "string" &&
o.copiedFromTitle.trim().length > 0
) {
copiedFromTitle = o.copiedFromTitle;
}
let copiedNameHit: CopiedNameHit | null | undefined;
if (o.copiedNameHit && typeof o.copiedNameHit === "object") {
const hit = o.copiedNameHit as Record<string, unknown>;
if (
typeof hit.categoryName === "string" &&
typeof hit.entryTitle === "string"
) {
copiedNameHit = {
categoryName: hit.categoryName,
entryTitle: hit.entryTitle,
};
}
}
return {
id: o.id,
title: o.title,
summary,
document: doc as Record<string, unknown>,
...(copiedFromTitle ? { copiedFromTitle } : {}),
...(copiedNameHit ? { copiedNameHit } : {}),
};
} catch {
return null;
+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,
};
}
+74
View File
@@ -0,0 +1,74 @@
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
import { formatInviteCountWord } from "./parseInviteEmails";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
import sharedTerms from "../../messages/en/create/sharedTerms.json";
const DESCRIPTION_MAX = 140;
/** Initials for the invite email disc when the group has no photo. */
export function communityInviteInitials(name: string): string {
const words = name
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
if (words.length === 0) return "·";
if (words.length === 1) {
const word = words[0] ?? "";
return word.slice(0, 2).toUpperCase();
}
const first = words[0]?.charAt(0) ?? "";
const second = words[1]?.charAt(0) ?? "";
return `${first}${second}`.toUpperCase();
}
/** Possessive for footer copy (`Share’s` / `Members’`). */
export function communityPossessive(name: string): string {
const trimmed = name.trim();
if (trimmed.length === 0) return "this community’s";
if (/s$/i.test(trimmed)) return `${trimmed}’`;
return `${trimmed}’s`;
}
/** Community description on the invite card, cut at 140 characters. */
export function clipCommunityInviteDescription(
raw: string | null | undefined,
): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
if (trimmed.length <= DESCRIPTION_MAX) return trimmed;
return trimmed.slice(0, DESCRIPTION_MAX).trimEnd();
}
/** Values vs method cards on a published Rule. Shared terms do not count. */
export function inviteRuleValueAndCardCounts(document: unknown): {
values: number;
cards: number;
} {
const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
let values = 0;
let cards = 0;
for (const section of sections) {
const n = section.entries.length;
if (section.categoryName === RULE_SECTION_CATEGORY.values) {
values += n;
} else if (section.categoryName !== sharedTerms.sectionTitle) {
cards += n;
}
}
return { values, cards };
}
/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */
export function inviteRuleCountsLabel(document: unknown): string {
const { values, cards } = inviteRuleValueAndCardCounts(document);
const valuePart = values === 1 ? "1 VALUE" : `${values} VALUES`;
const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`;
return `${valuePart} AND ${cardPart}`;
}
/** Spoken counts for Make a copy (`three values and six cards`). */
export function inviteRuleCountsSpoken(document: unknown): string {
const { values, cards } = inviteRuleValueAndCardCounts(document);
const valueNoun = values === 1 ? "value" : "values";
const cardNoun = cards === 1 ? "card" : "cards";
return `${formatInviteCountWord(values)} ${valueNoun} and ${formatInviteCountWord(cards)} ${cardNoun}`;
}
+5 -1
View File
@@ -8,7 +8,11 @@ export function isChromelessNavigationPath(
if (!pathname) {
return false;
}
if (pathname.startsWith("/create") || pathname === "/login") {
if (
pathname.startsWith("/create") ||
pathname === "/login" ||
pathname.startsWith("/invite")
) {
return true;
}
return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname);
+16 -16
View File
@@ -6,6 +6,11 @@ import {
type WorkshopInquiryMailInput,
} from "../workshopSignupMail";
import { getSmtpUrl } from "./env";
import {
buildRuleStakeholderInviteMail,
stakeholderInviteReplyTo,
type RuleStakeholderInviteMailInput,
} from "./ruleStakeholderInviteMail";
function escapeHtml(value: string): string {
return value
@@ -80,37 +85,32 @@ export async function sendMagicLinkEmail(
): Promise<void> {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"Open this link to sign in (it expires in 60 minutes):",
"Open this link to log in (it expires in 60 minutes):",
"If you did not request this, you can ignore this email.\n\nSent because you saved a draft at communityrule.info",
"Sign in",
"Log in",
);
await sendHtmlMail({
to,
subject: "Sign in to Community Rule",
subject: "Log in to Community Rule",
text,
html,
devLog: `[dev] Magic link for ${to}: ${verifyUrl}`,
});
}
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
/** Stakeholder invite after finishing a Rule (one-time link, same Mailhog pattern as magic link). */
export async function sendRuleStakeholderInviteEmail(
to: string,
verifyUrl: string,
ruleTitle: string,
input: RuleStakeholderInviteMailInput,
): Promise<void> {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
`You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 60 minutes and works once:`,
"If you did not expect this, you can ignore this email.",
"Open the rule",
);
const { subject, text, html } = buildRuleStakeholderInviteMail(input);
const replyTo = stakeholderInviteReplyTo(input.inviterEmail);
await sendHtmlMail({
to,
subject: `You're invited to view a Community Rule: ${ruleTitle}`,
to: input.to,
subject,
text,
html,
devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
...(replyTo ? { replyTo } : {}),
devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`,
});
}
+57
View File
@@ -224,3 +224,60 @@ export async function listProfileRulesForUser(
});
return combined.slice(0, cap);
}
export type StakeholderInviteView = {
ruleId: string;
title: string;
summary: string | null;
document: Record<string, unknown>;
updatedAt: string;
inviterEmail: string | null;
};
/**
* Published rule an accepted stakeholder may read with invited chrome.
* `userId` is set only after the invite link is used.
*/
export async function getAcceptedStakeholderInviteView(opts: {
userId: string;
ruleId: string;
}): Promise<StakeholderInviteView | null> {
if (!isDatabaseConfigured()) return null;
const userId = opts.userId.trim();
const ruleId = opts.ruleId.trim();
if (userId.length === 0 || ruleId.length === 0) return null;
try {
const row = await prisma.ruleStakeholder.findFirst({
where: { ruleId, userId },
select: {
invitedBy: { select: { email: true } },
rule: {
select: {
id: true,
title: true,
summary: true,
document: true,
updatedAt: true,
},
},
},
});
if (!row) return null;
const document =
row.rule.document !== null &&
typeof row.rule.document === "object" &&
!Array.isArray(row.rule.document)
? (row.rule.document as Record<string, unknown>)
: {};
return {
ruleId: row.rule.id,
title: row.rule.title,
summary: row.rule.summary,
document,
updatedAt: row.rule.updatedAt.toISOString(),
inviterEmail: row.invitedBy?.email ?? null,
};
} catch {
return null;
}
}
+271
View File
@@ -0,0 +1,271 @@
import { ASSETS, getAssetPath } from "../assetUtils";
import {
clipCommunityInviteDescription,
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
} from "../create/ruleInviteCardMeta";
export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK);
/** Letter name when neither the inviter nor the rule owner has an email. */
export const RULE_STAKEHOLDER_INVITER_FALLBACK = "Someone from this community";
const MAILBOX_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Reply-To only when the inviter value is a mailbox. */
export function stakeholderInviteReplyTo(
inviterEmail: string,
): string | undefined {
const mailbox = inviterEmail.trim().toLowerCase();
if (!MAILBOX_PATTERN.test(mailbox)) return undefined;
return mailbox;
}
/** Email addresses stay lowercase; any other label is kept for the letter. */
export function stakeholderInviteInviterLabel(inviterEmail: string): string {
const replyTo = stakeholderInviteReplyTo(inviterEmail);
if (replyTo) return replyTo;
const trimmed = inviterEmail.trim();
return trimmed.length > 0 ? trimmed : RULE_STAKEHOLDER_INVITER_FALLBACK;
}
const PREVIEW_TEXT =
"It covers how they talk, who belongs, how they decide and what happens when they disagree.";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function ruleStakeholderInviteCopyUrl(verifyUrl: string): string {
return `${verifyUrl}${verifyUrl.includes("?") ? "&" : "?"}copy=1`;
}
export type RuleStakeholderInviteMailInput = {
to: string;
verifyUrl: string;
origin: string;
communityName: string;
inviterEmail: string;
summary?: string | null;
document?: unknown;
};
export type RuleStakeholderInviteMailContent = {
subject: string;
previewText: string;
text: string;
html: string;
};
export function buildRuleStakeholderInviteMail(
input: RuleStakeholderInviteMailInput,
): RuleStakeholderInviteMailContent {
const communityName = input.communityName.trim() || "A community";
const inviterEmail = stakeholderInviteInviterLabel(input.inviterEmail);
const origin = input.origin.replace(/\/$/, "");
const verifyUrl = input.verifyUrl;
const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl);
const possessive = communityPossessive(communityName);
const description = clipCommunityInviteDescription(input.summary);
const initials = communityInviteInitials(communityName);
const countsLabel = inviteRuleCountsLabel(input.document);
const subject = `${communityName} invited you to read their Rule`;
const intro = `${inviterEmail} sent the invitation. Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.`;
const privacy = `Every Rule starts private. Only the people ${communityName} invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.`;
const ownLink =
"Your own link. Forwarding this email shares it. Ask the community to invite anyone else who should read the Rule.";
const copyLead =
"Writing a Rule for another community? Make a copy of this one and change what does not fit.";
const why = `You got this email because ${inviterEmail} added your address to ${possessive} Rule on CommunityRule. If you do not know the community, you can ignore this email.`;
const madeBy =
"CommunityRule is made by the Media Economies Design Lab at the University of Colorado Boulder.";
const privacyUrl = `${origin}/privacy`;
const aboutUrl = `${origin}/about`;
const logoUrl = `${origin}${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`;
const text = [
subject,
"",
intro,
"",
"Read the Rule",
verifyUrl,
"",
privacy,
"",
ownLink,
"",
copyLead,
copyUrl,
"",
why,
"",
madeBy,
`Privacy ${privacyUrl}`,
].join("\n");
const html = inviteHtml({
subject,
intro,
verifyUrl,
copyUrl,
communityName,
description,
initials,
countsLabel,
privacy,
ownLink,
copyLead,
why,
madeBy,
privacyUrl,
aboutUrl,
logoUrl,
});
return { subject, previewText: PREVIEW_TEXT, text, html };
}
function inviteHtml(opts: {
subject: string;
intro: string;
verifyUrl: string;
copyUrl: string;
communityName: string;
description: string;
initials: string;
countsLabel: string;
privacy: string;
ownLink: string;
copyLead: string;
why: string;
madeBy: string;
privacyUrl: string;
aboutUrl: string;
logoUrl: string;
}): string {
const heading = escapeHtml(opts.subject);
const intro = escapeHtml(opts.intro);
const verifyHref = escapeHtml(opts.verifyUrl);
const copyHref = escapeHtml(opts.copyUrl);
const name = escapeHtml(opts.communityName);
const description = escapeHtml(opts.description);
const initials = escapeHtml(opts.initials);
const counts = escapeHtml(opts.countsLabel);
const privacyHtml = privacyHtmlWithLead(opts.privacy);
const ownLinkHtml = leadInHtml("Your own link.", opts.ownLink);
const copyHtml =
`<strong style="color:#000000;">Writing a Rule for another community?</strong>` +
` <a href="${copyHref}" style="color:#000000;text-decoration:underline;">Make a copy</a>` +
` of this one and change what does not fit.`;
const why = escapeHtml(opts.why);
const madeBy = escapeHtml(opts.madeBy);
const privacyUrl = escapeHtml(opts.privacyUrl);
const aboutUrl = escapeHtml(opts.aboutUrl);
const logoUrl = escapeHtml(opts.logoUrl);
const cardBody = description
? `<p style="margin:16px 0 0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:18px;font-weight:500;color:#000000;">${description}</p>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:0;background:#e4e4e4;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(
PREVIEW_TEXT,
)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#e4e4e4;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:600px;background:#ffffff;">
<tr>
<td style="background:#000000;padding:24px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:22px;line-height:27px;color:#ffffff;padding-right:6px;">CommunityRule</td>
<td>
<img src="${logoUrl}" width="27" height="27" alt="" style="display:block;border:0;width:27px;height:27px;" />
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:40px 32px 56px;">
<h1 style="margin:0 0 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:36px;line-height:44px;font-weight:700;color:#000000;">${heading}</h1>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:18px;line-height:24px;color:#303030;">${intro}</p>
<p style="margin:0 0 16px;">
<a href="${verifyHref}" style="display:inline-block;background:#141414;color:#ffffff;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:20px;font-weight:500;text-decoration:none;border-radius:9999px;padding:12px 16px;">Read the Rule</a>
</p>
<p style="margin:0 0 4px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">Or paste this link into a browser</p>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;">
<a href="${verifyHref}" style="color:#000000;text-decoration:underline;">${verifyHref}</a>
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#c9fef9;border-radius:12px;">
<tr>
<td style="padding:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-bottom:1px solid #000000;">
<tr>
<td width="74" valign="middle" style="padding:8px 8px 8px 4px;">
<div style="width:56px;height:56px;border-radius:9999px;background:#fffdd2;font-family:Inter,Arial,sans-serif;font-size:24px;line-height:56px;font-weight:500;color:#000000;text-align:center;">${initials}</div>
</td>
<td valign="middle" style="border-left:1px solid #000000;padding:12px 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:24px;line-height:32px;font-weight:700;color:#000000;">${name}</td>
</tr>
</table>
${cardBody}
<p style="margin:16px 0 0;">
<span style="display:inline-block;background:#303030;color:#c9fef9;font-family:Inter,Arial,sans-serif;font-size:10px;line-height:12px;font-weight:500;letter-spacing:0.2px;text-transform:uppercase;border-radius:2px;padding:2px 4px;">${counts}</span>
</p>
</td>
</tr>
</table>
<div style="height:1px;background:#e4e4e4;margin:32px 0;"></div>
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${privacyHtml}</p>
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${ownLinkHtml}</p>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${copyHtml}</p>
<div style="border-top:1px solid #e4e4e4;padding-top:24px;">
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${why}</p>
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${madeBy}</p>
<p style="margin:0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">
<a href="${privacyUrl}" style="color:#464646;text-decoration:underline;">Privacy</a>
&nbsp;&nbsp;
<a href="${aboutUrl}" style="color:#464646;text-decoration:underline;">About CommunityRule</a>
</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function privacyHtmlWithLead(privacy: string): string {
const lead = "Every Rule starts private.";
if (privacy.startsWith(lead)) {
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
privacy.slice(lead.length),
)}`;
}
return escapeHtml(privacy);
}
function leadInHtml(lead: string, full: string): string {
if (full.startsWith(lead)) {
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
full.slice(lead.length),
)}`;
}
return escapeHtml(full);
}
+12 -1
View File
@@ -18,8 +18,11 @@ export async function createRuleStakeholderInviteAndSendMail(opts: {
origin: string;
ruleId: string;
ruleTitle: string;
summary?: string | null;
document?: unknown;
email: string;
invitedByUserId: string;
inviterEmail: string;
pepper: string;
}): Promise<{ ok: true } | { ok: false }> {
const token = newSessionToken();
@@ -38,7 +41,15 @@ export async function createRuleStakeholderInviteAndSendMail(opts: {
const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token);
try {
await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle);
await sendRuleStakeholderInviteEmail({
to: opts.email,
verifyUrl,
origin: opts.origin,
communityName: opts.ruleTitle,
inviterEmail: opts.inviterEmail,
summary: opts.summary,
document: opts.document,
});
return { ok: true };
} catch (err) {
logRouteError(opts.scope, opts.requestId, err, {
@@ -223,6 +223,25 @@ export const postRuleStakeholderBodySchema = z.object({
email: stakeholderEmailSchema,
});
export const makeCopyRuleBodySchema = z.object({
title: z
.string()
.max(48)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
});
export type MakeCopyRuleBody = z.infer<typeof makeCopyRuleBodySchema>;
export const resendExpiredStakeholderInviteBodySchema = z.object({
token: z.string().min(10).max(200),
email: stakeholderEmailSchema,
});
export type ResendExpiredStakeholderInviteBody = z.infer<
typeof resendExpiredStakeholderInviteBodySchema
>;
export type PostRuleStakeholderBody = z.infer<
typeof postRuleStakeholderBodySchema
>;
@@ -7,6 +7,7 @@
"chipClose": "Close",
"chipSelect": "Select {label}",
"chipDeselect": "Deselect {label}",
"chipMissingWords": "{label}, missing words",
"selectPlaceholder": "Choose an option",
"selectAriaLabel": "Select an option",
"multiSelectAddDefault": "Add organization type",
+1 -1
View File
@@ -12,7 +12,7 @@
"confirmDetails": "Confirm details",
"confirmDescription": "Confirm description",
"confirmSize": "Confirm size",
"publishCommunityRule": "Publish CommunityRule",
"publishCommunityRule": "Finish",
"confirmStakeholders": "Confirm Stakeholders",
"confirmCoreValues": "Confirm values",
"confirmCommunication": "Confirm",
@@ -1,8 +1,14 @@
{
"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.",
"copyReadyToastTitle": "Your copy is ready",
"copyReadyToastDescription": "It started from {possessive} Rule. Read it through and change what does not fit before you invite your members.",
"copyNameHitTitle": "One line names {name}",
"copyNameHitDescription": "It is in {entry} under {category}.",
"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 publishing, or publish a rule first.",
"shareNoRuleDescription": "Open this page from your profile after you finish, or finish a rule first.",
"shareLinkCopiedTitle": "Link copied",
"shareLinkCopiedDescription": "Paste the link anywhere to share your public CommunityRule.",
"shareCopyFailedTitle": "Could not copy link",
@@ -16,7 +22,7 @@
"exportEmptyDocumentTitle": "Could not export",
"exportEmptyDocumentDescription": "Your rule document is not available to export yet.",
"guestInvitesSkippedTitle": "Stakeholder invites were not sent",
"guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.",
"guestClaimHintTitle": "Sign in to keep this rule on your profile",
"guestClaimHintDescription": "Sign in on this same browser to attach the rule to your account so you can edit it later."
"guestInvitesSkippedDescription": "Log in to invite people by email. Until then, share the public link to this CommunityRule.",
"guestClaimHintTitle": "Log in to keep this rule on your profile",
"guestClaimHintDescription": "Log in on this same browser to attach the rule to your account so you can edit it later."
}
@@ -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}",
@@ -1,9 +1,15 @@
{
"title": "Review your CommunityRule",
"description": "Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again.",
"title": "Review your Rule",
"description": "Here's what other people will see.",
"editPublishedTitle": "Edit your CommunityRule",
"editPublishedDescription": "Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule.",
"editPublishedDescription": "Update what others see on your Rule. Save & Exit or Finish applies the changes.",
"ruleCardTitleFallback": "Your community",
"addValue": "Add value",
"addCard": "Add card",
"flags": {
"noValues": "No values yet. You can add them any time.",
"noCard": "No card yet. Add one before you finish."
},
"chipEditModal": {
"saveButton": "Save",
"readOnlyCloseButton": "Close",
@@ -35,7 +41,7 @@
},
{
"name": "Membership",
"chips": ["Open Admission"]
"chips": ["Open admission"]
},
{
"name": "Decision-making",
@@ -1,6 +1,6 @@
{
"publishBannerTitle": "Couldn't publish",
"missingCommunityName": "Add a community name before publishing.",
"publishButtonPublishing": "Publishing…",
"publishBannerTitle": "Couldn't finish",
"missingCommunityName": "Add a community name before you finish.",
"publishButtonPublishing": "Finishing…",
"genericPublishFailed": "Something went wrong. Try again."
}
+1 -1
View File
@@ -2,7 +2,7 @@
"errors": {
"generic": "Something went wrong while uploading. Try again.",
"tooLarge": "That file is too large. Community photos can be up to 5 MB; attachments up to 10 MB.",
"unauthorized": "Sign in to upload files. Use Save progress if you started without an account.",
"unauthorized": "Log in to upload files. Use Save progress if you started without an account.",
"misconfigured": "Uploads are not available on this server yet.",
"empty": "That file is empty. Choose a file that has content.",
"svg": "SVG files aren't supported. Export a JPEG, PNG, WebP, or GIF instead.",
+2
View File
@@ -34,6 +34,7 @@ import login from "./pages/login.json";
import profile from "./pages/profile.json";
import notFoundPage from "./pages/notFoundPage.json";
import ruleDetail from "./pages/ruleDetail.json";
import invite from "./pages/invite.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
@@ -114,6 +115,7 @@ export default {
profile,
notFoundPage,
ruleDetail,
invite,
},
create: {
community: {
+10 -2
View File
@@ -50,6 +50,14 @@
"title": "Log in",
"description": "Enter your email and we'll send you a magic link to sign in. No password needed!"
},
"invite": {
"title": "Invitation",
"description": "Read a CommunityRule you were invited to see."
},
"inviteExpired": {
"title": "This link has expired",
"description": "Ask for a new invitation link to read the Rule."
},
"profile": {
"title": "Profile",
"description": "Manage your CommunityRules, saved progress, and account."
@@ -157,8 +165,8 @@
"keywords": ["terms of service", "license", "communityrule"]
},
"cookies": {
"title": "Cookies Settings",
"description": "CommunityRule uses cookies to keep you signed in and to let you claim a rule you published without an account.",
"title": "Cookies",
"description": "CommunityRule uses cookies to keep you logged in and to let you claim a rule you finished without logging in.",
"keywords": ["cookies", "session", "privacy", "communityrule"]
}
}
+6 -6
View File
@@ -1,21 +1,21 @@
{
"_comment": "Cookies Settings. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle.",
"_comment": "Cookies. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle. The footer does not link here.",
"banner": {
"title": "Cookies Settings",
"description": "CommunityRule uses cookies to keep you signed in and to let you claim a rule you published without an account.",
"title": "Cookies",
"description": "CommunityRule uses cookies to keep you logged in and to let you claim a rule you finished without logging in.",
"author": "Media Economies Design Lab",
"date": "2026-08-15"
},
"updated": "Last updated August 15, 2026.",
"intro": [
"CommunityRule sets a cookie named cr_session after you sign in with a magic link. It keeps you signed in for up to 30 days. If you publish a CommunityRule without signing in, we also set cr_rule_claim so that signing in later on this browser can attach that rule to your account. There are no advertising or analytics cookies to turn off."
"CommunityRule sets a cookie named cr_session after you log in with a magic link. It keeps you logged in for up to 30 days. Set when you finish a Rule without logging in: we also set cr_rule_claim so that logging in later on this browser can attach that rule to your account. There are no advertising or analytics cookies to turn off."
],
"sections": [
{
"heading": "The session cookie",
"paragraphs": [
"Sign out to clear the session cookie on this browser. Blocking it will sign you out and keep you from staying signed in. Browsing templates does not set this cookie.",
"The claim cookie is set only when you publish without an account. It is httpOnly, lasts up to 30 days, and is cleared after a successful sign-in claim. Clearing this site’s cookies means you cannot attach that guest-published rule to an account from this browser."
"Log out to clear the session cookie on this browser. Blocking it will log you out and keep you from staying logged in. Browsing templates does not set this cookie.",
"Set when you finish a Rule without logging in. It is httpOnly, lasts up to 30 days, and is cleared after a successful login claim. Clearing this site’s cookies means you cannot attach that guest-finished rule to an account from this browser."
]
},
{
+50
View File
@@ -0,0 +1,50 @@
{
"_comment": "Invited Rule reader, Make a copy, and expired invitation link.",
"makeACopy": "Make a copy",
"makeACopyAria": "Make a copy of this Rule",
"navAria": "Invitation",
"bannerTitle": "{email} invited you to read this Rule",
"bannerTitleNoEmail": "You were invited to read this Rule",
"bannerDescription": "Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.",
"downloadPdf": "Download PDF",
"print": "Print",
"factsHeading": "About this Rule",
"whoCanEdit": "Who can edit",
"whoCanEditUnknown": "The person who invited you",
"whoCanSee": "Who can see it",
"whoCanSeeValue": "Only people the community invites",
"lastChanged": "Last changed",
"copyCardTitle": "Start a Rule from this one",
"copyCardBody": "You get its {counts} word for word. The name, description, photo, invited people and history stay with the original.",
"privacy": "Every Rule starts private. Only the people the community invites can read it until the person who holds the pen turns on a link.",
"exportFailedTitle": "Could not export",
"exportFailedDescription": "Something went wrong. Refresh the page and try again.",
"dialogTitle": "Make a copy for your community",
"dialogDescription": "Your copy starts with {possessive} {counts} word for word.",
"communityNameLabel": "Community name",
"communityNamePlaceholder": "Enter community name",
"characterCountTemplate": "{current}/{max}",
"comesWithTitle": "Comes with the copy",
"comesWithBody": "The cards and the text on them.",
"staysWithTitle": "Stays with the original",
"staysWithBody": "The name, description, photo, invited people and history.",
"saveTo": "The copy saves to {email}.",
"cancel": "Cancel",
"confirm": "Make a copy",
"copyFailed": "Could not make a copy. Try again.",
"nameRequired": "Add your community’s name to continue.",
"expiredTitle": "This link has expired",
"expiredDescription": "Enter the email the invitation was sent to. We’ll send a new link that works for 60 minutes.",
"emailLabel": "Email address",
"emailPlaceholder": "you@example.com",
"sendNewLink": "Send a new link",
"notInvitedAddress": "Not the address you were invited at?",
"backToHome": "← Back to home",
"sentTitle": "Check your email",
"sentBody": "If that address still has an invitation, we sent a new link. Open it within 60 minutes.",
"emailInvalid": "Enter a valid email address.",
"genericError": "Something went wrong. Try again.",
"rateLimited": "Too many requests. Try again in {seconds} seconds.",
"missingToken": "This page needs the original invitation link. Ask the person who invited you to send it again.",
"loadingFallback": "Loading…"
}
+10 -7
View File
@@ -1,16 +1,19 @@
{
"title": "Log in to CommunityRule",
"subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!",
"subtitle": "Enter your email and we'll send you a magic link to log in. No password needed!",
"saveProgressTitle": "Save your progress?",
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.",
"keepRuleTitle": "Sign in to keep this rule on your profile",
"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",
"sendMagicLink": "Send me a magic link",
"successTitle": "Check your email",
"successBody": "We sent a sign-in link. Open it on this device to continue. If you don't see it, check spam or promotions.",
"successBody": "We sent a login link. Open it on this device to continue. If you don't see it, check spam or promotions.",
"legalPrefix": "By continuing, you agree to our ",
"legalAnd": " and ",
"legalSuffix": ".",
@@ -21,9 +24,9 @@
"emailInvalid": "Enter a valid email address.",
"rateLimited": "Too many requests. Try again in {seconds} seconds.",
"generic": "Something went wrong. Try again.",
"invalidLink": "That sign-in link is not valid. Request a new one from the login page.",
"expiredLink": "That sign-in link has expired. Request a new one from the login page.",
"invalidLink": "That login link is not valid. Request a new one from the login page.",
"expiredLink": "That login link has expired. Request a new one from the login page.",
"serverError": "Something went wrong on our end. Try again later.",
"stakeholderWrongAccount": "Sign out and open the stakeholder invite link again, or use the same email you were invited with."
"stakeholderWrongAccount": "Log out and open the invite link again, or use the same email you were invited with."
}
}
+3 -3
View File
@@ -14,14 +14,14 @@
{
"heading": "What we collect",
"paragraphs": [
"Your email, to sign you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account. If you publish without signing in, a claim cookie on this browser so a later sign-in can attach that Rule to your account.",
"Your email, to log you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account. If you finish a Rule without logging in, a claim cookie on this browser so a later login can attach that Rule to your account.",
"We do not sell this information or use it for advertising. As a public university project, some records may be subject to the Colorado Open Records Act."
]
},
{
"heading": "Where it is stored",
"paragraphs": [
"Accounts, drafts, published Rules, uploads, and the recommendation table live on servers MEDLab operates. The app is not hosted on a third-party cloud app platform.",
"Accounts, drafts, finished Rules, uploads, and the recommendation table live on servers MEDLab operates. The app is not hosted on a third-party cloud app platform.",
"Sign-in and invite emails are sent through MEDLab’s mail system, which relays through Amazon SES to deliver them. Ordinary server logs may include the request URL and IP address."
]
},
@@ -35,7 +35,7 @@
{
"heading": "Your account",
"paragraphs": [
"You can sign out, delete a Rule you published, or delete your account from your profile. Deleting your account removes your login and draft. Published Rules are public and stay public unless you delete them first.",
"You can log out, delete a Rule you finished, or delete your account from your profile. Logging out clears the session cookie on this browser. Deleting your account deletes every Rule you made. The people you invited lose access to them.",
"Questions: [medlab@colorado.edu](mailto:medlab@colorado.edu)."
]
}
+12 -10
View File
@@ -1,21 +1,23 @@
{
"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",
"optionChangeEmail": "Change your account email",
"intro": "Manage your CommunityRules, saved progress, and account.",
"signInPrompt": "Sign in to view your profile and saved rules.",
"signInPrompt": "Log in to view your profile and saved rules.",
"signInCta": "Log in",
"loading": "Loading…",
"yourRulesHeading": "Your CommunityRules",
"yourRulesEmpty": "You have not published a rule yet. Create one to see it here.",
"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.",
@@ -26,7 +28,7 @@
"manageRule": "Manage",
"duplicate": "Duplicate",
"deleteRule": "Delete",
"deleteRuleConfirm": "Delete this published rule? This cannot be undone.",
"deleteRuleConfirm": "Delete this finished rule? This cannot be undone.",
"deleteRuleModalTitle": "Delete this rule?",
"deleteRuleModalBody": "This cannot be undone.",
"deleteRuleCancel": "Cancel",
@@ -50,13 +52,13 @@
"emailChangeVerifyInvalid": "That confirmation link is not valid.",
"emailChangeVerifyTaken": "That address was claimed by another account while you were confirming. Try a different email.",
"emailChangeRateLimited": "Too many requests. Try again in about {{seconds}} seconds.",
"signOut": "Sign out",
"signOut": "Log out",
"deleteAccount": "Delete account",
"deleteAccountIntro": "Permanently delete your account and sign-in data. Your published rules will remain visible without an owner name.",
"deleteAccountIntro": "This deletes your account and every Rule you made. The people you invited lose access to them. Export any Rule you want to keep first.",
"deleteAccountCancel": "Cancel",
"deleteAccountConfirm": "Delete my account",
"deleteAccountModalTitle": "Delete your account?",
"deleteAccountModalBody": "This will remove your account and sign you out. Published rules you created stay public as anonymous community rules.",
"deleteAccountModalBody": "This deletes your account and every Rule you made. The people you invited lose access to them. Export any Rule you want to keep first.",
"actionError": "Something went wrong. Try again.",
"rulesLoadBannerTitle": "Could not load your CommunityRules",
"rulesLoadBannerDescription": "Check your connection and refresh the page.",
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

@@ -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.",
},
},
},
+10
View File
@@ -64,4 +64,14 @@ describe("Chip", () => {
expect(chip).toHaveAttribute("aria-pressed", "true");
expect(chip.querySelector("svg[aria-hidden]")).not.toBeNull();
});
it("shows a warning icon and names the missing words", () => {
render(
<Chip label="Signal" state="unselected" hasWarning />,
);
const chip = screen.getByRole("button", {
name: "Signal, missing words",
});
expect(chip.querySelector("svg")).not.toBeNull();
});
});
+41 -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,16 +120,34 @@ 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();
});
it("shows copy-ready toast when copied query is set", () => {
sessionStorage.setItem(
CREATE_FLOW_LAST_PUBLISHED_KEY,
JSON.stringify({
...storedRuleFixture,
copiedFromTitle: "Juniper Street Food Share",
}),
);
mockSearchParams({ copied: "1" });
render(<CompletedScreen />);
expect(screen.getByText("Your copy is ready")).toBeInTheDocument();
expect(
screen.getByText(
"It started from Juniper Street Food Share’s Rule. Read it through and change what does not fit before you invite your members.",
),
).toBeInTheDocument();
});
it("shows post-publish toast in status region when celebrate query is set", () => {
mockSearchParams({
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
@@ -123,20 +155,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();
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { ExpiredInviteScreen } from "../../app/(app)/invite/_components/ExpiredInviteScreen";
const { navMock, resendMock } = vi.hoisted(() => ({
navMock: {
searchParams: new URLSearchParams("token=abcdefghijkl"),
push: vi.fn(),
},
resendMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: navMock.push,
replace: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
useSearchParams: () => navMock.searchParams,
usePathname: () => "/invite/expired",
}));
vi.mock("../../lib/create/api", () => ({
resendExpiredStakeholderInvite: (...args: unknown[]) => resendMock(...args),
}));
describe("ExpiredInviteScreen", () => {
beforeEach(() => {
navMock.searchParams = new URLSearchParams("token=abcdefghijkl");
navMock.push.mockReset();
resendMock.mockReset();
resendMock.mockResolvedValue({ ok: true });
});
it("asks for the invited email and sends a new link", async () => {
const user = userEvent.setup();
render(<ExpiredInviteScreen />);
expect(
screen.getByRole("heading", { name: "This link has expired" }),
).toBeInTheDocument();
await user.type(
screen.getByLabelText("Email address"),
"inv@example.com",
);
await user.click(
screen.getByRole("button", { name: "Send a new link" }),
);
expect(resendMock).toHaveBeenCalledWith(
"abcdefghijkl",
"inv@example.com",
);
expect(
await screen.findByRole("heading", { name: "Check your email" }),
).toBeInTheDocument();
});
});
+24 -16
View File
@@ -14,7 +14,9 @@ import { clearCoreValueDetailsLocalStorage } from "../../app/(app)/create/utils/
import type { CreateFlowState } from "../../app/(app)/create/types";
function catalogChipName(label: string) {
return new RegExp(`^(Select|Deselect) ${label}$`);
return new RegExp(
`^((Select|Deselect) ${label}|${label}, missing words)$`,
);
}
async function confirmDiscardCustomizeEdits() {
@@ -75,7 +77,7 @@ describe("FinalReviewScreen", () => {
render(<FinalReviewScreen />);
expect(
screen.getByRole("heading", {
name: "Review your CommunityRule",
name: "Review your Rule",
}),
).toBeInTheDocument();
});
@@ -83,9 +85,7 @@ describe("FinalReviewScreen", () => {
it("renders lockup description", () => {
render(<FinalReviewScreen />);
expect(
screen.getByText(
/Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again./i,
),
screen.getByText("Here's what other people will see."),
).toBeInTheDocument();
});
@@ -182,11 +182,19 @@ describe("FinalReviewScreen", () => {
expect(screen.getByText("Conflict management")).toBeInTheDocument();
});
it("renders category chips", () => {
it("shows add chips and flags when no cards or values are picked", () => {
render(<FinalReviewScreen />);
expect(screen.getByText("Consciousness")).toBeInTheDocument();
expect(screen.getByText("Signal")).toBeInTheDocument();
expect(screen.getByText("Open Admission")).toBeInTheDocument();
expect(
screen.getByText("No values yet. You can add them any time."),
).toBeInTheDocument();
expect(
screen.getAllByText("No card yet. Add one before you finish.").length,
).toBe(4);
expect(
screen.getByRole("button", { name: "Add value" }),
).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Add card" })).toHaveLength(4);
expect(screen.queryByText("Consciousness")).not.toBeInTheDocument();
});
});
@@ -225,10 +233,10 @@ describe("FinalReviewScreen — prefilled selections", () => {
expect(screen.getByText("Lazy consensus")).toBeInTheDocument();
expect(screen.getByText("Conflict ladder")).toBeInTheDocument();
// Demo chips from `finalReview.json` must not leak through once the
// user has real selections. The fallback still says "Open Admission".
expect(screen.queryByText("Open Admission")).not.toBeInTheDocument();
expect(screen.queryByText("Consciousness")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Signal, missing words" }),
).toBeInTheDocument();
});
it("warns when binding arbitration sits next to a join that records no yes", async () => {
@@ -1288,20 +1296,20 @@ describe("FinalReviewScreen — edit published title and description", () => {
).toBeInTheDocument();
expect(
screen.getByText(
/Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule./i,
/Update what others see on your Rule. Save & Exit or Finish applies the changes./i,
),
).toBeInTheDocument();
});
it("does not expose click-to-edit title or description on default final review", () => {
it("exposes click-to-edit title and description on default final review", () => {
render(
<FinalReviewWithFlowState
title="Oak"
communityContext="Visible body"
/>,
);
expect(screen.queryByTestId("rule-title-edit")).not.toBeInTheDocument();
expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument();
expect(screen.getByTestId("rule-title-edit")).toBeInTheDocument();
expect(screen.getByTestId("rule-description-edit")).toBeInTheDocument();
});
it("opens Save modal from title click and updates title", async () => {
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import { InvitedRuleView } from "../../app/(app)/invite/_components/InvitedRuleView";
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
}));
const document = {
sections: [
{
categoryName: "Values",
entries: [
{
title: "Care",
body: "We care for each other.",
},
],
},
],
};
describe("InvitedRuleView", () => {
it("names the inviter by email and offers Make a copy", () => {
render(
<InvitedRuleView
ruleId="rule-1"
title="Juniper Street Food Share"
summary="Neighbors sharing food."
document={document}
updatedAt="2026-11-03T00:00:00.000Z"
inviterEmail="maya@example.com"
viewerEmail="reader@example.com"
countsSpoken="one value and zero cards"
/>,
);
expect(
screen.getByText("maya@example.com invited you to read this Rule"),
).toBeInTheDocument();
expect(
screen.getByText(
"Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.",
),
).toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: "Make a copy of this Rule" })
.length,
).toBeGreaterThan(0);
expect(
screen.getByText("Only people the community invites"),
).toBeInTheDocument();
expect(
screen.getByText(
"Every Rule starts private. Only the people the community invites can read it until the person who holds the pen turns on a link.",
),
).toBeInTheDocument();
expect(screen.getByText("Care")).toBeInTheDocument();
});
});
+42 -4
View File
@@ -124,7 +124,7 @@ describe("LoginForm", () => {
expect(
await screen.findByRole("heading", { name: /check your email/i }),
).toBeInTheDocument();
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
expect(screen.getByText(/we sent a login link/i)).toBeInTheDocument();
expect(screen.getByText(/check spam or promotions/i)).toBeInTheDocument();
});
@@ -191,7 +191,7 @@ describe("LoginForm", () => {
);
expect(
screen.getByRole("heading", {
name: /sign in to keep this rule on your profile/i,
name: /log in to keep this rule on your profile/i,
}),
).toBeInTheDocument();
await user.type(
@@ -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();
@@ -300,7 +338,7 @@ describe("LoginForm", () => {
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
expect(
screen.getByText(/that sign-in link has expired/i),
screen.getByText(/that login link has expired/i),
).toBeInTheDocument();
});
@@ -308,7 +346,7 @@ describe("LoginForm", () => {
navMock.searchParams = new URLSearchParams("error=invalid_link");
renderLoginForm();
expect(
screen.getByText(/that sign-in link is not valid/i),
screen.getByText(/that login link is not valid/i),
).toBeInTheDocument();
});
+14
View File
@@ -287,4 +287,18 @@ describe("MultiSelect – behaviour specifics", () => {
);
expect(screen.getByText("Pick one")).toBeInTheDocument();
});
it("shows a flag after the add chip in the same row", () => {
render(
<MultiSelect
options={[]}
addButton
addButtonText="Add card"
flag="No card yet. Add one before you finish."
/>,
);
expect(
screen.getByText("No card yet. Add one before you finish."),
).toBeInTheDocument();
});
});
+1 -1
View File
@@ -46,7 +46,7 @@ describe("CreateFlowTextFieldScreen (community description)", () => {
<CreateFlowTextFieldScreen
messageNamespace="create.community.communityContext"
stateField="communityContext"
locationField="communityLocation"
locationField
maxLength={200}
multiline
/>,
+52 -1
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>
@@ -192,7 +203,7 @@ describe("AuthModalProvider (header overlay)", () => {
});
expect(
screen.getByRole("heading", {
name: /sign in to keep this rule on your profile/i,
name: /log in to keep this rule on your profile/i,
}),
).toBeInTheDocument();
await user.type(
@@ -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();
});
});
+36 -22
View File
@@ -10,9 +10,21 @@ const NAMES = {
conflict: "Conflict management",
};
function rowsWith(
chipsByName: Partial<Record<string, string[]>>,
): { name: string; chips: string[] }[] {
return [
"Values",
"Communication",
"Membership",
"Decision-making",
"Conflict management",
].map((name) => ({ name, chips: chipsByName[name] ?? [] }));
}
describe("buildFinalReviewCategoriesFromState", () => {
it("returns [] when state has no selections and no sections", () => {
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual([]);
it("returns five empty rows when state has no selections and no sections", () => {
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual(rowsWith({}));
});
it("resolves method ids to labels from message presets", () => {
@@ -33,7 +45,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
expect(byName.get("Membership")).toEqual(["Open admission"]);
expect(byName.get("Decision-making")).toEqual(["Lazy consensus"]);
expect(byName.get("Conflict management")).toEqual(["Conflict ladder"]);
expect(byName.has("Values")).toBe(false);
expect(byName.get("Values")).toEqual([]);
});
it("derives core values via buildCoreValuesForDocument (snapshot + selected ids)", () => {
@@ -46,9 +58,9 @@ describe("buildFinalReviewCategoriesFromState", () => {
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility", "Resilience"] },
]);
expect(rows).toEqual(
rowsWith({ Values: ["Accessibility", "Resilience"] }),
);
});
it("drops unknown ids silently instead of inserting empty labels", () => {
@@ -56,7 +68,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["signal", "bogus-id"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
expect(rows).toEqual(rowsWith({ Communication: ["Signal"] }));
});
it("resolves user-authored method ids from customMethodCardMetaById", () => {
@@ -77,7 +89,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["signal", "signal"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
expect(rows).toEqual(rowsWith({ Communication: ["Signal"] }));
});
it("uses section titles for method facets when selections were cleared (use-without-changes)", () => {
@@ -98,10 +110,12 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: [],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Consciousness", "Ecology"] },
{ name: "Communication", chips: ["Signal"] },
]);
expect(rows).toEqual(
rowsWith({
Values: ["Consciousness", "Ecology"],
Communication: ["Signal"],
}),
);
});
it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => {
@@ -115,9 +129,9 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["in-person-meetings"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Communication", chips: ["In-person meetings"] },
]);
expect(rows).toEqual(
rowsWith({ Communication: ["In-person meetings"] }),
);
});
it("shows custom communication chips when sections exist and selections include a UUID", () => {
@@ -153,10 +167,12 @@ describe("buildFinalReviewCategoriesFromState", () => {
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility"] },
{ name: "Communication", chips: ["Signal"] },
]);
expect(rows).toEqual(
rowsWith({
Values: ["Accessibility"],
Communication: ["Signal"],
}),
);
});
it("prefers the chip snapshot over a duplicated Values section in sections", () => {
@@ -177,8 +193,6 @@ describe("buildFinalReviewCategoriesFromState", () => {
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility"] },
]);
expect(rows).toEqual(rowsWith({ Values: ["Accessibility"] }));
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
findCopiedNameHit,
stripCopiedRuleDocument,
} from "../../lib/create/copiedRuleDocument";
describe("stripCopiedRuleDocument", () => {
it("drops name, description, photo, and save-email", () => {
const out = stripCopiedRuleDocument({
title: "Juniper Street Food Share",
communityContext: "A neighborhood share.",
communityAvatarUrl: "https://example.test/a.png",
communityAvatarFileName: "a.png",
communityLocation: "Boulder",
communitySaveEmail: "a@example.com",
sections: [
{
categoryName: "Values",
entries: [{ title: "Care", body: "We care." }],
},
],
});
expect(out.title).toBeUndefined();
expect(out.communityContext).toBeUndefined();
expect(out.communityAvatarUrl).toBeUndefined();
expect(out.communityAvatarFileName).toBeUndefined();
expect(out.communityLocation).toBeUndefined();
expect(out.communitySaveEmail).toBeUndefined();
expect(out.sections).toEqual([
{
categoryName: "Values",
entries: [{ title: "Care", body: "We care." }],
},
]);
});
});
describe("findCopiedNameHit", () => {
it("returns the first entry that still names the original group", () => {
expect(
findCopiedNameHit(
{
sections: [
{
categoryName: "Values",
entries: [
{
title: "Solidarity",
body: "Juniper Street Food Share members share food.",
},
],
},
],
},
"Juniper Street Food Share",
),
).toEqual({
categoryName: "Values",
entryTitle: "Solidarity",
});
});
it("returns null when the name is gone", () => {
expect(
findCopiedNameHit(
{
sections: [
{
categoryName: "Values",
entries: [{ title: "Care", body: "We care." }],
},
],
},
"Juniper Street Food Share",
),
).toBeNull();
});
});
+6 -4
View File
@@ -20,10 +20,12 @@ describe("create footer messages", () => {
expect(messages.create.footer.confirmSize).toBe("Confirm size");
});
it("exposes publishCommunityRule for the final-review footer CTA", () => {
expect(messages.create.footer.publishCommunityRule).toBe(
"Publish CommunityRule",
);
it("exposes Continue for the final-review footer CTA", () => {
expect(messages.create.footer.continue).toBe("Continue");
});
it("exposes Finish for the edit-rule footer CTA", () => {
expect(messages.create.footer.publishCommunityRule).toBe("Finish");
});
it("exposes Continue and Skip for the setup questions step", () => {
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { isFinalReviewContinueBlocked } from "../../app/(app)/create/utils/createFlowFooterLabels";
import { communityInitialsFromTitle } from "../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../lib/create/finalReviewChipWarnings";
import type { CreateFlowState } from "../../app/(app)/create/types";
const completeCards: CreateFlowState = {
title: "Oak Park Commons",
selectedCommunicationMethodIds: ["signal"],
selectedMembershipMethodIds: ["open-admission"],
selectedDecisionApproachIds: ["lazy-consensus"],
selectedConflictManagementIds: ["conflict-ladder"],
};
describe("isFinalReviewContinueBlocked", () => {
it("blocks when the community name is empty", () => {
expect(
isFinalReviewContinueBlocked({
...completeCards,
title: " ",
}),
).toBe(true);
});
it("blocks when any of the four questions has no card", () => {
expect(
isFinalReviewContinueBlocked({
...completeCards,
selectedMembershipMethodIds: [],
}),
).toBe(true);
});
it("does not block when values are empty and the four questions have cards", () => {
expect(isFinalReviewContinueBlocked(completeCards)).toBe(false);
});
});
describe("finalReviewChipHasWarning", () => {
it("warns when a value is missing meaning or signals", () => {
expect(
finalReviewChipHasWarning(
{
coreValueDetailsByChipId: {
"1": { meaning: "Care", signals: "" },
},
},
"coreValues",
"1",
),
).toBe(true);
});
it("warns when a method card has no saved words", () => {
expect(
finalReviewChipHasWarning({}, "communication", "signal"),
).toBe(true);
});
it("does not warn when meaning and signals are both filled", () => {
expect(
finalReviewChipHasWarning(
{
coreValueDetailsByChipId: {
"1": { meaning: "Care", signals: "We talk over people." },
},
},
"coreValues",
"1",
),
).toBe(false);
});
});
describe("communityInitialsFromTitle", () => {
it("uses the first letters of the first two words", () => {
expect(communityInitialsFromTitle("Oak Park Commons")).toBe("OP");
});
it("uses two letters from a single word", () => {
expect(communityInitialsFromTitle("Neighbors")).toBe("NE");
});
});
+25 -23
View File
@@ -16,9 +16,9 @@ import {
} from "../../app/(app)/create/utils/flowSteps";
describe("flowSteps", () => {
it("places confirm-stakeholders immediately before final-review", () => {
const i = FLOW_STEP_ORDER.indexOf("confirm-stakeholders");
const j = FLOW_STEP_ORDER.indexOf("final-review");
it("places final-review immediately before confirm-stakeholders", () => {
const i = FLOW_STEP_ORDER.indexOf("final-review");
const j = FLOW_STEP_ORDER.indexOf("confirm-stakeholders");
expect(i).toBeGreaterThanOrEqual(0);
expect(j).toBe(i + 1);
});
@@ -27,8 +27,8 @@ describe("flowSteps", () => {
expect(getNextStep("communication-methods")).toBe("membership-methods");
expect(getNextStep("membership-methods")).toBe("decision-approaches");
expect(getNextStep("decision-approaches")).toBe("conflict-management");
expect(getNextStep("conflict-management")).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders")).toBe("final-review");
expect(getNextStep("conflict-management")).toBe("final-review");
expect(getNextStep("final-review")).toBe("confirm-stakeholders");
});
it("getNextStep returns null for last step or invalid", () => {
@@ -39,7 +39,8 @@ describe("flowSteps", () => {
});
it("getPreviousStep returns prior step or null", () => {
expect(getPreviousStep("final-review")).toBe("confirm-stakeholders");
expect(getPreviousStep("final-review")).toBe("conflict-management");
expect(getPreviousStep("confirm-stakeholders")).toBe("final-review");
expect(getPreviousStep("informational")).toBeNull();
expect(getPreviousStep(null)).toBeNull();
});
@@ -91,19 +92,17 @@ describe("flowSteps", () => {
expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
});
it("useWithoutChangesIdentityOnly walks name → description → photo → stakeholders", () => {
it("useWithoutChangesIdentityOnly walks name → description → photo → final-review", () => {
const opts = { useWithoutChangesIdentityOnly: true } as const;
expect(getNextStep("community-name", opts)).toBe("community-context");
expect(getNextStep("community-context", opts)).toBe("community-upload");
expect(getNextStep("community-size", opts)).toBe("community-upload");
expect(getNextStep("setup-questions", opts)).toBe("community-upload");
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review");
expect(getNextStep("community-upload", opts)).toBe("final-review");
expect(getNextStep("final-review", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("community-context", opts)).toBe("community-name");
expect(getPreviousStep("community-upload", opts)).toBe("community-context");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
expect(getPreviousStep("final-review", opts)).toBe("community-upload");
expect(getPreviousStep("community-name", opts)).toBeNull();
});
@@ -112,16 +111,14 @@ describe("flowSteps", () => {
skipCommunitySave: true,
useWithoutChangesIdentityOnly: true,
} as const;
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
expect(getNextStep("community-upload", opts)).toBe("final-review");
expect(getPreviousStep("final-review", opts)).toBe("community-upload");
});
it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => {
it("resolveCreateFlowBackTarget returns template review when use-without slug is set on final-review", () => {
expect(
resolveCreateFlowBackTarget(
"confirm-stakeholders",
"final-review",
undefined,
"mutual-aid-mondays",
),
@@ -138,10 +135,10 @@ describe("flowSteps", () => {
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" });
});
it("resolveCreateFlowBackTarget uses photo step behind stakeholders on the identity-only path", () => {
it("resolveCreateFlowBackTarget uses photo step behind final-review on the identity-only path", () => {
expect(
resolveCreateFlowBackTarget(
"confirm-stakeholders",
"final-review",
{ useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays",
),
@@ -151,13 +148,13 @@ describe("flowSteps", () => {
it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => {
expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined),
).toEqual({ kind: "step", step: "conflict-management" });
).toEqual({ kind: "step", step: "final-review" });
});
it("resolveCreateFlowBackTarget ignores whitespace-only slug", () => {
expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, " "),
).toEqual({ kind: "step", step: "conflict-management" });
).toEqual({ kind: "step", step: "final-review" });
});
it("buildTemplateReviewHref encodes slug and optional fromFlow query", () => {
@@ -186,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"),
@@ -200,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 });
@@ -129,7 +129,7 @@ describe("useTemplateReviewActions", () => {
await result.current.handleUseWithoutChanges();
});
expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders");
expect(router.push).toHaveBeenCalledWith("/create/final-review");
expect(applied?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly");
expect(applied?.setupTraits).toEqual({ employer: true });
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
inviteExpiredPath,
invitedRulePath,
} from "../../lib/create/invitePaths";
describe("invitedRulePath", () => {
it("encodes the rule id", () => {
expect(invitedRulePath("ab/c")).toBe("/invite/ab%2Fc");
});
it("appends copy=1", () => {
expect(invitedRulePath("rule-1", { copy: true })).toBe(
"/invite/rule-1?copy=1",
);
});
});
describe("inviteExpiredPath", () => {
it("omits the token when missing", () => {
expect(inviteExpiredPath()).toBe("/invite/expired");
});
it("encodes the token", () => {
expect(inviteExpiredPath("a+b")).toBe("/invite/expired?token=a%2Bb");
});
});
@@ -6,6 +6,8 @@ describe("isChromelessNavigationPath", () => {
["/create", true],
["/create/completed", true],
["/login", true],
["/invite/expired", true],
["/invite/rule-1", true],
["/use-cases/mutual-aid-colorado/rule", true],
["/use-cases/food-not-bombs/rule/", true],
["/", false],
@@ -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");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
clipCommunityInviteDescription,
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
inviteRuleCountsSpoken,
} from "../../lib/create/ruleInviteCardMeta";
describe("communityInviteInitials", () => {
it("uses the first two words", () => {
expect(communityInviteInitials("Juniper Street Food Share")).toBe("JS");
});
it("uses two letters of a single word", () => {
expect(communityInviteInitials("Garden")).toBe("GA");
});
});
describe("communityPossessive", () => {
it("adds a curly ’s when the name does not end in s", () => {
expect(communityPossessive("Juniper Street Food Share")).toBe(
"Juniper Street Food Share’s",
);
});
it("adds only a curly apostrophe when the name ends in s", () => {
expect(communityPossessive("Members")).toBe("Members’");
});
});
describe("clipCommunityInviteDescription", () => {
it("cuts at 140 characters", () => {
const long = "a".repeat(160);
expect(clipCommunityInviteDescription(long)).toHaveLength(140);
});
});
describe("inviteRuleCountsLabel", () => {
it("counts values and other cards, not shared terms", () => {
expect(
inviteRuleCountsLabel({
sections: [
{
categoryName: "Values",
entries: [
{ title: "Care", body: "We care." },
{ title: "Share", body: "We share." },
{ title: "Listen", body: "We listen." },
],
},
{
categoryName: "Communication",
entries: [{ title: "Open channel", body: "We talk in the hall." }],
},
{
categoryName: "Membership",
entries: [{ title: "Open admission", body: "Anyone who shares the work." }],
},
],
}),
).toBe("3 VALUES AND 2 CARDS");
});
});
describe("inviteRuleCountsSpoken", () => {
it("uses lowercase words", () => {
expect(
inviteRuleCountsSpoken({
sections: [
{
categoryName: "Values",
entries: [
{ title: "Care", body: "We care." },
{ title: "Share", body: "We share." },
{ title: "Listen", body: "We listen." },
],
},
{
categoryName: "Communication",
entries: [{ title: "Open channel", body: "How we talk." }],
},
],
}),
).toBe("three values and one card");
});
});
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more