Show invited people the Rule, let them copy its cards for their community, and replace an expired invitation link.

This commit is contained in:
adilallo committed 2026-11-06 09:16:06 -07:00
1 parent 727a92a9ac
commit 3c984e00c5
37 files changed
+1962 -19

No files matched your search

@@ -0,0 +1,149 @@
"use client";
import { useId, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import Login from "../../../components/modals/Login";
import Button from "../../../components/buttons/Button";
import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup";
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
import { INVITE_EMAIL_PATTERN } from "../../../../lib/create/parseInviteEmails";
import { resendExpiredStakeholderInvite } from "../../../../lib/create/api";
const expiredPageBgClass =
"min-h-[100dvh] bg-[var(--color-surface-invert-brand-primary)]";
export function ExpiredInviteScreen() {
const t = useTranslation("pages.invite");
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const titleId = useId();
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false);
const heading = sent
? { title: t("sentTitle"), description: t("sentBody") }
: { title: t("expiredTitle"), description: t("expiredDescription") };
async function sendLink() {
const trimmed = email.trim().toLowerCase();
if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("emailInvalid"));
return;
}
if (token.length < 10) {
setFormError(t("missingToken"));
return;
}
setEmailError("");
setFormError("");
setSubmitting(true);
const result = await resendExpiredStakeholderInvite(token, trimmed);
setSubmitting(false);
if (result.ok === false) {
if (result.retryAfterMs) {
const seconds = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
setFormError(t("rateLimited").replace("{seconds}", String(seconds)));
return;
}
setFormError(result.error || t("genericError"));
return;
}
setSent(true);
}
return (
<div className={expiredPageBgClass}>
<Login
isOpen
usePortal={false}
backdropVariant="solid"
onClose={() => {
router.push("/");
}}
ariaLabelledBy={titleId}
belowCard={
<Link
href="/"
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
{t("backToHome")}
</Link>
}
>
<div className="flex flex-col gap-6">
<ContentLockup
titleId={titleId}
title={heading.title}
description={heading.description}
variant="login"
alignment="left"
/>
{formError ? (
<p
role="alert"
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{formError}
</p>
) : null}
{!sent ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void sendLink();
}}
noValidate
>
<TextInput
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
type="email"
name="email"
autoComplete="email"
inputMode="email"
value={email}
onChange={(event) => {
setEmail(event.target.value);
setEmailError("");
}}
disabled={submitting}
error={Boolean(emailError)}
showHelpIcon
maxLength={EMAIL_MAX_LEN}
/>
{emailError ? (
<p
role="alert"
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{emailError}
</p>
) : null}
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("notInvitedAddress")}
</p>
<Button
type="submit"
size="large"
buttonType="filled"
palette="default"
disabled={submitting}
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
>
{t("sendNewLink")}
</Button>
</form>
) : null}
</div>
</Login>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Logo from "../../../components/asset/Logo";
import Button from "../../../components/buttons/Button";
import Alert from "../../../components/modals/Alert";
import CommunityRule from "../../../components/type/CommunityRule";
import { useTranslation } from "../../../contexts/MessagesContext";
import { makeCopyPublishedRule } from "../../../../lib/create/api";
import { communityPossessive } from "../../../../lib/create/ruleInviteCardMeta";
import { CREATE_FLOW_COPIED_QUERY, CREATE_FLOW_COPIED_VALUE } from "../../../../lib/create/invitePaths";
import { formatInviteLastChanged } from "../../../../lib/create/inviteLastChanged";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { downloadStoredRuleAsPdf } from "../../../../lib/create/ruleExport";
import { createCompletedPath } from "../../create/utils/createFlowPaths";
import { CreateFlowHeaderLockup } from "../../create/components/CreateFlowHeaderLockup";
import { useCreateFlowMdUp } from "../../create/hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
} from "../../create/components/createFlowLayoutTokens";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { MakeACopyDialog } from "./MakeACopyDialog";
import type { CommunityRuleSection } from "../../../components/type/CommunityRule/CommunityRule.types";
const outlineButtonClass =
"md:!text-x-small-label !text-xx-small-label !px-[var(--space-150)] md:!px-[var(--space-200)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
const SURFACE = "bg-[var(--color-teal-teal50,#c9fef9)]";
export type InvitedRuleViewProps = {
ruleId: string;
title: string;
summary: string | null;
document: Record<string, unknown>;
updatedAt: string;
inviterEmail: string | null;
viewerEmail: string;
countsSpoken: string;
openCopyDialog?: boolean;
};
export function InvitedRuleView({
ruleId,
title,
summary,
document,
updatedAt,
inviterEmail,
viewerEmail,
countsSpoken,
openCopyDialog = false,
}: InvitedRuleViewProps) {
const t = useTranslation("pages.invite");
const router = useRouter();
const mdUp = useCreateFlowMdUp();
const [copyOpen, setCopyOpen] = useState(openCopyDialog);
const [copyBusy, setCopyBusy] = useState(false);
const [copyError, setCopyError] = useState<string | null>(null);
const [exportError, setExportError] = useState(false);
const sections: CommunityRuleSection[] =
parsePublishedDocumentForCommunityRuleDisplay(document);
const possessive = communityPossessive(title);
const lastChanged = formatInviteLastChanged(updatedAt);
const bannerTitle = inviterEmail
? t("bannerTitle").replace("{email}", inviterEmail)
: t("bannerTitleNoEmail");
const whoCanEdit = inviterEmail ?? t("whoCanEditUnknown");
useEffect(() => {
if (!openCopyDialog) return;
router.replace(`/invite/${encodeURIComponent(ruleId)}`);
}, [openCopyDialog, ruleId, router]);
const handlePdf = useCallback(() => {
try {
downloadStoredRuleAsPdf({
id: ruleId,
title,
summary,
document,
});
setExportError(false);
} catch {
setExportError(true);
}
}, [document, ruleId, summary, title]);
const handleCopy = useCallback(
async (communityName: string) => {
setCopyBusy(true);
setCopyError(null);
const result = await makeCopyPublishedRule(ruleId, communityName);
setCopyBusy(false);
if (result.ok === false) {
setCopyError(t("copyFailed"));
return;
}
writeLastPublishedRule({
id: result.id,
title: result.title,
summary: result.summary,
document: result.document,
copiedFromTitle: result.copiedFromTitle,
copiedNameHit: result.copiedNameHit,
});
router.push(
createCompletedPath({
[CREATE_FLOW_COPIED_QUERY]: CREATE_FLOW_COPIED_VALUE,
}),
);
},
[ruleId, router, t],
);
return (
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} ${SURFACE}`}>
<header
className={`${SURFACE} w-full print:hidden`}
role="banner"
>
<nav
className={`mx-auto flex w-full max-w-[1920px] items-center justify-between ${CREATE_FLOW_PAGE_GUTTER_CLASS} py-[var(--space-300)] md:py-[var(--space-400)]`}
aria-label={t("navAria")}
>
<Logo size="createFlow" wordmark palette="inverse" />
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
type="button"
ariaLabel={t("makeACopyAria")}
className={outlineButtonClass}
onClick={() => {
setCopyError(null);
setCopyOpen(true);
}}
>
{t("makeACopy")}
</Button>
</nav>
</header>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
<div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex shrink-0 flex-col justify-start min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={title}
description={summary ?? undefined}
justification="left"
size="L"
palette="inverse"
/>
</div>
<div
className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div className="flex w-full min-w-0 flex-col gap-[var(--space-600)] py-0 md:pb-8">
<div className="print:hidden">
<Alert
type="banner"
status="default"
title={bannerTitle}
description={t("bannerDescription")}
hasLeadingIcon
hasBodyText
hasTrailingIcon={false}
className="w-full"
/>
</div>
<div className="flex flex-wrap gap-[var(--space-300)] print:hidden">
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
type="button"
className={outlineButtonClass}
onClick={handlePdf}
>
{t("downloadPdf")}
</Button>
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
type="button"
className={outlineButtonClass}
onClick={() => window.print()}
>
{t("print")}
</Button>
</div>
{exportError ? (
<p role="alert" className="text-small-paragraph print:hidden">
{t("exportFailedDescription")}
</p>
) : null}
<CommunityRule
sections={sections}
useCardStyle={!mdUp}
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
/>
<div className="flex w-full flex-col border-t border-[var(--color-content-invert-secondary)] print:hidden">
<h2 className="sr-only">{t("factsHeading")}</h2>
<FactRow label={t("whoCanEdit")} value={whoCanEdit} />
<FactRow label={t("whoCanSee")} value={t("whoCanSeeValue")} />
<FactRow label={t("lastChanged")} value={lastChanged} />
</div>
<div className="flex w-full min-w-0 flex-col gap-[var(--space-400)] rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-600)] text-[var(--color-content-invert-primary)] print:hidden">
<p className="font-bricolage text-x-small-heading">
{t("copyCardTitle")}
</p>
<p className="text-small-paragraph">
{t("copyCardBody").replace("{counts}", countsSpoken)}
</p>
<Button
buttonType="outline"
palette="inverse"
size="small"
type="button"
className="self-start"
onClick={() => {
setCopyError(null);
setCopyOpen(true);
}}
>
{t("makeACopy")}
</Button>
</div>
<p className="text-small-paragraph text-[var(--color-content-invert-secondary)] print:hidden">
{t("privacy")}
</p>
</div>
</div>
</div>
</div>
<MakeACopyDialog
isOpen={copyOpen}
onClose={() => {
if (!copyBusy) setCopyOpen(false);
}}
possessive={possessive}
countsSpoken={countsSpoken}
viewerEmail={viewerEmail}
busy={copyBusy}
error={copyError}
onConfirm={(communityName) => {
void handleCopy(communityName);
}}
/>
</div>
);
}
function FactRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-[var(--space-400)] border-b border-[var(--color-content-invert-secondary)] py-[var(--space-300)]">
<p className="text-small-label text-[var(--color-content-invert-secondary)]">
{label}
</p>
<p className="text-right text-small-paragraph text-[var(--color-content-invert-primary)]">
{value}
</p>
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import Button from "../../../components/buttons/Button";
import TextInput from "../../../components/controls/TextInput";
import Dialog from "../../../components/modals/Dialog";
import { useTranslation } from "../../../contexts/MessagesContext";
const COMMUNITY_NAME_MAX = 48;
export function MakeACopyDialog({
isOpen,
onClose,
possessive,
countsSpoken,
viewerEmail,
busy,
error,
onConfirm,
}: {
isOpen: boolean;
onClose: () => void;
possessive: string;
countsSpoken: string;
viewerEmail: string;
busy: boolean;
error: string | null;
onConfirm: (_title: string) => void;
}) {
const t = useTranslation("pages.invite");
const [title, setTitle] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const trimmed = title.trim();
return (
<Dialog
isOpen={isOpen}
onClose={() => {
if (!busy) onClose();
}}
title={t("dialogTitle")}
description={t("dialogDescription")
.replace("{possessive}", possessive)
.replace("{counts}", countsSpoken)}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onClose}
disabled={busy}
>
{t("cancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
disabled={busy}
onClick={() => {
if (trimmed.length === 0) {
setNameError(t("nameRequired"));
return;
}
onConfirm(trimmed);
}}
>
{t("confirm")}
</Button>
</>
}
>
<div className="flex w-full min-w-0 flex-col gap-[var(--space-500)]">
<TextInput
id="make-copy-community-name"
inputSize="small"
showHelpIcon={false}
label={t("communityNameLabel")}
placeholder={t("communityNamePlaceholder")}
value={title}
maxLength={COMMUNITY_NAME_MAX}
error={Boolean(nameError)}
textHint={
nameError ||
t("characterCountTemplate")
.replace("{current}", String(title.length))
.replace("{max}", String(COMMUNITY_NAME_MAX))
}
onChange={(event) => {
setTitle(event.target.value);
setNameError(null);
}}
/>
<div className="grid w-full grid-cols-1 gap-[var(--space-300)] sm:grid-cols-2">
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
<p className="text-small-label">{t("comesWithTitle")}</p>
<p className="mt-[var(--space-200)] text-small-paragraph">
{t("comesWithBody")}
</p>
</div>
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
<p className="text-small-label">{t("staysWithTitle")}</p>
<p className="mt-[var(--space-200)] text-small-paragraph">
{t("staysWithBody")}
</p>
</div>
</div>
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("saveTo").replace("{email}", viewerEmail)}
</p>
{error ? (
<p role="alert" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]">
{error}
</p>
) : null}
</div>
</Dialog>
);
}