mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
import { AppError } from '@documenso/lib/errors/app-error';
|
|
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
|
|
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
|
import { zEmail } from '@documenso/lib/utils/zod';
|
|
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 { 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 {
|
|
type DocumentMeta,
|
|
type EnvelopeItem,
|
|
type Field,
|
|
FieldType,
|
|
type Recipient,
|
|
type Signature,
|
|
} from '@prisma/client';
|
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|
import { DateTime } from 'luxon';
|
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
|
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
|
import { injectCss } from '~/utils/css-vars';
|
|
import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages';
|
|
|
|
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
|
import { EmbedClientLoading } from './embed-client-loading';
|
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
|
import { EmbedDocumentFields } from './embed-document-fields';
|
|
|
|
export type EmbedDirectTemplateClientPageProps = {
|
|
token: string;
|
|
envelopeId: string;
|
|
updatedAt: Date;
|
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
|
|
recipient: Recipient;
|
|
fields: Field[];
|
|
metadata?: DocumentMeta | null;
|
|
hidePoweredBy?: boolean;
|
|
allowWhiteLabelling?: boolean;
|
|
};
|
|
|
|
export const EmbedDirectTemplateClientPage = ({
|
|
token,
|
|
envelopeId,
|
|
updatedAt,
|
|
envelopeItems,
|
|
recipient,
|
|
fields,
|
|
metadata,
|
|
hidePoweredBy = false,
|
|
allowWhiteLabelling = false,
|
|
}: EmbedDirectTemplateClientPageProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
|
|
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
const [hasCompletedDocument, setHasCompletedDocument] = useState(false);
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
const [isEmailLocked, setIsEmailLocked] = useState(false);
|
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
|
|
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
const [emailError, setEmailError] = useState<string | null>(null);
|
|
|
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
|
|
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
|
|
|
const [pendingFields, _completedFields] = [
|
|
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
|
|
localFields.filter((field) => field.inserted),
|
|
];
|
|
|
|
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
|
|
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
|
|
|
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
|
|
|
const onSignField = (payload: TSignFieldWithTokenMutationSchema) => {
|
|
setLocalFields((fields) =>
|
|
fields.map((field) => {
|
|
if (field.id !== payload.fieldId) {
|
|
return field;
|
|
}
|
|
|
|
const newField: DirectTemplateLocalField = structuredClone({
|
|
...field,
|
|
customText: payload.value ?? '',
|
|
inserted: true,
|
|
signedValue: payload,
|
|
});
|
|
|
|
if (field.type === FieldType.SIGNATURE) {
|
|
newField.signature = {
|
|
id: 1,
|
|
created: new Date(),
|
|
recipientId: 1,
|
|
fieldId: 1,
|
|
signatureImageAsBase64: payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
|
typedSignature: payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
|
} satisfies Signature;
|
|
}
|
|
|
|
if (field.type === FieldType.DATE) {
|
|
newField.customText = DateTime.now()
|
|
.setZone(metadata?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
|
.toFormat(metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
|
}
|
|
|
|
return newField;
|
|
}),
|
|
);
|
|
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'field-signed',
|
|
data: null,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
setShowPendingFieldTooltip(false);
|
|
};
|
|
|
|
const onUnsignField = (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
|
|
setLocalFields((fields) =>
|
|
fields.map((field) => {
|
|
if (field.id !== payload.fieldId) {
|
|
return field;
|
|
}
|
|
|
|
return structuredClone({
|
|
...field,
|
|
customText: '',
|
|
inserted: false,
|
|
signedValue: undefined,
|
|
signature: undefined,
|
|
});
|
|
}),
|
|
);
|
|
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'field-unsigned',
|
|
data: null,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
setShowPendingFieldTooltip(false);
|
|
};
|
|
|
|
const onNextFieldClick = () => {
|
|
validateFieldsInserted(pendingFields);
|
|
|
|
setShowPendingFieldTooltip(true);
|
|
setIsExpanded(false);
|
|
};
|
|
|
|
const onCompleteClick = async () => {
|
|
try {
|
|
const valid = validateFieldsInserted(pendingFields);
|
|
|
|
if (!valid) {
|
|
setShowPendingFieldTooltip(true);
|
|
return;
|
|
}
|
|
|
|
const { success: isEmailValid } = zEmail().safeParse(email);
|
|
|
|
if (!isEmailValid) {
|
|
setEmailError(_(msg`A valid email is required`));
|
|
setIsExpanded(true);
|
|
return;
|
|
}
|
|
|
|
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
|
|
|
if (directTemplateExternalId) {
|
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
|
}
|
|
|
|
const {
|
|
documentId,
|
|
token: documentToken,
|
|
recipientId,
|
|
} = await createDocumentFromDirectTemplate({
|
|
directTemplateToken: token,
|
|
directTemplateExternalId,
|
|
directRecipientName: fullName,
|
|
directRecipientEmail: email,
|
|
templateUpdatedAt: updatedAt,
|
|
signedFieldValues: localFields
|
|
.filter((field) => {
|
|
return field.signedValue && (isRequiredField(field) || field.inserted);
|
|
})
|
|
.map((field) => field.signedValue!),
|
|
});
|
|
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-completed',
|
|
data: {
|
|
token: documentToken,
|
|
documentId,
|
|
recipientId,
|
|
},
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
setHasCompletedDocument(true);
|
|
} catch (err) {
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-error',
|
|
data: String(err),
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
const error = AppError.parseError(err);
|
|
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
|
|
|
toast({
|
|
title: _(errorMessage.title),
|
|
description: _(errorMessage.description),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
const hash = window.location.hash.slice(1);
|
|
|
|
try {
|
|
const data = ZDirectTemplateEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
|
|
|
if (data.email) {
|
|
setEmail(data.email);
|
|
setIsEmailLocked(!!data.lockEmail);
|
|
}
|
|
|
|
if (data.name) {
|
|
setFullName(data.name);
|
|
setIsNameLocked(!!data.lockName);
|
|
}
|
|
|
|
if (data.darkModeDisabled) {
|
|
document.documentElement.classList.add('dark-mode-disabled');
|
|
}
|
|
|
|
if (allowWhiteLabelling) {
|
|
injectCss({
|
|
css: data.css,
|
|
cssVars: data.cssVars,
|
|
});
|
|
}
|
|
|
|
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
|
void dynamicActivate(data.language).finally(() => {
|
|
setHasFinishedInit(true);
|
|
});
|
|
} else {
|
|
setHasFinishedInit(true);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
setHasFinishedInit(true);
|
|
}
|
|
|
|
// !: While the two setters are stable we still want to ensure we're avoiding
|
|
// !: re-renders.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-ready',
|
|
data: null,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
}, [hasFinishedInit, hasDocumentLoaded]);
|
|
|
|
if (hasCompletedDocument) {
|
|
return (
|
|
<EmbedDocumentCompleted
|
|
name={fullName}
|
|
signature={{
|
|
id: 1,
|
|
fieldId: 1,
|
|
recipientId: 1,
|
|
created: new Date(),
|
|
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
|
typedSignature: signature?.startsWith('data:') ? null : signature,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
|
|
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
|
</div>
|
|
|
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
{/* Viewer */}
|
|
<div className="flex-1">
|
|
<PDFViewerLazy
|
|
data={getDocumentDataUrlForPdfViewer({
|
|
envelopeId: envelopeItems[0]?.envelopeId,
|
|
envelopeItemId: envelopeItems[0]?.id,
|
|
documentDataId: envelopeItems[0]?.documentDataId,
|
|
version: 'current',
|
|
token: recipient.token,
|
|
presignToken: undefined,
|
|
})}
|
|
scrollParentRef="window"
|
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Widget */}
|
|
<div
|
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
className="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-4 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
|
|
data-expanded={isExpanded || undefined}
|
|
>
|
|
<div className="flex h-fit w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
|
{/* Header */}
|
|
<div>
|
|
<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>
|
|
|
|
{isExpanded ? (
|
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden" onClick={() => setIsExpanded(false)}>
|
|
<LucideChevronDown className="h-5 w-5 text-muted-foreground" />
|
|
</Button>
|
|
) : pendingFields.length > 0 ? (
|
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden" onClick={() => setIsExpanded(true)}>
|
|
<LucideChevronUp className="h-5 w-5 text-muted-foreground" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="md:hidden"
|
|
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
|
loading={isSubmitting}
|
|
onClick={() => throttledOnCompleteClick()}
|
|
>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="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="-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={cn('mt-2 bg-background', emailError && 'border-destructive ring-2 ring-destructive/20')}
|
|
disabled={isEmailLocked}
|
|
value={email}
|
|
onChange={(e) => {
|
|
if (!isEmailLocked) {
|
|
setEmail(e.target.value.trim());
|
|
setEmailError(null);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{emailError && <p className="mt-2 font-medium text-destructive text-xs">{emailError}</p>}
|
|
</div>
|
|
|
|
{hasSignatureField && (
|
|
<div>
|
|
<Label htmlFor="Signature">
|
|
<Trans>Signature</Trans>
|
|
</Label>
|
|
|
|
<SignaturePadDialog
|
|
className="mt-2"
|
|
disabled={isThrottled || isSubmitting}
|
|
disableAnimation
|
|
fullName={fullName}
|
|
value={signature ?? ''}
|
|
onChange={(v) => setSignature(v ?? '')}
|
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
|
|
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
{pendingFields.length > 0 ? (
|
|
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
|
<Trans>Next</Trans>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
className="col-start-2"
|
|
disabled={isThrottled}
|
|
loading={isSubmitting}
|
|
onClick={() => throttledOnCompleteClick()}
|
|
>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{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 */}
|
|
<EmbedDocumentFields
|
|
fields={localFields}
|
|
metadata={metadata}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
</div>
|
|
|
|
{!hidePoweredBy && (
|
|
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100">
|
|
<span>
|
|
<Trans>Powered by</Trans>
|
|
</span>
|
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|