diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..f8b8e97aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: "Continuous Integration" + +on: + push: + branches: [ "feat/refresh" ] + pull_request: + branches: [ "feat/refresh" ] + +env: + HUSKY: 0 + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..6610d88b5 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run commitlint -- $1 diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/apps/marketing/content/blog/pre-seed.mdx b/apps/marketing/content/blog/pre-seed.mdx index 3a9431ac1..9042aaa9e 100644 --- a/apps/marketing/content/blog/pre-seed.mdx +++ b/apps/marketing/content/blog/pre-seed.mdx @@ -13,18 +13,24 @@ tags: Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it. -### Two more for the road (to open signing) -We are ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We are also fortunate to be joined by Orricks very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed properly using Documenso. +## Two more for the road (to open signing) +We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso. -### Open Source, Open Metrics -If you follow us, you know we are firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" is not precisely defined (and probably will never be, just like startup). There is however a [great writeup](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com. The two main takeaways are: +## Open Source, Open Metrics +If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com. + +The two main takeaways are: - "Any Startup that shares its metrics as open as technically and operationally possible is an Open Startup." - "Why should I care? Frankly speaking, Open Startups have a tough time screwing you over." -The more open the culture, the less shady stuff is going on. While this may sounnd trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open. For us, there are two sides to being an open startup: +The more open the culture, the less shady stuff is going on. While this may sound trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open. -- The company side: Sharing Financial KPIs like growth, funding, team structure, salary, internal processes, and tools -- The product side: Sharing insights and data like usage, reach, and GitHub activity +For us, there are two sides to being an Open Startup: -Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS. Starting today, we are releasing a lot of our data as part of the Open Startup movement. You can find the juicy details on our funding and more here: [documen.so/open](http://documen.so/open) \ No newline at end of file +- The company side: Sharing Financial KPIs like growth, funding, team structure, salary, internal processes, and tools. +- The product side: Sharing insights and data like usage, reach, and GitHub activity. + +Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS. + +Starting today, we're releasing a lot of our data as part of the Open Startup movement. You can find the juicy details on our funding and more here: [documen.so/open](https://documen.so/open) diff --git a/apps/marketing/src/app/(marketing)/open/cap-table.tsx b/apps/marketing/src/app/(marketing)/open/cap-table.tsx index e1a0913c1..d5194e5f5 100644 --- a/apps/marketing/src/app/(marketing)/open/cap-table.tsx +++ b/apps/marketing/src/app/(marketing)/open/cap-table.tsx @@ -2,7 +2,7 @@ import { HTMLAttributes, useEffect, useState } from 'react'; -import { Cell, Pie, PieChart, Tooltip } from 'recharts'; +import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'; import { cn } from '@documenso/ui/lib/utils'; @@ -60,8 +60,8 @@ export const CapTable = ({ className, ...props }: CapTableProps) => { cy="50%" labelLine={false} label={renderCustomizedLabel} - outerRadius={180} - innerRadius={100} + outerRadius={160} + innerRadius={80} fill="#8884d8" dataKey="percentage" > @@ -69,6 +69,11 @@ export const CapTable = ({ className, ...props }: CapTableProps) => { ))} + { + return {value}; + }} + /> { return [`${percent}%`, name || props['name'] || props['payload']['name']]; 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 (
-
+