fix: improve mobile signing ux (#2003)

Improves the mobile signing UX making actions available via the floating
navbar more obvious.

Also adds an automatic switch to the complete button once all fields
have been signed.
This commit is contained in:
Lucas Smith
2025-08-28 16:15:52 +10:00
committed by GitHub
parent 184ebdedf1
commit 657db3bc84
4 changed files with 210 additions and 115 deletions

View File

@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
token, token,
updatedAt, updatedAt,
documentData, documentData,
recipient, recipient: _recipient,
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
@ -95,6 +95,8 @@ export const EmbedDirectTemplateClientPage = ({
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
trpc.template.createDocumentFromDirectTemplate.useMutation(); trpc.template.createDocumentFromDirectTemplate.useMutation();
@ -345,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Sign document</Trans> <Trans>Sign document</Trans>
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> {isExpanded ? (
{isExpanded ? ( <Button
<LucideChevronDown variant="outline"
className="text-muted-foreground h-5 w-5" className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground h-5 w-5" />
<LucideChevronUp </Button>
className="text-muted-foreground h-5 w-5" ) : pendingFields.length > 0 ? (
onClick={() => setIsExpanded(true)} <Button
/> variant="outline"
)} className="h-8 w-8 p-0 md:hidden"
</Button> onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div> </div>
</div> </div>

View File

@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false); useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@ -118,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const assistantSignersId = useId(); const assistantSignersId = useId();
const onNextFieldClick = () => { const onNextFieldClick = () => {
@ -307,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({
)} )}
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> {isExpanded ? (
{isExpanded ? ( <Button
<LucideChevronDown variant="outline"
className="text-muted-foreground h-5 w-5" className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp </Button>
className="text-muted-foreground h-5 w-5" ) : pendingFields.length > 0 ? (
onClick={() => setIsExpanded(true)} <Button
/> variant="outline"
)} className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
</Button> onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div> </div>
</div> </div>

View File

@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void; setSelectedSignerId?: (id: number | null) => void;
completeDocument: (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
}; };
export const DocumentSigningForm = ({ export const DocumentSigningForm = ({
document, document,
recipient, recipient,
fields, fields,
redirectUrl,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
setSelectedSignerId, setSelectedSignerId,
completeDocument,
isSubmitting,
fieldsValidated,
nextRecipient,
}: DocumentSigningFormProps) => { }: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const analytics = useAnalytics();
const assistantSignersId = useId(); const assistantSignersId = useId();
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: { defaultValues: {
selectedSignerId: undefined, selectedSignerId: undefined,
}, },
}); });
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo( const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired), () => fields.filter(isFieldUnsignedAndRequired),
[fields], [fields],
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]); }, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => { const localFieldsValidated = () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation); fieldsValidated();
}; };
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
@ -126,55 +118,6 @@ export const DocumentSigningForm = ({
} }
}; };
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{validateUninsertedFields && uninsertedFields[0] && ( {validateUninsertedFields && uninsertedFields[0] && (
@ -205,7 +148,7 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner); await completeDocument(undefined, nextSigner);
}} }}
@ -364,7 +307,7 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting || isAssistantSubmitting} isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={localFieldsValidated}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner); await completeDocument(undefined, nextSigner);

View File

@ -1,15 +1,18 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern'; import { useNavigate } from 'react-router';
import { P, match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { import {
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
ZDropdownFieldMeta, ZDropdownFieldMeta,
@ -18,8 +21,11 @@ import {
ZTextFieldMeta, ZTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields'; import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -40,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = { export type DocumentSigningPageViewProps = {
@ -63,9 +70,56 @@ export const DocumentSigningPageView = ({
}: DocumentSigningPageViewProps) => { }: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsValidated = () => {
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
let senderName = document.user.name ?? ''; let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`; let senderEmail = `(${document.user.email})`;
@ -78,8 +132,31 @@ export const DocumentSigningPageView = ({
const targetSigner = const targetSigner =
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const highestPageNumber = Math.max(...fields.map((field) => field.page)); const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
return ( return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}> <DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl sm:px-6"> <div className="mx-auto w-full max-w-screen-xl sm:px-6">
@ -165,19 +242,55 @@ export const DocumentSigningPageView = ({
.otherwise(() => null)} .otherwise(() => null)}
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> {match({ hasPendingFields, isExpanded, role: recipient.role })
{isExpanded ? ( .with(
<LucideChevronDown {
className="text-muted-foreground h-5 w-5" hasPendingFields: false,
role: P.not(RecipientRole.ASSISTANT),
isExpanded: false,
},
() => (
<div className="md:hidden">
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
),
)
.with({ isExpanded: true }, () => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
/> >
) : ( <LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp </Button>
className="text-muted-foreground h-5 w-5" ))
.otherwise(() => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
/> >
)} <LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button> </Button>
))}
</div> </div>
<div className="hidden group-data-[expanded]/document-widget:block md:block"> <div className="hidden group-data-[expanded]/document-widget:block md:block">
@ -206,10 +319,13 @@ export const DocumentSigningPageView = ({
document={document} document={document}
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId} setSelectedSignerId={setSelectedSignerId}
completeDocument={completeDocument}
isSubmitting={isSubmitting}
fieldsValidated={fieldsValidated}
nextRecipient={nextRecipient}
/> />
</div> </div>
</div> </div>