Files
documenso/apps/remix/app/components/general/envelope/envelope-upload-button.tsx
Ephraim Atta-Duncan 8e2ca94020 chore: refactor
2025-11-18 20:07:04 +00:00

362 lines
11 KiB
TypeScript

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 { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
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 { 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 { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
import { useCurrentTeam } from '~/providers/team';
import {
type RecipientForCreation,
detectRecipientsInDocument,
ensureRecipientEmails,
} from '~/utils/detect-document-recipients';
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, maximumEnvelopeItemCount } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const [showRecipientDetectionPrompt, setShowAiPromptDialog] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showSuggestedRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.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 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).catch((error) => {
console.error(error);
throw error;
});
void refreshLimits();
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
// Show AI prompt dialog for documents only
if (type === EnvelopeType.DOCUMENT) {
setUploadedDocumentId(id);
setPendingRecipients(null);
setShowAiRecipientsDialog(false);
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(true);
toast({
title: t`Document uploaded`,
description: t`Your document has been uploaded successfully.`,
duration: 5000,
});
} else {
// Templates - navigate immediately
await navigate(`${pathPrefix}/${id}/edit`);
toast({
title: t`Template uploaded`,
description: 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.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred while uploading your document.`);
toast({
title: t`Error`,
description: errorMessage,
variant: 'destructive',
duration: 7500,
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = (fileRejections: FileRejection[]) => {
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;
}
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
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,
});
throw new Error('NO_RECIPIENTS_DETECTED');
}
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false);
setShowAiPromptDialog(false);
setShowAiRecipientsDialog(true);
} catch (err) {
if (!(err instanceof Error && err.message === 'NO_RECIPIENTS_DETECTED')) {
const error = AppError.parseError(err);
toast({
title: t`Failed to analyze recipients`,
description: error.userMessage || t`You can add recipients manually in the editor`,
variant: 'destructive',
duration: 7500,
});
}
throw err;
}
};
const handleSkipRecipientDetection = () => {
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(false);
navigateToEnvelopeEditor();
};
const handleRecipientsCancel = () => {
setShowAiRecipientsDialog(false);
setPendingRecipients(null);
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} recipient(s)`,
duration: 5000,
});
setShowAiRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
} catch (err) {
const error = AppError.parseError(err);
toast({
title: t`Failed to add recipients`,
description: error.userMessage || t`Please review the recipients and try again`,
variant: 'destructive',
duration: 7500,
});
throw err;
}
};
const handlePromptDialogOpenChange = (open: boolean) => {
setShowAiPromptDialog(open);
if (open) {
setShouldNavigateAfterPromptClose(true);
return;
}
if (!open && shouldNavigateAfterPromptClose) {
navigateToEnvelopeEditor();
}
};
return (
<div className={cn('relative', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DocumentUploadButton
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
type={type}
internalVersion="2"
maxFiles={maximumEnvelopeItemCount}
/>
</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>
<RecipientDetectionPromptDialog
open={showRecipientDetectionPrompt}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection}
/>
<SuggestedRecipientsDialog
open={showSuggestedRecipientsDialog}
recipients={pendingRecipients}
onOpenChange={(open) => {
if (!open) {
handleRecipientsCancel();
} else {
setShowAiRecipientsDialog(true);
}
}}
onCancel={handleRecipientsCancel}
onSubmit={handleRecipientsConfirm}
/>
</div>
);
};