mirror of
https://github.com/documenso/documenso.git
synced 2025-11-21 04:01:45 +10:00
chore: add pending ui to signing completion page (#2224)
Adds a pending UI state to the signing completion page for when all recipients have finished signing but the document hasn't completed the sealing background job. <img width="695" height="562" alt="image" src="https://github.com/user-attachments/assets/b015bc38-9489-4baa-ac0a-07cb1ac24b25" />
This commit is contained in:
@ -1,10 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react';
|
import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
|
||||||
import { Link, useRevalidator } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
@ -18,7 +16,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
|
|||||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -120,6 +118,24 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
returnToHomePath,
|
returnToHomePath,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
|
// Poll signing status every few seconds
|
||||||
|
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
|
||||||
|
{
|
||||||
|
token: recipient?.token || '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchInterval: 3000,
|
||||||
|
initialData: match(document?.status)
|
||||||
|
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
|
||||||
|
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
|
||||||
|
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
|
||||||
|
.otherwise(() => ({ status: 'PENDING' }) as const),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use signing status from query if available, otherwise fall back to document status
|
||||||
|
const signingStatus = signingStatusData?.status ?? 'PENDING';
|
||||||
|
|
||||||
if (!isDocumentAccessValid) {
|
if (!isDocumentAccessValid) {
|
||||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||||
}
|
}
|
||||||
@ -161,8 +177,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
|
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
.with({ status: 'COMPLETED' }, () => (
|
||||||
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@ -170,6 +186,14 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
.with({ status: 'PROCESSING' }, () => (
|
||||||
|
<div className="mt-4 flex items-center text-center text-orange-600">
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
<span className="text-sm">
|
||||||
|
<Trans>Processing document</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
.with({ deletedAt: null }, () => (
|
.with({ deletedAt: null }, () => (
|
||||||
<div className="mt-4 flex items-center text-center text-blue-600">
|
<div className="mt-4 flex items-center text-center text-blue-600">
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
@ -187,14 +211,22 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
.with({ status: 'COMPLETED' }, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
<Trans>
|
<Trans>
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
|
.with({ status: 'PROCESSING' }, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
<Trans>
|
||||||
|
All recipients have signed. The document is being processed and you will receive
|
||||||
|
an Email copy shortly.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
))
|
||||||
.with({ deletedAt: null }, () => (
|
.with({ deletedAt: null }, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
<Trans>
|
<Trans>
|
||||||
@ -218,7 +250,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
className="w-full max-w-none md:flex-1"
|
className="w-full max-w-none md:flex-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isDocumentCompleted(document.status) && (
|
{isDocumentCompleted(document) && (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={document.envelopeId}
|
envelopeId={document.envelopeId}
|
||||||
envelopeStatus={document.status}
|
envelopeStatus={document.status}
|
||||||
@ -261,33 +293,6 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PollUntilDocumentCompleted document={document} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PollUntilDocumentCompletedProps = {
|
|
||||||
document: Pick<Document, 'id' | 'status' | 'deletedAt'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentCompletedProps) => {
|
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (window.document.hasFocus()) {
|
|
||||||
void revalidate();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [document.status]);
|
|
||||||
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope';
|
|||||||
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
||||||
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
||||||
import { signEnvelopeFieldRoute } from './sign-envelope-field';
|
import { signEnvelopeFieldRoute } from './sign-envelope-field';
|
||||||
|
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
|
||||||
import { updateEnvelopeRoute } from './update-envelope';
|
import { updateEnvelopeRoute } from './update-envelope';
|
||||||
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
||||||
import { useEnvelopeRoute } from './use-envelope';
|
import { useEnvelopeRoute } from './use-envelope';
|
||||||
@ -72,4 +73,5 @@ export const envelopeRouter = router({
|
|||||||
duplicate: duplicateEnvelopeRoute,
|
duplicate: duplicateEnvelopeRoute,
|
||||||
distribute: distributeEnvelopeRoute,
|
distribute: distributeEnvelopeRoute,
|
||||||
redistribute: redistributeEnvelopeRoute,
|
redistribute: redistributeEnvelopeRoute,
|
||||||
|
signingStatus: signingStatusEnvelopeRoute,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { maybeAuthenticatedProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZSigningStatusEnvelopeRequestSchema,
|
||||||
|
ZSigningStatusEnvelopeResponseSchema,
|
||||||
|
} from './signing-status-envelope.types';
|
||||||
|
|
||||||
|
// Internal route - not intended for public API usage
|
||||||
|
export const signingStatusEnvelopeRoute = maybeAuthenticatedProcedure
|
||||||
|
.input(ZSigningStatusEnvelopeRequestSchema)
|
||||||
|
.output(ZSigningStatusEnvelopeResponseSchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { token } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
signingStatus: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if envelope is rejected
|
||||||
|
if (envelope.status === DocumentStatus.REJECTED) {
|
||||||
|
return {
|
||||||
|
status: 'REJECTED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isComplete =
|
||||||
|
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||||
|
envelope.recipients.every(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
return {
|
||||||
|
status: 'PROCESSING',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'PENDING',
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const EnvelopeSigningStatus = z.enum(['PENDING', 'PROCESSING', 'COMPLETED', 'REJECTED']);
|
||||||
|
|
||||||
|
export const ZSigningStatusEnvelopeRequestSchema = z.object({
|
||||||
|
token: z.string().describe('The recipient token to check the signing status for'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZSigningStatusEnvelopeResponseSchema = z.object({
|
||||||
|
status: EnvelopeSigningStatus.describe('The current signing status of the envelope'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSigningStatusEnvelopeRequest = z.infer<typeof ZSigningStatusEnvelopeRequestSchema>;
|
||||||
|
export type TSigningStatusEnvelopeResponse = z.infer<typeof ZSigningStatusEnvelopeResponseSchema>;
|
||||||
Reference in New Issue
Block a user