Merge branch 'feat/refresh' into feat/funding

This commit is contained in:
Timur Ercan
2023-08-18 17:48:31 +02:00
committed by GitHub
57 changed files with 3533 additions and 105 deletions

View File

@ -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)
- 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)

View File

@ -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) => {
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Legend
formatter={(value) => {
return <span className="text-sm text-black">{value}</span>;
}}
/>
<Tooltip
formatter={(percent: number, name, props) => {
return [`${percent}%`, name || props['name'] || props['payload']['name']];

View File

@ -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"
},

View File

@ -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: () => (
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</div>
),
});
export type EditDocumentFormProps = {
className?: string;
user: User;
@ -71,7 +57,7 @@ export const EditDocumentForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
<LazyPDFViewer document={documentUrl} />
</CardContent>
</Card>

View File

@ -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({
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</NextAuthProvider>
);
}

View File

@ -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<HTMLButtonElement> & {
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 (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !document}
onClick={onDownloadClick}
{...props}
>
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
};

View File

@ -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 (
<div className="flex flex-col items-center pt-24">
{/* Card with recipient */}
<SigningCard name={recipientName} />
<div className="mt-6">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))}
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed "{document.title}"
</h2>
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1">
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<DownloadButton
className="flex-1"
fileName={document.title}
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
</div>
<p className="text-muted-foreground/60 mt-36 text-sm">
Want so send slick signing links like this one?{' '}
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
Check out Documenso.
</Link>
</p>
</div>
);
}

View File

@ -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 (
<div className="relative w-full max-w-xs md:max-w-sm">
<Card
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
degrees={-145}
gradient
>
<CardContent
className="font-signature p-6 text-center"
style={{
container: 'main',
}}
>
<span
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
style={{
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
}}
>
{name}
</span>
</CardContent>
</Card>
<motion.div
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
scale: 1,
opacity: 0.5,
}}
transition={{
delay: 0.5,
duration: 0.5,
}}
>
<Image
src={signingCelebration}
alt="background pattern"
className="w-full"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</motion.div>
</div>
);
};

View File

@ -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 (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Date</p>
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
)}
</SigningFieldContainer>
);
};

View File

@ -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 (
<form
className={cn(
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
console.log({
signpadValue: value,
});
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
size="lg"
disabled={!isComplete || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Complete
</Button>
</div>
</div>
</div>
</div>
</form>
);
};

View File

@ -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 (
<NextAuthProvider>
<div className="min-h-screen overflow-hidden">
{user && <AuthenticatedHeader user={user} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>
</NextAuthProvider>
);
}

View File

@ -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 (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Name</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
<DialogContent>
<DialogTitle>
Sign as {recipient.name}{' '}
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="py-4">
<Label htmlFor="signature">Full Name</Label>
<Input
type="text"
className="mt-2"
value={localFullName}
onChange={(e) => setLocalFullName(e.target.value)}
/>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowFullNameModal(false);
setLocalFullName('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localFullName}
onClick={() => {
setShowFullNameModal(false);
onSign('local');
}}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@ -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 (
<SigningProvider email={recipient.email} fullName={recipient.name}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to sign this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-8">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm document={document} recipient={recipient} fields={fields} />
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
</SigningProvider>
);
}

View File

@ -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<SigningContextValue | null>(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 (
<SigningContext.Provider
value={{
fullName,
setFullName,
email,
setEmail,
signature,
setSignature,
}}
>
{children}
</SigningContext.Provider>
);
};
SigningProvider.displayName = 'SigningProvider';

View File

@ -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<string | null>(null);
const state = useMemo<SignatureFieldState>(() => {
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 (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
Signature
</p>
)}
{state === 'signed-image' && signature?.signatureImageAsBase64 && (
<img
src={signature.signatureImageAsBase64}
alt={`Signature for ${recipient.name}`}
className="h-full w-full object-contain"
/>
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{signature?.typedSignature}
</p>
)}
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
<DialogContent>
<DialogTitle>
Sign as {recipient.name}{' '}
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="">
<Label htmlFor="signature">Signature</Label>
<SignaturePad
id="signature"
className="border-border mt-2 h-44 w-full rounded-md border"
onChange={(value) => setLocalSignature(value)}
/>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowSignatureModal(false);
setLocalSignature(null);
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localSignature}
onClick={() => {
setShowSignatureModal(false);
onSign('local');
}}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@ -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> | void;
onRemove?: () => Promise<void> | 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 (
<div
className="absolute"
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<Card
className="bg-background relative h-full w-full"
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{!field.inserted && !loading && (
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
)}
{field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</CardContent>
</Card>
</div>
);
};

View File

@ -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 (
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
<html
lang="en"
className={cn(fontInter.variable, fontCaveat.variable)}
suppressHydrationWarning
>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

View File

@ -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;

View File

@ -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[
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>

View File

@ -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: () => (
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</div>
),
},
);

View File

@ -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;
};

View File

@ -1,3 +1,5 @@
'use client';
import React, { HTMLAttributes } from 'react';
import { Loader } from 'lucide-react';

View File

@ -1,3 +1,5 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Trash } from 'lucide-react';

View File

@ -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<EditFormContextValue>(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<OnPDFViewerPageClick>());
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 (
<EditFormContext.Provider
value={{
firePageClickEvent,
registerPageClickHandler,
unregisterPageClickHandler,
}}
>
{children}
</EditFormContext.Provider>
);
};

View File

@ -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({

View File

@ -0,0 +1,7 @@
'use client';
import { motion } from 'framer-motion';
export * from 'framer-motion';
export const MotionDiv = motion.div;

View File

@ -24,7 +24,12 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
onChange?: (_signatureDataUrl: string | null) => void;
};
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
export const SignaturePad = ({
className,
defaultValue,
onChange,
...props
}: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(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 (
<div className="relative block">
<canvas
@ -202,10 +224,10 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp
{...props}
/>
<div className="absolute bottom-2 right-2">
<div className="absolute bottom-4 right-4">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background rounded-full p-2 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2"
className="focus-visible:ring-ring ring-offset-background rounded-full p-0 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()}
>
Clear Signature

View File

@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from 'react';
import { Field } from '@documenso/prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { getBoundingClientRect } from '~/helpers/getBoundingClientRect';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
// X and Y are percentages of the page's height and width
const fieldX = (Number(field.positionX) / 100) * width + left;
const fieldY = (Number(field.positionY) / 100) * height + top;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return coords;
};

View File

@ -48,7 +48,7 @@ export default async function handler(
// We had intended to do this with Zod but we can only validate it
// as a persistent file which does not include the properties that we
// need.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
resolve({ ...fields, ...files } as any);
});
},