From 6e2e1915ae0e9e9706d2fefdb742269c4f02599b Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:36:34 -0700 Subject: [PATCH 1/6] Show the whole Rule on one review card, with quiet flags for missing cards and Continue once each question has one. --- app/(app)/create/CreateFlowLayoutClient.tsx | 29 ++- .../create/hooks/useCreateFlowNavigation.ts | 2 +- .../create/hooks/useTemplateReviewActions.ts | 4 +- .../screens/review/FinalReviewScreen.tsx | 170 +++++++----------- .../create/utils/createFlowFooterLabels.ts | 48 ++++- .../utils/createFlowProportionProgress.ts | 2 +- .../create/utils/createFlowScreenRegistry.ts | 12 +- app/(app)/create/utils/flowSteps.ts | 10 +- app/components/cards/Rule/Rule.types.ts | 4 + app/components/cards/Rule/Rule.view.tsx | 3 +- .../controls/Chip/Chip.container.tsx | 11 +- app/components/controls/Chip/Chip.types.ts | 6 + app/components/controls/Chip/Chip.view.tsx | 10 +- .../MultiSelect/MultiSelect.container.tsx | 3 + .../controls/MultiSelect/MultiSelect.types.ts | 9 + .../controls/MultiSelect/MultiSelect.view.tsx | 33 +++- docs/create-flow.md | 12 +- lib/create/buildFinalReviewCategories.ts | 59 +++++- lib/create/communityInitials.ts | 7 + lib/create/finalReviewChipWarnings.ts | 90 ++++++++++ messages/en/components/controlsChrome.json | 1 + .../create/reviewAndComplete/finalReview.json | 12 +- tests/components/Chip.test.tsx | 10 ++ tests/components/FinalReviewPage.test.tsx | 38 ++-- tests/components/MultiSelect.test.tsx | 14 ++ tests/unit/buildFinalReviewCategories.test.ts | 58 +++--- tests/unit/createFooterMessages.test.ts | 6 +- tests/unit/finalReviewContinue.test.ts | 83 +++++++++ tests/unit/flowSteps.test.ts | 41 ++--- .../hooks/useTemplateReviewActions.test.tsx | 2 +- 30 files changed, 571 insertions(+), 218 deletions(-) create mode 100644 lib/create/communityInitials.ts create mode 100644 lib/create/finalReviewChipWarnings.ts create mode 100644 tests/unit/finalReviewContinue.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 33d3f3c..fa465cd 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -90,6 +90,7 @@ import { isCommunityNameStepBlocked, isCommunitySizeStepBlocked, isCommunityStructureStepBlocked, + isFinalReviewContinueBlocked, } from "./utils/createFlowFooterLabels"; import { EMPTY_SETUP_TRAITS, @@ -912,7 +913,7 @@ function CreateFlowLayoutContent({ progressBar={ !isTemplateReviewRoute && !isCreateTemplatesPickerRoute && - !isFinalReviewLike && + currentStep !== "edit-rule" && reviewReturnTarget !== "edit-rule" } proportionBarProgress={proportionBarProgress} @@ -1146,14 +1147,22 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={ - isPublishing || (isFinalReviewLike && !sessionResolved) + isPublishing || + (isEditRuleStep && !sessionResolved) || + (currentStep === "final-review" && + isFinalReviewContinueBlocked(state)) } className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { - if (isFinalReviewLike) { + if (isEditRuleStep) { void handleFinalize(); return; } + if (currentStep === "final-review") { + if (isFinalReviewContinueBlocked(state)) return; + goToNextStep(); + return; + } markAttempted(); if ( isCommunityStructureStepBlocked( @@ -1180,12 +1189,14 @@ function CreateFlowLayoutContent({ goToNextStep(); }} > - {isFinalReviewLike - ? isPublishing - ? messages.create.reviewAndComplete.publish - .publishButtonPublishing - : footer.publishCommunityRule - : getDefaultFooterLabel(currentStep, footer)} + {currentStep === "final-review" + ? footer.continue + : isFinalReviewLike + ? isPublishing + ? messages.create.reviewAndComplete.publish + .publishButtonPublishing + : footer.publishCommunityRule + : getDefaultFooterLabel(currentStep, footer)} ) : null } diff --git a/app/(app)/create/hooks/useCreateFlowNavigation.ts b/app/(app)/create/hooks/useCreateFlowNavigation.ts index 92780e1..70e3a1f 100644 --- a/app/(app)/create/hooks/useCreateFlowNavigation.ts +++ b/app/(app)/create/hooks/useCreateFlowNavigation.ts @@ -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 diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index e44321b..ae70e53 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -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; }; @@ -268,7 +268,7 @@ export function useTemplateReviewActions({ }); router.push( fromCreateWizard - ? createFlowStepPath("confirm-stakeholders") + ? createFlowStepPath("final-review") : createFlowStepPath("community-name"), ); }, [ diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 7f2bcc1..c1d57b3 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -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, - })), - })), + values: get(0), + communication: get(1), + membership: get(2), + decisions: get(3), + conflict: get(4), }; } @@ -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({ 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,28 +312,24 @@ export function FinalReviewScreen({ onClose={() => setActiveReadOnlyDetail(null)} detail={activeReadOnlyDetail} /> - {variant === "editPublished" ? ( - <> - setTitleModalOpen(false)} - initialValue={rawTitleForModal} - onSave={(value) => { - markCreateFlowInteraction(); - updateState({ title: value }); - }} - /> - setCommunityContextModalOpen(false)} - initialValue={rawCommunityContextForModal} - onSave={(value) => { - markCreateFlowInteraction(); - updateState({ communityContext: value, summary: value }); - }} - /> - - ) : null} + setTitleModalOpen(false)} + initialValue={rawTitleForModal} + onSave={(value) => { + markCreateFlowInteraction(); + updateState({ title: value }); + }} + /> + setCommunityContextModalOpen(false)} + initialValue={rawCommunityContextForModal} + onSave={(value) => { + markCreateFlowInteraction(); + updateState({ communityContext: value, summary: value }); + }} + /> ); } diff --git a/app/(app)/create/utils/createFlowFooterLabels.ts b/app/(app)/create/utils/createFlowFooterLabels.ts index 865126a..54392d1 100644 --- a/app/(app)/create/utils/createFlowFooterLabels.ts +++ b/app/(app)/create/utils/createFlowFooterLabels.ts @@ -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, diff --git a/app/(app)/create/utils/createFlowProportionProgress.ts b/app/(app)/create/utils/createFlowProportionProgress.ts index c30899b..68c95a8 100644 --- a/app/(app)/create/utils/createFlowProportionProgress.ts +++ b/app/(app)/create/utils/createFlowProportionProgress.ts @@ -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; diff --git a/app/(app)/create/utils/createFlowScreenRegistry.ts b/app/(app)/create/utils/createFlowScreenRegistry.ts index f435d80..ff3f146 100644 --- a/app/(app)/create/utils/createFlowScreenRegistry.ts +++ b/app/(app)/create/utils/createFlowScreenRegistry.ts @@ -123,18 +123,18 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< messageNamespace: "create.customRule.conflictManagement", centeredBodyBelowMd: false, }, + "final-review": { + layoutKind: "review", + figmaNodeId: "20907-213155", + messageNamespace: "create.reviewAndComplete.finalReview", + centeredBodyBelowMd: false, + }, "confirm-stakeholders": { layoutKind: "select", figmaNodeId: "21104-46594", messageNamespace: "create.reviewAndComplete.confirmStakeholders", centeredBodyBelowMd: false, }, - "final-review": { - layoutKind: "review", - figmaNodeId: "20907-212767", - messageNamespace: "create.reviewAndComplete.finalReview", - centeredBodyBelowMd: false, - }, "edit-rule": { layoutKind: "review", figmaNodeId: "20907-212767", diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index 08d284c..b5ea210 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -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([ "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 ) { diff --git a/app/components/cards/Rule/Rule.types.ts b/app/components/cards/Rule/Rule.types.ts index 1bca614..78bd705 100644 --- a/app/components/cards/Rule/Rule.types.ts +++ b/app/components/cards/Rule/Rule.types.ts @@ -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?: ( diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx index 687cc41..d56bba6 100644 --- a/app/components/cards/Rule/Rule.view.tsx +++ b/app/components/cards/Rule/Rule.view.tsx @@ -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" /> ))} diff --git a/app/components/controls/Chip/Chip.container.tsx b/app/components/controls/Chip/Chip.container.tsx index cb3bcc6..0d9951f 100644 --- a/app/components/controls/Chip/Chip.container.tsx +++ b/app/components/controls/Chip/Chip.container.tsx @@ -21,6 +21,7 @@ const ChipContainer = memo( onRemove, onCheck, onClose, + hasWarning = false, ariaLabel, }) => { const t = useTranslation("controlsChrome"); @@ -89,11 +90,19 @@ const ChipContainer = memo( 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")} diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts index f0d2974..e341981 100644 --- a/app/components/controls/Chip/Chip.types.ts +++ b/app/components/controls/Chip/Chip.types.ts @@ -48,6 +48,11 @@ export interface ChipProps { * Optional callback when custom chip is closed/removed. */ onClose?: (event: React.MouseEvent) => 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) => void; onCheck?: (value: string, event: React.MouseEvent) => void; onClose?: (event: React.MouseEvent) => void; + hasWarning?: boolean; inputValue?: string; onInputChange?: (value: string) => void; onInputKeyDown?: (event: React.KeyboardEvent) => void; diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 1f1a394..361953a 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -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 ? ( + + ) : isSelected ? ( ( onAddClick, addButton: addButtonProp = true, addButtonText, + flag, formHeader = true, helperText, errorMessage, @@ -45,11 +46,13 @@ const MultiSelectContainer = memo( 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} diff --git a/app/components/controls/MultiSelect/MultiSelect.types.ts b/app/components/controls/MultiSelect/MultiSelect.types.ts index 9f8a076..02b5a08 100644 --- a/app/components/controls/MultiSelect/MultiSelect.types.ts +++ b/app/components/controls/MultiSelect/MultiSelect.types.ts @@ -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; diff --git a/app/components/controls/MultiSelect/MultiSelect.view.tsx b/app/components/controls/MultiSelect/MultiSelect.view.tsx index ce6bcaa..67b7f15 100644 --- a/app/components/controls/MultiSelect/MultiSelect.view.tsx +++ b/app/components/controls/MultiSelect/MultiSelect.view.tsx @@ -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,13 +97,19 @@ function MultiSelectView({ palette={palette} size={chipSize} disabled={chipDisabled} + hasWarning={option.hasWarning} ariaLabel={ isCustom || !option.label ? undefined - : (isSelected - ? chipDeselectAriaLabel - : chipSelectAriaLabel - ).replace("{label}", option.label) + : option.hasWarning + ? chipMissingWordsAriaLabel.replace( + "{label}", + option.label, + ) + : (isSelected + ? chipDeselectAriaLabel + : chipSelectAriaLabel + ).replace("{label}", option.label) } onClick={() => { if (!isCustom && !chipDisabled && onChipClick) { @@ -180,6 +189,22 @@ function MultiSelectView({ )} )} + {flag ? ( + + + {flag} + + ) : null} {errorMessage ? (

(); + 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, diff --git a/lib/create/communityInitials.ts b/lib/create/communityInitials.ts new file mode 100644 index 0000000..6e6d7f6 --- /dev/null +++ b/lib/create/communityInitials.ts @@ -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(); +} diff --git a/lib/create/finalReviewChipWarnings.ts b/lib/create/finalReviewChipWarnings.ts new file mode 100644 index 0000000..aa1e7c5 --- /dev/null +++ b/lib/create/finalReviewChipWarnings.ts @@ -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], + ); + } +} diff --git a/messages/en/components/controlsChrome.json b/messages/en/components/controlsChrome.json index d60b4a9..cd14c12 100644 --- a/messages/en/components/controlsChrome.json +++ b/messages/en/components/controlsChrome.json @@ -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", diff --git a/messages/en/create/reviewAndComplete/finalReview.json b/messages/en/create/reviewAndComplete/finalReview.json index ee0cde5..77ca4b3 100644 --- a/messages/en/create/reviewAndComplete/finalReview.json +++ b/messages/en/create/reviewAndComplete/finalReview.json @@ -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.", "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", diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx index 5bfb662..787b63b 100644 --- a/tests/components/Chip.test.tsx +++ b/tests/components/Chip.test.tsx @@ -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( + , + ); + const chip = screen.getByRole("button", { + name: "Signal, missing words", + }); + expect(chip.querySelector("svg")).not.toBeNull(); + }); }); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 25b9237..102cb29 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -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(); expect( screen.getByRole("heading", { - name: "Review your CommunityRule", + name: "Review your Rule", }), ).toBeInTheDocument(); }); @@ -83,9 +85,7 @@ describe("FinalReviewScreen", () => { it("renders lockup description", () => { render(); 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(); - 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 () => { @@ -1293,15 +1301,15 @@ describe("FinalReviewScreen — edit published title and description", () => { ).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( , ); - 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 () => { diff --git a/tests/components/MultiSelect.test.tsx b/tests/components/MultiSelect.test.tsx index ac8ca39..b86abd2 100644 --- a/tests/components/MultiSelect.test.tsx +++ b/tests/components/MultiSelect.test.tsx @@ -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( + , + ); + expect( + screen.getByText("No card yet. Add one before you finish."), + ).toBeInTheDocument(); + }); }); diff --git a/tests/unit/buildFinalReviewCategories.test.ts b/tests/unit/buildFinalReviewCategories.test.ts index f9f893d..3ae1ad5 100644 --- a/tests/unit/buildFinalReviewCategories.test.ts +++ b/tests/unit/buildFinalReviewCategories.test.ts @@ -10,9 +10,21 @@ const NAMES = { conflict: "Conflict management", }; +function rowsWith( + chipsByName: Partial>, +): { 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"] })); }); }); diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts index bbbbd7b..a655911 100644 --- a/tests/unit/createFooterMessages.test.ts +++ b/tests/unit/createFooterMessages.test.ts @@ -20,7 +20,11 @@ describe("create footer messages", () => { expect(messages.create.footer.confirmSize).toBe("Confirm size"); }); - it("exposes publishCommunityRule for the final-review footer CTA", () => { + it("exposes Continue for the final-review footer CTA", () => { + expect(messages.create.footer.continue).toBe("Continue"); + }); + + it("exposes publishCommunityRule for the edit-rule footer CTA", () => { expect(messages.create.footer.publishCommunityRule).toBe( "Publish CommunityRule", ); diff --git a/tests/unit/finalReviewContinue.test.ts b/tests/unit/finalReviewContinue.test.ts new file mode 100644 index 0000000..2351fcf --- /dev/null +++ b/tests/unit/finalReviewContinue.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index d833026..c96567a 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -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", () => { diff --git a/tests/unit/hooks/useTemplateReviewActions.test.tsx b/tests/unit/hooks/useTemplateReviewActions.test.tsx index 458f276..2f36b56 100644 --- a/tests/unit/hooks/useTemplateReviewActions.test.tsx +++ b/tests/unit/hooks/useTemplateReviewActions.test.tsx @@ -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 }); From 88bfccd1e755a8a905ac7deb263d9266626c98c3 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:39:00 -0700 Subject: [PATCH 2/6] Say Finish instead of Publish, and match login, cookies, privacy, and account-delete copy to the decided wording. --- lib/server/mail.ts | 6 +++--- messages/en/create/footer.json | 2 +- .../en/create/reviewAndComplete/completed.json | 8 ++++---- .../en/create/reviewAndComplete/finalReview.json | 2 +- messages/en/create/reviewAndComplete/publish.json | 6 +++--- messages/en/create/upload.json | 2 +- messages/en/metadata.json | 4 ++-- messages/en/pages/cookies.json | 12 ++++++------ messages/en/pages/login.json | 14 +++++++------- messages/en/pages/privacy.json | 6 +++--- messages/en/pages/profile.json | 12 ++++++------ tests/components/FinalReviewPage.test.tsx | 2 +- tests/components/LoginForm.test.tsx | 8 ++++---- tests/contexts/AuthModalContext.test.tsx | 2 +- tests/unit/createFooterMessages.test.ts | 6 ++---- 15 files changed, 45 insertions(+), 47 deletions(-) diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 181024e..fc02755 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -80,13 +80,13 @@ export async function sendMagicLinkEmail( ): Promise { 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}`, diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json index eb0c060..644239c 100644 --- a/messages/en/create/footer.json +++ b/messages/en/create/footer.json @@ -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", diff --git a/messages/en/create/reviewAndComplete/completed.json b/messages/en/create/reviewAndComplete/completed.json index 51c7274..34f1035 100644 --- a/messages/en/create/reviewAndComplete/completed.json +++ b/messages/en/create/reviewAndComplete/completed.json @@ -2,7 +2,7 @@ "toastTitle": "This is what folks see when you share your CommunityRule", "toastDescription": "Your group can use this document as an operating manual.", "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 +16,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." } diff --git a/messages/en/create/reviewAndComplete/finalReview.json b/messages/en/create/reviewAndComplete/finalReview.json index 77ca4b3..59a6508 100644 --- a/messages/en/create/reviewAndComplete/finalReview.json +++ b/messages/en/create/reviewAndComplete/finalReview.json @@ -2,7 +2,7 @@ "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", diff --git a/messages/en/create/reviewAndComplete/publish.json b/messages/en/create/reviewAndComplete/publish.json index 34362e9..94ff407 100644 --- a/messages/en/create/reviewAndComplete/publish.json +++ b/messages/en/create/reviewAndComplete/publish.json @@ -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." } diff --git a/messages/en/create/upload.json b/messages/en/create/upload.json index 2e46eb0..244c14e 100644 --- a/messages/en/create/upload.json +++ b/messages/en/create/upload.json @@ -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.", diff --git a/messages/en/metadata.json b/messages/en/metadata.json index 21d2de0..682c911 100644 --- a/messages/en/metadata.json +++ b/messages/en/metadata.json @@ -157,8 +157,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"] } } diff --git a/messages/en/pages/cookies.json b/messages/en/pages/cookies.json index 7416759..f26d42d 100644 --- a/messages/en/pages/cookies.json +++ b/messages/en/pages/cookies.json @@ -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." ] }, { diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index 9fb560d..7c177cc 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -1,16 +1,16 @@ { "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.", "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 +21,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." } } diff --git a/messages/en/pages/privacy.json b/messages/en/pages/privacy.json index 5d58890..6dd10c6 100644 --- a/messages/en/pages/privacy.json +++ b/messages/en/pages/privacy.json @@ -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)." ] } diff --git a/messages/en/pages/profile.json b/messages/en/pages/profile.json index 5dd5377..8949a55 100644 --- a/messages/en/pages/profile.json +++ b/messages/en/pages/profile.json @@ -9,11 +9,11 @@ "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.", + "yourRulesEmpty": "You have not finished a rule yet. Create one to see it here.", "draftHeading": "Saved in progress", "draftInProgressBadge": "In progress", "continueDraft": "Continue", @@ -26,7 +26,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 +50,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.", diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 102cb29..6bf107d 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -1296,7 +1296,7 @@ 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(); }); diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index f94d757..07e1eaf 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -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( @@ -300,7 +300,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 +308,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(); }); diff --git a/tests/contexts/AuthModalContext.test.tsx b/tests/contexts/AuthModalContext.test.tsx index fe67d4f..663bd12 100644 --- a/tests/contexts/AuthModalContext.test.tsx +++ b/tests/contexts/AuthModalContext.test.tsx @@ -192,7 +192,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( diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts index a655911..6b474de 100644 --- a/tests/unit/createFooterMessages.test.ts +++ b/tests/unit/createFooterMessages.test.ts @@ -24,10 +24,8 @@ describe("create footer messages", () => { expect(messages.create.footer.continue).toBe("Continue"); }); - it("exposes publishCommunityRule for the edit-rule footer CTA", () => { - expect(messages.create.footer.publishCommunityRule).toBe( - "Publish CommunityRule", - ); + 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", () => { From 70ceb73104d0c4b91b987bb6c8abf40d0f346338 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:56:13 -0700 Subject: [PATCH 3/6] Invite people after review, then finish the Rule, with login before inviting and Invite your members on Profile. --- app/(app)/create/CreateFlowLayoutClient.tsx | 94 +++- .../MembershipInvitePreviewCard.tsx | 43 ++ .../create/hooks/useCreateFlowFinalize.ts | 279 ++++++----- .../screens/completed/CompletedScreen.tsx | 24 +- .../select/ConfirmStakeholdersScreen.tsx | 446 ++++++++++++------ .../PublishedStakeholdersManagePanel.tsx | 41 +- .../create/utils/createFlowScreenRegistry.ts | 2 +- app/(app)/create/utils/flowSteps.ts | 15 +- app/(app)/profile/ProfilePageClient.tsx | 40 ++ .../profile/_components/ProfilePage.view.tsx | 18 +- .../modals/Login/Login.container.tsx | 22 + app/components/modals/Login/Login.types.ts | 2 + app/components/modals/Login/Login.view.tsx | 16 +- app/components/modals/Login/LoginForm.tsx | 86 ++-- app/contexts/AuthModalContext.tsx | 19 +- lib/create/membershipInvitePreview.ts | 43 ++ lib/create/parseInviteEmails.ts | 82 ++++ .../create/reviewAndComplete/completed.json | 6 +- .../confirmStakeholders.json | 47 +- messages/en/pages/login.json | 3 + messages/en/pages/profile.json | 10 +- .../pages/ConfirmStakeholdersPage.stories.js | 4 +- tests/components/CompletedPage.test.tsx | 36 +- .../ConfirmStakeholdersPage.test.tsx | 126 ++++- tests/components/LoginForm.test.tsx | 38 ++ tests/contexts/AuthModalContext.test.tsx | 51 ++ tests/pages/profile.test.tsx | 42 ++ tests/unit/flowSteps.test.ts | 7 +- .../unit/hooks/useCreateFlowFinalize.test.tsx | 65 +++ tests/unit/membershipInvitePreview.test.ts | 28 ++ tests/unit/parseInviteEmails.test.ts | 54 +++ 31 files changed, 1388 insertions(+), 401 deletions(-) create mode 100644 app/(app)/create/components/MembershipInvitePreviewCard.tsx create mode 100644 lib/create/membershipInvitePreview.ts create mode 100644 lib/create/parseInviteEmails.ts create mode 100644 tests/unit/membershipInvitePreview.test.ts create mode 100644 tests/unit/parseInviteEmails.test.ts 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"); + }); +}); From 727a92a9acc638627338e4fed7ac2c67e2377ff8 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:00:44 -0700 Subject: [PATCH 4/6] Send the invitation as a light letter that names the community, the inviter by email, and who can read the Rule. --- .gitignore | 3 + .../[stakeholderId]/resend/route.ts | 15 +- app/api/rules/[id]/stakeholders/route.ts | 5 +- app/api/rules/route.ts | 10 +- lib/assetUtils.ts | 2 + lib/create/ruleInviteCardMeta.ts | 56 ++++ lib/server/mail.ts | 24 +- lib/server/ruleStakeholderInviteMail.ts | 249 ++++++++++++++++++ lib/server/ruleStakeholderInviteOps.ts | 13 +- public/assets/logos/community-rule-mark.png | Bin 0 -> 547 bytes tests/unit/ruleInviteCardMeta.test.ts | 63 +++++ tests/unit/ruleStakeholderInviteMail.test.ts | 90 +++++++ tests/unit/rulesPublishPostRoute.test.ts | 11 +- .../rulesStakeholderMutationsRoute.test.ts | 19 +- 14 files changed, 534 insertions(+), 26 deletions(-) create mode 100644 lib/create/ruleInviteCardMeta.ts create mode 100644 lib/server/ruleStakeholderInviteMail.ts create mode 100644 public/assets/logos/community-rule-mark.png create mode 100644 tests/unit/ruleInviteCardMeta.test.ts create mode 100644 tests/unit/ruleStakeholderInviteMail.test.ts diff --git a/.gitignore b/.gitignore index a28b5ab..a6fd955 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts index 222c170..61618de 100644 --- a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts @@ -46,7 +46,7 @@ export const POST = apiRoute( 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( }, }); - 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", diff --git a/app/api/rules/[id]/stakeholders/route.ts b/app/api/rules/[id]/stakeholders/route.ts index 02fe87b..13840be 100644 --- a/app/api/rules/[id]/stakeholders/route.ts +++ b/app/api/rules/[id]/stakeholders/route.ts @@ -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( origin, ruleId: rule.id, ruleTitle: rule.title, + summary: rule.summary, + document: rule.document, email, invitedByUserId: user.id, + inviterEmail: user.email, pepper, }); diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 246ea1f..56de731 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -208,7 +208,15 @@ export const POST = apiRoute( try { for (const inv of invites) { const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); - await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title); + await sendRuleStakeholderInviteEmail({ + to: inv.email, + verifyUrl, + origin, + communityName: title, + inviterEmail: user.email, + summary, + document, + }); } } catch (err) { logRouteError("rules.publish", requestId, err, { diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts index 5e28dc5..d4df3c3 100644 --- a/lib/assetUtils.ts +++ b/lib/assetUtils.ts @@ -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", diff --git a/lib/create/ruleInviteCardMeta.ts b/lib/create/ruleInviteCardMeta.ts new file mode 100644 index 0000000..5ff0d13 --- /dev/null +++ b/lib/create/ruleInviteCardMeta.ts @@ -0,0 +1,56 @@ +import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections"; +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(); +} + +/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */ +export function inviteRuleCountsLabel(document: unknown): string { + 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; + } + } + const valuePart = values === 1 ? "1 VALUE" : `${values} VALUES`; + const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`; + return `${valuePart} AND ${cardPart}`; +} diff --git a/lib/server/mail.ts b/lib/server/mail.ts index fc02755..1fa6705 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -6,6 +6,10 @@ import { type WorkshopInquiryMailInput, } from "../workshopSignupMail"; import { getSmtpUrl } from "./env"; +import { + buildRuleStakeholderInviteMail, + type RuleStakeholderInviteMailInput, +} from "./ruleStakeholderInviteMail"; function escapeHtml(value: string): string { return value @@ -93,24 +97,18 @@ export async function sendMagicLinkEmail( }); } -/** 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 { - 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); 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: input.inviterEmail.trim().toLowerCase(), + devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`, }); } diff --git a/lib/server/ruleStakeholderInviteMail.ts b/lib/server/ruleStakeholderInviteMail.ts new file mode 100644 index 0000000..b8df030 --- /dev/null +++ b/lib/server/ruleStakeholderInviteMail.ts @@ -0,0 +1,249 @@ +import { ASSETS, getAssetPath } from "../assetUtils"; +import { + clipCommunityInviteDescription, + communityInviteInitials, + communityPossessive, + inviteRuleCountsLabel, +} from "../create/ruleInviteCardMeta"; + +export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK); + +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, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +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 = input.inviterEmail.trim().toLowerCase(); + 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 = + `Writing a Rule for another community?` + + ` Make a copy` + + ` 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 + ? `

    ${description}

    ` + : ""; + + return ` + + + + +${heading} + + +
    ${escapeHtml( + PREVIEW_TEXT, + )}
    + + + + +
    + + + + + + + +
    + + + + + +
    CommunityRule + +
    +
    +

    ${heading}

    +

    ${intro}

    +

    + Read the Rule +

    +

    Or paste this link into a browser

    +

    + ${verifyHref} +

    + + + + +
    + + + + + +
    +
    ${initials}
    +
    ${name}
    + ${cardBody} +

    + ${counts} +

    +
    +
    +

    ${privacyHtml}

    +

    ${ownLinkHtml}

    +

    ${copyHtml}

    +
    +

    ${why}

    +

    ${madeBy}

    +

    + Privacy +    + About CommunityRule +

    +
    +
    +
    + +`; +} + +function privacyHtmlWithLead(privacy: string): string { + const lead = "Every Rule starts private."; + if (privacy.startsWith(lead)) { + return `${escapeHtml(lead)}${escapeHtml( + privacy.slice(lead.length), + )}`; + } + return escapeHtml(privacy); +} + +function leadInHtml(lead: string, full: string): string { + if (full.startsWith(lead)) { + return `${escapeHtml(lead)}${escapeHtml( + full.slice(lead.length), + )}`; + } + return escapeHtml(full); +} diff --git a/lib/server/ruleStakeholderInviteOps.ts b/lib/server/ruleStakeholderInviteOps.ts index 91516da..946ca50 100644 --- a/lib/server/ruleStakeholderInviteOps.ts +++ b/lib/server/ruleStakeholderInviteOps.ts @@ -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, { diff --git a/public/assets/logos/community-rule-mark.png b/public/assets/logos/community-rule-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..cbaed36ab111f1a4243bc9cbe066c69a73068f4a GIT binary patch literal 547 zcmV+;0^I$HP)@dLeASc!JuF%2nrT9ad8&{{-&K;_XKAVCfZLg%ssNY7di&JrL$tyuzu<~%C;CtTeD>1VC(fY6v5XsynG z1UX0rRyRPhSgRW#S*_Itki6FF0LUoTsticiT006{)c^^8KN?t-0Eum_N`Mq$tqOn? zX{~HPindl3Apd2p3_yb4%lCl(p?k02L**LvpBVuYRMy~Vq8KpzLZ;n_`X}15`g@+{ zGAdU&3?_vErnaW?3^%j=Us7`hmGhiSG>Hy4^pySmn4 z>E@D?2tg)^0+=EJ?Tic-A^{E!{VI(NV7rz1JTQDIzQe_I%DGNUHzW{&$UP%(w&gcLD002ovPDHLkV1iZ`?Zp59 literal 0 HcmV?d00001 diff --git a/tests/unit/ruleInviteCardMeta.test.ts b/tests/unit/ruleInviteCardMeta.test.ts new file mode 100644 index 0000000..2616e1d --- /dev/null +++ b/tests/unit/ruleInviteCardMeta.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + clipCommunityInviteDescription, + communityInviteInitials, + communityPossessive, + inviteRuleCountsLabel, +} 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"); + }); +}); diff --git a/tests/unit/ruleStakeholderInviteMail.test.ts b/tests/unit/ruleStakeholderInviteMail.test.ts new file mode 100644 index 0000000..9856167 --- /dev/null +++ b/tests/unit/ruleStakeholderInviteMail.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + RULE_STAKEHOLDER_INVITE_LOGO_PATH, + buildRuleStakeholderInviteMail, + ruleStakeholderInviteCopyUrl, +} from "../../lib/server/ruleStakeholderInviteMail"; + +const VERIFY_URL = + "https://communityrule.info/api/invites/rule-stakeholder/verify?token=abc"; + +describe("ruleStakeholderInviteCopyUrl", () => { + it("adds copy=1 to the invitation link", () => { + expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe( + `${VERIFY_URL}©=1`, + ); + }); +}); + +describe("buildRuleStakeholderInviteMail", () => { + const mail = buildRuleStakeholderInviteMail({ + to: "rosa@example.org", + verifyUrl: VERIFY_URL, + origin: "https://communityrule.info", + communityName: "Juniper Street Food Share", + inviterEmail: "maya@example.org", + summary: + "A food share that runs every Sunday on Juniper Street. Volunteers collect surplus from two grocers and a farm stand and share it with neighbors.", + document: { + sections: [ + { + categoryName: "Values", + entries: [ + { title: "Care", body: "We care." }, + { title: "Share", body: "We share." }, + { title: "Listen", body: "We listen." }, + ], + }, + { + categoryName: "Communication", + entries: Array.from({ length: 6 }, (_, i) => ({ + title: `Card ${i + 1}`, + body: "Body", + })), + }, + ], + }, + }); + + it("uses the launch subject, preview, and privacy line", () => { + expect(mail.subject).toBe( + "Juniper Street Food Share invited you to read their Rule", + ); + expect(mail.previewText).toBe( + "It covers how they talk, who belongs, how they decide and what happens when they disagree.", + ); + expect(mail.text).toContain( + "Every Rule starts private. Only the people Juniper Street Food Share 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.", + ); + expect(mail.html).toContain("Every Rule starts private."); + }); + + it("names the inviter by email and points replies at that address in copy", () => { + expect(mail.text).toContain("maya@example.org sent the invitation."); + expect(mail.text).toContain( + "how they talk, who belongs, how they decide and what happens when they disagree.", + ); + expect(mail.html).not.toContain("Maya Ortiz"); + }); + + it("includes read and copy links, footer, and the 2x mark", () => { + expect(mail.text).toContain(VERIFY_URL); + expect(mail.text).toContain(`${VERIFY_URL}©=1`); + expect(mail.html).toContain(`href="${VERIFY_URL}"`); + expect(mail.html).toContain(`href="${VERIFY_URL}&copy=1"`); + expect(mail.html).toContain("Read the Rule"); + expect(mail.html).toContain("Make a copy"); + expect(mail.html).toContain("3 VALUES AND 6 CARDS"); + expect(mail.html).toContain("JS"); + expect(mail.html).toContain("#e4e4e4"); + expect(mail.html).toContain("font-size:14px;line-height:20px;color:#464646"); + expect(mail.html).toContain( + `https://communityrule.info${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`, + ); + expect(mail.html).toContain("https://communityrule.info/privacy"); + expect(mail.html).toContain("https://communityrule.info/about"); + expect(mail.text).toContain( + "Juniper Street Food Share’s Rule on CommunityRule", + ); + }); +}); diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts index 5431be2..1126321 100644 --- a/tests/unit/rulesPublishPostRoute.test.ts +++ b/tests/unit/rulesPublishPostRoute.test.ts @@ -148,9 +148,14 @@ describe("POST /api/rules", () => { expect(res.status).toBe(200); expect(transactionMock).toHaveBeenCalledTimes(1); expect(sendInviteMock).toHaveBeenCalledTimes(1); - expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com"); - expect(String(sendInviteMock.mock.calls[0][1])).toContain( - "/api/invites/rule-stakeholder/verify?token=", + expect(sendInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "stakeholder@example.com", + communityName: "Published title", + verifyUrl: expect.stringContaining( + "/api/invites/rule-stakeholder/verify?token=", + ), + }), ); expect(publishedRuleCreateMock).not.toHaveBeenCalled(); }); diff --git a/tests/unit/rulesStakeholderMutationsRoute.test.ts b/tests/unit/rulesStakeholderMutationsRoute.test.ts index 9498b12..309b024 100644 --- a/tests/unit/rulesStakeholderMutationsRoute.test.ts +++ b/tests/unit/rulesStakeholderMutationsRoute.test.ts @@ -130,7 +130,13 @@ describe("POST /api/rules/[id]/stakeholders", () => { routeCtx, ); expect(res.status).toBe(201); - expect(createInviteMock).toHaveBeenCalled(); + expect(createInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + email: "inv@example.com", + ruleTitle: "My rule", + inviterEmail: owner.email, + }), + ); const body = (await res.json()) as { stakeholder: { email: string; status: string }; }; @@ -214,9 +220,14 @@ describe("POST /api/rules/[id]/stakeholders/[stakeholderId]/resend", () => { expect(await res.json()).toEqual({ ok: true }); expect(updateMock).toHaveBeenCalled(); expect(sendInviteEmailMock).toHaveBeenCalledWith( - "inv@example.com", - expect.stringContaining("/api/invites/rule-stakeholder/verify?token="), - "My rule", + expect.objectContaining({ + to: "inv@example.com", + communityName: "My rule", + inviterEmail: owner.email, + verifyUrl: expect.stringContaining( + "/api/invites/rule-stakeholder/verify?token=", + ), + }), ); }); }); From 3c984e00c52c36518b338460edba2c085fc9f70c Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:16:06 -0700 Subject: [PATCH 5/6] Show invited people the Rule, let them copy its cards for their community, and replace an expired invitation link. --- app/(app)/create/CreateFlowLayoutClient.tsx | 18 +- .../screens/completed/CompletedScreen.tsx | 67 ++++- app/(app)/invite/[ruleId]/page.tsx | 50 ++++ .../_components/ExpiredInviteScreen.tsx | 149 ++++++++++ .../invite/_components/InvitedRuleView.tsx | 276 ++++++++++++++++++ .../invite/_components/MakeACopyDialog.tsx | 122 ++++++++ app/(app)/invite/expired/layout.tsx | 18 ++ app/(app)/invite/expired/page.tsx | 24 ++ app/(app)/invite/layout.tsx | 14 + .../profile/_components/ProfilePage.view.tsx | 2 +- .../invites/rule-stakeholder/resend/route.ts | 138 +++++++++ .../invites/rule-stakeholder/verify/route.ts | 12 +- app/api/rules/[id]/make-copy/route.ts | 105 +++++++ lib/create/api.ts | 113 +++++++ lib/create/copiedRuleDocument.ts | 59 ++++ lib/create/inviteLastChanged.ts | 10 + lib/create/invitePaths.ts | 20 ++ lib/create/lastPublishedRule.ts | 29 +- lib/create/ruleInviteCardMeta.ts | 22 +- lib/navigationChromelessPath.ts | 6 +- lib/server/publishedRules.ts | 57 ++++ lib/server/validation/createFlowSchemas.ts | 19 ++ .../create/reviewAndComplete/completed.json | 4 + messages/en/index.ts | 2 + messages/en/metadata.json | 8 + messages/en/pages/invite.json | 50 ++++ tests/components/CompletedPage.test.tsx | 18 ++ tests/components/ExpiredInviteScreen.test.tsx | 63 ++++ tests/components/InvitedRuleView.test.tsx | 68 +++++ tests/components/TextPage.test.tsx | 2 +- tests/unit/copiedRuleDocument.test.ts | 78 +++++ tests/unit/invitePaths.test.ts | 27 ++ .../unit/lib/navigationChromelessPath.test.ts | 2 + tests/unit/ruleInviteCardMeta.test.ts | 24 ++ ...takeholderInviteResendExpiredRoute.test.ts | 112 +++++++ .../ruleStakeholderInviteVerifyRoute.test.ts | 48 ++- tests/unit/rulesMakeCopyRoute.test.ts | 145 +++++++++ 37 files changed, 1962 insertions(+), 19 deletions(-) create mode 100644 app/(app)/invite/[ruleId]/page.tsx create mode 100644 app/(app)/invite/_components/ExpiredInviteScreen.tsx create mode 100644 app/(app)/invite/_components/InvitedRuleView.tsx create mode 100644 app/(app)/invite/_components/MakeACopyDialog.tsx create mode 100644 app/(app)/invite/expired/layout.tsx create mode 100644 app/(app)/invite/expired/page.tsx create mode 100644 app/(app)/invite/layout.tsx create mode 100644 app/api/invites/rule-stakeholder/resend/route.ts create mode 100644 app/api/rules/[id]/make-copy/route.ts create mode 100644 lib/create/copiedRuleDocument.ts create mode 100644 lib/create/inviteLastChanged.ts create mode 100644 lib/create/invitePaths.ts create mode 100644 messages/en/pages/invite.json create mode 100644 tests/components/ExpiredInviteScreen.test.tsx create mode 100644 tests/components/InvitedRuleView.test.tsx create mode 100644 tests/unit/copiedRuleDocument.test.ts create mode 100644 tests/unit/invitePaths.test.ts create mode 100644 tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts create mode 100644 tests/unit/rulesMakeCopyRoute.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 9e46c6b..33ce2ab 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -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, @@ -220,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 @@ -548,9 +553,12 @@ function CreateFlowLayoutContent({ 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(() => { @@ -913,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" ? ( diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index f85dd25..9e86423 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -26,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; @@ -80,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; @@ -88,6 +98,7 @@ export function CompletedScreen() { const [toastDismissed, setToastDismissed] = useState(false); /** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */ const [showCelebrateToast] = useState(celebrateInUrl); + const [showCopiedReady] = useState(copiedInUrl); const [askOrganizerOpen, setAskOrganizerOpen] = useState(false); const [headerTitle, setHeaderTitle] = useState(initial.headerTitle); const [headerDescription, setHeaderDescription] = useState< @@ -141,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) ? (
    setToastDismissed(true)} @@ -166,7 +194,7 @@ export function CompletedScreen() { return ( <> -
    +
    @@ -180,7 +208,7 @@ export function CompletedScreen() { size="L" palette="inverse" /> - {headerTitle ? ( + {headerTitle && !surfaceCopied ? (

    {completed.askOrganizerLead}{" "}