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() {
-
+
);
}