diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index fa465cd..9e46c6b 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -199,9 +199,7 @@ function CreateFlowLayoutContent({ const editingPublishedRuleIdTrimmed = state.editingPublishedRuleId?.trim() ?? ""; const isConfirmStakeholdersManagePublished = - currentStep === "confirm-stakeholders" && - manageStakeholdersIntent && - editingPublishedRuleIdTrimmed.length > 0; + currentStep === "confirm-stakeholders" && manageStakeholdersIntent; const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt(); @@ -277,6 +275,7 @@ function CreateFlowLayoutContent({ : createFlowStepPathWithSyncDraft("final-review"); const { + publishBannerTitle, publishBannerMessage, setPublishBannerMessage, isPublishing, @@ -450,7 +449,10 @@ function CreateFlowLayoutContent({ }, [currentStep]); useEffect(() => { - if (currentStep !== "edit-rule") return; + const editingFromCompleted = + currentStep === "final-review" && + (state.editingPublishedRuleId?.trim() ?? "").length > 0; + if (currentStep !== "edit-rule" && !editingFromCompleted) return; const last = readLastPublishedRule(); if (!last) { router.replace(CREATE_ROUTES.completed); @@ -515,6 +517,33 @@ function CreateFlowLayoutContent({ state.customMethodCardMetaById, ]); + useEffect(() => { + if (currentStep !== "confirm-stakeholders") return; + if (!manageStakeholdersIntent) return; + if (editingPublishedRuleIdTrimmed.length > 0) return; + const last = readLastPublishedRule(); + if (!last) { + router.replace(CREATE_ROUTES.completed); + return; + } + const patch = createFlowStateFromPublishedRule(last); + const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch); + updateState({ + ...patch, + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, + }); + }, [ + currentStep, + manageStakeholdersIntent, + editingPublishedRuleIdTrimmed.length, + router, + updateState, + state.methodSectionsPinCommitted, + ]); + useEffect(() => { if (currentStep !== "completed") { setCompletedFlowBanner(null); @@ -705,6 +734,7 @@ function CreateFlowLayoutContent({ key: "publish", status: "danger" as const, title: + publishBannerTitle ?? messages.create.reviewAndComplete.publish.publishBannerTitle, description: publishBannerMessage, onClose: () => setPublishBannerMessage(null), @@ -862,7 +892,7 @@ function CreateFlowLayoutContent({ editingPublishedRuleId: last.id, sections: [], }); - router.push(createFlowStepPath("edit-rule")); + router.push(createFlowStepPath("final-review")); } : undefined } @@ -1125,12 +1155,14 @@ function CreateFlowLayoutContent({ buttonType="filled" palette="default" size="xsmall" - disabled={isPublishing} + disabled={ + isPublishing || editingPublishedRuleIdTrimmed.length === 0 + } className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { router.push( createFlowStepPathAfterStrippingReviewReturn( - "edit-rule", + reviewReturnTarget ?? "completed", searchParams, ), ); @@ -1141,6 +1173,39 @@ function CreateFlowLayoutContent({ .footerDone } + ) : currentStep === "confirm-stakeholders" ? ( +
+ + +
) : nextStep || isFinalReviewLike ? ( + ) : null} + + ); } 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(null); + const [draftEmail, setDraftEmail] = useState(""); + const [fieldError, setFieldError] = useState(null); + const [pasteResult, setPasteResult] = useState(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(null); - const [stakeholderOptions, setStakeholderOptions] = useState( - () => emailsToChipOptions(state.stakeholderEmails ?? []), + const invitees = useMemo(() => { + const raw = state.stakeholderEmails ?? []; + const owner = ownerEmail; + return raw + .map((email) => email.trim().toLowerCase()) + .filter((email) => email.length > 0 && email !== owner); + }, [state.stakeholderEmails, ownerEmail]); + + const rows = useMemo(() => { + const list: Array<{ email: string; isYou: boolean }> = []; + if (ownerEmail) { + list.push({ email: ownerEmail, isYou: true }); + } + for (const email of invitees) { + list.push({ email, isYou: false }); + } + return list; + }, [ownerEmail, invitees]); + + const visibleRows = expanded + ? rows + : rows.slice(0, VISIBLE_WHEN_COLLAPSED); + const hiddenCount = Math.max(0, rows.length - visibleRows.length); + + const preview = membershipInvitePreview(state); + + const countLine = + rows.length === 1 + ? t("countOne") + : t("countMany").replace("{count}", String(rows.length)); + + const persistInvitees = useCallback( + (next: string[]) => { + updateState({ stakeholderEmails: next }); + }, + [updateState], ); - useEffect(() => { - setStakeholderOptions((prev) => { - const inFlight = prev.filter((c) => c.state === "custom"); - const nextPersisted = emailsToChipOptions(state.stakeholderEmails ?? []); - return [...nextPersisted, ...inFlight]; - }); - }, [persistedKey]); + const addOne = useCallback( + (raw: string): boolean => { + const trimmed = raw.trim().toLowerCase(); + if (!INVITE_EMAIL_PATTERN.test(trimmed)) { + setFieldError(t("invalidEmail")); + return false; + } + if (ownerEmail && trimmed === ownerEmail) { + setFieldError(t("duplicateEmail")); + return false; + } + if (invitees.includes(trimmed)) { + setFieldError(t("duplicateEmail")); + return false; + } + if (invitees.length >= MAX_STAKEHOLDER_EMAILS) { + setFieldError(t("maxStakeholders")); + return false; + } + persistInvitees([...invitees, trimmed]); + return true; + }, + [invitees, ownerEmail, persistInvitees, t], + ); - const handleAddStakeholder = () => { + const handleAdd = () => { markCreateFlowInteraction(); - setChipError(null); - const confirmed = state.stakeholderEmails ?? []; - const customCount = stakeholderOptions.filter( - (o) => o.state === "custom", - ).length; - if (confirmed.length + customCount >= MAX_STAKEHOLDER_EMAILS) { - setChipError(t("maxStakeholders")); - return; + setPasteResult(null); + if (addOne(draftEmail)) { + setDraftEmail(""); + setFieldError(null); } - setStakeholderOptions((prev) => [ - ...prev, - { id: crypto.randomUUID(), label: "", state: "custom" }, - ]); }; - const handleCustomChipConfirm = (chipId: string, value: string) => { + const handlePaste = (event: ClipboardEvent) => { + const text = event.clipboardData.getData("text"); + const tokens = text + .split(/[\s,;]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 0); + if (tokens.length <= 1) return; + + event.preventDefault(); markCreateFlowInteraction(); - setChipError(null); - const trimmed = value.trim().toLowerCase(); - if (!EMAIL_PATTERN.test(trimmed)) { - setChipError(t("invalidEmail")); - return; + setFieldError(null); + + const existing = ownerEmail ? [ownerEmail, ...invitees] : [...invitees]; + const parsed = parseInvitePaste(text, existing, MAX_STAKEHOLDER_EMAILS); + if (parsed.added.length > 0) { + persistInvitees([...invitees, ...parsed.added]); } - const current = state.stakeholderEmails ?? []; - if (current.includes(trimmed)) { - setChipError(t("duplicateEmail")); - setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); - return; + setDraftEmail(parsed.invalidRemainder); + if (parsed.invalidRemainder.length > 0) { + setFieldError(t("invalidEmail")); } - if (current.length >= MAX_STAKEHOLDER_EMAILS) { - setChipError(t("maxStakeholders")); - setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); - return; + + const parts: string[] = []; + if (parsed.added.length > 0) { + const people = + parsed.added.length === 1 ? t("peopleNounOne") : t("peopleNounMany"); + parts.push( + t("pasteAdded") + .replace("{count}", formatInviteCountWord(parsed.added.length)) + .replace("{people}", people), + ); } - setStakeholderOptions((prev) => - prev.map((opt) => - opt.id === chipId - ? { id: trimmed, label: trimmed, state: "selected" as const } - : opt, - ), - ); - updateState({ stakeholderEmails: [...current, trimmed] }); + const invalidCount = parsed.invalidRemainder + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0).length; + if (invalidCount === 1) { + parts.push(t("pasteInvalidOne")); + } else if (invalidCount > 1) { + parts.push( + t("pasteInvalidMany").replace( + "{count}", + formatInviteCountWord(invalidCount), + ), + ); + } + setPasteResult(parts.length > 0 ? parts.join(" ") : null); }; - const handleCustomChipClose = (chipId: string) => { + const handleRemove = (email: string) => { markCreateFlowInteraction(); - setChipError(null); - setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); + setPasteResult(null); + persistInvitees(invitees.filter((item) => item !== email)); }; - const handleChipClick = (chipId: string) => { - markCreateFlowInteraction(); - setChipError(null); - setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); - updateState({ - stakeholderEmails: (state.stakeholderEmails ?? []).filter( - (e) => e !== chipId, - ), - }); - }; + const header = ( +
+ + {preview ? ( + + ) : null} +
+ ); - if (managePublishedMode) { + if (manageStakeholdersIntent) { return ( - - } - > - + + {editingPublishedRuleId.length > 0 ? ( + + ) : null} ); } return ( - <> - - } - > - {chipError ? ( -

- {chipError} + +

+
+
+ { + setDraftEmail(event.target.value); + setFieldError(null); + setPasteResult(null); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + handleAdd(); + }} + /> +
+
+ +
+
+ + {pasteResult ? ( +
+ +

+ {pasteResult} +

+
+ ) : null} + + {rows.length > 0 ? ( +
+ {visibleRows.map((row) => ( + handleRemove(row.email) + } + /> + ))} + {hiddenCount > 0 ? ( +
+ { + markCreateFlowInteraction(); + setExpanded(true); + }} + > + {t("showMore").replace("{count}", String(hiddenCount))} + +
+ ) : null} +
+ ) : null} + + {rows.length > 0 ? ( +

+ {countLine}

) : null} - - - - {!toastDismissed && ( -
- setToastDismissed(true)} - className="w-full !px-[var(--space-600)] !py-[var(--space-400)] md:!py-4" - /> -
- )} - +

+ {t("privacy")} +

+
+
); } diff --git a/app/(app)/create/screens/select/PublishedStakeholdersManagePanel.tsx b/app/(app)/create/screens/select/PublishedStakeholdersManagePanel.tsx index 0e451d6..07f851a 100644 --- a/app/(app)/create/screens/select/PublishedStakeholdersManagePanel.tsx +++ b/app/(app)/create/screens/select/PublishedStakeholdersManagePanel.tsx @@ -129,28 +129,28 @@ export function PublishedStakeholdersManagePanel({ {t("managePublished.empty")}

) : ( -
    +
      {items.map((row) => (
    • -
      - +
      + {row.email} - + {row.status === "pending" ? t("managePublished.pending") : t("managePublished.accepted")}
      -
      +
      {row.status === "pending" ? (
    )} -
    +
    - +
    + +
    ); diff --git a/app/(app)/create/utils/createFlowScreenRegistry.ts b/app/(app)/create/utils/createFlowScreenRegistry.ts index ff3f146..1e8d6eb 100644 --- a/app/(app)/create/utils/createFlowScreenRegistry.ts +++ b/app/(app)/create/utils/createFlowScreenRegistry.ts @@ -131,7 +131,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< }, "confirm-stakeholders": { layoutKind: "select", - figmaNodeId: "21104-46594", + figmaNodeId: "25336-62720", messageNamespace: "create.reviewAndComplete.confirmStakeholders", centeredBodyBelowMd: false, }, diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index b5ea210..92348b7 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -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; diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx index 3d05b03..55da5d4 100644 --- a/app/(app)/profile/ProfilePageClient.tsx +++ b/app/(app)/profile/ProfilePageClient.tsx @@ -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, + }); + 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} diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index 925c38f..f8b8970 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -63,6 +63,7 @@ export type ProfilePageViewProps = { onCloseDeleteRule: () => void; onConfirmDeleteRule: () => void; onDuplicateRule: (id: string) => void; + onInviteMembers: (id: string) => void; onContinueDraft: () => void; onDeleteDraft: () => void; onCloseDeleteDraft: () => void; @@ -185,6 +186,7 @@ export function ProfilePageView({ onCloseDeleteRule, onConfirmDeleteRule, onDuplicateRule, + onInviteMembers, onContinueDraft, onDeleteDraft, onCloseDeleteDraft, @@ -332,6 +334,11 @@ export function ProfilePageView({ expanded size={ruleCardSize} hasBottomLinks + bottomStatusLabel={ + rule.role === "stakeholder" + ? undefined + : t("finishedBadge") + } bottomLinks={ rule.role === "stakeholder" ? [ @@ -343,14 +350,9 @@ export function ProfilePageView({ ] : [ { - id: "view", - label: t("viewPublic"), - href: `/rules/${encodeURIComponent(rule.id)}`, - }, - { - id: "manage", - label: t("manageRule"), - href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, + id: "invite", + label: t("inviteMembers"), + onClick: () => onInviteMembers(rule.id), }, { id: "dup", diff --git a/app/components/modals/Login/Login.container.tsx b/app/components/modals/Login/Login.container.tsx index 5de2e7f..f343e4c 100644 --- a/app/components/modals/Login/Login.container.tsx +++ b/app/components/modals/Login/Login.container.tsx @@ -24,6 +24,9 @@ const LoginContainer = memo( const backdropRef = useRef(null); const previousActiveElementRef = useRef(null); const [portalReady, setPortalReady] = useState(() => !usePortal); + const [viewportHeight, setViewportHeight] = useState( + 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( 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( portalReady={portalReady} usePortal={usePortal} backdropVariant={backdropVariant} + viewportHeight={viewportHeight} > {children} diff --git a/app/components/modals/Login/Login.types.ts b/app/components/modals/Login/Login.types.ts index 784ba64..310a178 100644 --- a/app/components/modals/Login/Login.types.ts +++ b/app/components/modals/Login/Login.types.ts @@ -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; } diff --git a/app/components/modals/Login/Login.view.tsx b/app/components/modals/Login/Login.view.tsx index 281cdcf..74fec90 100644 --- a/app/components/modals/Login/Login.view.tsx +++ b/app/components/modals/Login/Login.view.tsx @@ -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 = (
    { 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()} > @@ -52,7 +59,10 @@ export function LoginView({
    {belowCard ? ( -
    e.stopPropagation()}> +
    e.stopPropagation()} + > {belowCard}
    ) : null} diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 2699de3..827358b 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -44,7 +44,11 @@ function MailIconInline() { const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export type LoginFormVariant = "default" | "saveProgress" | "keepRule"; +export type LoginFormVariant = + | "default" + | "saveProgress" + | "keepRule" + | "invite"; function loginFormHeading( variant: LoginFormVariant, @@ -66,6 +70,12 @@ function loginFormHeading( description: t("keepRuleSubtitle"), }; } + if (variant === "invite") { + return { + title: t("inviteTitle"), + description: t("inviteSubtitle"), + }; + } return { title: t("title"), description: t("subtitle") }; } @@ -74,7 +84,11 @@ function shouldAttachCreateFlowDraft( nextPath: string, ): boolean { if (variant === "keepRule") return false; - return variant === "saveProgress" || nextPath.includes("syncDraft=1"); + return ( + variant === "saveProgress" || + variant === "invite" || + nextPath.includes("syncDraft=1") + ); } export type LoginFormProps = { @@ -266,46 +280,48 @@ export default function LoginForm({ {emailError}

    ) : null} - - {onDismiss ? ( +
    - ) : null} -

    - {t("legalPrefix")} - - {tFooter("legal.termsOfService")} - - {t("legalAnd")} - - {tFooter("legal.privacyPolicy")} - - {t("legalSuffix")} -

    + {onDismiss ? ( + + ) : null} +

    + {t("legalPrefix")} + + {tFooter("legal.termsOfService")} + + {t("legalAnd")} + + {tFooter("legal.privacyPolicy")} + + {t("legalSuffix")} +

    +
    ) : null}
    diff --git a/app/contexts/AuthModalContext.tsx b/app/contexts/AuthModalContext.tsx index bc715a2..55d4364 100644 --- a/app/contexts/AuthModalContext.tsx +++ b/app/contexts/AuthModalContext.tsx @@ -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(null); export function AuthModalProvider({ children }: { children: ReactNode }) { + const tLogin = useTranslation("pages.login"); const [open, setOpen] = useState(false); const [opts, setOpts] = useState({}); @@ -72,6 +78,17 @@ export function AuthModalProvider({ children }: { children: ReactNode }) { backdropVariant={backdropVariant} usePortal ariaLabelledBy="login-modal-heading" + belowCard={ + opts.variant === "invite" ? ( + + ) : undefined + } > 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, + }; +} diff --git a/lib/create/parseInviteEmails.ts b/lib/create/parseInviteEmails.ts new file mode 100644 index 0000000..42b0293 --- /dev/null +++ b/lib/create/parseInviteEmails.ts @@ -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, + }; +} diff --git a/messages/en/create/reviewAndComplete/completed.json b/messages/en/create/reviewAndComplete/completed.json index 34f1035..ee2309c 100644 --- a/messages/en/create/reviewAndComplete/completed.json +++ b/messages/en/create/reviewAndComplete/completed.json @@ -1,6 +1,8 @@ { - "toastTitle": "This is what folks see when you share your CommunityRule", - "toastDescription": "Your group can use this document as an operating manual.", + "toastTitle": "Your Rule is finished", + "toastDescription": "This is what the people you invite will see.", + "askOrganizerLead": "Has your Rule changed how your community works?", + "askOrganizerLink": "Tell an organizer what changed.", "shareNoRuleTitle": "Nothing to share yet", "shareNoRuleDescription": "Open this page from your profile after you finish, or finish a rule first.", "shareLinkCopiedTitle": "Link copied", diff --git a/messages/en/create/reviewAndComplete/confirmStakeholders.json b/messages/en/create/reviewAndComplete/confirmStakeholders.json index 27324bb..6bd8c93 100644 --- a/messages/en/create/reviewAndComplete/confirmStakeholders.json +++ b/messages/en/create/reviewAndComplete/confirmStakeholders.json @@ -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}", diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index 7c177cc..eff0895 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -5,6 +5,9 @@ "saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to log in. If you don't save now you could lose your progress.", "keepRuleTitle": "Log in to keep this rule on your profile", "keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.", + "inviteTitle": "Log in to invite your members", + "inviteSubtitle": "Only a logged-in person can invite. Enter your email and we’ll send you a magic link to log in.", + "backToYourRule": "← Back to your Rule", "continueWithoutSaving": "Continue without saving", "emailLabel": "Email address", "emailPlaceholder": "you@example.com", diff --git a/messages/en/pages/profile.json b/messages/en/pages/profile.json index 8949a55..81ba486 100644 --- a/messages/en/pages/profile.json +++ b/messages/en/pages/profile.json @@ -1,9 +1,9 @@ { "pageTitle": "Your profile", "welcomeTitle": "Congrats {{name}}!", - "welcomeBodyFirstRule": "You’ve made your first CommunityRule. Make sure to link it in your community resources and evolve it as your group grows and changes.", + "welcomeBodyFirstRule": "Invite the people in your community to read it.", "welcomeBodyNoRules": "Create your first CommunityRule to capture how your group makes decisions—then link it from your community resources as you grow.", - "yourOptionsHeading": "Your Options", + "yourOptionsHeading": "Your options", "optionCreateCustom": "Create new custom Rule", "optionCreateTemplate": "Create new Rule from template", "optionLogout": "Log out of CommunityRule", @@ -12,10 +12,12 @@ "signInPrompt": "Log in to view your profile and saved rules.", "signInCta": "Log in", "loading": "Loading…", - "yourRulesHeading": "Your CommunityRules", + "yourRulesHeading": "Your Rules", "yourRulesEmpty": "You have not finished a rule yet. Create one to see it here.", "draftHeading": "Saved in progress", - "draftInProgressBadge": "In progress", + "draftInProgressBadge": "Draft", + "finishedBadge": "Finished", + "inviteMembers": "Invite your members", "continueDraft": "Continue", "createHeading": "Create", "createCustomDescription": "Start a new custom rule from the beginning.", diff --git a/stories/pages/ConfirmStakeholdersPage.stories.js b/stories/pages/ConfirmStakeholdersPage.stories.js index 85c2d0c..31ca160 100644 --- a/stories/pages/ConfirmStakeholdersPage.stories.js +++ b/stories/pages/ConfirmStakeholdersPage.stories.js @@ -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.", }, }, }, diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx index 6e0dc01..9834382 100644 --- a/tests/components/CompletedPage.test.tsx +++ b/tests/components/CompletedPage.test.tsx @@ -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(); + expect( + screen.getByText("Has your Rule changed how your community works?"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Tell an organizer what changed." }), + ).toBeInTheDocument(); }); it("renders header and document from sessionStorage", () => { @@ -106,12 +120,12 @@ describe("CompletedScreen", () => { render(); expect( screen.queryByText( - "This is what folks see when you share your CommunityRule", + "Your Rule is finished", ), ).not.toBeInTheDocument(); expect( screen.queryByText( - "Your group can use this document as an operating manual.", + "This is what the people you invite will see.", ), ).not.toBeInTheDocument(); }); @@ -123,20 +137,16 @@ describe("CompletedScreen", () => { }); render(); 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); }); diff --git a/tests/components/ConfirmStakeholdersPage.test.tsx b/tests/components/ConfirmStakeholdersPage.test.tsx index a878344..8428235 100644 --- a/tests/components/ConfirmStakeholdersPage.test.tsx +++ b/tests/components/ConfirmStakeholdersPage.test.tsx @@ -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(); + return { + ...actual, + fetchAuthSession: vi.fn(async () => ({ + user: { id: "u1", email: "maya@example.org" }, + })), + }; +}); + +function SeedCreateFlow({ patch }: { patch: Partial }) { + 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(); 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(); expect( - screen.getByRole("button", { name: "Add stakeholder" }), - ).toBeInTheDocument(); - }); - - it("shows draft toast and can dismiss it", async () => { - const user = userEvent.setup(); - render(); - 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(); + 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(); + 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( + <> + + + , + ); + 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(); + }); }); diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 07e1eaf..2c368d7 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -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( + + + , + ); + 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(); diff --git a/tests/contexts/AuthModalContext.test.tsx b/tests/contexts/AuthModalContext.test.tsx index 663bd12..84fbc22 100644 --- a/tests/contexts/AuthModalContext.test.tsx +++ b/tests/contexts/AuthModalContext.test.tsx @@ -87,6 +87,17 @@ function LoginTrigger() { > Open keep rule then leave + @@ -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( + + + , + ); + 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"); + }); }); diff --git a/tests/pages/profile.test.tsx b/tests/pages/profile.test.tsx index ac62914..88ffb34 100644 --- a/tests/pages/profile.test.tsx +++ b/tests/pages/profile.test.tsx @@ -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( + , + ); + + 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(); + }); }); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index c96567a..652b58c 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -183,7 +183,7 @@ describe("flowSteps", () => { ).toBeNull(); }); - it("parseReviewReturnSearchParam accepts final-review, edit-rule, and review", () => { + it("parseReviewReturnSearchParam accepts final-review, edit-rule, review, and completed", () => { expect( parseReviewReturnSearchParam( new URLSearchParams("reviewReturn=final-review"), @@ -197,6 +197,11 @@ describe("flowSteps", () => { expect( parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")), ).toBe("review"); + expect( + parseReviewReturnSearchParam( + new URLSearchParams("reviewReturn=completed"), + ), + ).toBe("completed"); expect( parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")), ).toBeNull(); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx index 126555d..6934b21 100644 --- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -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 }); diff --git a/tests/unit/membershipInvitePreview.test.ts b/tests/unit/membershipInvitePreview.test.ts new file mode 100644 index 0000000..2dc04cf --- /dev/null +++ b/tests/unit/membershipInvitePreview.test.ts @@ -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.", + ); + }); +}); diff --git a/tests/unit/parseInviteEmails.test.ts b/tests/unit/parseInviteEmails.test.ts new file mode 100644 index 0000000..0940c65 --- /dev/null +++ b/tests/unit/parseInviteEmails.test.ts @@ -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"); + }); +});