diff --git a/apps/remix/app/components/general/avatar-with-recipient.tsx b/apps/remix/app/components/general/avatar-with-recipient.tsx deleted file mode 100644 index 53f5eae6e..000000000 --- a/apps/remix/app/components/general/avatar-with-recipient.tsx +++ /dev/null @@ -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 ( -
- - -
-

{recipient.email || recipient.name}

-

{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}

-
-
- ); -} diff --git a/apps/remix/app/components/general/stack-avatar.tsx b/apps/remix/app/components/general/stack-avatar.tsx index 48caed2ee..22eb096bc 100644 --- a/apps/remix/app/components/general/stack-avatar.tsx +++ b/apps/remix/app/components/general/stack-avatar.tsx @@ -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 ( - + {fallbackText} ); diff --git a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx index ef096afe6..6ff083ddd 100644 --- a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx +++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx @@ -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 ( } + trigger={children || } 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 && ( -
-

- 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)}; -}