mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
Hides the reject dialog on the V2 signing page sidebar and mobile menu, the embed V1 signing page and the multi-sign embed view when the document's allowDocumentRejection is false. In embeds the reject option is shown only when both the embed's allowDocumentRejection option and the document setting allow it, and the embed V1 footer layout follows the same condition. Exposes the field in the multi-sign document response so the embed view can read it. Documents the relationship between the embed's allowDocumentRejection option and the document setting in the iframe and embedded editor docs, and in the API description of the field. Part of #2561
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
|
import { trpc } from '@documenso/trpc/react';
|
|
import type {
|
|
TRemovedSignedFieldWithTokenMutationSchema,
|
|
TSignFieldWithTokenMutationSchema,
|
|
} from '@documenso/trpc/server/field-router/schema';
|
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
import { Input } from '@documenso/ui/primitives/input';
|
|
import { Label } from '@documenso/ui/primitives/label';
|
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { DocumentStatus, SigningStatus } from '@prisma/client';
|
|
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { match, P } from 'ts-pattern';
|
|
|
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
|
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
|
|
|
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
|
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
|
import { EmbedDocumentFields } from '../embed-document-fields';
|
|
|
|
interface MultiSignDocumentSigningViewProps {
|
|
token: string;
|
|
recipientId: number;
|
|
onBack: () => void;
|
|
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
|
|
onDocumentRejected?: (data: { token: string; documentId: number; recipientId: number; reason: string }) => void;
|
|
onDocumentError?: () => void;
|
|
onDocumentReady?: () => void;
|
|
isNameLocked?: boolean;
|
|
allowDocumentRejection?: boolean;
|
|
}
|
|
|
|
export const MultiSignDocumentSigningView = ({
|
|
token,
|
|
recipientId,
|
|
onBack,
|
|
onDocumentCompleted,
|
|
onDocumentRejected,
|
|
onDocumentError,
|
|
onDocumentReady,
|
|
isNameLocked = false,
|
|
allowDocumentRejection = false,
|
|
}: MultiSignDocumentSigningViewProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
const analytics = useAnalytics();
|
|
|
|
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
|
|
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
|
|
const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery(
|
|
{ token },
|
|
{
|
|
staleTime: 0,
|
|
},
|
|
);
|
|
|
|
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
|
const { mutateAsync: removeSignedFieldWithToken } = trpc.field.removeSignedFieldWithToken.useMutation();
|
|
|
|
const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation();
|
|
|
|
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
|
|
|
const [pendingFields, completedFields] = [
|
|
sortFieldsByPosition(
|
|
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? [],
|
|
),
|
|
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? [],
|
|
];
|
|
|
|
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
|
|
|
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
|
try {
|
|
await signFieldWithToken(payload);
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
|
|
analytics.captureException(err, {
|
|
source: 'embed',
|
|
location: 'sign_field',
|
|
recipientId,
|
|
documentId: document?.id,
|
|
});
|
|
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`An error occurred while signing the document.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
|
|
try {
|
|
await removeSignedFieldWithToken(payload);
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
|
|
analytics.captureException(err, {
|
|
source: 'embed',
|
|
location: 'remove_field',
|
|
recipientId,
|
|
documentId: document?.id,
|
|
});
|
|
}
|
|
};
|
|
|
|
const onDocumentComplete = async () => {
|
|
try {
|
|
setIsSubmitting(true);
|
|
|
|
await completeDocumentWithToken({
|
|
documentId: document!.id,
|
|
token,
|
|
});
|
|
|
|
onBack();
|
|
|
|
onDocumentCompleted?.({
|
|
token,
|
|
documentId: document!.id,
|
|
recipientId,
|
|
});
|
|
} catch (err) {
|
|
analytics.captureException(err, {
|
|
source: 'embed',
|
|
location: 'complete_document',
|
|
recipientId,
|
|
documentId: document?.id,
|
|
});
|
|
|
|
onDocumentError?.();
|
|
|
|
const error = AppError.parseError(err);
|
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
|
|
|
toast({
|
|
title: _(toastMessage.title),
|
|
description: _(toastMessage.description),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const onNextFieldClick = () => {
|
|
setShowPendingFieldTooltip(true);
|
|
|
|
setIsExpanded(false);
|
|
};
|
|
|
|
const onRejected = (reason: string) => {
|
|
if (onDocumentRejected && document) {
|
|
onDocumentRejected({
|
|
token,
|
|
documentId: document.id,
|
|
recipientId,
|
|
reason,
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="relative h-full w-full p-8">
|
|
{match({ isLoading, document })
|
|
.with({ isLoading: true }, () => (
|
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Loading document...</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
.with({ isLoading: false, document: undefined }, () => (
|
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Failed to load document</Trans>
|
|
</p>
|
|
</div>
|
|
))
|
|
.with({ document: P.nonNullable }, ({ document }) => (
|
|
<>
|
|
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="font-semibold text-2xl">{document.title}</h1>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{allowDocumentRejection && (document.documentMeta?.allowDocumentRejection ?? true) && (
|
|
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
|
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="embed--DocumentContainer relative mx-auto mt-8 flex w-full max-w-screen-xl flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
<div
|
|
className={cn('embed--DocumentViewer flex-1', {
|
|
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
|
})}
|
|
>
|
|
<PDFViewerLazy
|
|
data={getDocumentDataUrlForPdfViewer({
|
|
envelopeId: document.envelopeId,
|
|
envelopeItemId: document.envelopeItems[0]?.id,
|
|
documentDataId: document.documentData.id,
|
|
version: 'current',
|
|
token,
|
|
presignToken: undefined,
|
|
})}
|
|
scrollParentRef="window"
|
|
onDocumentLoad={() => {
|
|
setHasDocumentLoaded(true);
|
|
onDocumentReady?.();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Widget */}
|
|
{document.status !== DocumentStatus.COMPLETED && (
|
|
<div
|
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-0 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
|
|
data-expanded={isExpanded || undefined}
|
|
>
|
|
<div className="embed--DocumentWidget flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
|
|
{/* Header */}
|
|
<div className="embed--DocumentWidgetHeader">
|
|
<div className="flex items-center justify-between gap-x-2">
|
|
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
|
|
<Trans>Sign document</Trans>
|
|
</h3>
|
|
|
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
|
{isExpanded ? (
|
|
<LucideChevronDown
|
|
className="h-5 w-5 text-muted-foreground"
|
|
onClick={() => setIsExpanded(false)}
|
|
/>
|
|
) : (
|
|
<LucideChevronUp
|
|
className="h-5 w-5 text-muted-foreground"
|
|
onClick={() => setIsExpanded(true)}
|
|
/>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
|
<p className="mt-2 text-muted-foreground text-sm">
|
|
<Trans>Sign the document to complete the process.</Trans>
|
|
</p>
|
|
|
|
<hr className="mt-4 mb-8 border-border" />
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
|
<div className="flex flex-1 flex-col gap-y-4">
|
|
{
|
|
<>
|
|
<div>
|
|
<Label htmlFor="full-name">
|
|
<Trans>Full Name</Trans>
|
|
</Label>
|
|
|
|
<Input
|
|
type="text"
|
|
id="full-name"
|
|
className="mt-2 bg-background"
|
|
disabled={isNameLocked}
|
|
value={fullName}
|
|
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="email">
|
|
<Trans>Email</Trans>
|
|
</Label>
|
|
|
|
<Input type="email" id="email" className="mt-2 bg-background" value={email} disabled />
|
|
</div>
|
|
|
|
{hasSignatureField && (
|
|
<div>
|
|
<Label htmlFor="Signature">
|
|
<Trans>Signature</Trans>
|
|
</Label>
|
|
|
|
<SignaturePadDialog
|
|
className="mt-2"
|
|
disabled={isSubmitting}
|
|
disableAnimation
|
|
fullName={fullName}
|
|
value={signature ?? ''}
|
|
onChange={(v) => setSignature(v ?? '')}
|
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
|
|
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
{uninsertedFields.length > 0 ? (
|
|
<Button className="col-start-2" onClick={onNextFieldClick}>
|
|
<Trans>Next</Trans>
|
|
</Button>
|
|
) : (
|
|
<Button className="col-span-2" loading={isSubmitting} onClick={onDocumentComplete}>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
|
|
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}>
|
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
|
<Trans>Click to insert field</Trans>
|
|
</FieldToolTip>
|
|
</ElementVisible>
|
|
)}
|
|
|
|
{/* Fields */}
|
|
{hasDocumentLoaded && (
|
|
<EmbedDocumentFields
|
|
fields={pendingFields}
|
|
metadata={document.documentMeta}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
)}
|
|
|
|
{/* Completed fields */}
|
|
{document.status !== DocumentStatus.COMPLETED && (
|
|
<DocumentReadOnlyFields documentMeta={document.documentMeta ?? undefined} fields={completedFields} />
|
|
)}
|
|
</div>
|
|
</>
|
|
))
|
|
.otherwise(() => null)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|