From 1bbe5611627e418b0364ab6a161fa26628129b82 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 20 Nov 2025 15:07:26 +1100 Subject: [PATCH] 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. image --- .../_recipient+/sign.$token+/complete.tsx | 79 +++++++++--------- .../trpc/server/envelope-router/router.ts | 2 + .../signing-status-envelope.ts | 82 +++++++++++++++++++ .../signing-status-envelope.types.ts | 14 ++++ 4 files changed, 140 insertions(+), 37 deletions(-) create mode 100644 packages/trpc/server/envelope-router/signing-status-envelope.ts create mode 100644 packages/trpc/server/envelope-router/signing-status-envelope.types.ts diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx index ed2597cb3..a8ac432f6 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx @@ -1,10 +1,8 @@ -import { useEffect } from 'react'; - import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; -import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react'; -import { Link, useRevalidator } from 'react-router'; +import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react'; +import { Link } from 'react-router'; import { match } from 'ts-pattern'; 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 { isDocumentCompleted } from '@documenso/lib/utils/document'; 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 { SigningCard3D } from '@documenso/ui/components/signing-card'; import { cn } from '@documenso/ui/lib/utils'; @@ -120,6 +118,24 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp returnToHomePath, } = 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) { return ; } @@ -161,8 +177,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp {recipient.role === RecipientRole.APPROVER && Document Approved} - {match({ status: document.status, deletedAt: document.deletedAt }) - .with({ status: DocumentStatus.COMPLETED }, () => ( + {match({ status: signingStatus, deletedAt: document.deletedAt }) + .with({ status: 'COMPLETED' }, () => (
@@ -170,6 +186,14 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
)) + .with({ status: 'PROCESSING' }, () => ( +
+ + + Processing document + +
+ )) .with({ deletedAt: null }, () => (
@@ -187,14 +211,22 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
))} - {match({ status: document.status, deletedAt: document.deletedAt }) - .with({ status: DocumentStatus.COMPLETED }, () => ( + {match({ status: signingStatus, deletedAt: document.deletedAt }) + .with({ status: 'COMPLETED' }, () => (

Everyone has signed! You will receive an Email copy of the signed document.

)) + .with({ status: 'PROCESSING' }, () => ( +

+ + All recipients have signed. The document is being processed and you will receive + an Email copy shortly. + +

+ )) .with({ deletedAt: null }, () => (

@@ -218,7 +250,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp className="w-full max-w-none md:flex-1" /> - {isDocumentCompleted(document.status) && ( + {isDocumentCompleted(document) && ( - - ); } - -export type PollUntilDocumentCompletedProps = { - document: Pick; -}; - -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 <>; -}; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index c4662c2ab..e11709eb2 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -25,6 +25,7 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope'; import { setEnvelopeFieldsRoute } from './set-envelope-fields'; import { setEnvelopeRecipientsRoute } from './set-envelope-recipients'; import { signEnvelopeFieldRoute } from './sign-envelope-field'; +import { signingStatusEnvelopeRoute } from './signing-status-envelope'; import { updateEnvelopeRoute } from './update-envelope'; import { updateEnvelopeItemsRoute } from './update-envelope-items'; import { useEnvelopeRoute } from './use-envelope'; @@ -72,4 +73,5 @@ export const envelopeRouter = router({ duplicate: duplicateEnvelopeRoute, distribute: distributeEnvelopeRoute, redistribute: redistributeEnvelopeRoute, + signingStatus: signingStatusEnvelopeRoute, }); diff --git a/packages/trpc/server/envelope-router/signing-status-envelope.ts b/packages/trpc/server/envelope-router/signing-status-envelope.ts new file mode 100644 index 000000000..f29a622ce --- /dev/null +++ b/packages/trpc/server/envelope-router/signing-status-envelope.ts @@ -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', + }; + }); diff --git a/packages/trpc/server/envelope-router/signing-status-envelope.types.ts b/packages/trpc/server/envelope-router/signing-status-envelope.types.ts new file mode 100644 index 000000000..ccdb7e632 --- /dev/null +++ b/packages/trpc/server/envelope-router/signing-status-envelope.types.ts @@ -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; +export type TSigningStatusEnvelopeResponse = z.infer;