import { type ReactNode, useState } from 'react'; import { plural } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { Loader } from 'lucide-react'; import { ErrorCode as DropzoneErrorCode, ErrorCode, type FileRejection, useDropzone, } from 'react-dropzone'; import { Link, useNavigate, useParams } from 'react-router'; import { match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog'; import { useCurrentTeam } from '~/providers/team'; import { type RecipientForCreation, detectRecipientsInDocument, ensureRecipientEmails, } from '~/utils/detect-document-recipients'; export interface EnvelopeDropZoneWrapperProps { children: ReactNode; type: EnvelopeType; className?: string; } export const EnvelopeDropZoneWrapper = ({ children, type, className, }: EnvelopeDropZoneWrapperProps) => { const { t } = useLingui(); const { toast } = useToast(); const { user } = useSession(); const { folderId } = useParams(); const team = useCurrentTeam(); const navigate = useNavigate(); const analytics = useAnalytics(); const organisation = useCurrentOrganisation(); const [isLoading, setIsLoading] = useState(false); const [showExtractionPrompt, setShowExtractionPrompt] = useState(false); const [uploadedDocumentId, setUploadedDocumentId] = useState(null); const [pendingRecipients, setPendingRecipients] = useState(null); const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true); const userTimezone = TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ?? DEFAULT_DOCUMENT_TIME_ZONE; const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits(); const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation(); const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation(); const isUploadDisabled = remaining.documents === 0 || !user.emailVerified; const onFileDrop = async (files: File[]) => { if (isUploadDisabled && IS_BILLING_ENABLED()) { await navigate(`/o/${organisation.url}/settings/billing`); return; } try { setIsLoading(true); const payload = { folderId, type, title: files[0].name, meta: { timezone: userTimezone, }, } satisfies TCreateEnvelopePayload; const formData = new FormData(); formData.append('payload', JSON.stringify(payload)); for (const file of files) { formData.append('files', file); } const { id } = await createEnvelope(formData); void refreshLimits(); 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, }); if (type === EnvelopeType.DOCUMENT) { analytics.capture('App: Document Uploaded', { userId: user.id, documentId: id, timestamp: new Date().toISOString(), }); // Show AI prompt dialog for documents setUploadedDocumentId(id); setPendingRecipients(null); setShouldNavigateAfterPromptClose(true); setShowExtractionPrompt(true); } else { // Templates - navigate immediately const pathPrefix = formatTemplatesPath(team.url); await navigate(`${pathPrefix}/${id}/edit`); } } catch (err) { const error = AppError.parseError(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.`, ) .with( 'ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope`, ) .otherwise(() => t`An error occurred during upload.`); toast({ title: t`Upload failed`, description: errorMessage, variant: 'destructive', duration: 7500, }); } finally { setIsLoading(false); } }; const onFileDropRejected = (fileRejections: FileRejection[]) => { if (!fileRejections.length) { return; } const maxItemsReached = fileRejections.some((fileRejection) => fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles), ); if (maxItemsReached) { toast({ title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`, duration: 5000, variant: 'destructive', }); return; } // Since users can only upload only one file (no multi-upload), we only handle the first file rejection const { file, errors } = fileRejections[0]; if (!errors.length) { return; } const errorNodes = errors.map((error, index) => ( {match(error.code) .with(ErrorCode.FileTooLarge, () => ( File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB )) .with(ErrorCode.FileInvalidType, () => Only PDF files are allowed) .with(ErrorCode.FileTooSmall, () => File is too small) .with(ErrorCode.TooManyFiles, () => ( Only one file can be uploaded at a time )) .otherwise(() => ( Unknown error ))} )); const description = ( <> {file.name} couldn't be uploaded: {errorNodes} ); toast({ title: t`Upload failed`, description, duration: 5000, variant: 'destructive', }); }; const navigateToEnvelopeEditor = () => { if (!uploadedDocumentId) { return; } const pathPrefix = formatDocumentsPath(team.url); void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`); }; const handleStartRecipientDetection = async () => { if (!uploadedDocumentId) { return; } try { const recipients = await detectRecipientsInDocument(uploadedDocumentId); if (recipients.length === 0) { toast({ title: t`No recipients detected`, description: t`You can add recipients manually in the editor`, duration: 5000, }); setShouldNavigateAfterPromptClose(true); setShowExtractionPrompt(false); navigateToEnvelopeEditor(); return; } const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId); setPendingRecipients(recipientsWithEmails); setShouldNavigateAfterPromptClose(false); } catch (error) { if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) { const parsedError = AppError.parseError(error); toast({ title: t`Failed to detect recipients`, description: parsedError.userMessage || t`You can add recipients manually in the editor`, variant: 'destructive', duration: 7500, }); } throw error; } }; const handleSkipRecipientDetection = () => { setShouldNavigateAfterPromptClose(true); setShowExtractionPrompt(false); navigateToEnvelopeEditor(); }; const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => { if (!uploadedDocumentId) { return; } try { await createRecipients({ envelopeId: uploadedDocumentId, data: recipientsToCreate, }); toast({ title: t`Recipients added`, description: t`Successfully detected ${recipientsToCreate.length} ${plural( recipientsToCreate.length, { one: 'recipient', other: 'recipients', }, )}`, duration: 5000, }); setShowExtractionPrompt(false); setPendingRecipients(null); navigateToEnvelopeEditor(); } catch (error) { const parsedError = AppError.parseError(error); toast({ title: t`Failed to add recipients`, description: parsedError.userMessage || t`Please review the recipients and try again`, variant: 'destructive', duration: 7500, }); throw error; } }; const handlePromptDialogOpenChange = (open: boolean) => { setShowExtractionPrompt(open); if (open) { setShouldNavigateAfterPromptClose(true); return; } if (!open && shouldNavigateAfterPromptClose) { navigateToEnvelopeEditor(); } }; const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: { 'application/pdf': ['.pdf'], }, multiple: true, maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), maxFiles: maximumEnvelopeItemCount, onDrop: (files) => void onFileDrop(files), onDropRejected: onFileDropRejected, noClick: true, noDragEventsBubbling: true, }); return (
{children} {isDragActive && (

{type === EnvelopeType.DOCUMENT ? ( Upload Document ) : ( Upload Template )}

Drag and drop your PDF file here

{isUploadDisabled && IS_BILLING_ENABLED() && ( Upgrade your plan to upload more documents )} {!isUploadDisabled && team?.id === undefined && remaining.documents > 0 && Number.isFinite(remaining.documents) && (

{remaining.documents} of {quota.documents} documents remaining this month.

)}
)} {isLoading && (

Uploading

)}
); };