diff --git a/apps/web/package.json b/apps/web/package.json index f0fe6f48b..31e8ff4f1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "react-icons": "^4.8.0", "react-pdf": "^7.1.1", "react-rnd": "^10.4.1", + "ts-pattern": "^5.0.5", "typescript": "5.1.6", "zod": "^3.21.4" }, diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index d0c369e8d..6e10fb45d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -2,29 +2,15 @@ import { useState } from 'react'; -import dynamic from 'next/dynamic'; - -import { Loader } from 'lucide-react'; - import { Document, Field, Recipient, User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer'; import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields'; import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers'; import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject'; -const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { - ssr: false, - loading: () => ( -
- - -

Loading document...

-
- ), -}); - export type EditDocumentFormProps = { className?: string; user: User; @@ -71,7 +57,7 @@ export const EditDocumentForm = ({ gradient > - + diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 90a0afda8..b1f7d1a1a 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -8,6 +8,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { Header } from '~/components/(dashboard)/layout/header'; +import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; import { NextAuthProvider } from '~/providers/next-auth'; export type AuthenticatedDashboardLayoutProps = { @@ -30,6 +31,8 @@ export default async function AuthenticatedDashboardLayout({
{children}
+ + ); } diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx new file mode 100644 index 000000000..2195e2e70 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { HTMLAttributes } from 'react'; + +import { Download } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export type DownloadButtonProps = HTMLAttributes & { + disabled?: boolean; + fileName?: string; + document?: string; +}; + +export const DownloadButton = ({ + className, + fileName, + document, + disabled, + ...props +}: DownloadButtonProps) => { + /** + * Convert the document from base64 to a blob and download it. + */ + const onDownloadClick = () => { + if (!document) { + return; + } + + let decodedDocument = document; + + try { + decodedDocument = atob(document); + } catch (err) { + // We're just going to ignore this error and try to download the document + console.error(err); + } + + const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0))); + + const blob = new Blob([documentBytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + + link.href = window.URL.createObjectURL(blob); + link.download = fileName || 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx new file mode 100644 index 000000000..b97b7f8d6 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -0,0 +1,107 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { CheckCircle2, Clock8, Share } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { DocumentStatus, FieldType } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +import { DownloadButton } from './download-button'; +import { SigningCard } from './signing-card'; + +export type CompletedSigningPageProps = { + params: { + token?: string; + }; +}; + +export default async function CompletedSigningPage({ + params: { token }, +}: CompletedSigningPageProps) { + if (!token) { + return notFound(); + } + + const document = await getDocumentAndSenderByToken({ + token, + }).catch(() => null); + + if (!document) { + return notFound(); + } + + const [fields, recipient] = await Promise.all([ + getFieldsForToken({ token }), + getRecipientByToken({ token }), + ]); + + const recipientName = + recipient.name || + fields.find((field) => field.type === FieldType.NAME)?.customText || + recipient.email; + + return ( +
+ {/* Card with recipient */} + + +
+ {match(document.status) + .with(DocumentStatus.COMPLETED, () => ( +
+ + Everyone has signed +
+ )) + .otherwise(() => ( +
+ + Waiting for others to sign +
+ ))} +
+ +

+ You have signed "{document.title}" +

+ + {match(document.status) + .with(DocumentStatus.COMPLETED, () => ( +

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

+ )) + .otherwise(() => ( +

+ You will receive an Email copy of the signed document once everyone has signed. +

+ ))} + +
+ {/* TODO: Hook this up */} + + + +
+ +

+ Want so send slick signing links like this one?{' '} + + Check out Documenso. + +

+
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx new file mode 100644 index 000000000..791c61231 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx @@ -0,0 +1,67 @@ +'use client'; + +import Image from 'next/image'; + +import { motion } from 'framer-motion'; + +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import signingCelebration from '~/assets/signing-celebration.png'; + +export type SigningCardProps = { + name: string; +}; + +export const SigningCard = ({ name }: SigningCardProps) => { + return ( +
+ + + + {name} + + + + + + background pattern + +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx new file mode 100644 index 000000000..8e2201df9 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { Recipient } from '@documenso/prisma/client'; +import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SigningFieldContainer } from './signing-field-container'; + +export type DateFieldProps = { + field: FieldWithSignature; + recipient: Recipient; +}; + +export const DateField = ({ field, recipient }: DateFieldProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const onSign = async () => { + try { + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: '', + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

Date

+ )} + + {field.inserted && ( +

{field.customText}

+ )} +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx new file mode 100644 index 000000000..eab0ff2b2 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; + +import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { Document, Field, Recipient } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; + +import { SignaturePad } from '~/components/signature-pad'; + +import { useRequiredSigningContext } from './provider'; + +export type SigningFormProps = { + document: Document; + recipient: Recipient; + fields: Field[]; +}; + +export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => { + const router = useRouter(); + + const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + + const { + handleSubmit, + formState: { isSubmitting }, + } = useForm(); + + const isComplete = fields.every((f) => f.inserted); + + const onFormSubmit = async () => { + if (!isComplete) { + return; + } + + await completeDocumentWithToken({ + token: recipient.token, + documentId: document.id, + }); + + router.push(`/sign/${recipient.token}/complete`); + }; + + return ( +
+
+
+

Sign Document

+ +

+ Please review the document before signing. +

+ +
+ +
+
+
+ + + setFullName(e.target.value)} + /> +
+ +
+ + + + + { + console.log({ + signpadValue: value, + }); + setSignature(value); + }} + /> + + +
+
+ +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx new file mode 100644 index 000000000..3c56c1718 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; + +import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; +import { NextAuthProvider } from '~/providers/next-auth'; + +export type SigningLayoutProps = { + children: React.ReactNode; +}; + +export default async function SigningLayout({ children }: SigningLayoutProps) { + const user = await getServerComponentSession(); + + return ( + +
+ {user && } + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx new file mode 100644 index 000000000..f200d94cd --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { Recipient } from '@documenso/prisma/client'; +import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredSigningContext } from './provider'; +import { SigningFieldContainer } from './signing-field-container'; + +export type NameFieldProps = { + field: FieldWithSignature; + recipient: Recipient; +}; + +export const NameField = ({ field, recipient }: NameFieldProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { fullName: providedFullName, setFullName: setProvidedFullName } = + useRequiredSigningContext(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const [showFullNameModal, setShowFullNameModal] = useState(false); + const [localFullName, setLocalFullName] = useState(''); + + const onSign = async (source: 'local' | 'provider' = 'provider') => { + try { + if (!providedFullName && !localFullName) { + setShowFullNameModal(true); + return; + } + + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: source === 'local' && localFullName ? localFullName : providedFullName ?? '', + isBase64: false, + }); + + if (source === 'local' && !providedFullName) { + setProvidedFullName(localFullName); + } + + setLocalFullName(''); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

Name

+ )} + + {field.inserted &&

{field.customText}

} + + + + + Sign as {recipient.name}{' '} + ({recipient.email}) + + +
+ + + setLocalFullName(e.target.value)} + /> +
+ + +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx new file mode 100644 index 000000000..c5bee1cf7 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -0,0 +1,94 @@ +import { notFound } from 'next/navigation'; + +import { match } from 'ts-pattern'; + +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; +import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { FieldType } from '@documenso/prisma/client'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; + +import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer'; +import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types'; + +import { DateField } from './date-field'; +import { SigningForm } from './form'; +import { NameField } from './name-field'; +import { SigningProvider } from './provider'; +import { SignatureField } from './signature-field'; + +export type SigningPageProps = { + params: { + token?: string; + }; +}; + +export default async function SigningPage({ params: { token } }: SigningPageProps) { + if (!token) { + return notFound(); + } + + const [document, fields, recipient] = await Promise.all([ + getDocumentAndSenderByToken({ + token, + }).catch(() => null), + getFieldsForToken({ token }), + getRecipientByToken({ token }), + viewedDocument({ token }), + ]); + + if (!document) { + return notFound(); + } + + const documentUrl = `data:application/pdf;base64,${document.document}`; + + return ( + +
+

+ {document.title} +

+ +
+

+ {document.User.name} ({document.User.email}) has invited you to sign this document. +

+
+ +
+ + + + + + +
+ +
+
+ + + {fields.map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .otherwise(() => null), + )} + +
+
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/provider.tsx b/apps/web/src/app/(signing)/sign/[token]/provider.tsx new file mode 100644 index 000000000..40d2bd0bb --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/provider.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { createContext, useContext, useState } from 'react'; + +export type SigningContextValue = { + fullName: string; + setFullName: (_value: string) => void; + email: string; + setEmail: (_value: string) => void; + signature: string | null; + setSignature: (_value: string | null) => void; +}; + +const SigningContext = createContext(null); + +export const useSigningContext = () => { + return useContext(SigningContext); +}; + +export const useRequiredSigningContext = () => { + const context = useSigningContext(); + + if (!context) { + throw new Error('Signing context is required'); + } + + return context; +}; + +export interface SigningProviderProps { + fullName?: string; + email?: string; + signature?: string; + children: React.ReactNode; +} + +export const SigningProvider = ({ + fullName: initialFullName, + email: initialEmail, + signature: initialSignature, + children, +}: SigningProviderProps) => { + const [fullName, setFullName] = useState(initialFullName || ''); + const [email, setEmail] = useState(initialEmail || ''); + const [signature, setSignature] = useState(initialSignature || null); + + return ( + + {children} + + ); +}; + +SigningProvider.displayName = 'SigningProvider'; diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx new file mode 100644 index 000000000..1c00c5eaa --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useMemo, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { Recipient } from '@documenso/prisma/client'; +import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SignaturePad } from '~/components/signature-pad'; + +import { useRequiredSigningContext } from './provider'; +import { SigningFieldContainer } from './signing-field-container'; + +type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; + +export type SignatureFieldProps = { + field: FieldWithSignature; + recipient: Recipient; +}; + +export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { + const router = useRouter(); + + const { toast } = useToast(); + const { signature: providedSignature, setSignature: setProvidedSignature } = + useRequiredSigningContext(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(); + + const { Signature: signature } = field; + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const [showSignatureModal, setShowSignatureModal] = useState(false); + const [localSignature, setLocalSignature] = useState(null); + + const state = useMemo(() => { + if (!field.inserted) { + return 'empty'; + } + + if (signature?.signatureImageAsBase64) { + return 'signed-image'; + } + + return 'signed-text'; + }, [field.inserted, signature?.signatureImageAsBase64]); + + const onSign = async (source: 'local' | 'provider' = 'provider') => { + try { + console.log({ + providedSignature, + localSignature, + }); + + if (!providedSignature && !localSignature) { + setShowSignatureModal(true); + return; + } + + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: source === 'local' && localSignature ? localSignature : providedSignature ?? '', + isBase64: true, + }); + + if (source === 'local' && !providedSignature) { + setProvidedSignature(localSignature); + } + + setLocalSignature(null); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {state === 'empty' && ( +

+ Signature +

+ )} + + {state === 'signed-image' && signature?.signatureImageAsBase64 && ( + {`Signature + )} + + {state === 'signed-text' && ( +

+ {signature?.typedSignature} +

+ )} + + + + + Sign as {recipient.name}{' '} + ({recipient.email}) + + +
+ + + setLocalSignature(value)} + /> +
+ + +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx new file mode 100644 index 000000000..d5efcb3df --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -0,0 +1,81 @@ +'use client'; + +import React from 'react'; + +import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { useFieldPageCoords } from '~/hooks/use-field-page-coords'; + +export type SignatureFieldProps = { + field: FieldWithSignature; + loading?: boolean; + children: React.ReactNode; + onSign?: () => Promise | void; + onRemove?: () => Promise | void; +}; + +export const SigningFieldContainer = ({ + field, + loading, + onSign, + onRemove, + children, +}: SignatureFieldProps) => { + const coords = useFieldPageCoords(field); + + const onSignFieldClick = async () => { + if (field.inserted) { + return; + } + + await onSign?.(); + }; + + const onRemoveSignedFieldClick = async () => { + if (!field.inserted) { + return; + } + + await onRemove?.(); + }; + + return ( +
+ + + {!field.inserted && !loading && ( + + )} + + {children} + + +
+ ); +}; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9c60bbadf..5bf2b9403 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,7 @@ -import { Inter } from 'next/font/google'; +import { Caveat, Inter } from 'next/font/google'; import { TrpcProvider } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; import { Toaster } from '@documenso/ui/primitives/toaster'; import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; @@ -10,6 +11,7 @@ import { PlausibleProvider } from '~/providers/plausible'; import './globals.css'; const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); +const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); export const metadata = { title: 'Documenso - The Open Source DocuSign Alternative', @@ -37,7 +39,11 @@ export const metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + diff --git a/apps/web/src/assets/signing-celebration.png b/apps/web/src/assets/signing-celebration.png new file mode 100644 index 000000000..a3fb5bc65 Binary files /dev/null and b/apps/web/src/assets/signing-celebration.png differ diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx index e79a2e71b..adab288cd 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx @@ -12,7 +12,7 @@ export type StackAvatarProps = { first?: boolean; zIndex?: string; fallbackText?: string; - type: 'unsigned' | 'waiting' | 'completed'; + type: 'unsigned' | 'waiting' | 'opened' | 'completed'; }; export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => { @@ -28,6 +28,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr case 'unsigned': classes = 'bg-dawn-200 text-dawn-900'; break; + case 'opened': + classes = 'bg-yellow-200 text-yellow-700'; + break; case 'waiting': classes = 'bg-water text-water-700'; break; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index dbd1dc712..7143add36 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -13,15 +13,19 @@ import { StackAvatars } from './stack-avatars'; export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => { const waitingRecipients = recipients.filter( - (recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED', + (recipient) => getRecipientType(recipient) === 'waiting', + ); + + const openedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'opened', ); const completedRecipients = recipients.filter( - (recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED', + (recipient) => getRecipientType(recipient) === 'completed', ); const uncompletedRecipients = recipients.filter( - (recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED', + (recipient) => getRecipientType(recipient) === 'unsigned', ); return ( @@ -66,6 +70,23 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[ )} + {openedRecipients.length > 0 && ( +
+

Opened

+ {openedRecipients.map((recipient: Recipient) => ( +
+ + {recipient.email} +
+ ))} +
+ )} + {uncompletedRecipients.length > 0 && (

Uncompleted

diff --git a/apps/web/src/components/(dashboard)/pdf-viewer/lazy-pdf-viewer.tsx b/apps/web/src/components/(dashboard)/pdf-viewer/lazy-pdf-viewer.tsx new file mode 100644 index 000000000..f75920ade --- /dev/null +++ b/apps/web/src/components/(dashboard)/pdf-viewer/lazy-pdf-viewer.tsx @@ -0,0 +1,19 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +import { Loader } from 'lucide-react'; + +export const LazyPDFViewer = dynamic( + async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), + { + ssr: false, + loading: () => ( +
+ + +

Loading document...

+
+ ), + }, +); diff --git a/apps/web/src/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx b/apps/web/src/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx new file mode 100644 index 000000000..1b2f529b8 --- /dev/null +++ b/apps/web/src/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useCallback, useEffect } from 'react'; + +import { useRouter } from 'next/navigation'; + +export const RefreshOnFocus = () => { + const { refresh } = useRouter(); + + const onFocus = useCallback(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + window.addEventListener('focus', onFocus); + + return () => { + window.removeEventListener('focus', onFocus); + }; + }, [onFocus]); + + return null; +}; diff --git a/apps/web/src/components/forms/edit-document/container.tsx b/apps/web/src/components/forms/edit-document/container.tsx index 47a2005ad..04e998719 100644 --- a/apps/web/src/components/forms/edit-document/container.tsx +++ b/apps/web/src/components/forms/edit-document/container.tsx @@ -1,3 +1,5 @@ +'use client'; + import React, { HTMLAttributes } from 'react'; import { Loader } from 'lucide-react'; diff --git a/apps/web/src/components/forms/edit-document/field-item.tsx b/apps/web/src/components/forms/edit-document/field-item.tsx index dfbf89745..fe0b4541c 100644 --- a/apps/web/src/components/forms/edit-document/field-item.tsx +++ b/apps/web/src/components/forms/edit-document/field-item.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useCallback, useEffect, useState } from 'react'; import { Trash } from 'lucide-react'; diff --git a/apps/web/src/components/forms/edit-document/provider.tsx b/apps/web/src/components/forms/edit-document/provider.tsx deleted file mode 100644 index ea5d7cd62..000000000 --- a/apps/web/src/components/forms/edit-document/provider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { createContext, useRef } from 'react'; - -import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer'; - -type EditFormContextValue = { - firePageClickEvent: OnPDFViewerPageClick; - registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void; - unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void; -} | null; - -const EditFormContext = createContext(null); - -export type EditFormProviderProps = { - children: React.ReactNode; -}; - -export const useEditForm = () => { - const context = React.useContext(EditFormContext); - - if (!context) { - throw new Error('useEditForm must be used within a EditFormProvider'); - } - - return context; -}; - -export const EditFormProvider = ({ children }: EditFormProviderProps) => { - const handlers = useRef(new Set()); - - const firePageClickEvent: OnPDFViewerPageClick = (event) => { - handlers.current.forEach((handler) => handler(event)); - }; - - const registerPageClickHandler = (handler: OnPDFViewerPageClick) => { - handlers.current.add(handler); - }; - - const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => { - handlers.current.delete(handler); - }; - - return ( - - {children} - - ); -}; diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 6eceef65f..3b6941a44 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useRouter } from 'next/navigation'; + import { zodResolver } from '@hookform/resolvers/zod'; import { Loader } from 'lucide-react'; import { Controller, useForm } from 'react-hook-form'; @@ -30,6 +32,8 @@ export type ProfileFormProps = { }; export const ProfileForm = ({ className, user }: ProfileFormProps) => { + const router = useRouter(); + const { toast } = useToast(); const { @@ -59,6 +63,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { description: 'Your profile has been updated successfully.', duration: 5000, }); + + router.refresh(); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ diff --git a/apps/web/src/components/motion.tsx b/apps/web/src/components/motion.tsx new file mode 100644 index 000000000..2e9d19eae --- /dev/null +++ b/apps/web/src/components/motion.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { motion } from 'framer-motion'; + +export * from 'framer-motion'; + +export const MotionDiv = motion.div; diff --git a/apps/web/src/components/signature-pad/signature-pad.tsx b/apps/web/src/components/signature-pad/signature-pad.tsx index 4ff0a6137..66d8d4582 100644 --- a/apps/web/src/components/signature-pad/signature-pad.tsx +++ b/apps/web/src/components/signature-pad/signature-pad.tsx @@ -24,7 +24,12 @@ export type SignaturePadProps = Omit, 'onChang onChange?: (_signatureDataUrl: string | null) => void; }; -export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => { +export const SignaturePad = ({ + className, + defaultValue, + onChange, + ...props +}: SignaturePadProps) => { const $el = useRef(null); const [isPressed, setIsPressed] = useState(false); @@ -127,7 +132,7 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp setPoints(newPoints); } - if ($el.current) { + if ($el.current && newPoints.length > 0) { const ctx = $el.current.getContext('2d'); if (ctx) { @@ -188,6 +193,23 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp } }, []); + useEffect(() => { + console.log({ defaultValue }); + if ($el.current && typeof defaultValue === 'string') { + const ctx = $el.current.getContext('2d'); + + const { width, height } = $el.current; + + const img = new Image(); + + img.onload = () => { + ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height)); + }; + + img.src = defaultValue; + } + }, [defaultValue]); + return (
-
+