fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-11-12 12:26:14 +00:00
317 changed files with 13305 additions and 11517 deletions

View File

@ -1,61 +0,0 @@
import { useState } from 'react';
import type { DocumentData } from '@prisma/client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Dialog, DialogOverlay, DialogPortal, DialogTrigger } from '../../primitives/dialog';
import PDFViewer from '../../primitives/pdf-viewer';
export type DocumentDialogProps = {
trigger?: React.ReactNode;
documentData: DocumentData;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
/**
* A dialog which renders the provided document.
*/
export default function DocumentDialog({ trigger, documentData, ...props }: DocumentDialogProps) {
const [documentLoaded, setDocumentLoaded] = useState(false);
const onDocumentLoad = () => {
setDocumentLoaded(true);
};
return (
<Dialog {...props}>
<DialogPortal>
<DialogOverlay className="bg-black/80" />
{trigger && (
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger}
</DialogTrigger>
)}
<DialogPrimitive.Content
className={cn(
'animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 pointer-events-none fixed z-50 h-screen w-screen overflow-y-auto px-2 py-14 opacity-0 transition-opacity lg:py-32',
{
'opacity-100': documentLoaded,
},
)}
onClick={() => props.onOpenChange?.(false)}
>
<PDFViewer
className="mx-auto w-full max-w-3xl xl:max-w-5xl"
documentData={documentData}
onClick={(e) => e.stopPropagation()}
onDocumentLoad={onDocumentLoad}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
<X className="h-6 w-6 text-white" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}

View File

@ -1,69 +0,0 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData } from '@prisma/client';
import { Download } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Button } from '../../primitives/button';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
documentData?: DocumentData;
};
export const DocumentDownloadButton = ({
className,
fileName,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
setIsLoading(true);
if (!documentData) {
setIsLoading(false);
return;
}
await downloadPDF({ documentData, fileName }).then(() => {
setIsLoading(false);
});
} catch (err) {
setIsLoading(false);
toast({
title: _('Something went wrong'),
description: _('An error occurred while downloading your document.'),
variant: 'destructive',
});
}
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
{!isLoading && <Download className="mr-2 h-5 w-5" />}
<Trans>Download</Trans>
</Button>
);
};

View File

@ -0,0 +1,206 @@
import { useCallback, useEffect, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
import { extractInitials } from '../../../lib/utils/recipient-formatter';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { Avatar, AvatarFallback } from '../../primitives/avatar';
import { Badge } from '../../primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
import { PopoverHover } from '../../primitives/popover';
interface EnvelopeRecipientFieldTooltipProps {
field: Pick<
Field,
| 'id'
| 'inserted'
| 'positionX'
| 'positionY'
| 'width'
| 'height'
| 'page'
| 'type'
| 'fieldMeta'
> & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
};
showFieldStatus?: boolean;
showRecipientTooltip?: boolean;
showRecipientColors?: boolean;
}
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
};
/**
* Renders a tooltip for a given field.
*/
export function EnvelopeRecipientFieldTooltip({
field,
showFieldStatus = false,
showRecipientTooltip = false,
showRecipientColors = false,
}: EnvelopeRecipientFieldTooltipProps) {
const { t } = useLingui();
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { height, width } = getBoundingClientRect($page);
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
const fieldX = (Number(field.positionX) / 100) * width + Number(fieldWidth);
const fieldY = (Number(field.positionY) / 100) * height;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
if (hideField) {
return null;
}
return (
<div
id="field-recipient-tooltip"
className={cn('absolute z-40')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
}}
>
<PopoverHover
trigger={
<Avatar className="absolute -left-3 -top-3 z-50 h-6 w-6 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field?.fieldMeta?.readOnly
? 'neutral'
: field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field?.fieldMeta?.readOnly ? (
<>
<LockIcon className="mr-1 h-3 w-3" />
<Trans>Read Only</Trans>
</>
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<ClockIcon className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>{t(FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{getRecipientDisplayText(field.recipient)}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => setHideField(true)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
);
}

View File

@ -2,11 +2,14 @@ import React, { Suspense, lazy } from 'react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;

View File

@ -1,13 +1,17 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } 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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
export type LoadedPDFDocument = PDFDocumentProxy;
@ -19,6 +23,10 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
import.meta.url,
).toString();
const pdfViewerOptions = {
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps`,
};
const PDFLoader = () => (
<>
<Loader className="text-documenso h-12 w-12 animate-spin" />
@ -29,10 +37,31 @@ const PDFLoader = () => (
</>
);
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
const RendererErrorMessages: Record<
PdfViewerRendererMode,
{ title: MessageDescriptor; description: MessageDescriptor }
> = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
};
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
@ -40,18 +69,21 @@ export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
renderer,
...props
}: PdfViewerKonvaProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem } = useCurrentEnvelopeRender();
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const envelopeItemFile = useMemo(() => {
const data = getPdfBuffer(currentEnvelopeItem?.documentDataId || '');
const data = getPdfBuffer(currentEnvelopeItem?.id || '');
if (!data || data.status !== 'loaded') {
return null;
@ -60,7 +92,7 @@ export const PdfViewerKonva = ({
return {
data: new Uint8Array(data.file),
};
}, [currentEnvelopeItem?.documentDataId, getPdfBuffer]);
}, [currentEnvelopeItem?.id, getPdfBuffer]);
const onDocumentLoaded = useCallback(
(doc: PDFDocumentProxy) => {
@ -92,6 +124,13 @@ export const PdfViewerKonva = ({
return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
</Alert>
)}
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
@ -133,6 +172,7 @@ export const PdfViewerKonva = ({
</div>
</div>
}
// options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)

View File

@ -78,6 +78,6 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "3.25.76"
"zod": "^3.25.76"
}
}

View File

@ -58,7 +58,7 @@ const Combobox = ({
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start">
<PopoverContent className="z-[1001] p-0" side="bottom" align="start">
<Command>
<CommandInput placeholder={value || placeholderValue} />

View File

@ -16,6 +16,12 @@ export const numberFormatValues = [
},
];
export enum CheckboxValidationRules {
SELECT_AT_LEAST = 'Select at least',
SELECT_EXACTLY = 'Select exactly',
SELECT_AT_MOST = 'Select at most',
}
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const checkboxValidationSigns = [

View File

@ -2,6 +2,7 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Upload } from 'lucide-react';
import type { DropEvent, FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
@ -16,29 +17,32 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { Button } from './button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
export type DocumentDropzoneProps = {
export type DocumentUploadButtonProps = {
className?: string;
disabled?: boolean;
loading?: boolean;
disabledMessage?: MessageDescriptor;
onDrop?: (_files: File[]) => void | Promise<void>;
onDropRejected?: (fileRejections: FileRejection[], event: DropEvent) => void;
type?: 'document' | 'template' | 'envelope';
type: EnvelopeType;
internalVersion: '1' | '2';
maxFiles?: number;
[key: string]: unknown;
};
export const DocumentDropzone = ({
export const DocumentUploadButton = ({
className,
loading,
onDrop,
onDropRejected,
disabled,
disabledMessage = msg`You cannot upload documents at this time.`,
type = 'document',
type,
internalVersion,
maxFiles,
...props
}: DocumentDropzoneProps) => {
}: DocumentUploadButtonProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
@ -51,7 +55,7 @@ export const DocumentDropzone = ({
accept: {
'application/pdf': ['.pdf'],
},
multiple: type === 'envelope',
multiple: internalVersion === '2',
disabled,
maxFiles,
onDrop: (acceptedFiles) => {
@ -64,9 +68,10 @@ export const DocumentDropzone = ({
});
const heading = {
document: msg`Upload Document`,
template: msg`Upload Template Document`,
envelope: msg`Envelope (beta)`,
[EnvelopeType.DOCUMENT]:
internalVersion === '1' ? msg`Document (Legacy)` : msg`Upload Document`,
[EnvelopeType.TEMPLATE]:
internalVersion === '1' ? msg`Template (Legacy)` : msg`Upload Template`,
};
if (disabled && IS_BILLING_ENABLED()) {

View File

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],

View File

@ -1,17 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData } from '@prisma/client';
import type { EnvelopeItem } from '@prisma/client';
import { base64 } from '@scure/base';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
// import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
// import 'react-pdf/dist/esm/Page/TextLayer.css';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { cn } from '../lib/utils';
import { useToast } from './use-toast';
@ -26,6 +28,10 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
import.meta.url,
).toString();
const pdfViewerOptions = {
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps`,
};
export type OnPDFViewerPageClick = (_event: {
pageNumber: number;
numPages: number;
@ -48,17 +54,23 @@ const PDFLoader = () => (
export type PDFViewerProps = {
className?: string;
documentData: Pick<DocumentData, 'type' | 'data'>;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
version: 'original' | 'signed';
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick;
overrideData?: string;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PDFViewer = ({
className,
documentData,
envelopeItem,
token,
version,
onDocumentLoad,
onPageClick,
overrideData,
...props
}: PDFViewerProps) => {
const { _ } = useLingui();
@ -67,17 +79,14 @@ export const PDFViewer = ({
const $el = useRef<HTMLDivElement>(null);
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(
overrideData ? base64.decode(overrideData) : null,
);
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const memoizedData = useMemo(
() => ({ type: documentData.type, data: documentData.data }),
[documentData.data, documentData.type],
);
const isLoading = isDocumentBytesLoading || !documentBytes;
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
@ -142,13 +151,26 @@ export const PDFViewer = ({
}, []);
useEffect(() => {
if (overrideData) {
const bytes = base64.decode(overrideData);
setDocumentBytes(bytes);
return;
}
const fetchDocumentBytes = async () => {
try {
setIsDocumentBytesLoading(true);
const bytes = await getFile(memoizedData);
const documentUrl = getEnvelopeItemPdfUrl({
type: 'view',
envelopeItem: envelopeItem,
token,
});
setDocumentBytes(bytes);
const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
setDocumentBytes(new Uint8Array(bytes));
setIsDocumentBytesLoading(false);
} catch (err) {
@ -163,7 +185,7 @@ export const PDFViewer = ({
};
void fetchDocumentBytes();
}, [memoizedData, toast]);
}, [envelopeItem.envelopeId, envelopeItem.id, token, version, toast, overrideData]);
return (
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
@ -217,6 +239,7 @@ export const PDFViewer = ({
</div>
</div>
}
// options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)

View File

@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
'bg-popover z-9999 text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className,
)}
{...props}