358 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|