feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions
@@ -2,13 +2,12 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
@@ -34,9 +33,13 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
return recipient.email;
};
export type DocumentField = Field & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
};
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
showFieldStatus?: boolean;
@@ -60,7 +60,7 @@ export const DocumentShareButton = ({
mutateAsync: createOrGetShareLink,
data: shareLink,
isPending: isCreatingOrGettingShareLink,
} = trpc.shareLink.createOrGetShareLink.useMutation();
} = trpc.document.share.useMutation();
const isLoading = isCreatingOrGettingShareLink || isCopyingShareLink;
@@ -29,7 +29,10 @@ const tooltipVariants = cva('font-semibold', {
interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
children: React.ReactNode;
className?: string;
field: Field;
field: Pick<
Field,
'id' | 'inserted' | 'fieldMeta' | 'positionX' | 'positionY' | 'width' | 'height' | 'page'
>;
}
/**
@@ -48,6 +51,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
return createPortal(
<div
id="field-tooltip"
className={cn('pointer-events-none absolute')}
style={{
top: `${coords.y}px`,
@@ -0,0 +1,23 @@
import React, { Suspense, lazy } from 'react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
return (
<Suspense fallback={<div>Loading client component...</div>}>
<EnvelopePdfViewer {...props} />
</Suspense>
);
};
export default PDFViewerKonvaLazy;
@@ -0,0 +1,173 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import Konva from 'konva';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
export type LoadedPDFDocument = PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.js',
import.meta.url,
).toString();
const PDFLoader = () => (
<>
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
...props
}: PdfViewerKonvaProps) => {
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const envelopeItemFile = useMemo(() => {
const data = getPdfBuffer(currentEnvelopeItem?.documentDataId || '');
if (!data || data.status !== 'loaded') {
return null;
}
return {
data: new Uint8Array(data.file),
};
}, [currentEnvelopeItem?.documentDataId, getPdfBuffer]);
const onDocumentLoaded = useCallback(
(doc: PDFDocumentProxy) => {
setNumPages(doc.numPages);
},
[onDocumentLoad],
);
useEffect(() => {
if ($el.current) {
const $current = $el.current;
const { width } = $current.getBoundingClientRect();
setWidth(width);
const onResize = () => {
const { width } = $current.getBoundingClientRect();
setWidth(width);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}
}, []);
return (
<div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full overflow-hidden rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
{pdfError ? (
<div className="text-muted-foreground text-center">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<div className="text-muted-foreground text-center">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="border-border overflow-hidden rounded border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
/>
</div>
<p className="text-muted-foreground/80 my-2 text-center text-[11px]">
<Trans>
Page {i + 1} of {numPages}
</Trans>
</p>
</div>
))}
</PDFDocument>
) : (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<PDFLoader />
</div>
)}
</div>
);
};
export default PdfViewerKonva;
+2 -2
View File
@@ -10,7 +10,7 @@ import { Card, CardContent } from '../primitives/card';
export type SigningCardProps = {
className?: string;
name: string;
signature?: Signature;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'>;
signingCelebrationImage?: string;
};
@@ -154,7 +154,7 @@ export const SigningCard3D = ({
type SigningCardContentProps = {
name: string;
signature?: Signature;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'>;
className?: string;
};