import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor'; import { nanoid } from '@documenso/lib/universal/id'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config'; import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { trpc } from '@documenso/trpc/react'; import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types'; import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { useToast } from '@documenso/ui/primitives/use-toast'; import type { DropResult } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { msg, plural } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; type LocalFile = { id: string; title: string; envelopeItemId: string | null; isUploading: boolean; isReplacing: boolean; isError: boolean; }; export const EnvelopeEditorUploadPage = () => { const organisation = useCurrentOrganisation(); const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); const { toast } = useToast(); const { envelope, setLocalEnvelope, editorFields, editorConfig, isEmbedded, navigateToStep, registerExternalFlush, registerPendingMutation, } = useCurrentEnvelopeEditor(); const { envelopeItems: uploadConfig } = editorConfig; const [localFiles, setLocalFiles] = useState( envelope.envelopeItems .sort((a, b) => a.order - b.order) .map((item) => ({ id: item.id, title: item.title, envelopeItemId: item.id, isUploading: false, isReplacing: false, isError: false, })), ); const replacingItemIdRef = useRef(null); const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({ accept: { 'application/pdf': ['.pdf'] }, maxFiles: 1, maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), multiple: false, noClick: true, noKeyboard: true, noDrag: true, onDrop: (acceptedFiles) => { const file = acceptedFiles[0]; const replacingItemId = replacingItemIdRef.current; if (file && replacingItemId) { void onReplacePdf(replacingItemId, file); replacingItemIdRef.current = null; } }, onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections), onFileDialogCancel: () => { replacingItemIdRef.current = null; }, }); const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } = trpc.envelope.item.createMany.useMutation({ onSuccess: ({ data }) => { const createdEnvelopes = data.filter( (item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id), ); setLocalEnvelope({ envelopeItems: [...envelope.envelopeItems, ...createdEnvelopes], }); }, }); const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({ onSuccess: ({ data }) => { setLocalEnvelope({ envelopeItems: envelope.envelopeItems.map((originalItem) => { const updatedItem = data.find((item) => item.id === originalItem.id); if (updatedItem) { return { ...originalItem, ...updatedItem, }; } return originalItem; }), }); }, }); const envelopeItemPermissions = useMemo( () => getEnvelopeItemPermissions(envelope, envelope.recipients), [envelope, envelope.recipients], ); const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({ onSuccess: ({ data, fields }) => { // Update the envelope item with the new documentDataId. setLocalEnvelope({ envelopeItems: envelope.envelopeItems.map((item) => item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item, ), }); // When fields were created or deleted during the replacement, // the server returns the full updated field list. if (fields) { setLocalEnvelope({ fields }); editorFields.resetForm(fields); } }, }); const onFileDrop = async (files: File[]) => { const newUploadingFiles: (LocalFile & { file: File; data: TEditorEnvelope['envelopeItems'][number]['data'] | null; })[] = await Promise.all( files.map(async (file) => { return { id: nanoid(), envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null, title: file.name, file, isUploading: isEmbedded ? false : true, isReplacing: false, // Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once) data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null, isError: false, }; }), ); setLocalFiles((prev) => [...prev, ...newUploadingFiles]); // Directly commit the files for embedded documents since those are not uploaded // until the end of the embedded flow. if (isEmbedded) { setLocalEnvelope({ envelopeItems: [ ...envelope.envelopeItems, ...newUploadingFiles.map((file) => ({ id: file.envelopeItemId!, title: file.title, order: envelope.envelopeItems.length + 1, envelopeId: envelope.id, data: file.data!, documentDataId: '', })), ], }); return; } const payload = { envelopeId: envelope.id, } satisfies TCreateEnvelopeItemsPayload; const formData = new FormData(); formData.append('payload', JSON.stringify(payload)); for (const file of files) { formData.append('files', file); } const createPromise = createEnvelopeItems(formData); registerPendingMutation(createPromise); const { data } = await createPromise.catch((error) => { console.error(error); // Set error state on files in batch upload. setLocalFiles((prev) => prev.map((uploadingFile) => uploadingFile.id === newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id ? { ...uploadingFile, isError: true, isUploading: false } : uploadingFile, ), ); throw error; }); setLocalFiles((prev) => { const filteredFiles = prev.filter( (uploadingFile) => uploadingFile.id !== newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id, ); return filteredFiles.concat( data.map((item) => ({ id: item.id, envelopeItemId: item.id, title: item.title, isUploading: false, isReplacing: false, isError: false, })), ); }); }; const onReplacePdf = async (envelopeItemId: string, file: File) => { setLocalFiles((prev) => prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f))); try { if (isEmbedded) { // For embedded mode, store the file data locally on the envelope item. // The actual replacement will happen when the embed flow submits. const arrayBuffer = await file.arrayBuffer(); const data = new Uint8Array(arrayBuffer.slice(0)); // Count pages in the new PDF to remove out-of-bounds fields. const { PDF } = await import('@libpdf/core'); const pdfDoc = await PDF.load(data); const newPageCount = pdfDoc.getPageCount(); // Remove fields that are on pages beyond the new PDF's page count. const remainingFields = envelope.fields.filter( (field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount, ); setLocalEnvelope({ envelopeItems: envelope.envelopeItems.map((item) => (item.id === envelopeItemId ? { ...item, data } : item)), fields: remainingFields, }); editorFields.resetForm(remainingFields); return; } // Normal mode: upload immediately via tRPC. const payload = { envelopeId: envelope.id, envelopeItemId, } satisfies TReplaceEnvelopeItemPdfPayload; const formData = new FormData(); formData.append('payload', JSON.stringify(payload)); formData.append('file', file); const replacePromise = replaceEnvelopeItemPdf(formData); registerPendingMutation(replacePromise); await replacePromise; } catch (error) { console.error(error); toast({ title: t`Replace failed`, description: t`Something went wrong while replacing the PDF`, duration: 5000, variant: 'destructive', }); } finally { setLocalFiles((prev) => prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)), ); } }; /** * Hide the envelope item from the list on deletion. */ const onFileDelete = (envelopeItemId: string) => { setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId)); const fieldsWithoutDeletedItem = envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId); setLocalEnvelope({ envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId), fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId), }); // Reset editor fields. editorFields.resetForm(fieldsWithoutDeletedItem); }; /** * Handle drag end for reordering files. */ const onDragEnd = (result: DropResult) => { if (!result.destination) { return; } const items = Array.from(localFiles); const [reorderedItem] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, reorderedItem); setLocalFiles(items); debouncedUpdateEnvelopeItems(items); }; const { triggerSave: debouncedUpdateEnvelopeItems, flush: flushUpdateEnvelopeItems } = useEnvelopeAutosave( async (files: LocalFile[]) => { if (isEmbedded) { const nextEnvelopeItems = files .filter((item) => item.envelopeItemId) .map((item, index) => { const originalEnvelopeItem = envelope.envelopeItems.find( (envelopeItem) => envelopeItem.id === item.envelopeItemId, ); return { id: item.envelopeItemId || '', title: item.title, order: index + 1, envelopeId: envelope.id, data: originalEnvelopeItem?.data, documentDataId: originalEnvelopeItem?.documentDataId || '', }; }); setLocalEnvelope({ envelopeItems: nextEnvelopeItems, }); return; } await updateEnvelopeItems({ envelopeId: envelope.id, data: files .filter((item) => item.envelopeItemId) .map((item, index) => ({ envelopeItemId: item.envelopeItemId || '', order: index + 1, title: item.title, })), }); }, isEmbedded ? 0 : 1000, ); const flushUpdateEnvelopeItemsRef = useRef(flushUpdateEnvelopeItems); flushUpdateEnvelopeItemsRef.current = flushUpdateEnvelopeItems; // Register the flush callback with the provider so flushAutosave can await // pending envelope item mutations. We intentionally do NOT unregister on unmount // because the upload page is unmounted (replaced with a spinner) before // flushAutosave runs during step transitions. The hook's internal refs survive // unmounting, so the flush callback remains valid. useEffect(() => { registerExternalFlush('envelopeItems', async () => flushUpdateEnvelopeItemsRef.current()); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => { const newLocalFilesValue = localFiles.map((uploadingFile) => uploadingFile.envelopeItemId === envelopeItemId ? { ...uploadingFile, title } : uploadingFile, ); setLocalFiles(newLocalFilesValue); debouncedUpdateEnvelopeItems(newLocalFilesValue); }; const dropzoneDisabledMessage = useMemo(() => { if (!envelopeItemPermissions.canFileBeChanged) { return msg`Cannot upload items after the document has been sent`; } if (organisation.subscription && remaining.documents === 0) { return msg`Document upload disabled due to unpaid invoices`; } if (maximumEnvelopeItemCount <= localFiles.length) { return msg({ message: plural(maximumEnvelopeItemCount, { one: `You cannot upload more than # item per envelope.`, other: `You cannot upload more than # items per envelope.`, }), }); } return null; // eslint-disable-next-line react-hooks/exhaustive-deps }, [localFiles.length, maximumEnvelopeItemCount, remaining.documents]); const onFileDropRejected = (fileRejections: FileRejection[]) => { const maxItemsReached = fileRejections.some((fileRejection) => fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles), ); if (maxItemsReached) { toast({ title: plural(maximumEnvelopeItemCount, { one: `You cannot upload more than # item per envelope.`, other: `You cannot upload more than # items per envelope.`, }), duration: 5000, variant: 'destructive', }); return; } toast({ title: t`Upload failed`, description: i18n._(buildDropzoneRejectionDescription(fileRejections)), duration: 5000, variant: 'destructive', }); }; return (
Documents Add and configure multiple documents {uploadConfig?.allowUpload && ( )} {/* Uploaded Files List */}
{(provided) => (
{localFiles.map((localFile, index) => ( {(provided, snapshot) => (
{uploadConfig?.allowConfigureOrder && (
)}
{localFile.envelopeItemId !== null ? ( { onEnvelopeItemTitleChange(localFile.envelopeItemId!, title); }} /> ) : (

{localFile.title}

)}
{localFile.isUploading ? ( Uploading ) : localFile.isError ? ( Something went wrong while uploading this file ) : null}
{localFile.isUploading && (
)} {localFile.isError && (
)} {localFile.envelopeItemId && envelopeItemPermissions.canFileBeChanged && uploadConfig?.allowReplace && ( )} {localFile.envelopeItemId && uploadConfig?.allowDelete && (isEmbedded ? ( ) : ( } /> ))}
)}
))} {provided.placeholder}
)}
{/* Recipients Section */} {editorConfig.general.allowAddFieldsStep && (
)}
); };