Files
community-rule/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx
T

358 lines
12 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
type ClipboardEvent,
} from "react";
import { useSearchParams } from "next/navigation";
import Button from "../../../../components/buttons/Button";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import TextInput from "../../../../components/controls/TextInput";
import Icon from "../../../../components/asset/icon";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { fetchAuthSession } from "../../../../../lib/create/api";
import { membershipInvitePreview } from "../../../../../lib/create/membershipInvitePreview";
import {
INVITE_EMAIL_PATTERN,
formatInviteCountWord,
parseInvitePaste,
} from "../../../../../lib/create/parseInviteEmails";
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { MembershipInvitePreviewCard } from "../../components/MembershipInvitePreviewCard";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
} from "../../utils/flowSteps";
import { PublishedStakeholdersManagePanel } from "./PublishedStakeholdersManagePanel";
const VISIBLE_WHEN_COLLAPSED = 6;
function InvitePersonRow({
email,
youLabel,
removeAria,
onRemove,
}: {
email: string;
youLabel?: string;
removeAria: string;
onRemove?: () => void;
}) {
return (
<div className="flex w-full items-center gap-[var(--space-300)] border-b border-[var(--color-border-default-secondary)] py-[var(--space-200)]">
<div className="flex min-w-0 flex-1 items-center gap-[var(--space-200)]">
<p className="truncate text-medium-paragraph text-[var(--color-content-default-primary)]">
{email}
</p>
{youLabel ? (
<p className="shrink-0 text-small-label text-[var(--color-content-default-tertiary)]">
{youLabel}
</p>
) : null}
</div>
{onRemove ? (
<button
type="button"
onClick={onRemove}
aria-label={removeAria}
className="flex size-[30px] shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
>
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_CLOSE)}
alt=""
width={14}
height={14}
className="size-[14px]"
/>
</button>
) : null}
</div>
);
}
export function ConfirmStakeholdersScreen() {
const searchParams = useSearchParams();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
const manageStakeholdersIntent =
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
const editingPublishedRuleId = state.editingPublishedRuleId?.trim() ?? "";
const [ownerEmail, setOwnerEmail] = useState<string | null>(null);
const [draftEmail, setDraftEmail] = useState("");
const [fieldError, setFieldError] = useState<string | null>(null);
const [pasteResult, setPasteResult] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
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 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],
);
const addOne = useCallback(
(raw: string): boolean => {
const trimmed = raw.trim().toLowerCase();
if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
setFieldError(t("invalidEmail"));
return false;
}
if (ownerEmail && trimmed === ownerEmail) {
setFieldError(t("duplicateEmail"));
return false;
}
if (invitees.includes(trimmed)) {
setFieldError(t("duplicateEmail"));
return false;
}
if (invitees.length >= MAX_STAKEHOLDER_EMAILS) {
setFieldError(t("maxStakeholders"));
return false;
}
persistInvitees([...invitees, trimmed]);
return true;
},
[invitees, ownerEmail, persistInvitees, t],
);
const handleAdd = () => {
markCreateFlowInteraction();
setPasteResult(null);
if (addOne(draftEmail)) {
setDraftEmail("");
setFieldError(null);
}
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const text = event.clipboardData.getData("text");
const tokens = text
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter((token) => token.length > 0);
if (tokens.length <= 1) return;
event.preventDefault();
markCreateFlowInteraction();
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]);
}
setDraftEmail(parsed.invalidRemainder);
if (parsed.invalidRemainder.length > 0) {
setFieldError(t("invalidEmail"));
}
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),
);
}
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 handleRemove = (email: string) => {
markCreateFlowInteraction();
setPasteResult(null);
persistInvitees(invitees.filter((item) => item !== email));
};
const header = (
<div className="flex w-full min-w-0 flex-col gap-[var(--space-800)]">
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
{preview ? (
<MembershipInvitePreviewCard
preview={preview}
fromYourRuleLabel={t("fromYourRule")}
whoBelongsLabel={t("whoBelongsLabel")}
joiningLabel={t("joiningLabel")}
/>
) : null}
</div>
);
if (manageStakeholdersIntent) {
return (
<CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
{editingPublishedRuleId.length > 0 ? (
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
) : null}
</CreateFlowTwoColumnSelectShell>
);
}
return (
<CreateFlowTwoColumnSelectShell header={header} lgVerticalAlign="start">
<div className="flex w-full min-w-0 flex-col gap-[var(--space-600)]">
<div
className="flex w-full items-start gap-[var(--space-200)]"
onPasteCapture={handlePaste}
>
<div className="min-w-0 flex-1">
<TextInput
id="invite-member-email"
type="email"
inputSize="small"
showHelpIcon={false}
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
value={draftEmail}
autoComplete="email"
autoFocus
error={Boolean(fieldError)}
textHint={fieldError || false}
onChange={(event) => {
setDraftEmail(event.target.value);
setFieldError(null);
setPasteResult(null);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
handleAdd();
}}
/>
</div>
<div className="shrink-0 pt-[29px]">
<Button
type="button"
size="small"
buttonType="outline"
palette="default"
onClick={handleAdd}
>
{t("add")}
</Button>
</div>
</div>
{pasteResult ? (
<div className="flex items-center gap-2" role="status">
<Icon name="warning" size={24} />
<p className="text-medium-label text-[var(--color-content-default-primary)]">
{pasteResult}
</p>
</div>
) : null}
{rows.length > 0 ? (
<div className="flex w-full flex-col border-t border-[var(--color-border-default-secondary)]">
{visibleRows.map((row) => (
<InvitePersonRow
key={row.isYou ? `you:${row.email}` : row.email}
email={row.email}
youLabel={row.isYou ? t("you") : undefined}
removeAria={t("removeAria").replace("{email}", row.email)}
onRemove={
row.isYou ? undefined : () => handleRemove(row.email)
}
/>
))}
{hiddenCount > 0 ? (
<div className="pt-[var(--space-300)] pb-[var(--space-100)]">
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{t("showMore").replace("{count}", String(hiddenCount))}
</InlineTextButton>
</div>
) : null}
</div>
) : null}
{rows.length > 0 ? (
<p className="text-medium-label text-[var(--color-content-default-primary)]">
{countLine}
</p>
) : null}
<p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
{t("privacy")}
</p>
</div>
</CreateFlowTwoColumnSelectShell>
);
}