import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import type { TGetQrSignatureSessionResponse, TQrSignatureSessionContext, } from '@documenso/trpc/server/signature-router/qr/get-qr-signature-session.types'; import { Button } from '@documenso/ui/primitives/button'; import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet'; import { SignaturePadDraw } from '@documenso/ui/primitives/signature-pad/signature-pad-draw'; import { i18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { CheckCircle2Icon, ClockIcon, FileTextIcon, Loader2Icon, PenLineIcon, XCircleIcon } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { match } from 'ts-pattern'; import type { Route } from './+types/mobile-signature.$token'; export function meta() { return [ { title: i18n._(msg`Sign on mobile - Documenso`) }, { name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' }, ]; } export default function MobileSignaturePage({ params }: Route.ComponentProps) { const { token } = params; const { data: session, isError: isSessionError, isLoading: isSessionLoading, } = trpc.signature.qr.getSession.useQuery( { token, }, { // Do not refetch the session. staleTime: Number.POSITIVE_INFINITY, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, ); if (isSessionLoading || !session) { return (
Loading
); } if (session.status !== 'VALID' || isSessionError) { return ; } return (
); } type QrSignatureState = 'SIGNING' | 'SUCCESS' | 'EXPIRED' | 'ALREADY_SUBMITTED'; type QrSignatureProps = { token: string; context: TQrSignatureSessionContext; }; const QrSignature = ({ token, context }: QrSignatureProps) => { const { t } = useLingui(); const [signature, setSignature] = useState(''); const [hasSubmissionError, setHasSubmissionError] = useState(false); const [state, setState] = useState('SIGNING'); // Portrait renders the pad in a bottom sheet beneath the document context; // landscape renders a single card. This component only ever renders on the // client (behind the session query), so the initial value can be read // synchronously - no flicker on landscape devices. const [isPortrait, setIsPortrait] = useState( () => typeof window === 'undefined' || window.matchMedia('(orientation: portrait)').matches, ); useEffect(() => { const mediaQuery = window.matchMedia('(orientation: portrait)'); setIsPortrait(mediaQuery.matches); const onOrientationChange = (event: MediaQueryListEvent) => { setIsPortrait(event.matches); }; mediaQuery.addEventListener('change', onOrientationChange); return () => { mediaQuery.removeEventListener('change', onOrientationChange); }; }, []); const { mutateAsync: completeQrSignature, isPending } = trpc.signature.qr.complete.useMutation({ // The session query must not refetch on completion: it would resolve to // ALREADY_SUBMITTED and replace the success screen with an error card. ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }); const contextInfo = useMemo( () => match(context) .with({ type: 'DOCUMENT_SIGNATURE' }, (documentContext) => ({ title: documentContext.documentTitle, subtitle: `${documentContext.teamName} ยท ${t`Signature requested`}`, icon: , })) .with({ type: 'PROFILE_SIGNATURE' }, () => ({ title: t`Your signature`, subtitle: t`Signature requested`, icon: , })) // Context-less sessions show no subtitle, which would just repeat the title. .with({ type: 'NONE' }, () => ({ title: t`Signature requested`, subtitle: null, icon: , })) .exhaustive(), [context, t], ); const onSubmitClick = async () => { setHasSubmissionError(false); try { await completeQrSignature({ token, signature, }); setState('SUCCESS'); } catch (err) { const error = AppError.parseError(err); if (error.code === AppErrorCode.EXPIRED_CODE || error.code === AppErrorCode.NOT_FOUND) { setState('EXPIRED'); return; } if (error.code === AppErrorCode.INVALID_REQUEST) { setState('ALREADY_SUBMITTED'); return; } setHasSubmissionError(true); } }; if (state === 'EXPIRED' || state === 'ALREADY_SUBMITTED') { return ; } if (state === 'SUCCESS') { return (

Success

You can now return to your main device to continue.

); } if (isPortrait) { return ( <> {/* Document context hero. */}
{contextInfo.icon}

{contextInfo.title}

{contextInfo.subtitle &&

{contextInfo.subtitle}

}
Connected
{/* Persistent signing sheet - cannot be dismissed. */} event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} >
Draw your signature
setSignature(value)} />
{hasSubmissionError && (

Something went wrong. Please try again.

)}
); } // Landscape: a single card, no sheet. return ( // Need this to override the parent layout styling.
{/* The column width IS the pad width: all height left beneath the fixed h-12 header (100svh - 2*p-2 - h-12 - mb-2 = 100svh - 4.5rem) is converted through the pad's 16/7 aspect ratio, clamped by the viewport width. The header is w-full of the same column, so it always matches the pad width exactly. */}
{contextInfo.icon}

{contextInfo.title}

{contextInfo.subtitle}

setSignature(value)} />
{hasSubmissionError && (

Something went wrong. Please try again.

)}
); }; type QrSignatureErrorReason = Exclude; type QrSignatureErrorProps = { reason?: QrSignatureErrorReason; }; const QrSignatureError = ({ reason }: QrSignatureErrorProps) => { const content = match(reason) .with('EXPIRED', () => ({ icon: , title: This link has expired, description: Generate a new QR code on the original device and scan it again., })) .with('ALREADY_SUBMITTED', () => ({ icon: , title: Signature already sent, description: This link has already been used. Return to your computer to continue., })) .with('INVALID', () => ({ icon: , title: This signing request is invalid, description: ( The request is invalid or no longer exists. Scan the new QR code on the original device to try again. ), })) .with(undefined, () => ({ icon: , title: Something went wrong, description: We couldn't load this signing request. Please refresh the page to try again., })) .exhaustive(); return (
{content.icon}

{content.title}

{content.description}

); };