import { useEffect, useLayoutEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { type DocumentData, type Field, FieldType } from '@prisma/client'; import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { DateTime } from 'luxon'; import { useSearchParams } from 'react-router'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; 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 { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema'; import { injectCss } from '~/utils/css-vars'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; 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; updatedAt: Date; documentData: DocumentData; recipient: Recipient; fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; hidePoweredBy?: boolean; allowWhiteLabelling?: boolean; }; export const EmbedDirectTemplateClientPage = ({ token, updatedAt, documentData, recipient, fields, metadata, hidePoweredBy = false, allowWhiteLabelling = false, }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); const [searchParams] = useSearchParams(); const { fullName, email, signature, signatureValid, setFullName, setEmail, setSignature, setSignatureValid, } = 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 [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [localFields, setLocalFields] = useState(() => fields); const [pendingFields, _completedFields] = [ localFields.filter((field) => !field.inserted), localFields.filter((field) => field.inserted), ]; const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); 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.startsWith('data:') ? payload.value : null, typedSignature: payload.value.startsWith('data:') ? null : payload.value, } 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(localFields); setShowPendingFieldTooltip(true); setIsExpanded(false); }; const onCompleteClick = async () => { try { if (hasSignatureField && !signatureValid) { return; } const valid = validateFieldsInserted(localFields); if (!valid) { setShowPendingFieldTooltip(true); return; } let directTemplateExternalId = searchParams?.get('externalId') || undefined; if (directTemplateExternalId) { directTemplateExternalId = decodeURIComponent(directTemplateExternalId); } localFields.forEach((field) => { if (!field.signedValue) { throw new Error('Invalid configuration'); } }); const { documentId, token: documentToken, recipientId, } = await createDocumentFromDirectTemplate({ directTemplateToken: token, directTemplateExternalId, directRecipientName: fullName, directRecipientEmail: email, templateUpdatedAt: updatedAt, signedFieldValues: localFields.map((field) => { if (!field.signedValue) { throw new Error('Invalid configuration'); } return 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), }, '*', ); } toast({ title: _(msg`Something went wrong`), description: _( msg`We were unable to submit this document at this time. Please try again later.`, ), 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, }); } } 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 ( ); } return (
{(!hasFinishedInit || !hasDocumentLoaded) && }
{/* Viewer */}
setHasDocumentLoaded(true)} />
{/* Widget */}
{/* Header */}

Sign document

Sign the document to complete the process.


{/* Form */}
!isNameLocked && setFullName(e.target.value)} />
!isEmailLocked && setEmail(e.target.value.trim())} />
{ setSignature(value); }} onValidityChange={(isValid) => { setSignatureValid(isValid); }} allowTypedSignature={Boolean( metadata && 'typedSignatureEnabled' in metadata && metadata.typedSignatureEnabled, )} /> {hasSignatureField && !signatureValid && (
Signature is too small. Please provide a more complete signature.
)}
{pendingFields.length > 0 ? ( ) : ( )}
{showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field )} {/* Fields */}
{!hidePoweredBy && (
Powered by
)}
); };