diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx similarity index 66% rename from apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx rename to apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 83cc021a0..374aa6d55 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -1,10 +1,15 @@ import { type ReactNode, useState } from 'react'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; +import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; import { Loader } from 'lucide-react'; -import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; +import { + ErrorCode as DropzoneErrorCode, + ErrorCode, + type FileRejection, + useDropzone, +} from 'react-dropzone'; import { Link, useNavigate, useParams } from 'react-router'; import { match } from 'ts-pattern'; @@ -16,21 +21,26 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l 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 } from '@documenso/lib/utils/teams'; +import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; -import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; +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 { useCurrentTeam } from '~/providers/team'; -export interface DocumentDropZoneWrapperProps { +export interface EnvelopeDropZoneWrapperProps { children: ReactNode; + type: EnvelopeType; className?: string; } -export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => { - const { _ } = useLingui(); +export const EnvelopeDropZoneWrapper = ({ + children, + type, + className, +}: EnvelopeDropZoneWrapperProps) => { + const { t } = useLingui(); const { toast } = useToast(); const { user } = useSession(); const { folderId } = useParams(); @@ -47,13 +57,13 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ?? DEFAULT_DOCUMENT_TIME_ZONE; - const { quota, remaining, refreshLimits } = useLimits(); + const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits(); - const { mutateAsync: createDocument } = trpc.document.create.useMutation(); + const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation(); const isUploadDisabled = remaining.documents === 0 || !user.emailVerified; - const onFileDrop = async (file: File) => { + const onFileDrop = async (files: File[]) => { if (isUploadDisabled && IS_BILLING_ENABLED()) { await navigate(`/o/${organisation.url}/settings/billing`); return; @@ -63,51 +73,67 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon setIsLoading(true); const payload = { - title: file.name, - timezone: userTimezone, - folderId: folderId ?? undefined, - } satisfies TCreateDocumentPayloadSchema; + folderId, + type, + title: files[0].name, + meta: { + timezone: userTimezone, + }, + } satisfies TCreateEnvelopePayload; const formData = new FormData(); formData.append('payload', JSON.stringify(payload)); - formData.append('file', file); - const { envelopeId: id } = await createDocument(formData); + for (const file of files) { + formData.append('files', file); + } + + const { id } = await createEnvelope(formData); void refreshLimits(); toast({ - title: _(msg`Document uploaded`), - description: _(msg`Your document has been uploaded successfully.`), + 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, }); - analytics.capture('App: Document Uploaded', { - userId: user.id, - documentId: id, - timestamp: new Date().toISOString(), - }); + if (type === EnvelopeType.DOCUMENT) { + analytics.capture('App: Document Uploaded', { + userId: user.id, + documentId: id, + timestamp: new Date().toISOString(), + }); + } - await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`); + const pathPrefix = + type === EnvelopeType.DOCUMENT + ? formatDocumentsPath(team.url) + : formatTemplatesPath(team.url); + + await navigate(`${pathPrefix}/${id}/edit`); } catch (err) { const error = AppError.parseError(err); const errorMessage = match(error.code) - .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`) + .with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`) .with( AppErrorCode.LIMIT_EXCEEDED, - () => msg`You have reached your document limit for this month. Please upgrade your plan.`, + () => t`You have reached your document limit for this month. Please upgrade your plan.`, ) .with( 'ENVELOPE_ITEM_LIMIT_EXCEEDED', - () => msg`You have reached the limit of the number of files per envelope`, + () => t`You have reached the limit of the number of files per envelope`, ) - .otherwise(() => msg`An error occurred while uploading your document.`); + .otherwise(() => t`An error occurred during upload.`); toast({ - title: _(msg`Error`), - description: _(errorMessage), + title: t`Error`, + description: errorMessage, variant: 'destructive', duration: 7500, }); @@ -121,6 +147,20 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon 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]; @@ -155,7 +195,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon ); toast({ - title: _(msg`Upload failed`), + title: t`Upload failed`, description, duration: 5000, variant: 'destructive', @@ -165,17 +205,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon accept: { 'application/pdf': ['.pdf'], }, - //disabled: isUploadDisabled, - multiple: false, + multiple: true, maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), - onDrop: ([acceptedFile]) => { - if (acceptedFile) { - void onFileDrop(acceptedFile); - } - }, - onDropRejected: (fileRejections) => { - onFileDropRejected(fileRejections); - }, + maxFiles: maximumEnvelopeItemCount, + onDrop: (files) => void onFileDrop(files), + onDropRejected: onFileDropRejected, noClick: true, noDragEventsBubbling: true, }); @@ -189,7 +223,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon

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

@@ -224,7 +262,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon

- Uploading document... + Uploading

diff --git a/apps/remix/app/components/general/document/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx similarity index 100% rename from apps/remix/app/components/general/document/envelope-upload-button.tsx rename to apps/remix/app/components/general/envelope/envelope-upload-button.tsx diff --git a/apps/remix/app/components/general/folder/folder-grid.tsx b/apps/remix/app/components/general/folder/folder-grid.tsx index fa5cb3612..e8ef9dc3d 100644 --- a/apps/remix/app/components/general/folder/folder-grid.tsx +++ b/apps/remix/app/components/general/folder/folder-grid.tsx @@ -20,7 +20,7 @@ import { DocumentUploadButtonLegacy } from '~/components/general/document/docume import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card'; import { useCurrentTeam } from '~/providers/team'; -import { EnvelopeUploadButton } from '../document/envelope-upload-button'; +import { EnvelopeUploadButton } from '../envelope/envelope-upload-button'; export type FolderGridProps = { type: FolderType; diff --git a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx deleted file mode 100644 index 94fced8ba..000000000 --- a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { type ReactNode, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { Loader } from 'lucide-react'; -import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; -import { useNavigate, useParams } from 'react-router'; -import { match } from 'ts-pattern'; - -import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; -import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; -import { formatTemplatesPath } from '@documenso/lib/utils/teams'; -import { trpc } from '@documenso/trpc/react'; -import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema'; -import { cn } from '@documenso/ui/lib/utils'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCurrentTeam } from '~/providers/team'; - -export interface TemplateDropZoneWrapperProps { - children: ReactNode; - className?: string; -} - -export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - const { folderId } = useParams(); - - const team = useCurrentTeam(); - - const navigate = useNavigate(); - - const [isLoading, setIsLoading] = useState(false); - - const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation(); - - const onFileDrop = async (file: File) => { - try { - setIsLoading(true); - - const payload = { - title: file.name, - folderId: folderId ?? undefined, - } satisfies TCreateTemplatePayloadSchema; - - const formData = new FormData(); - - formData.append('payload', JSON.stringify(payload)); - formData.append('file', file); - - const { envelopeId: id } = await createTemplate(formData); - - toast({ - title: _(msg`Template uploaded`), - description: _( - msg`Your template has been uploaded successfully. You will be redirected to the template page.`, - ), - duration: 5000, - }); - - await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`); - } catch { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`Please try again later.`), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - const onFileDropRejected = (fileRejections: FileRejection[]) => { - if (!fileRejections.length) { - 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: _(msg`Upload failed`), - description, - duration: 5000, - variant: 'destructive', - }); - }; - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { - 'application/pdf': ['.pdf'], - }, - //disabled: isUploadDisabled, - multiple: false, - maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), - onDrop: ([acceptedFile]) => { - if (acceptedFile) { - void onFileDrop(acceptedFile); - } - }, - onDropRejected: (fileRejections) => { - onFileDropRejected(fileRejections); - }, - noClick: true, - noDragEventsBubbling: true, - }); - - return ( -
- - {children} - - {isDragActive && ( -
-
-

- Upload Template -

- -

- Drag and drop your PDF file here -

-
-
- )} - - {isLoading && ( -
-
- -

- Uploading template... -

-
-
- )} -
- ); -}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index f3385db68..58e80af6e 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; import { FolderType, OrganisationType } from '@prisma/client'; import { useParams, useSearchParams } from 'react-router'; import { Link } from 'react-router'; @@ -18,9 +19,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog'; -import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper'; import { DocumentSearch } from '~/components/general/document/document-search'; import { DocumentStatus } from '~/components/general/document/document-status'; +import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; import { PeriodSelector } from '~/components/general/period-selector'; import { DocumentsTable } from '~/components/tables/documents-table'; @@ -108,9 +109,8 @@ export default function DocumentsPage() { } }, [data?.stats]); - // Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready. return ( - +
@@ -210,6 +210,6 @@ export default function DocumentsPage() { /> )}
-
+ ); } diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index 56e046f13..c97df2739 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -1,4 +1,5 @@ import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; import { Bird } from 'lucide-react'; import { useParams, useSearchParams } from 'react-router'; @@ -8,8 +9,8 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; -import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper'; import { TemplatesTable } from '~/components/tables/templates-table'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -37,7 +38,7 @@ export default function TemplatesPage() { }); return ( - +
@@ -85,6 +86,6 @@ export default function TemplatesPage() {
- + ); }