mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: backport the embedded mobile signing ux to main application (#1919)
This PR improves the mobile experience of the document signing page by implementing a collapsible widget design for the signing form. On mobile devices, the form now appears as a fixed bottom sheet that can be expanded/collapsed, while maintaining the sticky sidebar layout on desktop.
This commit is contained in:
@ -16,7 +16,6 @@ import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/uti
|
|||||||
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
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';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
@ -177,15 +176,7 @@ export const DocumentSigningForm = ({
|
|||||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-full flex-col">
|
||||||
className={cn(
|
|
||||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
|
||||||
{
|
|
||||||
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
|
|
||||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
@ -194,21 +185,8 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
|
||||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{recipient.role === RecipientRole.VIEWER ? (
|
{recipient.role === RecipientRole.VIEWER ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Please mark as viewed to complete</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
<div className="flex flex-1 flex-col gap-y-4" />
|
<div className="flex flex-1 flex-col gap-y-4" />
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
@ -245,15 +223,6 @@ export const DocumentSigningForm = ({
|
|||||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Complete the fields for the following signers. Once reviewed, they will inform
|
|
||||||
you if any modifications are needed.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border my-4" />
|
|
||||||
|
|
||||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||||
<Controller
|
<Controller
|
||||||
name="selectedSignerId"
|
name="selectedSignerId"
|
||||||
@ -340,88 +309,76 @@ export const DocumentSigningForm = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<fieldset
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
disabled={isSubmitting}
|
||||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||||
<Trans>Please review the document before approving.</Trans>
|
>
|
||||||
) : (
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
<Trans>Please review the document before signing.</Trans>
|
<div>
|
||||||
)}
|
<Label htmlFor="full-name">
|
||||||
</p>
|
<Trans>Full Name</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="full-name"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset
|
{hasSignatureField && (
|
||||||
disabled={isSubmitting}
|
|
||||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full-name">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Full Name</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<SignaturePadDialog
|
||||||
type="text"
|
className="mt-2"
|
||||||
id="full-name"
|
disabled={isSubmitting}
|
||||||
className="bg-background mt-2"
|
value={signature ?? ''}
|
||||||
value={fullName}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<SignaturePadDialog
|
|
||||||
className="mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
value={signature ?? ''}
|
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
onSignatureComplete={async (nextSigner) => {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
}}
|
|
||||||
role={recipient.role}
|
|
||||||
allowDictateNextSigner={
|
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
|
||||||
}
|
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={async () => navigate(-1)}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
|
role={recipient.role}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { 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 { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
@ -20,6 +21,7 @@ import type { CompletedField } from '@documenso/lib/types/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 { 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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
@ -62,6 +64,7 @@ export const DocumentSigningPageView = ({
|
|||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
let senderName = document.user.name ?? '';
|
let senderName = document.user.name ?? '';
|
||||||
let senderEmail = `(${document.user.email})`;
|
let senderEmail = `(${document.user.email})`;
|
||||||
@ -77,15 +80,15 @@ export const DocumentSigningPageView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||||
<h1
|
<h1
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||||
title={document.title}
|
title={document.title}
|
||||||
>
|
>
|
||||||
{document.title}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
|
||||||
<div className="max-w-[50ch]">
|
<div className="max-w-[50ch]">
|
||||||
<span className="text-muted-foreground truncate" title={senderName}>
|
<span className="text-muted-foreground truncate" title={senderName}>
|
||||||
{senderName} {senderEmail}
|
{senderName} {senderEmail}
|
||||||
@ -135,26 +138,79 @@ export const DocumentSigningPageView = ({
|
|||||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||||
<Card
|
<div className="flex-1">
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
gradient
|
<CardContent className="p-2">
|
||||||
>
|
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||||
<CardContent className="p-2">
|
</CardContent>
|
||||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
</Card>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
<div
|
||||||
<DocumentSigningForm
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
document={document}
|
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||||
recipient={recipient}
|
data-expanded={isExpanded || undefined}
|
||||||
fields={fields}
|
>
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
<div className="flex items-center justify-between gap-x-2">
|
||||||
allRecipients={allRecipients}
|
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
{match(recipient.role)
|
||||||
/>
|
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||||
|
{isExpanded ? (
|
||||||
|
<LucideChevronDown
|
||||||
|
className="text-muted-foreground h-5 w-5"
|
||||||
|
onClick={() => setIsExpanded(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LucideChevronUp
|
||||||
|
className="text-muted-foreground h-5 w-5"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
{match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => (
|
||||||
|
<Trans>Please mark as viewed to complete.</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<Trans>Please review the document before signing.</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<Trans>Please review the document before approving.</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.ASSISTANT, () => (
|
||||||
|
<Trans>Complete the fields for the following signers.</Trans>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="border-border mb-8 mt-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||||
|
<DocumentSigningForm
|
||||||
|
document={document}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
|
allRecipients={allRecipients}
|
||||||
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,11 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
|
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
|
||||||
|
{
|
||||||
|
'rounded-b-xl': position === 'start',
|
||||||
|
'rounded-t-xl': position === 'end',
|
||||||
|
},
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user