mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 10:43:53 +10:00
feat(ui): redesign recipient avatar stack hover popover (#3072)
This commit is contained in:
@@ -1,66 +0,0 @@
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: TRecipientLite;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||
|
||||
const onRecipientClick = () => {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('my-1 flex items-center gap-2', {
|
||||
'cursor-pointer hover:underline': signingToken,
|
||||
})}
|
||||
role={signingToken ? 'button' : undefined}
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
onClick={onRecipientClick}
|
||||
>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
>
|
||||
<p>{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
@@ -14,9 +15,10 @@ export type StackAvatarProps = {
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: RecipientStatusType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
@@ -46,7 +48,14 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
|
||||
<Avatar
|
||||
className={cn(
|
||||
zIndexClass,
|
||||
firstClass,
|
||||
'h-10 w-10 border-2 border-white border-solid dark:border-border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
RecipientStatusType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
import { useMemo } from 'react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleCheckIcon,
|
||||
CircleDashedIcon,
|
||||
CircleXIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
MailOpenIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
@@ -27,125 +44,238 @@ export const StackAvatarsWithTooltip = ({
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
|
||||
);
|
||||
const sections = useMemo(() => {
|
||||
const groups = groupRecipientsByStatus(recipients);
|
||||
|
||||
const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED);
|
||||
|
||||
const completedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
|
||||
);
|
||||
|
||||
const uncompletedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
|
||||
);
|
||||
|
||||
const rejectedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
return [
|
||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||
...otherRecipients.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
}),
|
||||
];
|
||||
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
recipients: groups[section.type],
|
||||
})).filter((section) => section.recipients.length > 0);
|
||||
}, [recipients]);
|
||||
|
||||
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
trigger={children || <StackAvatars recipients={sortedRecipients} />}
|
||||
trigger={children || <RecipientAvatarStack recipients={recipients} />}
|
||||
contentProps={{
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
className:
|
||||
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
|
||||
side: position,
|
||||
// Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers.
|
||||
collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 },
|
||||
// Opened via hover, so don't steal focus from wherever the user was.
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
}}
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
{sections.map((section) => (
|
||||
<div key={section.type} className="px-3 py-2">
|
||||
<div className={cn('flex items-center gap-1.5 font-medium text-xs', section.className)}>
|
||||
<section.icon className="h-3.5 w-3.5 shrink-0" strokeWidth={2} />
|
||||
<span>{_(section.label)}</span>
|
||||
<span>{section.recipients.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
{section.recipients.map((recipient) => (
|
||||
<RecipientRow
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
recipient={recipient}
|
||||
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rejectedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="font-medium text-base">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
|
||||
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<ReturnType<typeof setTimeout> | 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 = (
|
||||
<>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
className="h-6 w-6 shrink-0 border-0 text-[10px]"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1 text-xs leading-snug">
|
||||
<p className="truncate text-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="truncate text-muted-foreground">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!signingToken) {
|
||||
return <div className={recipientRowClassName}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
recipientRowClassName,
|
||||
'group w-[calc(100%+1rem)] cursor-pointer text-left transition-colors duration-300 hover:bg-muted',
|
||||
)}
|
||||
title={_(msg`Click to copy signing link for sending to recipient`)}
|
||||
onClick={onCopySigningLink}
|
||||
>
|
||||
{content}
|
||||
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-3 w-3 shrink-0 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<StackAvatar
|
||||
key="extra-recipients"
|
||||
first={index === 0}
|
||||
zIndex={zIndex}
|
||||
type={getExtraRecipientsType(sortedRecipients.slice(index))}
|
||||
fallbackText={`+${hiddenRecipients.length + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={index === 0}
|
||||
zIndex={zIndex}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
|
||||
const groups: Record<RecipientStatusType, TRecipientLite[]> = {
|
||||
[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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 (
|
||||
<StackAvatar
|
||||
key="extra-recipient"
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getExtraRecipientsType(recipients.slice(4))}
|
||||
fallbackText={`+${remainingItems + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <>{renderStackAvatars(recipients)}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user