feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -1,10 +1,13 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData } from '@prisma/client';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DateTime } from 'luxon';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -16,13 +19,16 @@ import {
} from '@documenso/ui/primitives/dialog';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
import { ShareDocumentDownloadButton } from '../share-document-download-button';
export type DocumentCertificateQRViewProps = {
documentId: number;
title: string;
documentData: DocumentData;
password?: string | null;
internalVersion: number;
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
documentTeamUrl: string;
recipientCount?: number;
completedDate?: Date;
};
@ -30,31 +36,32 @@ export type DocumentCertificateQRViewProps = {
export const DocumentCertificateQRView = ({
documentId,
title,
documentData,
password,
internalVersion,
envelopeItems,
documentTeamUrl,
recipientCount = 0,
completedDate,
}: DocumentCertificateQRViewProps) => {
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
const { data: documentViaUser } = trpc.document.get.useQuery({
documentId,
});
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
const formattedDate = completedDate
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
: '';
useEffect(() => {
if (documentUrl) {
if (documentViaUser) {
setIsDialogOpen(true);
}
}, [documentUrl]);
}, [documentViaUser]);
return (
<div className="mx-auto w-full max-w-screen-md">
{/* Dialog for internal document link */}
{documentUrl && (
{documentViaUser && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
@ -72,7 +79,11 @@ export const DocumentCertificateQRView = ({
<DialogFooter className="flex flex-row justify-end gap-2">
<Button asChild>
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
<a
href={`${formatDocumentsPath(documentTeamUrl)}/${documentViaUser.envelopeId}`}
target="_blank"
rel="noopener noreferrer"
>
<Trans>Go to document</Trans>
</a>
</Button>
@ -95,11 +106,21 @@ export const DocumentCertificateQRView = ({
</div>
</div>
<ShareDocumentDownloadButton title={title} documentData={documentData} />
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
</div>
<div className="mt-12 w-full">
<PDFViewer key={documentData.id} documentData={documentData} password={password} />
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</>
)}
</div>
</div>
);

View File

@ -64,7 +64,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const response = await putPdfFile(file);
const { id } = await createDocument({
const { legacyDocumentId: id } = await createDocument({
title: file.name,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.

View File

@ -83,7 +83,7 @@ export const DocumentEditForm = ({
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
const { mutateAsync: addFields } = trpc.field.setFieldsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ fields: newFields }) => {
utils.document.get.setData(
@ -230,6 +230,7 @@ export const DocumentEditForm = ({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
id: signer.nativeId,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
@ -253,6 +254,7 @@ export const DocumentEditForm = ({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
id: signer.nativeId,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
@ -292,7 +294,11 @@ export const DocumentEditForm = ({
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
return addFields({
documentId: document.id,
fields: data.fields,
fields: data.fields.map((field) => ({
...field,
id: field.nativeId,
envelopeItemId: document.documentData.envelopeItemId,
})),
});
};
@ -388,7 +394,7 @@ export const DocumentEditForm = ({
duration: 5000,
});
} else {
await navigate(`${documentRootPath}/${document.id}`);
await navigate(`${documentRootPath}/${document.envelopeId}`);
}
} catch (err) {
console.error(err);

View File

@ -1,7 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient, Team, User } from '@prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
@ -9,57 +8,43 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
document: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'>;
};
envelope: TEnvelope;
};
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const recipient = document.recipients.find((recipient) => recipient.email === user.email);
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(document);
const isPending = envelope.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(envelope);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const documentsPath = formatDocumentsPath(document.team.url);
const formatPath = `${documentsPath}/${document.id}/edit`;
const documentsPath = formatDocumentsPath(envelope.team.url);
const formatPath = `${documentsPath}/${envelope.id}/edit`;
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},
{
context: {
teamId: document.team?.id?.toString(),
},
},
);
// Todo; Envelopes - Support multiple items
const envelopeItem = envelope.envelopeItems[0];
const documentData = documentWithData?.documentData;
if (!documentData) {
if (!envelopeItem.documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title });
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),

View File

@ -3,7 +3,6 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient, Team, User } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import {
Copy,
@ -19,7 +18,9 @@ import { Link, useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -39,14 +40,10 @@ import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/d
import { useCurrentTeam } from '~/providers/team';
export type DocumentPageViewDropdownProps = {
document: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
envelope: TEnvelope;
};
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
@ -57,14 +54,14 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const recipient = document.recipients.find((recipient) => recipient.email === user.email);
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
const isOwner = document.user.id === user.id;
const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null;
const isComplete = isDocumentCompleted(document);
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isOwner = envelope.userId === user.id;
const isDraft = envelope.status === DocumentStatus.DRAFT;
const isPending = envelope.status === DocumentStatus.PENDING;
const isDeleted = envelope.deletedAt !== null;
const isComplete = isDocumentCompleted(envelope);
const isCurrentTeamDocument = team && envelope.teamId === team.id;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team.url);
@ -73,7 +70,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
{
context: {
@ -88,7 +85,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
return;
}
await downloadPDF({ documentData, fileName: document.title });
await downloadPDF({ documentData, fileName: envelope.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
@ -102,7 +99,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
{
context: {
@ -117,7 +114,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
return;
}
await downloadPDF({ documentData, fileName: document.title, version: 'original' });
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
@ -127,7 +124,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
}
};
const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
@ -142,7 +139,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${document.id}/edit`}>
<Link to={`${documentsPath}/${envelope.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@ -162,7 +159,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${document.id}/logs`}>
<Link to={`${documentsPath}/${envelope.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
<Trans>Audit Logs</Trans>
</Link>
@ -184,7 +181,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
{canManageDocument && (
<DocumentRecipientLinkCopyDialog
recipients={document.recipients}
recipients={envelope.recipients}
trigger={
<DropdownMenuItem
disabled={!isPending || isDeleted}
@ -197,10 +194,16 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
/>
)}
<DocumentResendDialog document={document} recipients={nonSignedRecipients} />
<DocumentResendDialog
document={{
...envelope,
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
}}
recipients={nonSignedRecipients}
/>
<DocumentShareButton
documentId={document.id}
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={isOwner ? undefined : recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
@ -214,9 +217,9 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
</DropdownMenuContent>
<DocumentDeleteDialog
id={document.id}
status={document.status}
documentTitle={document.title}
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen}
@ -227,7 +230,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
{isDuplicateDialogOpen && (
<DocumentDuplicateDialog
id={document.id}
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -3,21 +3,18 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient, User } from '@prisma/client';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope';
export type DocumentPageViewInformationProps = {
userId: number;
document: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
};
envelope: TEnvelope;
};
export const DocumentPageViewInformation = ({
document,
envelope,
userId,
}: DocumentPageViewInformationProps) => {
const isMounted = useIsMounted();
@ -29,23 +26,23 @@ export const DocumentPageViewInformation = ({
{
description: msg`Uploaded by`,
value:
userId === document.userId ? _(msg`You`) : (document.user.name ?? document.user.email),
userId === envelope.userId ? _(msg`You`) : (envelope.user.name ?? envelope.user.email),
},
{
description: msg`Created`,
value: DateTime.fromJSDate(document.createdAt)
value: DateTime.fromJSDate(envelope.createdAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
},
{
description: msg`Last modified`,
value: DateTime.fromJSDate(document.updatedAt)
value: DateTime.fromJSDate(envelope.updatedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]);
}, [isMounted, envelope, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">

View File

@ -2,7 +2,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import type { Document, Recipient } from '@prisma/client';
import {
AlertTriangle,
CheckIcon,
@ -17,6 +16,7 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
@ -27,20 +27,18 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = {
document: Document & {
recipients: Recipient[];
};
envelope: TEnvelope;
documentRootPath: string;
};
export const DocumentPageViewRecipients = ({
document,
envelope,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const recipients = document.recipients;
const recipients = envelope.recipients;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
@ -49,9 +47,9 @@ export const DocumentPageViewRecipients = ({
<Trans>Recipients</Trans>
</h1>
{!isDocumentCompleted(document.status) && (
{!isDocumentCompleted(envelope.status) && (
<Link
to={`${documentRootPath}/${document.id}/edit?step=signers`}
to={`${documentRootPath}/${envelope.id}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
@ -84,7 +82,7 @@ export const DocumentPageViewRecipients = ({
/>
<div className="flex flex-row items-center">
{document.status !== DocumentStatus.DRAFT &&
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
@ -95,7 +93,7 @@ export const DocumentPageViewRecipients = ({
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
envelope.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
<Trans>Sent</Trans>
@ -130,7 +128,7 @@ export const DocumentPageViewRecipients = ({
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
@ -138,7 +136,7 @@ export const DocumentPageViewRecipients = ({
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
trigger={
@ -158,7 +156,7 @@ export const DocumentPageViewRecipients = ({
</PopoverHover>
)}
{document.status === DocumentStatus.PENDING &&
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
<CopyTextButton

View File

@ -28,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadDropzoneProps = {
export type DocumentUploadButtonProps = {
className?: string;
};
export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => {
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@ -75,10 +75,10 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const response = await putPdfFile(file);
const { id } = await createDocument({
const { legacyDocumentId: id } = await createDocument({
title: file.name,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
timezone: userTimezone,
folderId: folderId ?? undefined,
});
@ -140,7 +140,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDrop={async (files) => onFileDrop(files[0])}
onDropRejected={onFileDropRejected}
/>
</div>

View File

@ -0,0 +1,198 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type EnvelopeUploadButtonProps = {
className?: string;
type: EnvelopeType;
folderId?: string;
};
/**
* Upload an envelope
*/
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const team = useCurrentTeam();
const navigate = useNavigate();
const organisation = useCurrentOrganisation();
const userTimezone = TIME_ZONES.find(
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
);
const { quota, remaining, refreshLimits } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const disabledMessage = useMemo(() => {
if (organisation.subscription && remaining.documents === 0) {
return msg`Document upload disabled due to unpaid invoices`;
}
if (remaining.documents === 0) {
return msg`You have reached your document limit.`;
}
if (!user.emailVerified) {
return msg`Verify your email to upload documents.`;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remaining.documents, user.emailVerified, team]);
const onFileDrop = async (files: File[]) => {
try {
setIsLoading(true);
const result = await Promise.all(
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
folderId,
type,
title: files[0].name,
items: envelopeItemsToCreate,
meta: {
timezone: userTimezone,
},
}).catch((error) => {
console.error(error);
throw error;
});
void refreshLimits();
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
description:
type === EnvelopeType.DOCUMENT
? t`Your document has been uploaded successfully.`
: t`Your template has been uploaded successfully.`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.otherwise(() => t`An error occurred while uploading your document.`);
toast({
title: t`Error`,
description: errorMessage,
variant: 'destructive',
duration: 7500,
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = () => {
toast({
title:
type === EnvelopeType.DOCUMENT
? t`Your document failed to upload.`
: t`Your template failed to upload.`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return (
<div className={cn('relative', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DocumentDropzone
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
type="envelope"
/>
</div>
</TooltipTrigger>
{type === EnvelopeType.DOCUMENT &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<TooltipContent>
<p className="text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
);
};