feat: add envelopes api (#2105)

This commit is contained in:
David Nguyen
2025-11-07 14:17:52 +11:00
committed by GitHub
parent d2a009d52e
commit d05bfa9fed
230 changed files with 10066 additions and 2812 deletions

View File

@ -1,61 +0,0 @@
import { useState } from 'react';
import type { DocumentData } from '@prisma/client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Dialog, DialogOverlay, DialogPortal, DialogTrigger } from '../../primitives/dialog';
import PDFViewer from '../../primitives/pdf-viewer';
export type DocumentDialogProps = {
trigger?: React.ReactNode;
documentData: DocumentData;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
/**
* A dialog which renders the provided document.
*/
export default function DocumentDialog({ trigger, documentData, ...props }: DocumentDialogProps) {
const [documentLoaded, setDocumentLoaded] = useState(false);
const onDocumentLoad = () => {
setDocumentLoaded(true);
};
return (
<Dialog {...props}>
<DialogPortal>
<DialogOverlay className="bg-black/80" />
{trigger && (
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger}
</DialogTrigger>
)}
<DialogPrimitive.Content
className={cn(
'animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 pointer-events-none fixed z-50 h-screen w-screen overflow-y-auto px-2 py-14 opacity-0 transition-opacity lg:py-32',
{
'opacity-100': documentLoaded,
},
)}
onClick={() => props.onOpenChange?.(false)}
>
<PDFViewer
className="mx-auto w-full max-w-3xl xl:max-w-5xl"
documentData={documentData}
onClick={(e) => e.stopPropagation()}
onDocumentLoad={onDocumentLoad}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
<X className="h-6 w-6 text-white" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}

View File

@ -1,69 +0,0 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData } from '@prisma/client';
import { Download } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Button } from '../../primitives/button';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
documentData?: DocumentData;
};
export const DocumentDownloadButton = ({
className,
fileName,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
setIsLoading(true);
if (!documentData) {
setIsLoading(false);
return;
}
await downloadPDF({ documentData, fileName }).then(() => {
setIsLoading(false);
});
} catch (err) {
setIsLoading(false);
toast({
title: _('Something went wrong'),
description: _('An error occurred while downloading your document.'),
variant: 'destructive',
});
}
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
{!isLoading && <Download className="mr-2 h-5 w-5" />}
<Trans>Download</Trans>
</Button>
);
};

View File

@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { ClockIcon, EyeOffIcon } from 'lucide-react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
import { extractInitials } from '../../../lib/utils/recipient-formatter';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { Avatar, AvatarFallback } from '../../primitives/avatar';
import { Badge } from '../../primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
import { PopoverHover } from '../../primitives/popover';
interface EnvelopeRecipientFieldTooltipProps {
field: Pick<
Field,
'id' | 'inserted' | 'positionX' | 'positionY' | 'width' | 'height' | 'page' | 'type'
> & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
};
showFieldStatus?: boolean;
showRecipientTooltip?: boolean;
showRecipientColors?: boolean;
}
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
};
/**
* Renders a tooltip for a given field.
*/
export function EnvelopeRecipientFieldTooltip({
field,
showFieldStatus = false,
showRecipientTooltip = false,
showRecipientColors = false,
}: EnvelopeRecipientFieldTooltipProps) {
const { t } = useLingui();
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { height, width } = getBoundingClientRect($page);
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
const fieldX = (Number(field.positionX) / 100) * width + Number(fieldWidth);
const fieldY = (Number(field.positionY) / 100) * height;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
if (hideField) {
return null;
}
return (
<div
id="field-recipient-tooltip"
className={cn('absolute z-40')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
}}
>
<PopoverHover
trigger={
<Avatar className="absolute -left-3 -top-3 z-50 h-6 w-6 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED ? 'default' : 'secondary'
}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<ClockIcon className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>{t(FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{getRecipientDisplayText(field.recipient)}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => setHideField(true)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
);
}