-
- Completed
-
- {completedRecipients.map((recipient) => (
-
-
(
+
+
+
+ {_(section.label)}
+ {section.recipients.length}
+
+
+
+ {section.recipients.map((recipient) => (
+
-
-
{recipient.email || recipient.name}
-
- {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
-
-
-
- ))}
+ ))}
+
- )}
-
- {rejectedRecipients.length > 0 && (
-
-
- Rejected
-
- {rejectedRecipients.map((recipient) => (
-
-
-
-
{recipient.email || recipient.name}
-
- {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
-
-
-
- ))}
-
- )}
-
- {waitingRecipients.length > 0 && (
-
-
- Waiting
-
- {waitingRecipients.map((recipient) => (
-
- ))}
-
- )}
-
- {openedRecipients.length > 0 && (
-
-
- Opened
-
- {openedRecipients.map((recipient) => (
-
- ))}
-
- )}
-
- {uncompletedRecipients.length > 0 && (
-
-
- Uncompleted
-
- {uncompletedRecipients.map((recipient) => (
-
- ))}
-
- )}
+ ))}
);
};
+
+type RecipientRowProps = {
+ recipient: TRecipientLite;
+ signingToken: string | null;
+};
+
+const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const [, copy] = useCopyToClipboard();
+
+ const [isCopied, setIsCopied] = useState(false);
+ const copiedTimeoutRef = useRef
| null>(null);
+
+ useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []);
+
+ const onCopySigningLink = () => {
+ if (!signingToken) {
+ return;
+ }
+
+ void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
+ setIsCopied(true);
+
+ clearTimeout(copiedTimeoutRef.current ?? undefined);
+ copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS);
+
+ toast({
+ title: _(msg`Copied to clipboard`),
+ description: _(msg`The signing link has been copied to your clipboard.`),
+ });
+ });
+ };
+
+ const content = (
+ <>
+
+
+
+
{recipient.email || recipient.name}
+
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
+
+ >
+ );
+
+ if (!signingToken) {
+ return {content}
;
+ }
+
+ return (
+
+ );
+};
+
+const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => {
+ const sortedRecipients = useMemo(() => {
+ const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id;
+
+ const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED);
+ const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED);
+
+ return [...rejected.sort(byId), ...others.sort(byId)];
+ }, [recipients]);
+
+ const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS);
+ const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS);
+
+ return (
+ <>
+ {visibleRecipients.map((recipient, index) => {
+ const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0;
+ const zIndex = String(50 - index * 10);
+
+ if (isOverflowSlot) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+ >
+ );
+};
+
+const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
+ const groups: Record = {
+ [RecipientStatusType.COMPLETED]: [],
+ [RecipientStatusType.REJECTED]: [],
+ [RecipientStatusType.WAITING]: [],
+ [RecipientStatusType.OPENED]: [],
+ [RecipientStatusType.UNSIGNED]: [],
+ };
+
+ for (const recipient of recipients) {
+ groups[getRecipientType(recipient)].push(recipient);
+ }
+
+ return groups;
+};
+
+const MAX_VISIBLE_AVATARS = 5;
+
+const COPIED_INDICATOR_DURATION_MS = 2000;
+
+const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1';
+
+type RecipientStatusSection = {
+ type: RecipientStatusType;
+ label: MessageDescriptor;
+ icon: LucideIcon;
+ className: string;
+ /** Whether recipients in this section still need to sign, so a signing link can be copied. */
+ hasSigningLink: boolean;
+};
+
+const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [
+ {
+ type: RecipientStatusType.COMPLETED,
+ label: msg`Completed`,
+ icon: CircleCheckIcon,
+ className: 'text-green-600 dark:text-green-400',
+ hasSigningLink: false,
+ },
+ {
+ type: RecipientStatusType.REJECTED,
+ label: msg`Rejected`,
+ icon: CircleXIcon,
+ className: 'text-red-600 dark:text-red-400',
+ hasSigningLink: false,
+ },
+ {
+ type: RecipientStatusType.WAITING,
+ label: msg`Waiting`,
+ icon: ClockIcon,
+ className: 'text-blue-600 dark:text-blue-400',
+ hasSigningLink: true,
+ },
+ {
+ type: RecipientStatusType.OPENED,
+ label: msg`Opened`,
+ icon: MailOpenIcon,
+ className: 'text-amber-600 dark:text-amber-400',
+ hasSigningLink: true,
+ },
+ {
+ type: RecipientStatusType.UNSIGNED,
+ label: msg`Uncompleted`,
+ icon: CircleDashedIcon,
+ className: 'text-muted-foreground',
+ hasSigningLink: true,
+ },
+];
diff --git a/apps/remix/app/components/general/stack-avatars.tsx b/apps/remix/app/components/general/stack-avatars.tsx
deleted file mode 100644
index cf2cd2ca6..000000000
--- a/apps/remix/app/components/general/stack-avatars.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
-import type { TRecipientLite } from '@documenso/lib/types/recipient';
-import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
-
-import { StackAvatar } from './stack-avatar';
-
-export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
- const renderStackAvatars = (recipients: TRecipientLite[]) => {
- const zIndex = 50;
- const itemsToRender = recipients.slice(0, 5);
- const remainingItems = recipients.length - itemsToRender.length;
-
- return itemsToRender.map((recipient, index: number) => {
- const first = index === 0;
-
- if (index === 4 && remainingItems > 0) {
- return (
-
- );
- }
-
- return (
-
- );
- });
- };
-
- return <>{renderStackAvatars(recipients)}>;
-}