feat: document authoring

This commit is contained in:
Mythie
2023-08-17 19:56:18 +10:00
parent c6327c77ea
commit 7a705e3b81
50 changed files with 2037 additions and 93 deletions

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);
});
},